@fancyrobot/fred-cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +76252 -0
- package/package.json +30 -0
- package/src/dev.ts +63 -0
- package/src/index.ts +145 -0
- package/src/test.ts +262 -0
- package/tsconfig.json +9 -0
package/package.json
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fancyrobot/fred-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Fred AI framework CLI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"fred": "./src/index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "bun build src/index.ts --outdir dist --target bun --format esm"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@fancyrobot/fred": "^0.1.0",
|
|
16
|
+
"@fancyrobot/fred-dev": "^0.1.0"
|
|
17
|
+
},
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"effect": "^3.19.0"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"effect": "^3.19.15",
|
|
23
|
+
"@types/bun": "latest"
|
|
24
|
+
},
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"keywords": ["fred", "cli", "ai", "agents"],
|
|
29
|
+
"license": "MIT"
|
|
30
|
+
}
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Dev command handler
|
|
5
|
+
* Starts the development chat interface with hot reload
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from 'path';
|
|
9
|
+
import { existsSync } from 'fs';
|
|
10
|
+
import { pathToFileURL } from 'url';
|
|
11
|
+
import { Fred } from '@fancyrobot/fred';
|
|
12
|
+
import { startDevChat } from '@fancyrobot/fred-dev';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Try to load and call project's setup() function if it exists
|
|
16
|
+
*/
|
|
17
|
+
async function loadProjectSetup(fred: Fred): Promise<void> {
|
|
18
|
+
// Try to find project's index.ts or src/index.ts
|
|
19
|
+
const possiblePaths = [
|
|
20
|
+
resolve(process.cwd(), 'src', 'index.ts'),
|
|
21
|
+
resolve(process.cwd(), 'index.ts'),
|
|
22
|
+
resolve(process.cwd(), 'src', 'index.js'),
|
|
23
|
+
resolve(process.cwd(), 'index.js'),
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
for (const indexPath of possiblePaths) {
|
|
27
|
+
if (existsSync(indexPath)) {
|
|
28
|
+
try {
|
|
29
|
+
// Dynamically import the project's index file
|
|
30
|
+
// Bun natively supports TypeScript imports, so we can import .ts files directly
|
|
31
|
+
const projectModule = await import(pathToFileURL(indexPath).href);
|
|
32
|
+
|
|
33
|
+
// Check if setup function is exported
|
|
34
|
+
if (typeof projectModule.setup === 'function') {
|
|
35
|
+
// Call the setup function with the Fred instance
|
|
36
|
+
await projectModule.setup(fred);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
// If import fails (e.g., syntax error, missing dependencies), continue to next path
|
|
41
|
+
// This is expected if the file has errors or doesn't export setup
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// If no setup function found, that's okay - dev-chat will use auto-agent creation
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Handle dev command
|
|
52
|
+
* Uses BunRuntime.runMain internally via startDevChat for proper signal handling.
|
|
53
|
+
* This function never returns - it runs until interrupted.
|
|
54
|
+
*/
|
|
55
|
+
export function handleDevCommand(): void {
|
|
56
|
+
const setupHook = async (fred: Fred) => {
|
|
57
|
+
await loadProjectSetup(fred);
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// startDevChat uses BunRuntime.runMain internally
|
|
61
|
+
// It will handle signals and cleanup, and never returns
|
|
62
|
+
startDevChat(setupHook);
|
|
63
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fred CLI
|
|
5
|
+
* Main entry point for CLI commands
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { handleTestCommand } from './test';
|
|
9
|
+
import { handleDevCommand } from './dev';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Options that require a value
|
|
13
|
+
*/
|
|
14
|
+
const OPTIONS_REQUIRING_VALUE = new Set([
|
|
15
|
+
'record',
|
|
16
|
+
'config',
|
|
17
|
+
'traces-dir',
|
|
18
|
+
'tracesDir',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse command line arguments
|
|
23
|
+
*/
|
|
24
|
+
function parseArgs(args: string[]): { command: string; args: string[]; options: Record<string, any> } {
|
|
25
|
+
const command = args[0] || 'help';
|
|
26
|
+
const remainingArgs: string[] = [];
|
|
27
|
+
const options: Record<string, any> = {};
|
|
28
|
+
|
|
29
|
+
for (let i = 1; i < args.length; i++) {
|
|
30
|
+
const arg = args[i];
|
|
31
|
+
|
|
32
|
+
if (arg.startsWith('--')) {
|
|
33
|
+
const key = arg.substring(2);
|
|
34
|
+
const nextArg = args[i + 1];
|
|
35
|
+
const requiresValue = OPTIONS_REQUIRING_VALUE.has(key);
|
|
36
|
+
|
|
37
|
+
// Check if option requires a value
|
|
38
|
+
if (requiresValue) {
|
|
39
|
+
// Validate that a value is provided
|
|
40
|
+
if (nextArg === undefined || nextArg.startsWith('--')) {
|
|
41
|
+
throw new Error(`Option --${key} requires a value. Example: --${key} <value>`);
|
|
42
|
+
}
|
|
43
|
+
options[key] = nextArg;
|
|
44
|
+
i++; // Skip next arg as it's the value
|
|
45
|
+
} else {
|
|
46
|
+
// Handle boolean flags (options that don't require values)
|
|
47
|
+
if (nextArg === undefined || nextArg.startsWith('--')) {
|
|
48
|
+
options[key] = true;
|
|
49
|
+
} else {
|
|
50
|
+
// If a value is provided for a boolean flag, treat it as the value
|
|
51
|
+
// (some flags might accept optional values)
|
|
52
|
+
options[key] = nextArg;
|
|
53
|
+
i++; // Skip next arg as it's the value
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
remainingArgs.push(arg);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return { command, args: remainingArgs, options };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Show help message
|
|
66
|
+
*/
|
|
67
|
+
function showHelp(): void {
|
|
68
|
+
console.log(`
|
|
69
|
+
Fred CLI
|
|
70
|
+
|
|
71
|
+
Usage:
|
|
72
|
+
fred <command> [options]
|
|
73
|
+
|
|
74
|
+
Commands:
|
|
75
|
+
dev Start development chat interface with hot reload
|
|
76
|
+
- If your project exports setup(fred) from src/index.(ts|js) or index.(ts|js), it will be executed before chat starts
|
|
77
|
+
test Run golden trace tests
|
|
78
|
+
test --record <message> Record a new golden trace
|
|
79
|
+
test --update Update existing golden traces
|
|
80
|
+
test <pattern> Run tests matching pattern
|
|
81
|
+
|
|
82
|
+
Options:
|
|
83
|
+
--config <file> Path to Fred config file
|
|
84
|
+
--traces-dir <dir> Directory for golden traces (default: tests/golden-traces)
|
|
85
|
+
|
|
86
|
+
Examples:
|
|
87
|
+
fred dev
|
|
88
|
+
fred test
|
|
89
|
+
fred test --record "Hello, world!"
|
|
90
|
+
fred test --update
|
|
91
|
+
fred test --config fred.config.yaml
|
|
92
|
+
`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Main CLI entry point
|
|
97
|
+
*/
|
|
98
|
+
async function main(): Promise<void> {
|
|
99
|
+
const args = process.argv.slice(2);
|
|
100
|
+
|
|
101
|
+
if (args.length === 0 || args[0] === 'help' || args[0] === '--help' || args[0] === '-h') {
|
|
102
|
+
showHelp();
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const { command, args: commandArgs, options } = parseArgs(args);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
let exitCode = 0;
|
|
110
|
+
|
|
111
|
+
switch (command) {
|
|
112
|
+
case 'dev':
|
|
113
|
+
// handleDevCommand uses BunRuntime.runMain internally and never returns
|
|
114
|
+
// It handles signals and cleanup, and exits the process
|
|
115
|
+
handleDevCommand();
|
|
116
|
+
// This line is never reached
|
|
117
|
+
return;
|
|
118
|
+
|
|
119
|
+
case 'test':
|
|
120
|
+
exitCode = await handleTestCommand(commandArgs, {
|
|
121
|
+
pattern: commandArgs[0],
|
|
122
|
+
update: options.update === true,
|
|
123
|
+
record: options.record,
|
|
124
|
+
tracesDir: options['traces-dir'] || options.tracesDir,
|
|
125
|
+
configFile: options.config,
|
|
126
|
+
});
|
|
127
|
+
break;
|
|
128
|
+
|
|
129
|
+
default:
|
|
130
|
+
console.error(`Unknown command: ${command}`);
|
|
131
|
+
showHelp();
|
|
132
|
+
exitCode = 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
process.exit(exitCode);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error('Error:', error instanceof Error ? error.message : String(error));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Run if executed directly
|
|
143
|
+
if (import.meta.main) {
|
|
144
|
+
main();
|
|
145
|
+
}
|
package/src/test.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { readdir, readFile, writeFile, mkdir, unlink } from 'fs/promises';
|
|
2
|
+
import { join, resolve, dirname } from 'path';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { Fred } from '@fancyrobot/fred';
|
|
5
|
+
import { NoOpTracer, GoldenTraceRecorder, loadGoldenTrace, runTestCase, formatTestResults, TestCase } from '@fancyrobot/fred';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Test command options
|
|
10
|
+
*/
|
|
11
|
+
export interface TestCommandOptions {
|
|
12
|
+
pattern?: string;
|
|
13
|
+
update?: boolean;
|
|
14
|
+
record?: string;
|
|
15
|
+
tracesDir?: string;
|
|
16
|
+
configFile?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Find all golden trace files
|
|
21
|
+
*/
|
|
22
|
+
async function findGoldenTraces(tracesDir: string): Promise<string[]> {
|
|
23
|
+
if (!existsSync(tracesDir)) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const files = await readdir(tracesDir);
|
|
28
|
+
return files
|
|
29
|
+
.filter(file => file.endsWith('.json') && file.startsWith('trace-v'))
|
|
30
|
+
.map(file => join(tracesDir, file));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Find test case files (JSON files with test definitions)
|
|
35
|
+
*/
|
|
36
|
+
async function findTestCases(tracesDir: string): Promise<TestCase[]> {
|
|
37
|
+
const testCaseFile = join(tracesDir, 'test-cases.json');
|
|
38
|
+
|
|
39
|
+
if (!existsSync(testCaseFile)) {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const content = await readFile(testCaseFile, 'utf-8');
|
|
44
|
+
const testCases = JSON.parse(content);
|
|
45
|
+
|
|
46
|
+
if (!Array.isArray(testCases)) {
|
|
47
|
+
throw new Error('test-cases.json must contain an array of test cases');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return testCases;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Record a new golden trace
|
|
55
|
+
*/
|
|
56
|
+
export async function recordTrace(
|
|
57
|
+
message: string,
|
|
58
|
+
fred: Fred,
|
|
59
|
+
tracesDir: string,
|
|
60
|
+
options?: { conversationId?: string }
|
|
61
|
+
): Promise<string> {
|
|
62
|
+
// Create recorder with a base tracer
|
|
63
|
+
const baseTracer = new NoOpTracer();
|
|
64
|
+
const recorder = new GoldenTraceRecorder(baseTracer);
|
|
65
|
+
|
|
66
|
+
// Create tracer with callback to automatically capture spans
|
|
67
|
+
const tracer = new NoOpTracer((span) => {
|
|
68
|
+
recorder.addSpan(span);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
// Enable tracing with the callback-enabled tracer
|
|
72
|
+
fred.enableTracing(tracer);
|
|
73
|
+
|
|
74
|
+
// Record message
|
|
75
|
+
recorder.recordMessage(message);
|
|
76
|
+
|
|
77
|
+
// Process message (spans will be automatically captured via callback)
|
|
78
|
+
const response = await fred.processMessage(message, {
|
|
79
|
+
conversationId: options?.conversationId,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (!response) {
|
|
83
|
+
throw new Error('No response from agent');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Record response
|
|
87
|
+
recorder.recordResponse(response);
|
|
88
|
+
|
|
89
|
+
// Save trace (spans are already captured via callback)
|
|
90
|
+
const filepath = await recorder.saveToFile(tracesDir);
|
|
91
|
+
console.log(`Recorded golden trace: ${filepath}`);
|
|
92
|
+
|
|
93
|
+
return filepath;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Run golden trace tests
|
|
98
|
+
*/
|
|
99
|
+
export async function runTests(
|
|
100
|
+
tracesDir: string,
|
|
101
|
+
pattern?: string
|
|
102
|
+
): Promise<boolean> {
|
|
103
|
+
// Find test cases
|
|
104
|
+
const testCases = await findTestCases(tracesDir);
|
|
105
|
+
|
|
106
|
+
if (testCases.length === 0) {
|
|
107
|
+
console.log('No test cases found. Create a test-cases.json file in the traces directory.');
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter by pattern if provided
|
|
112
|
+
const filteredCases = pattern
|
|
113
|
+
? testCases.filter(tc => tc.name.includes(pattern))
|
|
114
|
+
: testCases;
|
|
115
|
+
|
|
116
|
+
if (filteredCases.length === 0) {
|
|
117
|
+
console.log(`No test cases match pattern: ${pattern}`);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Run tests
|
|
122
|
+
const results = [];
|
|
123
|
+
for (const testCase of filteredCases) {
|
|
124
|
+
const result = await runTestCase(testCase, tracesDir);
|
|
125
|
+
results.push(result);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Display results
|
|
129
|
+
console.log(formatTestResults(results));
|
|
130
|
+
|
|
131
|
+
// Return success if all passed
|
|
132
|
+
return results.every(r => r.passed);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Update golden traces
|
|
137
|
+
*/
|
|
138
|
+
export async function updateTraces(
|
|
139
|
+
tracesDir: string,
|
|
140
|
+
fred: Fred,
|
|
141
|
+
pattern?: string
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
const traceFiles = await findGoldenTraces(tracesDir);
|
|
144
|
+
|
|
145
|
+
if (traceFiles.length === 0) {
|
|
146
|
+
console.log('No golden traces found to update.');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Filter by pattern if provided
|
|
151
|
+
const filteredFiles = pattern
|
|
152
|
+
? traceFiles.filter(file => file.includes(pattern))
|
|
153
|
+
: traceFiles;
|
|
154
|
+
|
|
155
|
+
let successCount = 0;
|
|
156
|
+
for (const traceFile of filteredFiles) {
|
|
157
|
+
try {
|
|
158
|
+
const trace = await loadGoldenTrace(traceFile);
|
|
159
|
+
const message = trace.trace.message;
|
|
160
|
+
|
|
161
|
+
console.log(`Updating trace for: "${message.substring(0, 50)}..."`);
|
|
162
|
+
|
|
163
|
+
// Re-record trace
|
|
164
|
+
await recordTrace(message, fred, tracesDir, {
|
|
165
|
+
conversationId: trace.metadata.config?.conversationId,
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// Remove old trace file now that the new one is created
|
|
169
|
+
await unlink(traceFile);
|
|
170
|
+
successCount++;
|
|
171
|
+
} catch (error) {
|
|
172
|
+
console.error(`Failed to update trace file ${traceFile}:`, error instanceof Error ? error.message : String(error));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
console.log(`Updated ${successCount} trace(s)`);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Main test command handler
|
|
181
|
+
*/
|
|
182
|
+
export async function handleTestCommand(
|
|
183
|
+
args: string[],
|
|
184
|
+
options: TestCommandOptions
|
|
185
|
+
): Promise<number> {
|
|
186
|
+
const tracesDir = options.tracesDir || resolve(process.cwd(), 'tests', 'golden-traces');
|
|
187
|
+
|
|
188
|
+
// Ensure traces directory exists
|
|
189
|
+
if (!existsSync(tracesDir)) {
|
|
190
|
+
await mkdir(tracesDir, { recursive: true });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
// Handle record command
|
|
195
|
+
if (options.record) {
|
|
196
|
+
// Load Fred instance
|
|
197
|
+
let fred!: Fred;
|
|
198
|
+
if (options.configFile) {
|
|
199
|
+
fred = new Fred();
|
|
200
|
+
await fred.initializeFromConfig(options.configFile);
|
|
201
|
+
} else {
|
|
202
|
+
// Try to find default config
|
|
203
|
+
const defaultConfigs = ['fred.config.yaml', 'fred.config.yml', 'fred.config.json'];
|
|
204
|
+
let configFound = false;
|
|
205
|
+
|
|
206
|
+
for (const configFile of defaultConfigs) {
|
|
207
|
+
if (existsSync(configFile)) {
|
|
208
|
+
fred = new Fred();
|
|
209
|
+
await fred.initializeFromConfig(configFile);
|
|
210
|
+
configFound = true;
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!configFound) {
|
|
216
|
+
console.error('No config file found. Use --config to specify one.');
|
|
217
|
+
return 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
await recordTrace(options.record, fred, tracesDir);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Handle update command
|
|
226
|
+
if (options.update) {
|
|
227
|
+
// Load Fred instance
|
|
228
|
+
let fred!: Fred;
|
|
229
|
+
if (options.configFile) {
|
|
230
|
+
fred = new Fred();
|
|
231
|
+
await fred.initializeFromConfig(options.configFile);
|
|
232
|
+
} else {
|
|
233
|
+
const defaultConfigs = ['fred.config.yaml', 'fred.config.yml', 'fred.config.json'];
|
|
234
|
+
let configFound = false;
|
|
235
|
+
|
|
236
|
+
for (const configFile of defaultConfigs) {
|
|
237
|
+
if (existsSync(configFile)) {
|
|
238
|
+
fred = new Fred();
|
|
239
|
+
await fred.initializeFromConfig(configFile);
|
|
240
|
+
configFound = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (!configFound) {
|
|
246
|
+
console.error('No config file found. Use --config to specify one.');
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await updateTraces(tracesDir, fred, options.pattern);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Handle run command (default)
|
|
256
|
+
const success = await runTests(tracesDir, options.pattern);
|
|
257
|
+
return success ? 0 : 1;
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error('Error running tests:', error instanceof Error ? error.message : String(error));
|
|
260
|
+
return 1;
|
|
261
|
+
}
|
|
262
|
+
}
|