cli4ai 0.8.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/README.md +275 -0
- package/package.json +49 -0
- package/src/bin.ts +120 -0
- package/src/cli.ts +256 -0
- package/src/commands/add.ts +530 -0
- package/src/commands/browse.ts +449 -0
- package/src/commands/config.ts +126 -0
- package/src/commands/info.ts +102 -0
- package/src/commands/init.test.ts +163 -0
- package/src/commands/init.ts +560 -0
- package/src/commands/list.ts +89 -0
- package/src/commands/mcp-config.ts +59 -0
- package/src/commands/remove.ts +72 -0
- package/src/commands/routines.ts +393 -0
- package/src/commands/run.ts +45 -0
- package/src/commands/search.ts +148 -0
- package/src/commands/secrets.ts +273 -0
- package/src/commands/start.ts +40 -0
- package/src/commands/update.ts +218 -0
- package/src/core/config.test.ts +188 -0
- package/src/core/config.ts +649 -0
- package/src/core/execute.ts +507 -0
- package/src/core/link.test.ts +238 -0
- package/src/core/link.ts +190 -0
- package/src/core/lockfile.test.ts +337 -0
- package/src/core/lockfile.ts +308 -0
- package/src/core/manifest.test.ts +327 -0
- package/src/core/manifest.ts +319 -0
- package/src/core/routine-engine.test.ts +139 -0
- package/src/core/routine-engine.ts +725 -0
- package/src/core/routines.ts +111 -0
- package/src/core/secrets.test.ts +79 -0
- package/src/core/secrets.ts +430 -0
- package/src/lib/cli.ts +234 -0
- package/src/mcp/adapter.test.ts +132 -0
- package/src/mcp/adapter.ts +123 -0
- package/src/mcp/config-gen.test.ts +214 -0
- package/src/mcp/config-gen.ts +106 -0
- package/src/mcp/server.ts +363 -0
package/src/lib/cli.ts
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai - cliforai.com
|
|
3
|
+
* Standardized CLI framework for AI agent tools
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
|
|
8
|
+
export const BRAND = 'cli4ai - cliforai.com';
|
|
9
|
+
export const VERSION = '0.7.17';
|
|
10
|
+
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
// TYPES
|
|
13
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
|
+
|
|
15
|
+
export interface CLIError {
|
|
16
|
+
error: string;
|
|
17
|
+
message: string;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type CommandFn = (...args: unknown[]) => Promise<void> | void;
|
|
22
|
+
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
// ERROR CODES
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
export const ErrorCodes = {
|
|
28
|
+
ENV_MISSING: 'ENV_MISSING',
|
|
29
|
+
INVALID_INPUT: 'INVALID_INPUT',
|
|
30
|
+
NOT_FOUND: 'NOT_FOUND',
|
|
31
|
+
AUTH_FAILED: 'AUTH_FAILED',
|
|
32
|
+
API_ERROR: 'API_ERROR',
|
|
33
|
+
NETWORK_ERROR: 'NETWORK_ERROR',
|
|
34
|
+
RATE_LIMITED: 'RATE_LIMITED',
|
|
35
|
+
TIMEOUT: 'TIMEOUT',
|
|
36
|
+
PARSE_ERROR: 'PARSE_ERROR',
|
|
37
|
+
MANIFEST_ERROR: 'MANIFEST_ERROR',
|
|
38
|
+
INSTALL_ERROR: 'INSTALL_ERROR',
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
|
42
|
+
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
|
+
// ENV VALIDATION
|
|
45
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
46
|
+
|
|
47
|
+
// SECURITY NOTE: We intentionally do NOT auto-load .env files from the filesystem.
|
|
48
|
+
// This prevents supply chain attacks where malicious .env files in parent directories
|
|
49
|
+
// could inject credentials or override security settings.
|
|
50
|
+
//
|
|
51
|
+
// Use `cli4ai secrets set <key>` for secure credential storage, or set environment
|
|
52
|
+
// variables explicitly in your shell/CI environment.
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Require environment variables to be set. Exits with parseable error if missing.
|
|
56
|
+
*
|
|
57
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
58
|
+
*/
|
|
59
|
+
export function requireEnv(...variables: string[]): void {
|
|
60
|
+
const missing = variables.filter(v => !process.env[v]);
|
|
61
|
+
if (missing.length > 0) {
|
|
62
|
+
outputError('ENV_MISSING', `Missing required environment variables: ${missing.join(', ')}`, {
|
|
63
|
+
variables: missing,
|
|
64
|
+
hint: 'Use "cli4ai secrets set <key>" to store securely, or set in your shell environment'
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Get env var or exit with error
|
|
71
|
+
*
|
|
72
|
+
* NOTE: Does not auto-load .env files. Use `cli4ai secrets` for secure credential storage.
|
|
73
|
+
*/
|
|
74
|
+
export function env(name: string): string {
|
|
75
|
+
const value = process.env[name];
|
|
76
|
+
if (!value) {
|
|
77
|
+
outputError('ENV_MISSING', `Missing required environment variable: ${name}`, {
|
|
78
|
+
variables: [name],
|
|
79
|
+
hint: 'Use "cli4ai secrets set ' + name + '" to store securely, or set in your shell environment'
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get env var or return default
|
|
87
|
+
*/
|
|
88
|
+
export function envOr(name: string, defaultValue: string): string {
|
|
89
|
+
return process.env[name] || defaultValue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
93
|
+
// OUTPUT
|
|
94
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Output JSON data to stdout
|
|
98
|
+
*/
|
|
99
|
+
export function output(data: unknown): void {
|
|
100
|
+
console.log(JSON.stringify(data, null, 2));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Output error and exit. Format is parseable JSON.
|
|
105
|
+
*/
|
|
106
|
+
export function outputError(
|
|
107
|
+
code: string,
|
|
108
|
+
message: string,
|
|
109
|
+
details?: Record<string, unknown>
|
|
110
|
+
): never {
|
|
111
|
+
console.error(JSON.stringify({
|
|
112
|
+
error: code,
|
|
113
|
+
message,
|
|
114
|
+
...details
|
|
115
|
+
}));
|
|
116
|
+
process.exit(1);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Log to stderr (for progress messages)
|
|
121
|
+
*/
|
|
122
|
+
export function log(message: string): void {
|
|
123
|
+
console.error(message);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
127
|
+
// CLI CREATION
|
|
128
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a branded CLI program
|
|
132
|
+
*/
|
|
133
|
+
export function cli(name: string, version: string, description: string): Command {
|
|
134
|
+
const program = new Command()
|
|
135
|
+
.name(name)
|
|
136
|
+
.version(version, '-v, --version', 'Show version')
|
|
137
|
+
.description(description)
|
|
138
|
+
.addHelpText('beforeAll', `\n${BRAND}\n`)
|
|
139
|
+
.configureOutput({
|
|
140
|
+
writeErr: (str) => {
|
|
141
|
+
// Don't double-output errors
|
|
142
|
+
if (!str.includes('"error"')) {
|
|
143
|
+
process.stderr.write(str);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
.exitOverride((err) => {
|
|
148
|
+
if (err.code === 'commander.helpDisplayed' || err.code === 'commander.version') {
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
if (err.code === 'commander.missingArgument') {
|
|
152
|
+
outputError('INVALID_INPUT', err.message, { code: err.code });
|
|
153
|
+
}
|
|
154
|
+
if (err.code === 'commander.unknownCommand') {
|
|
155
|
+
outputError('INVALID_INPUT', err.message, { code: err.code });
|
|
156
|
+
}
|
|
157
|
+
if (err.code !== 'commander.help') {
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
program.addHelpCommand('help [command]', 'Show help for command');
|
|
163
|
+
|
|
164
|
+
return program;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
168
|
+
// ERROR HANDLING
|
|
169
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Wrap an async action with error handling
|
|
173
|
+
*/
|
|
174
|
+
export function withErrorHandling<T extends unknown[]>(
|
|
175
|
+
fn: (...args: T) => Promise<void>
|
|
176
|
+
): (...args: T) => Promise<void> {
|
|
177
|
+
return async (...args: T) => {
|
|
178
|
+
try {
|
|
179
|
+
await fn(...args);
|
|
180
|
+
} catch (err) {
|
|
181
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
182
|
+
outputError('API_ERROR', message);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
188
|
+
// UTILITY HELPERS
|
|
189
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Parse a string as JSON, or return error
|
|
193
|
+
*/
|
|
194
|
+
export function parseJson<T>(str: string, context?: string): T {
|
|
195
|
+
try {
|
|
196
|
+
return JSON.parse(str);
|
|
197
|
+
} catch {
|
|
198
|
+
outputError('PARSE_ERROR', `Invalid JSON${context ? ` in ${context}` : ''}`, {
|
|
199
|
+
input: str.slice(0, 100)
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Sleep for ms milliseconds
|
|
206
|
+
*/
|
|
207
|
+
export const sleep = (ms: number): Promise<void> =>
|
|
208
|
+
new Promise(r => setTimeout(r, ms));
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Format bytes to human readable
|
|
212
|
+
*/
|
|
213
|
+
export function formatBytes(bytes: number): string {
|
|
214
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
215
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
216
|
+
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
217
|
+
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Format date to ISO string (date only)
|
|
222
|
+
*/
|
|
223
|
+
export function formatDate(date: Date | number | string): string {
|
|
224
|
+
const d = new Date(date);
|
|
225
|
+
return d.toISOString().slice(0, 10);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Format date to ISO string (datetime)
|
|
230
|
+
*/
|
|
231
|
+
export function formatDateTime(date: Date | number | string): string {
|
|
232
|
+
const d = new Date(date);
|
|
233
|
+
return d.toISOString().slice(0, 19).replace('T', ' ');
|
|
234
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for mcp/adapter.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect } from 'bun:test';
|
|
6
|
+
import {
|
|
7
|
+
manifestToMcpTools,
|
|
8
|
+
commandToMcpTool,
|
|
9
|
+
type McpTool
|
|
10
|
+
} from './adapter.js';
|
|
11
|
+
import type { Manifest, CommandDef } from '../core/manifest.js';
|
|
12
|
+
|
|
13
|
+
describe('mcp/adapter', () => {
|
|
14
|
+
describe('commandToMcpTool', () => {
|
|
15
|
+
test('converts simple command', () => {
|
|
16
|
+
const manifest: Manifest = {
|
|
17
|
+
name: 'github',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
entry: 'run.ts'
|
|
20
|
+
};
|
|
21
|
+
const cmd: CommandDef = {
|
|
22
|
+
description: 'Get notifications'
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const tool = commandToMcpTool(manifest, 'notifs', cmd);
|
|
26
|
+
|
|
27
|
+
expect(tool.name).toBe('github_notifs');
|
|
28
|
+
expect(tool.description).toContain('Get notifications');
|
|
29
|
+
expect(tool.inputSchema.type).toBe('object');
|
|
30
|
+
expect(tool.inputSchema.properties).toEqual({});
|
|
31
|
+
expect(tool.inputSchema.required).toEqual([]);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('converts command with args', () => {
|
|
35
|
+
const manifest: Manifest = {
|
|
36
|
+
name: 'search',
|
|
37
|
+
version: '1.0.0',
|
|
38
|
+
entry: 'run.ts'
|
|
39
|
+
};
|
|
40
|
+
const cmd: CommandDef = {
|
|
41
|
+
description: 'Find items',
|
|
42
|
+
args: [
|
|
43
|
+
{ name: 'query', description: 'Search query', required: true },
|
|
44
|
+
{ name: 'limit', description: 'Max results', required: false }
|
|
45
|
+
]
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const tool = commandToMcpTool(manifest, 'find', cmd);
|
|
49
|
+
|
|
50
|
+
expect(tool.inputSchema.properties.query).toBeDefined();
|
|
51
|
+
expect(tool.inputSchema.properties.query.description).toBe('Search query');
|
|
52
|
+
expect(tool.inputSchema.properties.limit).toBeDefined();
|
|
53
|
+
expect(tool.inputSchema.required).toEqual(['query']);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('uses manifest name as prefix', () => {
|
|
57
|
+
const manifest: Manifest = {
|
|
58
|
+
name: 'my-tool',
|
|
59
|
+
version: '1.0.0',
|
|
60
|
+
entry: 'run.ts'
|
|
61
|
+
};
|
|
62
|
+
const cmd: CommandDef = { description: 'Do action' };
|
|
63
|
+
|
|
64
|
+
const tool = commandToMcpTool(manifest, 'action', cmd);
|
|
65
|
+
|
|
66
|
+
expect(tool.name).toBe('my-tool_action');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('manifestToMcpTools', () => {
|
|
71
|
+
test('returns empty array when no commands', () => {
|
|
72
|
+
const manifest: Manifest = {
|
|
73
|
+
name: 'tool',
|
|
74
|
+
version: '1.0.0',
|
|
75
|
+
entry: 'run.ts'
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const tools = manifestToMcpTools(manifest);
|
|
79
|
+
expect(tools).toEqual([]);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('returns empty array for empty commands', () => {
|
|
83
|
+
const manifest: Manifest = {
|
|
84
|
+
name: 'tool',
|
|
85
|
+
version: '1.0.0',
|
|
86
|
+
entry: 'run.ts',
|
|
87
|
+
commands: {}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const tools = manifestToMcpTools(manifest);
|
|
91
|
+
expect(tools).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('converts single command', () => {
|
|
95
|
+
const manifest: Manifest = {
|
|
96
|
+
name: 'github',
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
entry: 'run.ts',
|
|
99
|
+
commands: {
|
|
100
|
+
notifs: { description: 'Get notifications' }
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const tools = manifestToMcpTools(manifest);
|
|
105
|
+
|
|
106
|
+
expect(tools).toHaveLength(1);
|
|
107
|
+
expect(tools[0].name).toBe('github_notifs');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('converts multiple commands', () => {
|
|
111
|
+
const manifest: Manifest = {
|
|
112
|
+
name: 'api',
|
|
113
|
+
version: '1.0.0',
|
|
114
|
+
entry: 'run.ts',
|
|
115
|
+
commands: {
|
|
116
|
+
list: { description: 'List items' },
|
|
117
|
+
get: { description: 'Get item' },
|
|
118
|
+
create: { description: 'Create item' }
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const tools = manifestToMcpTools(manifest);
|
|
123
|
+
|
|
124
|
+
expect(tools).toHaveLength(3);
|
|
125
|
+
expect(tools.map(t => t.name).sort()).toEqual([
|
|
126
|
+
'api_create',
|
|
127
|
+
'api_get',
|
|
128
|
+
'api_list'
|
|
129
|
+
]);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Adapter - Converts CLI tools to MCP tools
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
import { type Manifest, type CommandDef } from '../core/manifest.js';
|
|
7
|
+
|
|
8
|
+
export interface McpTool {
|
|
9
|
+
name: string;
|
|
10
|
+
description: string;
|
|
11
|
+
inputSchema: {
|
|
12
|
+
type: 'object';
|
|
13
|
+
properties: Record<string, { type: string; description?: string }>;
|
|
14
|
+
required: string[];
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface McpToolResult {
|
|
19
|
+
content: Array<{ type: 'text'; text: string }>;
|
|
20
|
+
isError?: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Convert a CLI command definition to MCP tool format
|
|
25
|
+
*/
|
|
26
|
+
export function commandToMcpTool(manifest: Manifest, cmdName: string, cmd: CommandDef): McpTool {
|
|
27
|
+
const properties: Record<string, { type: string; description?: string }> = {};
|
|
28
|
+
const required: string[] = [];
|
|
29
|
+
|
|
30
|
+
// Convert args to JSON Schema properties
|
|
31
|
+
if (cmd.args) {
|
|
32
|
+
for (const arg of cmd.args) {
|
|
33
|
+
properties[arg.name] = {
|
|
34
|
+
type: 'string',
|
|
35
|
+
description: arg.description
|
|
36
|
+
};
|
|
37
|
+
if (arg.required) {
|
|
38
|
+
required.push(arg.name);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
name: `${manifest.name}_${cmdName}`,
|
|
45
|
+
description: `${manifest.name}: ${cmd.description || cmdName}`,
|
|
46
|
+
inputSchema: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties,
|
|
49
|
+
required
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Convert all commands in a manifest to MCP tools
|
|
56
|
+
*/
|
|
57
|
+
export function manifestToMcpTools(manifest: Manifest): McpTool[] {
|
|
58
|
+
const tools: McpTool[] = [];
|
|
59
|
+
|
|
60
|
+
if (manifest.commands) {
|
|
61
|
+
for (const [cmdName, cmdDef] of Object.entries(manifest.commands)) {
|
|
62
|
+
tools.push(commandToMcpTool(manifest, cmdName, cmdDef));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return tools;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Execute a CLI tool command and return MCP-formatted result
|
|
71
|
+
*/
|
|
72
|
+
export async function executeTool(
|
|
73
|
+
entryPath: string,
|
|
74
|
+
runtime: string,
|
|
75
|
+
command: string,
|
|
76
|
+
args: Record<string, string>
|
|
77
|
+
): Promise<McpToolResult> {
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
// Build command arguments
|
|
80
|
+
const cmdArgs = [command];
|
|
81
|
+
for (const [key, value] of Object.entries(args)) {
|
|
82
|
+
if (value !== undefined && value !== '') {
|
|
83
|
+
cmdArgs.push(value);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const proc = spawn(runtime, ['run', entryPath, ...cmdArgs], {
|
|
88
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
89
|
+
env: { ...process.env }
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
let stdout = '';
|
|
93
|
+
let stderr = '';
|
|
94
|
+
|
|
95
|
+
proc.stdout.on('data', (data) => {
|
|
96
|
+
stdout += data.toString();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
proc.stderr.on('data', (data) => {
|
|
100
|
+
stderr += data.toString();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
proc.on('close', (code) => {
|
|
104
|
+
if (code !== 0) {
|
|
105
|
+
resolve({
|
|
106
|
+
content: [{ type: 'text', text: stderr || `Command failed with exit code ${code}` }],
|
|
107
|
+
isError: true
|
|
108
|
+
});
|
|
109
|
+
} else {
|
|
110
|
+
resolve({
|
|
111
|
+
content: [{ type: 'text', text: stdout || 'Command completed successfully' }]
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
proc.on('error', (err) => {
|
|
117
|
+
resolve({
|
|
118
|
+
content: [{ type: 'text', text: `Failed to execute: ${err.message}` }],
|
|
119
|
+
isError: true
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
}
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for mcp/config-gen.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import { tmpdir } from 'os';
|
|
9
|
+
import {
|
|
10
|
+
generateServerConfig,
|
|
11
|
+
generateClaudeCodeConfig,
|
|
12
|
+
formatClaudeCodeConfig,
|
|
13
|
+
generateConfigSnippet
|
|
14
|
+
} from './config-gen.js';
|
|
15
|
+
import type { Manifest } from '../core/manifest.js';
|
|
16
|
+
|
|
17
|
+
describe('mcp/config-gen', () => {
|
|
18
|
+
let tempDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-config-gen-test-'));
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('generateServerConfig', () => {
|
|
29
|
+
test('generates config with cli4ai start command', () => {
|
|
30
|
+
const manifest: Manifest = {
|
|
31
|
+
name: 'github',
|
|
32
|
+
version: '1.0.0',
|
|
33
|
+
entry: 'run.ts'
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const config = generateServerConfig(manifest, '/path/to/package');
|
|
37
|
+
|
|
38
|
+
expect(config.command).toBe('cli4ai');
|
|
39
|
+
expect(config.args).toEqual(['start', 'github']);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('uses package name from manifest', () => {
|
|
43
|
+
const manifest: Manifest = {
|
|
44
|
+
name: 'custom-tool',
|
|
45
|
+
version: '2.0.0',
|
|
46
|
+
entry: 'index.ts'
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const config = generateServerConfig(manifest, '/any/path');
|
|
50
|
+
|
|
51
|
+
expect(config.args).toEqual(['start', 'custom-tool']);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe('formatClaudeCodeConfig', () => {
|
|
56
|
+
test('formats empty config', () => {
|
|
57
|
+
const config = { mcpServers: {} };
|
|
58
|
+
|
|
59
|
+
const output = formatClaudeCodeConfig(config);
|
|
60
|
+
const parsed = JSON.parse(output);
|
|
61
|
+
|
|
62
|
+
expect(parsed).toEqual({ mcpServers: {} });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('formats config with servers', () => {
|
|
66
|
+
const config = {
|
|
67
|
+
mcpServers: {
|
|
68
|
+
'cli4ai-github': {
|
|
69
|
+
command: 'cli4ai',
|
|
70
|
+
args: ['start', 'github']
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const output = formatClaudeCodeConfig(config);
|
|
76
|
+
const parsed = JSON.parse(output);
|
|
77
|
+
|
|
78
|
+
expect(parsed.mcpServers['cli4ai-github'].command).toBe('cli4ai');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('produces pretty-printed JSON', () => {
|
|
82
|
+
const config = { mcpServers: {} };
|
|
83
|
+
|
|
84
|
+
const output = formatClaudeCodeConfig(config);
|
|
85
|
+
|
|
86
|
+
expect(output).toContain('\n');
|
|
87
|
+
expect(output).toContain(' ');
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe('generateConfigSnippet', () => {
|
|
92
|
+
test('generates snippet for single package', () => {
|
|
93
|
+
const manifest: Manifest = {
|
|
94
|
+
name: 'slack',
|
|
95
|
+
version: '1.0.0',
|
|
96
|
+
entry: 'run.ts'
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const snippet = generateConfigSnippet(manifest, '/path/to/slack');
|
|
100
|
+
|
|
101
|
+
expect(snippet).toContain('"cli4ai-slack"');
|
|
102
|
+
expect(snippet).toContain('cli4ai');
|
|
103
|
+
expect(snippet).toContain('start');
|
|
104
|
+
expect(snippet).toContain('slack');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('snippet is valid JSON when wrapped', () => {
|
|
108
|
+
const manifest: Manifest = {
|
|
109
|
+
name: 'tool',
|
|
110
|
+
version: '1.0.0',
|
|
111
|
+
entry: 'run.ts'
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const snippet = generateConfigSnippet(manifest, '/path');
|
|
115
|
+
|
|
116
|
+
// Should be valid when wrapped in braces
|
|
117
|
+
const wrapped = `{${snippet}}`;
|
|
118
|
+
const parsed = JSON.parse(wrapped);
|
|
119
|
+
|
|
120
|
+
expect(parsed['cli4ai-tool']).toBeDefined();
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('generateClaudeCodeConfig', () => {
|
|
125
|
+
test('returns config with only local packages when no local packages exist', () => {
|
|
126
|
+
// Note: This may include global packages, so we just check it returns a valid config
|
|
127
|
+
const config = generateClaudeCodeConfig(tempDir);
|
|
128
|
+
|
|
129
|
+
expect(config.mcpServers).toBeDefined();
|
|
130
|
+
expect(typeof config.mcpServers).toBe('object');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('includes MCP-enabled packages', () => {
|
|
134
|
+
// Create a local package directory
|
|
135
|
+
const localDir = join(tempDir, '.cli4ai', 'packages', 'test-mcp');
|
|
136
|
+
mkdirSync(localDir, { recursive: true });
|
|
137
|
+
writeFileSync(
|
|
138
|
+
join(localDir, 'cli4ai.json'),
|
|
139
|
+
JSON.stringify({
|
|
140
|
+
name: 'test-mcp',
|
|
141
|
+
version: '1.0.0',
|
|
142
|
+
entry: 'run.ts',
|
|
143
|
+
mcp: { enabled: true }
|
|
144
|
+
})
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const config = generateClaudeCodeConfig(tempDir);
|
|
148
|
+
|
|
149
|
+
expect(config.mcpServers['cli4ai-test-mcp']).toBeDefined();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('excludes packages without MCP enabled', () => {
|
|
153
|
+
// Create a package without MCP
|
|
154
|
+
const localDir = join(tempDir, '.cli4ai', 'packages', 'no-mcp');
|
|
155
|
+
mkdirSync(localDir, { recursive: true });
|
|
156
|
+
writeFileSync(
|
|
157
|
+
join(localDir, 'cli4ai.json'),
|
|
158
|
+
JSON.stringify({
|
|
159
|
+
name: 'no-mcp',
|
|
160
|
+
version: '1.0.0',
|
|
161
|
+
entry: 'run.ts'
|
|
162
|
+
// No mcp field
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const config = generateClaudeCodeConfig(tempDir);
|
|
167
|
+
|
|
168
|
+
expect(config.mcpServers['cli4ai-no-mcp']).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test('excludes packages with mcp.enabled=false', () => {
|
|
172
|
+
const localDir = join(tempDir, '.cli4ai', 'packages', 'disabled-mcp');
|
|
173
|
+
mkdirSync(localDir, { recursive: true });
|
|
174
|
+
writeFileSync(
|
|
175
|
+
join(localDir, 'cli4ai.json'),
|
|
176
|
+
JSON.stringify({
|
|
177
|
+
name: 'disabled-mcp',
|
|
178
|
+
version: '1.0.0',
|
|
179
|
+
entry: 'run.ts',
|
|
180
|
+
mcp: { enabled: false }
|
|
181
|
+
})
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const config = generateClaudeCodeConfig(tempDir);
|
|
185
|
+
|
|
186
|
+
expect(config.mcpServers['cli4ai-disabled-mcp']).toBeUndefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('filters by specific packages', () => {
|
|
190
|
+
// Create multiple packages
|
|
191
|
+
for (const name of ['pkg-a', 'pkg-b', 'pkg-c']) {
|
|
192
|
+
const localDir = join(tempDir, '.cli4ai', 'packages', name);
|
|
193
|
+
mkdirSync(localDir, { recursive: true });
|
|
194
|
+
writeFileSync(
|
|
195
|
+
join(localDir, 'cli4ai.json'),
|
|
196
|
+
JSON.stringify({
|
|
197
|
+
name,
|
|
198
|
+
version: '1.0.0',
|
|
199
|
+
entry: 'run.ts',
|
|
200
|
+
mcp: { enabled: true }
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const config = generateClaudeCodeConfig(tempDir, {
|
|
206
|
+
packages: ['pkg-a', 'pkg-c']
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(config.mcpServers['cli4ai-pkg-a']).toBeDefined();
|
|
210
|
+
expect(config.mcpServers['cli4ai-pkg-b']).toBeUndefined();
|
|
211
|
+
expect(config.mcpServers['cli4ai-pkg-c']).toBeDefined();
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
});
|