Issue #215: Dynamic Class Loading Architecture
Historical design document. This records the problem analysis and design options considered for issue #215. It is not maintained against the current codebase — code samples below may not match today's APIs. For the current CLI architecture, see
smrt-app-cli(the distributable application CLI) and the core architecture guide.
Issue: #215 - SMRT CLI should discover objects from consumer plugin's aggregated manifest
Status: Historical (design record)
Branch: feat/issue-215-dynamic-class-loading
Started: 2025-11-05
Problem Statement
Current Behavior
The SMRT CLI only discovers objects from the local project, ignoring objects from external packages consumed via smrtConsumer plugin.
Example:
# Install external SMRT package
npm install @happyvertical/praeco
# CLI should show external objects but doesn't
npx smrt objects
# Output: Only shows local objects ❌
Root Cause
The CLI needs actual class constructors to:
- Instantiate collection classes
- Execute CRUD operations
- Call custom methods on objects
Current state:
- ✅ Consumer plugin aggregates manifests to
.smrt/manifest.json - ✅ CLI discovers the aggregated manifest
- ❌ CLI cannot execute commands without class code
Architecture Analysis
What Works ✅
1. Consumer Plugin Aggregation
File: packages/core/src/consumer-plugin/index.ts
async function aggregateTypeManifests(packages, projectRoot) {
const aggregatedManifest = {
version: '1.0.0',
timestamp: Date.now(),
objects: {}
};
// Scans node_modules for SMRT packages
// Loads manifests from each package
// Aggregates into single manifest
return aggregatedManifest;
}
// Saves to .smrt/manifest.json (lines 415-437)
await saveAggregatedManifest(typeManifest, projectRoot);
2. CLI Manifest Discovery
File: packages/cli/src/discovery/manifest-discovery.ts
async function findProjectManifests(projectRoot) {
const candidates = [
'.smrt/manifest.json', // ← Consumer plugin output
'dist/manifest.json',
// ... other locations
];
// Finds and loads .smrt/manifest.json
// Returns manifest metadata
}
3. CLI Metadata Registration
File: packages/cli/src/cli-generator.ts (lines 264-278)
if (manifest?.objects) {
for (const [name, objectDef] of Object.entries(manifest.objects)) {
// Registers metadata in ObjectRegistry
ObjectRegistry.registerFromManifest(name, objectDef, manifest.packageName);
}
}
What's Missing ❌
1. Package Metadata in Manifest
Current manifest doesn't include:
packageName- Which package the object comes fromimportPath- Where to dynamically import the class fromexportName- Named export to usecollectionExportName- Collection class export
Without this, CLI can't determine where to load classes from.
2. Dynamic Class Loading
File: packages/cli/src/cli-generator.ts (lines 113-228)
private async tryLoadUserClasses() {
// Only loads LOCAL entry point
const entryPoint = './dist/index.js';
await import(entryPoint);
// ❌ No mechanism to import from external packages
}
3. Collection Instantiation
File: packages/cli/src/cli-generator.ts (lines 1139-1181)
private async getCollection(objectName) {
const classInfo = ObjectRegistry.getClass(objectName);
if (!classInfo || !classInfo.collectionConstructor) {
throw new Error(
`Object ${objectName} not found or has no collection constructor.`
);
}
// ❌ Fails for external objects without loaded constructors
const collection = new classInfo.collectionConstructor({...});
}
Solution Architecture
Overview
5-Phase Implementation:
- Enhanced Manifest Schema - Add package metadata
- Consumer Plugin Enhancement - Preserve metadata during aggregation
- Dynamic Class Loader - Load classes from external packages at runtime
- CLI Integration - Use loaded classes for command execution
- Testing & Documentation - Comprehensive coverage
Phase 1: Enhanced Manifest Schema
Goal
Include package metadata needed for dynamic imports.
New Type Definition
File: packages/core/src/scanner/types.ts
export interface SmartObjectDefinition {
// Existing fields
name: string;
className: string;
collection: string;
filePath: string;
fields: Record<string, SmrtObjectField>;
methods: Record<string, SmrtObjectMethod>;
decoratorConfig: any;
extends?: string;
// NEW: Package metadata for dynamic loading
packageName: string; // e.g., "@happyvertical/praeco"
packageVersion?: string; // e.g., "1.2.3"
importPath: string; // e.g., "@happyvertical/praeco/objects"
modulePath?: string; // e.g., "dist/objects/agent.js"
exportName: string; // e.g., "Agent" or "default"
collectionExportName?: string; // e.g., "AgentCollection"
}
export interface SmartObjectManifest {
version: string;
timestamp: number;
packageName?: string; // NEW: Root package name
packageVersion?: string; // NEW: Root package version
objects: Record<string, SmartObjectDefinition>;
}
Implementation
File: packages/core/src/scanner/ast-scanner.ts
export function scanSmrtObjects(options: ScanOptions): SmartObjectManifest {
const packageJson = loadPackageJson(options.projectRoot);
// Scan for SMRT objects...
return {
version: '1.0.0',
timestamp: Date.now(),
packageName: packageJson.name, // NEW
packageVersion: packageJson.version, // NEW
objects: {
[className]: {
// ... existing fields ...
// NEW: Package metadata
packageName: packageJson.name,
packageVersion: packageJson.version,
importPath: determineImportPath(packageJson, filePath),
exportName: className,
collectionExportName: `${className}Collection`,
}
}
};
}
/**
* Determine import path from package.json exports
*/
function determineImportPath(packageJson: any, filePath: string): string {
// Try package.json exports field first
if (packageJson.exports) {
// Check for specific object exports
if (packageJson.exports['./objects']) {
return `${packageJson.name}/objects`;
}
// Check for main export
if (packageJson.exports['.']) {
return packageJson.name;
}
}
// Fallback to main field
if (packageJson.main) {
return packageJson.name;
}
// Default fallback
return `${packageJson.name}/dist/index.js`;
}
Backwards Compatibility
Old manifests without package metadata:
- Treated as local objects
packageNamedefaults to current projectimportPathdefaults to local entry point
Phase 2: Consumer Plugin Enhancement
Goal
Preserve package metadata when aggregating manifests.
Current Issue
File: packages/core/src/consumer-plugin/index.ts (line 254)
// Current: Loses package context during merge
Object.assign(aggregatedManifest.objects, manifest.objects);
Enhanced Implementation
async function aggregateTypeManifests(
packages: string[],
projectRoot: string,
): Promise<any> {
const aggregatedManifest = {
version: '1.0.0',
timestamp: Date.now(),
objects: {} as Record<string, any>,
};
for (const packageName of packages) {
try {
const packageDir = path.join(projectRoot, 'node_modules', packageName);
// Load package.json for version info
const packageJsonPath = path.join(packageDir, 'package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
// Try multiple manifest locations
const manifestCandidates = [
path.join(packageDir, 'dist', 'manifest', 'static-manifest.js'),
path.join(packageDir, 'dist', 'manifest.json'),
path.join(packageDir, 'manifest.json'),
];
for (const manifestPath of manifestCandidates) {
if (fs.existsSync(manifestPath)) {
let manifest;
if (manifestPath.endsWith('.js')) {
const manifestModule = await import(manifestPath);
manifest = manifestModule.staticManifest || manifestModule.default;
} else {
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
manifest = JSON.parse(manifestContent);
}
if (manifest?.objects) {
console.log(
`[smrt:consumer] Loaded manifest from ${packageName} (${Object.keys(manifest.objects).length} objects)`
);
// ENHANCED: Preserve package metadata for each object
for (const [objectName, objectDef] of Object.entries(manifest.objects)) {
aggregatedManifest.objects[objectName] = {
...objectDef,
// Ensure package metadata is preserved
packageName: manifest.packageName || packageName,
packageVersion: manifest.packageVersion || packageJson.version,
// Add fallback import paths if missing
importPath: objectDef.importPath || determineImportPath(packageJson),
exportName: objectDef.exportName || objectName,
collectionExportName: objectDef.collectionExportName || `${objectName}Collection`,
};
}
break; // Use first found manifest for this package
}
}
}
} catch (error) {
console.warn(
`[smrt:consumer] Error loading manifest from ${packageName}:`,
error,
);
}
}
return aggregatedManifest;
}
/**
* Determine import path from package.json
*/
function determineImportPath(packageJson: any): string {
if (packageJson.exports?.['./objects']) {
return `${packageJson.name}/objects`;
}
if (packageJson.exports?.['.']) {
return packageJson.name;
}
return `${packageJson.name}/dist/index.js`;
}
Phase 3: Dynamic Class Loader
Goal
Load classes from external packages at runtime.
New File: packages/cli/src/loaders/class-loader.ts
/**
* Dynamic Class Loader for SMRT CLI
*
* Loads SMRT object classes from external packages at runtime,
* enabling CLI commands to work with objects from installed packages.
*/
import { pathToFileURL } from 'node:url';
import { resolve } from 'node:path';
import { existsSync } from 'node:fs';
import type { SmartObjectDefinition } from '@happyvertical/smrt-core/scanner';
export interface LoadedClasses {
ObjectClass: any;
CollectionClass?: any;
}
/**
* Dynamically load SMRT classes from external packages
*/
export class DynamicClassLoader {
private loadedModules = new Map<string, any>();
private classCache = new Map<string, LoadedClasses>();
private verbose: boolean;
constructor(options: { verbose?: boolean } = {}) {
this.verbose = options.verbose || false;
}
/**
* Load a class from package using manifest metadata
*/
async loadClass(objectDef: SmartObjectDefinition): Promise<LoadedClasses> {
const cacheKey = `${objectDef.packageName}:${objectDef.className}`;
// Return cached if available
if (this.classCache.has(cacheKey)) {
if (this.verbose) {
console.log(`[ClassLoader] Using cached ${cacheKey}`);
}
return this.classCache.get(cacheKey)!;
}
try {
// Determine import path
const importPath = this.resolveImportPath(objectDef);
if (this.verbose) {
console.log(`[ClassLoader] Loading ${cacheKey} from ${importPath}`);
}
// Dynamic import
let module = this.loadedModules.get(importPath);
if (!module) {
module = await import(importPath);
this.loadedModules.set(importPath, module);
if (this.verbose) {
console.log(`[ClassLoader] Imported module ${importPath}`);
}
}
// Extract classes
const ObjectClass = module[objectDef.exportName] || module.default;
const CollectionClass = objectDef.collectionExportName
? module[objectDef.collectionExportName]
: undefined;
if (!ObjectClass) {
throw new Error(
`Failed to find export '${objectDef.exportName}' in ${importPath}`
);
}
const result = { ObjectClass, CollectionClass };
this.classCache.set(cacheKey, result);
if (this.verbose) {
console.log(
`[ClassLoader] Loaded ${cacheKey} (Collection: ${CollectionClass ? 'yes' : 'no'})`
);
}
return result;
} catch (error) {
throw new Error(
`Failed to load class ${objectDef.className} from ${objectDef.packageName}: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
}
/**
* Resolve import path with fallbacks
*/
private resolveImportPath(objectDef: SmartObjectDefinition): string {
// Try explicit import path first
if (objectDef.importPath) {
return objectDef.importPath;
}
// Fallback: Try package main export
if (objectDef.packageName) {
return objectDef.packageName;
}
throw new Error(
`Cannot determine import path for ${objectDef.className} - no importPath or packageName`
);
}
/**
* Try to load class with fallback strategies
*/
private async tryImportWithFallbacks(
importPath: string,
objectDef: SmartObjectDefinition
): Promise<any> {
const strategies = [
// Strategy 1: Direct import path
() => import(importPath),
// Strategy 2: Package name only
() => import(objectDef.packageName),
// Strategy 3: Package + /dist/index.js
() => import(`${objectDef.packageName}/dist/index.js`),
// Strategy 4: Package + /objects
() => import(`${objectDef.packageName}/objects`),
];
let lastError: Error | null = null;
for (const strategy of strategies) {
try {
const module = await strategy();
if (module) {
return module;
}
} catch (error) {
lastError = error as Error;
// Continue to next strategy
}
}
throw lastError || new Error('All import strategies failed');
}
/**
* Load all classes from manifest
*/
async loadAllFromManifest(
manifest: any
): Promise<Map<string, LoadedClasses>> {
const loaded = new Map<string, LoadedClasses>();
for (const [objectName, objectDef] of Object.entries(manifest.objects)) {
try {
const classes = await this.loadClass(objectDef as SmartObjectDefinition);
loaded.set(objectName, classes);
} catch (error) {
if (this.verbose) {
console.warn(
`[ClassLoader] Skipping ${objectName}:`,
error instanceof Error ? error.message : 'Unknown error'
);
}
}
}
return loaded;
}
/**
* Clear all caches
*/
clearCache(): void {
this.loadedModules.clear();
this.classCache.clear();
}
/**
* Get cache statistics
*/
getStats(): {
modulesLoaded: number;
classesCached: number;
} {
return {
modulesLoaded: this.loadedModules.size,
classesCached: this.classCache.size,
};
}
}
Error Handling
The loader provides detailed error messages:
// Missing package
Error: Failed to load class Agent from @happyvertical/praeco
Cause: Cannot find module '@happyvertical/praeco'
Solution: Install the package
npm install @happyvertical/praeco
// Wrong export name
Error: Failed to find export 'Agent' in @happyvertical/praeco
Solution: Check package documentation for correct export name
// Import path resolution failed
Error: Cannot determine import path for Agent - no importPath or packageName
Solution: Ensure manifest includes packageName and importPath
Phase 4: CLI Integration
Goal
Use dynamically loaded classes for command execution.
Updated: packages/cli/src/cli-generator.ts
4.1 Update tryLoadUserClasses()
/**
* Try to load user's compiled classes for runtime execution
*/
private async tryLoadUserClasses(): Promise<void> {
try {
// 1. Load local classes (existing code)
await this.loadLocalClasses();
// 2. NEW: Load classes from external packages
await this.loadExternalClasses();
const registeredCount = ObjectRegistry.getAllClasses().size;
if (this.config.verbose) {
console.log(`[CLI] Successfully loaded ${registeredCount} SMRT objects`);
}
} catch (error) {
const { getPackageConfig } = await import('@happyvertical/smrt-config');
const { DEFAULT_CLI_CONFIG } = await import('./config.js');
const config = getPackageConfig('cli', DEFAULT_CLI_CONFIG);
if (config.verbose) {
console.warn(
'[CLI] Failed to load classes:',
error instanceof Error ? error.message : 'Unknown error'
);
console.log('[CLI] Using manifest-only mode (some commands may not work)');
}
}
}
/**
* Load classes from local project (existing behavior)
*/
private async loadLocalClasses(): Promise<void> {
const { getPackageConfig } = await import('@happyvertical/smrt-config');
const { DEFAULT_CLI_CONFIG } = await import('./config.js');
const fs = await import('node:fs');
const path = await import('node:path');
const config = getPackageConfig('cli', DEFAULT_CLI_CONFIG);
// Determine entry point
let entryPoint: string | null = config.entryPoint;
if (!entryPoint) {
// Read from package.json
const packageJsonPath = path.resolve(process.cwd(), 'package.json');
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
entryPoint =
packageJson.exports?.['.']?.import ||
packageJson.exports?.['.'] ||
packageJson.main ||
'./dist/index.js';
}
}
if (!entryPoint) {
entryPoint = './dist/index.js';
}
const fullPath = path.resolve(process.cwd(), entryPoint);
if (!fs.existsSync(fullPath)) {
if (config.verbose) {
console.log(`[CLI] Entry point not found: ${fullPath}`);
}
return;
}
if (config.verbose) {
console.log(`[CLI] Loading local SMRT classes from ${entryPoint}...`);
}
const fileUrl = `file://${fullPath}`;
const importedModule = await import(fileUrl);
// Register collections from exports
for (const [exportName, exportValue] of Object.entries(importedModule)) {
if (exportValue && typeof exportValue === 'function') {
const itemClass = (exportValue as any)._itemClass;
if (itemClass) {
const tableName = itemClass.SMRT_TABLE_NAME || itemClass.name.toLowerCase();
const existing = ObjectRegistry.getClass(tableName);
if (existing && !existing.collectionConstructor) {
ObjectRegistry.registerCollection(tableName, exportValue as any);
if (config.verbose) {
console.log(`[CLI] Registered local collection ${exportName}`);
}
}
}
}
}
}
/**
* NEW: Load classes from external packages
*/
private async loadExternalClasses(): Promise<void> {
const { getPackageConfig } = await import('@happyvertical/smrt-config');
const { DEFAULT_CLI_CONFIG } = await import('./config.js');
const { DynamicClassLoader } = await import('./loaders/class-loader.js');
const fs = await import('node:fs');
const path = await import('node:path');
const config = getPackageConfig('cli', DEFAULT_CLI_CONFIG);
// Read .smrt/manifest.json
const manifestPath = path.join(process.cwd(), '.smrt/manifest.json');
if (!fs.existsSync(manifestPath)) {
if (config.verbose) {
console.log('[CLI] No .smrt/manifest.json found - no external packages');
}
return;
}
const manifestContent = fs.readFileSync(manifestPath, 'utf-8');
const manifest = JSON.parse(manifestContent);
if (!manifest.objects || Object.keys(manifest.objects).length === 0) {
if (config.verbose) {
console.log('[CLI] No objects in manifest');
}
return;
}
const loader = new DynamicClassLoader({ verbose: config.verbose });
// Load classes from external packages only
for (const [objectName, objectDef] of Object.entries(manifest.objects)) {
const def = objectDef as any;
// Skip local objects (already loaded)
if (!def.packageName || def.packageName === manifest.packageName) {
continue;
}
try {
const { ObjectClass, CollectionClass } = await loader.loadClass(def);
// Register in ObjectRegistry
const tableName = def.collection || objectName.toLowerCase();
// Check if already registered
const existing = ObjectRegistry.getClass(tableName);
if (!existing) {
// Register new object
ObjectRegistry.register(tableName, ObjectClass);
if (config.verbose) {
console.log(`[CLI] Registered ${objectName} from ${def.packageName}`);
}
}
// Register collection if available
if (CollectionClass && (!existing || !existing.collectionConstructor)) {
ObjectRegistry.registerCollection(tableName, CollectionClass);
if (config.verbose) {
console.log(
`[CLI] Registered ${objectName}Collection from ${def.packageName}`
);
}
}
} catch (error) {
console.warn(
`[CLI] Failed to load ${objectName} from ${def.packageName}:`,
error instanceof Error ? error.message : 'Unknown error'
);
}
}
const stats = loader.getStats();
if (config.verbose) {
console.log(
`[CLI] External classes loaded: ${stats.classesCached} (${stats.modulesLoaded} modules)`
);
}
}
4.2 Enhanced Error Messages
/**
* Get or create collection for an object
*/
private async getCollection(objectName: string): Promise<SmrtCollection<any>> {
if (!this.collections.has(objectName)) {
const classInfo = ObjectRegistry.getClass(objectName);
if (!classInfo || !classInfo.collectionConstructor) {
// Enhanced error message with troubleshooting
const availableObjects = Array.from(ObjectRegistry.getAllClasses().keys());
throw new Error(
`Object '${objectName}' not found or has no collection constructor.\n\n` +
`Available objects:\n ${availableObjects.join('\n ')}\n\n` +
`Troubleshooting:\n` +
`1. If from external package, ensure it's installed:\n` +
` npm install <package-name>\n\n` +
`2. Rebuild your project to regenerate manifest:\n` +
` npm run build\n\n` +
`3. Check .smrt/manifest.json contains the object\n\n` +
`4. Verify package exports classes correctly\n`
);
}
// ... rest of implementation
}
return this.collections.get(objectName)!;
}
Phase 5: Testing & Documentation
Unit Tests
New File: packages/cli/src/loaders/class-loader.spec.ts
import { describe, it, expect, beforeEach } from 'vitest';
import { DynamicClassLoader } from './class-loader.js';
describe('DynamicClassLoader', () => {
let loader: DynamicClassLoader;
beforeEach(() => {
loader = new DynamicClassLoader({ verbose: false });
});
describe('loadClass', () => {
it('should load class from package', async () => {
const objectDef = {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
// ... other required fields
} as any;
const { ObjectClass } = await loader.loadClass(objectDef);
expect(ObjectClass).toBeDefined();
expect(ObjectClass.name).toBe('SmrtObject');
});
it('should cache loaded modules', async () => {
const objectDef = {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
} as any;
await loader.loadClass(objectDef);
await loader.loadClass(objectDef); // Should use cache
const stats = loader.getStats();
expect(stats.modulesLoaded).toBe(1);
expect(stats.classesCached).toBe(1);
});
it('should handle import failures gracefully', async () => {
const invalidDef = {
packageName: 'non-existent-package-12345',
className: 'Fake',
importPath: 'non-existent-package-12345',
exportName: 'Fake',
} as any;
await expect(loader.loadClass(invalidDef)).rejects.toThrow(
/Failed to load class Fake/
);
});
it('should load collection class if available', async () => {
const objectDef = {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
collectionExportName: 'SmrtCollection',
} as any;
const { ObjectClass, CollectionClass } = await loader.loadClass(objectDef);
expect(ObjectClass).toBeDefined();
expect(CollectionClass).toBeDefined();
});
});
describe('loadAllFromManifest', () => {
it('should load all classes from manifest', async () => {
const manifest = {
objects: {
SmrtObject: {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
},
},
};
const loaded = await loader.loadAllFromManifest(manifest);
expect(loaded.size).toBe(1);
expect(loaded.has('SmrtObject')).toBe(true);
});
it('should skip objects that fail to load', async () => {
const manifest = {
objects: {
Valid: {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
},
Invalid: {
packageName: 'non-existent',
className: 'Fake',
importPath: 'non-existent',
exportName: 'Fake',
},
},
};
const loaded = await loader.loadAllFromManifest(manifest);
expect(loaded.size).toBe(1);
expect(loaded.has('Valid')).toBe(true);
expect(loaded.has('Invalid')).toBe(false);
});
});
describe('clearCache', () => {
it('should clear all caches', async () => {
const objectDef = {
packageName: '@happyvertical/smrt-core',
className: 'SmrtObject',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
} as any;
await loader.loadClass(objectDef);
expect(loader.getStats().classesCached).toBe(1);
loader.clearCache();
const stats = loader.getStats();
expect(stats.modulesLoaded).toBe(0);
expect(stats.classesCached).toBe(0);
});
});
});
Integration Tests
New File: packages/cli/src/cli-generator.integration.spec.ts
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { CLIGenerator } from './cli-generator.js';
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
describe('CLI with external packages', () => {
const testDir = join(process.cwd(), 'test-fixtures', 'cli-external');
beforeAll(() => {
// Create test project structure
mkdirSync(join(testDir, '.smrt'), { recursive: true });
// Create mock manifest with external package
const manifest = {
version: '1.0.0',
timestamp: Date.now(),
packageName: 'test-project',
objects: {
SmrtObject: {
name: 'SmrtObject',
className: 'SmrtObject',
collection: 'smrtobjects',
packageName: '@happyvertical/smrt-core',
importPath: '@happyvertical/smrt-core',
exportName: 'SmrtObject',
collectionExportName: 'SmrtCollection',
fields: {},
methods: {},
decoratorConfig: { cli: true },
},
},
};
writeFileSync(
join(testDir, '.smrt', 'manifest.json'),
JSON.stringify(manifest, null, 2)
);
});
afterAll(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('should list objects from external packages', async () => {
const cli = new CLIGenerator();
const commands = await cli['generateCommands']();
// Should discover SmrtObject from manifest
const objectCommands = commands.filter((c) =>
c.name.startsWith('smrtobject:')
);
expect(objectCommands.length).toBeGreaterThan(0);
});
it('should execute commands on external objects', async () => {
// This would require a full integration test with real database
// For now, we verify command generation works
const cli = new CLIGenerator();
const commands = await cli['generateCommands']();
const listCommand = commands.find((c) => c.name === 'smrtobject:list');
expect(listCommand).toBeDefined();
expect(listCommand?.handler).toBeDefined();
});
});
Documentation Updates
File: packages/cli/README.md (add section)
## Using SMRT Objects from External Packages
The SMRT CLI automatically discovers and loads objects from installed packages,
enabling you to use CLI commands for objects defined in external libraries.
### Quick Start
1. **Install a SMRT package**:
```bash
npm install @happyvertical/praeco
-
Build your project (generates
.smrt/manifest.json):npm run build -
Use CLI commands:
# List all available objects (includes external packages)npx smrt objects# Use commands on external objectsnpx smrt agent:listnpx smrt agent:get <id>npx smrt agent:research <id> --query "AI safety"
How It Works
-
Consumer Plugin: During build, the
smrtConsumerVite plugin scansnode_modulesfor packages with SMRT objects and aggregates their manifests into.smrt/manifest.json. -
CLI Discovery: When you run
npx smrt, the CLI reads the aggregated manifest and discovers all available objects. -
Dynamic Loading: The CLI dynamically imports classes from external packages at runtime, enabling full CRUD and custom method execution.
Troubleshooting
CLI doesn't see external objects
Check 1: Ensure package is installed
npm list @happyvertical/praeco
Check 2: Regenerate manifest
npm run build
Check 3: Verify manifest contains external objects
cat .smrt/manifest.json | grep packageName
Import errors
Error: Cannot find module '@happyvertical/praeco'
Solution: Package not installed or not in node_modules
npm install @happyvertical/praeco
Error: Failed to find export 'Agent' in @happyvertical/praeco
Solution: Package may not export classes correctly. Check package documentation.
Command execution fails
Error: Object 'Agent' not found or has no collection constructor
Cause: Class could not be loaded from external package.
Solutions:
-
Verify package exports classes:
// Package should export:export { Agent } from './objects/agent.js';export { AgentCollection } from './objects/agent.js'; -
Check package.json
exportsfield:{"exports": {".": "./dist/index.js","./objects": "./dist/objects/index.js"}} -
Enable verbose mode for debugging:
SMRT_CLI_VERBOSE=true npx smrt agent:list
Package Author Guidelines
If you're creating a package with SMRT objects:
-
Export classes properly:
// src/objects/index.tsexport { Agent, AgentCollection } from './agent.js';export { Document, DocumentCollection } from './document.js'; -
Configure package.json:
{"name": "@my-org/my-package","exports": {".": "./dist/index.js","./objects": "./dist/objects/index.js"},"dependencies": {"@happyvertical/smrt-core": "^0.7.0"}} -
Use smrtPlugin in vite.config.js:
import { smrtPlugin } from '@happyvertical/smrt-core/vite-plugin';export default {plugins: [smrtPlugin({include: ['src/**/*.ts'],generateTypes: true})]}; -
Build before publishing:
npm run buildnpm publish
Your package will now be automatically discoverable by any project that installs it!
---
## Implementation Timeline
### Week 1: Manifest Schema Enhancement
- [x] Plan architecture
- [ ] Update `SmartObjectDefinition` interface
- [ ] Update AST scanner to include package metadata
- [ ] Update consumer plugin to preserve metadata
- [ ] Add tests for enhanced manifest generation
- [ ] Update manifest JSON schema documentation
**Deliverable**: Enhanced manifests with package metadata
### Week 2: Dynamic Class Loader
- [ ] Create `DynamicClassLoader` class
- [ ] Implement import path resolution with fallbacks
- [ ] Add class and module caching
- [ ] Handle import errors gracefully
- [ ] Add comprehensive error messages
- [ ] Write unit tests with 90%+ coverage
**Deliverable**: Working class loader utility
### Week 3: CLI Integration
- [ ] Update `tryLoadUserClasses()` to load external classes
- [ ] Implement `loadExternalClasses()` method
- [ ] Register external classes in ObjectRegistry
- [ ] Update `getCollection()` with enhanced error messages
- [ ] Add verbose logging for debugging
- [ ] Integration tests with real packages
**Deliverable**: CLI can execute commands from external packages
### Week 4: Testing & Documentation
- [ ] End-to-end tests with external packages
- [ ] Test error scenarios (missing packages, import failures)
- [ ] Test backwards compatibility with old manifests
- [ ] Update CLI README with usage examples
- [ ] Add troubleshooting guide
- [ ] Create migration guide for existing projects
**Deliverable**: Production-ready feature with comprehensive docs
### Week 5+: Optional Enhancements
- [ ] Namespace support for avoiding conflicts
- [ ] Performance optimization (lazy loading)
- [ ] Type generation for external objects
- [ ] IDE autocomplete support
- [ ] CLI plugin system
**Deliverable**: Enhanced UX features
---
## Success Criteria
✅ **User Experience**:
```bash
# Zero-configuration workflow
npm install @happyvertical/praeco
npm run build
npx smrt agent:research <id> --query "AI safety" # Just works!
✅ Technical Requirements:
- CLI discovers objects from
.smrt/manifest.json - Dynamic loading works for all major package patterns
- Error messages are helpful and actionable
- Backwards compatible with existing projects
- Performance overhead < 200ms for 10 packages
- Test coverage > 90%
✅ Documentation:
- README examples work out of the box
- Troubleshooting covers common issues
- Package author guidelines are clear
Future Enhancements
Namespace Support
Avoid naming conflicts between packages:
# Fully qualified with package namespace
npx smrt praeco:agent research --query "AI"
# Auto-detect if unambiguous
npx smrt agent research --query "AI"
# Show package source
npx smrt objects --verbose
# Output:
# • Agent (from @happyvertical/praeco)
# • Document (local)
Performance Optimization
- Lazy loading: Only load classes when command is executed
- Parallel loading: Import multiple packages concurrently
- Incremental caching: Cache across CLI invocations
Type Generation
Generate TypeScript types for external objects:
// Auto-generated types
import type { Agent } from '@smrt/types';
const agent: Agent = {
id: '123',
name: 'Research Agent',
// Full autocomplete!
};
References
- Issue: #215
- Branch:
feat/issue-215-dynamic-class-loading - Related PRs: TBD
- Documentation:
packages/cli/README.md
This document serves as the architectural blueprint for implementing dynamic class loading in the SMRT CLI. It will be updated as implementation progresses.