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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for manifest.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
|
+
validateManifest,
|
|
11
|
+
loadManifestOrThrow,
|
|
12
|
+
tryLoadManifest,
|
|
13
|
+
findManifest,
|
|
14
|
+
createManifest,
|
|
15
|
+
MANIFEST_FILENAME,
|
|
16
|
+
ManifestValidationError,
|
|
17
|
+
ManifestNotFoundError,
|
|
18
|
+
ManifestParseError
|
|
19
|
+
} from './manifest.js';
|
|
20
|
+
|
|
21
|
+
describe('manifest', () => {
|
|
22
|
+
let tempDir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-manifest-test-'));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('validateManifest', () => {
|
|
33
|
+
test('accepts valid minimal manifest', () => {
|
|
34
|
+
const manifest = validateManifest({
|
|
35
|
+
name: 'my-tool',
|
|
36
|
+
version: '1.0.0',
|
|
37
|
+
entry: 'run.ts'
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(manifest.name).toBe('my-tool');
|
|
41
|
+
expect(manifest.version).toBe('1.0.0');
|
|
42
|
+
expect(manifest.entry).toBe('run.ts');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('accepts valid full manifest', () => {
|
|
46
|
+
const manifest = validateManifest({
|
|
47
|
+
name: 'github',
|
|
48
|
+
version: '2.1.0',
|
|
49
|
+
entry: 'run.ts',
|
|
50
|
+
description: 'GitHub CLI wrapper',
|
|
51
|
+
author: 'cliforai',
|
|
52
|
+
license: 'MIT',
|
|
53
|
+
runtime: 'bun',
|
|
54
|
+
commands: {
|
|
55
|
+
notifs: { description: 'Your notifications' },
|
|
56
|
+
repos: {
|
|
57
|
+
description: 'List repos',
|
|
58
|
+
args: [{ name: 'user', required: false }]
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
env: {
|
|
62
|
+
GITHUB_TOKEN: { required: false, description: 'Token' }
|
|
63
|
+
},
|
|
64
|
+
mcp: { enabled: true }
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(manifest.name).toBe('github');
|
|
68
|
+
expect(manifest.commands?.notifs.description).toBe('Your notifications');
|
|
69
|
+
expect(manifest.mcp?.enabled).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('accepts prerelease versions', () => {
|
|
73
|
+
const manifest = validateManifest({
|
|
74
|
+
name: 'tool',
|
|
75
|
+
version: '1.0.0-beta.1',
|
|
76
|
+
entry: 'run.ts'
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(manifest.version).toBe('1.0.0-beta.1');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('accepts all valid runtimes', () => {
|
|
83
|
+
for (const runtime of ['bun', 'node'] as const) {
|
|
84
|
+
const manifest = validateManifest({
|
|
85
|
+
name: 'tool',
|
|
86
|
+
version: '1.0.0',
|
|
87
|
+
entry: 'run.ts',
|
|
88
|
+
runtime
|
|
89
|
+
});
|
|
90
|
+
expect(manifest.runtime).toBe(runtime);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('rejects null manifest', () => {
|
|
95
|
+
expect(() => validateManifest(null)).toThrow();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('rejects non-object manifest', () => {
|
|
99
|
+
expect(() => validateManifest('string')).toThrow();
|
|
100
|
+
expect(() => validateManifest(123)).toThrow();
|
|
101
|
+
expect(() => validateManifest([])).toThrow();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('rejects missing name', () => {
|
|
105
|
+
expect(() => validateManifest({
|
|
106
|
+
version: '1.0.0',
|
|
107
|
+
entry: 'run.ts'
|
|
108
|
+
})).toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('rejects invalid name format', () => {
|
|
112
|
+
expect(() => validateManifest({
|
|
113
|
+
name: 'Invalid-Name',
|
|
114
|
+
version: '1.0.0',
|
|
115
|
+
entry: 'run.ts'
|
|
116
|
+
})).toThrow();
|
|
117
|
+
|
|
118
|
+
expect(() => validateManifest({
|
|
119
|
+
name: '123invalid',
|
|
120
|
+
version: '1.0.0',
|
|
121
|
+
entry: 'run.ts'
|
|
122
|
+
})).toThrow();
|
|
123
|
+
|
|
124
|
+
expect(() => validateManifest({
|
|
125
|
+
name: 'has_underscore',
|
|
126
|
+
version: '1.0.0',
|
|
127
|
+
entry: 'run.ts'
|
|
128
|
+
})).toThrow();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('rejects missing version', () => {
|
|
132
|
+
expect(() => validateManifest({
|
|
133
|
+
name: 'tool',
|
|
134
|
+
entry: 'run.ts'
|
|
135
|
+
})).toThrow();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('rejects invalid version format', () => {
|
|
139
|
+
expect(() => validateManifest({
|
|
140
|
+
name: 'tool',
|
|
141
|
+
version: 'invalid',
|
|
142
|
+
entry: 'run.ts'
|
|
143
|
+
})).toThrow();
|
|
144
|
+
|
|
145
|
+
expect(() => validateManifest({
|
|
146
|
+
name: 'tool',
|
|
147
|
+
version: '1.0',
|
|
148
|
+
entry: 'run.ts'
|
|
149
|
+
})).toThrow();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('rejects missing entry', () => {
|
|
153
|
+
expect(() => validateManifest({
|
|
154
|
+
name: 'tool',
|
|
155
|
+
version: '1.0.0'
|
|
156
|
+
})).toThrow();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('rejects empty entry', () => {
|
|
160
|
+
expect(() => validateManifest({
|
|
161
|
+
name: 'tool',
|
|
162
|
+
version: '1.0.0',
|
|
163
|
+
entry: ''
|
|
164
|
+
})).toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('rejects invalid runtime', () => {
|
|
168
|
+
expect(() => validateManifest({
|
|
169
|
+
name: 'tool',
|
|
170
|
+
version: '1.0.0',
|
|
171
|
+
entry: 'run.ts',
|
|
172
|
+
runtime: 'python'
|
|
173
|
+
})).toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('rejects commands without description', () => {
|
|
177
|
+
expect(() => validateManifest({
|
|
178
|
+
name: 'tool',
|
|
179
|
+
version: '1.0.0',
|
|
180
|
+
entry: 'run.ts',
|
|
181
|
+
commands: {
|
|
182
|
+
mycommand: {}
|
|
183
|
+
}
|
|
184
|
+
})).toThrow();
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe('loadManifestOrThrow', () => {
|
|
189
|
+
test('loads valid manifest from directory', () => {
|
|
190
|
+
writeFileSync(
|
|
191
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
192
|
+
JSON.stringify({
|
|
193
|
+
name: 'test-tool',
|
|
194
|
+
version: '1.0.0',
|
|
195
|
+
entry: 'run.ts'
|
|
196
|
+
})
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const manifest = loadManifestOrThrow(tempDir);
|
|
200
|
+
expect(manifest.name).toBe('test-tool');
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('throws ManifestNotFoundError when manifest not found', () => {
|
|
204
|
+
expect(() => loadManifestOrThrow(tempDir)).toThrow(ManifestNotFoundError);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('throws ManifestParseError on invalid JSON', () => {
|
|
208
|
+
writeFileSync(join(tempDir, MANIFEST_FILENAME), 'not json');
|
|
209
|
+
expect(() => loadManifestOrThrow(tempDir)).toThrow(ManifestParseError);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('throws ManifestValidationError on invalid manifest content', () => {
|
|
213
|
+
writeFileSync(
|
|
214
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
215
|
+
JSON.stringify({ name: 'Invalid' }) // Missing version, entry
|
|
216
|
+
);
|
|
217
|
+
expect(() => loadManifestOrThrow(tempDir)).toThrow(ManifestValidationError);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
describe('tryLoadManifest', () => {
|
|
222
|
+
test('returns manifest when valid', () => {
|
|
223
|
+
writeFileSync(
|
|
224
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
name: 'test-tool',
|
|
227
|
+
version: '1.0.0',
|
|
228
|
+
entry: 'run.ts'
|
|
229
|
+
})
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const manifest = tryLoadManifest(tempDir);
|
|
233
|
+
expect(manifest).not.toBeNull();
|
|
234
|
+
expect(manifest?.name).toBe('test-tool');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('returns null when not found', () => {
|
|
238
|
+
const manifest = tryLoadManifest(tempDir);
|
|
239
|
+
expect(manifest).toBeNull();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('returns null on invalid JSON', () => {
|
|
243
|
+
writeFileSync(join(tempDir, MANIFEST_FILENAME), 'not json');
|
|
244
|
+
const manifest = tryLoadManifest(tempDir);
|
|
245
|
+
expect(manifest).toBeNull();
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test('returns null on invalid manifest', () => {
|
|
249
|
+
writeFileSync(
|
|
250
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
251
|
+
JSON.stringify({ name: 'Invalid' })
|
|
252
|
+
);
|
|
253
|
+
const manifest = tryLoadManifest(tempDir);
|
|
254
|
+
expect(manifest).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
describe('findManifest', () => {
|
|
259
|
+
test('finds manifest in current directory', () => {
|
|
260
|
+
writeFileSync(
|
|
261
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
262
|
+
JSON.stringify({
|
|
263
|
+
name: 'found-tool',
|
|
264
|
+
version: '1.0.0',
|
|
265
|
+
entry: 'run.ts'
|
|
266
|
+
})
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
const result = findManifest(tempDir);
|
|
270
|
+
expect(result).not.toBeNull();
|
|
271
|
+
expect(result?.manifest.name).toBe('found-tool');
|
|
272
|
+
expect(result?.dir).toBe(tempDir);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('finds manifest in parent directory', () => {
|
|
276
|
+
const childDir = join(tempDir, 'child');
|
|
277
|
+
mkdirSync(childDir);
|
|
278
|
+
|
|
279
|
+
writeFileSync(
|
|
280
|
+
join(tempDir, MANIFEST_FILENAME),
|
|
281
|
+
JSON.stringify({
|
|
282
|
+
name: 'parent-tool',
|
|
283
|
+
version: '1.0.0',
|
|
284
|
+
entry: 'run.ts'
|
|
285
|
+
})
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
const result = findManifest(childDir);
|
|
289
|
+
expect(result).not.toBeNull();
|
|
290
|
+
expect(result?.manifest.name).toBe('parent-tool');
|
|
291
|
+
expect(result?.dir).toBe(tempDir);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('returns null when no manifest found', () => {
|
|
295
|
+
const result = findManifest(tempDir);
|
|
296
|
+
expect(result).toBeNull();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe('createManifest', () => {
|
|
301
|
+
test('creates minimal manifest', () => {
|
|
302
|
+
const manifest = createManifest('my-tool');
|
|
303
|
+
|
|
304
|
+
expect(manifest.name).toBe('my-tool');
|
|
305
|
+
expect(manifest.version).toBe('1.0.0');
|
|
306
|
+
expect(manifest.entry).toBe('run.ts');
|
|
307
|
+
expect(manifest.runtime).toBe('bun');
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test('normalizes name', () => {
|
|
311
|
+
const manifest = createManifest('My Tool Name');
|
|
312
|
+
expect(manifest.name).toBe('my-tool-name');
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test('accepts options', () => {
|
|
316
|
+
const manifest = createManifest('tool', {
|
|
317
|
+
version: '2.0.0',
|
|
318
|
+
description: 'Custom description',
|
|
319
|
+
runtime: 'node'
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(manifest.version).toBe('2.0.0');
|
|
323
|
+
expect(manifest.description).toBe('Custom description');
|
|
324
|
+
expect(manifest.runtime).toBe('node');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
});
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli4ai.json manifest validation and parsing
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync, existsSync } from 'fs';
|
|
6
|
+
import { resolve, dirname, basename } from 'path';
|
|
7
|
+
import { outputError } from '../lib/cli.js';
|
|
8
|
+
|
|
9
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
10
|
+
// ERRORS
|
|
11
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
|
+
|
|
13
|
+
export class ManifestValidationError extends Error {
|
|
14
|
+
constructor(
|
|
15
|
+
message: string,
|
|
16
|
+
public details?: Record<string, unknown>
|
|
17
|
+
) {
|
|
18
|
+
super(message);
|
|
19
|
+
this.name = 'ManifestValidationError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
// TYPES
|
|
25
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
|
+
|
|
27
|
+
export interface CommandArg {
|
|
28
|
+
name: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
required?: boolean;
|
|
31
|
+
type?: 'string' | 'number' | 'boolean';
|
|
32
|
+
default?: string | number | boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface CommandOption {
|
|
36
|
+
name: string;
|
|
37
|
+
short?: string;
|
|
38
|
+
description?: string;
|
|
39
|
+
type?: 'string' | 'number' | 'boolean';
|
|
40
|
+
default?: string | number | boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface CommandDef {
|
|
44
|
+
description: string;
|
|
45
|
+
args?: CommandArg[];
|
|
46
|
+
options?: CommandOption[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface EnvDef {
|
|
50
|
+
required?: boolean;
|
|
51
|
+
description?: string;
|
|
52
|
+
default?: string;
|
|
53
|
+
secret?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface McpConfig {
|
|
57
|
+
enabled?: boolean;
|
|
58
|
+
transport?: 'stdio' | 'http';
|
|
59
|
+
port?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface Manifest {
|
|
63
|
+
// Required fields
|
|
64
|
+
name: string;
|
|
65
|
+
version: string;
|
|
66
|
+
entry: string;
|
|
67
|
+
|
|
68
|
+
// Optional metadata
|
|
69
|
+
description?: string;
|
|
70
|
+
author?: string;
|
|
71
|
+
license?: string;
|
|
72
|
+
repository?: string;
|
|
73
|
+
homepage?: string;
|
|
74
|
+
keywords?: string[];
|
|
75
|
+
|
|
76
|
+
// Runtime (bun or node only - deno not supported)
|
|
77
|
+
runtime?: 'bun' | 'node';
|
|
78
|
+
|
|
79
|
+
// Commands (for MCP generation)
|
|
80
|
+
commands?: Record<string, CommandDef>;
|
|
81
|
+
|
|
82
|
+
// Environment variables
|
|
83
|
+
env?: Record<string, EnvDef>;
|
|
84
|
+
|
|
85
|
+
// Dependencies
|
|
86
|
+
dependencies?: Record<string, string>;
|
|
87
|
+
peerDependencies?: Record<string, string>;
|
|
88
|
+
|
|
89
|
+
// MCP configuration
|
|
90
|
+
mcp?: McpConfig;
|
|
91
|
+
|
|
92
|
+
// Scripts
|
|
93
|
+
scripts?: Record<string, string>;
|
|
94
|
+
|
|
95
|
+
// Files to include when publishing
|
|
96
|
+
files?: string[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
100
|
+
// VALIDATION
|
|
101
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
102
|
+
|
|
103
|
+
const NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
|
|
104
|
+
const VERSION_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/;
|
|
105
|
+
|
|
106
|
+
export function validateManifest(manifest: unknown, source?: string): Manifest {
|
|
107
|
+
if (!manifest || typeof manifest !== 'object') {
|
|
108
|
+
throw new ManifestValidationError('Manifest must be an object', { source });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const m = manifest as Record<string, unknown>;
|
|
112
|
+
|
|
113
|
+
// Required: name
|
|
114
|
+
if (typeof m.name !== 'string' || !NAME_PATTERN.test(m.name)) {
|
|
115
|
+
throw new ManifestValidationError('Invalid or missing "name" (lowercase letters, numbers, hyphens)', {
|
|
116
|
+
source,
|
|
117
|
+
got: m.name
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Required: version
|
|
122
|
+
if (typeof m.version !== 'string' || !VERSION_PATTERN.test(m.version)) {
|
|
123
|
+
throw new ManifestValidationError('Invalid or missing "version" (semver format: x.y.z)', {
|
|
124
|
+
source,
|
|
125
|
+
got: m.version
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Required: entry
|
|
130
|
+
if (typeof m.entry !== 'string' || m.entry.length === 0) {
|
|
131
|
+
throw new ManifestValidationError('Invalid or missing "entry" (path to main file)', {
|
|
132
|
+
source,
|
|
133
|
+
got: m.entry
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Optional: runtime (deno not supported)
|
|
138
|
+
if (m.runtime !== undefined && !['bun', 'node'].includes(m.runtime as string)) {
|
|
139
|
+
throw new ManifestValidationError('Invalid "runtime" (must be bun or node)', {
|
|
140
|
+
source,
|
|
141
|
+
got: m.runtime
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Optional: commands
|
|
146
|
+
if (m.commands !== undefined) {
|
|
147
|
+
if (typeof m.commands !== 'object' || m.commands === null) {
|
|
148
|
+
throw new ManifestValidationError('Invalid "commands" (must be an object)', { source });
|
|
149
|
+
}
|
|
150
|
+
for (const [cmdName, cmdDef] of Object.entries(m.commands as object)) {
|
|
151
|
+
if (typeof cmdDef !== 'object' || cmdDef === null) {
|
|
152
|
+
throw new ManifestValidationError(`Invalid command definition for "${cmdName}"`, { source });
|
|
153
|
+
}
|
|
154
|
+
const def = cmdDef as Record<string, unknown>;
|
|
155
|
+
if (typeof def.description !== 'string') {
|
|
156
|
+
throw new ManifestValidationError(`Command "${cmdName}" missing description`, { source });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return manifest as Manifest;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
165
|
+
// LOADING
|
|
166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
167
|
+
|
|
168
|
+
export const MANIFEST_FILENAME = 'cli4ai.json';
|
|
169
|
+
|
|
170
|
+
export class ManifestNotFoundError extends Error {
|
|
171
|
+
constructor(public path: string) {
|
|
172
|
+
super(`No ${MANIFEST_FILENAME} found at ${path}`);
|
|
173
|
+
this.name = 'ManifestNotFoundError';
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export class ManifestParseError extends Error {
|
|
178
|
+
constructor(public path: string, message: string) {
|
|
179
|
+
super(message);
|
|
180
|
+
this.name = 'ManifestParseError';
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Load and validate cli4ai.json from a directory (throws on error)
|
|
186
|
+
* Use this for programmatic/testable usage
|
|
187
|
+
*/
|
|
188
|
+
export function loadManifestOrThrow(dir: string): Manifest {
|
|
189
|
+
const manifestPath = resolve(dir, MANIFEST_FILENAME);
|
|
190
|
+
|
|
191
|
+
if (!existsSync(manifestPath)) {
|
|
192
|
+
throw new ManifestNotFoundError(manifestPath);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let content: string;
|
|
196
|
+
try {
|
|
197
|
+
content = readFileSync(manifestPath, 'utf-8');
|
|
198
|
+
} catch (err) {
|
|
199
|
+
throw new ManifestParseError(manifestPath, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let data: unknown;
|
|
203
|
+
try {
|
|
204
|
+
data = JSON.parse(content);
|
|
205
|
+
} catch {
|
|
206
|
+
throw new ManifestParseError(manifestPath, 'Invalid JSON');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return validateManifest(data, manifestPath);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Load manifest from package.json (fallback for npm packages)
|
|
214
|
+
*/
|
|
215
|
+
export function loadFromPackageJson(dir: string): Manifest | null {
|
|
216
|
+
const pkgJsonPath = resolve(dir, 'package.json');
|
|
217
|
+
if (!existsSync(pkgJsonPath)) return null;
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const content = readFileSync(pkgJsonPath, 'utf-8');
|
|
221
|
+
const pkg = JSON.parse(content);
|
|
222
|
+
|
|
223
|
+
// Extract name without @cli4ai/ scope
|
|
224
|
+
const name = pkg.name?.replace('@cli4ai/', '') || basename(dir);
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
name,
|
|
228
|
+
version: pkg.version || '1.0.0',
|
|
229
|
+
entry: pkg.main || 'run.ts',
|
|
230
|
+
description: pkg.description,
|
|
231
|
+
author: pkg.author,
|
|
232
|
+
license: pkg.license,
|
|
233
|
+
runtime: 'bun',
|
|
234
|
+
keywords: pkg.keywords
|
|
235
|
+
};
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Load and validate cli4ai.json from a directory
|
|
243
|
+
* Falls back to package.json for npm packages
|
|
244
|
+
* Exits with error on failure (for CLI usage)
|
|
245
|
+
*/
|
|
246
|
+
export function loadManifest(dir: string): Manifest {
|
|
247
|
+
// Try cli4ai.json first
|
|
248
|
+
try {
|
|
249
|
+
return loadManifestOrThrow(dir);
|
|
250
|
+
} catch (err) {
|
|
251
|
+
if (err instanceof ManifestNotFoundError) {
|
|
252
|
+
// Fallback to package.json
|
|
253
|
+
const fromPkgJson = loadFromPackageJson(dir);
|
|
254
|
+
if (fromPkgJson) return fromPkgJson;
|
|
255
|
+
|
|
256
|
+
outputError('NOT_FOUND', `No ${MANIFEST_FILENAME} found`, {
|
|
257
|
+
path: err.path,
|
|
258
|
+
hint: 'Run "cli4ai init" to create one'
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (err instanceof ManifestParseError) {
|
|
262
|
+
outputError('PARSE_ERROR', err.message, { path: err.path });
|
|
263
|
+
}
|
|
264
|
+
if (err instanceof ManifestValidationError) {
|
|
265
|
+
outputError('MANIFEST_ERROR', err.message, err.details);
|
|
266
|
+
}
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Try to load manifest, return null if not found
|
|
273
|
+
*/
|
|
274
|
+
export function tryLoadManifest(dir: string): Manifest | null {
|
|
275
|
+
const manifestPath = resolve(dir, MANIFEST_FILENAME);
|
|
276
|
+
if (!existsSync(manifestPath)) return null;
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
const content = readFileSync(manifestPath, 'utf-8');
|
|
280
|
+
const data = JSON.parse(content);
|
|
281
|
+
return validateManifest(data, manifestPath);
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Find manifest by walking up directory tree
|
|
289
|
+
*/
|
|
290
|
+
export function findManifest(startDir?: string): { manifest: Manifest; dir: string } | null {
|
|
291
|
+
let dir = startDir || process.cwd();
|
|
292
|
+
|
|
293
|
+
for (let i = 0; i < 10; i++) {
|
|
294
|
+
const manifest = tryLoadManifest(dir);
|
|
295
|
+
if (manifest) {
|
|
296
|
+
return { manifest, dir };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const parent = dirname(dir);
|
|
300
|
+
if (parent === dir) break;
|
|
301
|
+
dir = parent;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return null;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Create a minimal manifest
|
|
309
|
+
*/
|
|
310
|
+
export function createManifest(name: string, options: Partial<Manifest> = {}): Manifest {
|
|
311
|
+
return {
|
|
312
|
+
name: name.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
|
313
|
+
version: '1.0.0',
|
|
314
|
+
entry: 'run.ts',
|
|
315
|
+
runtime: 'bun',
|
|
316
|
+
description: options.description || `${name} tool`,
|
|
317
|
+
...options
|
|
318
|
+
};
|
|
319
|
+
}
|