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,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for link.ts
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
|
6
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, existsSync, statSync, mkdirSync } from 'fs';
|
|
7
|
+
import { join, resolve } from 'path';
|
|
8
|
+
import { tmpdir, homedir } from 'os';
|
|
9
|
+
import {
|
|
10
|
+
ensureBinDir,
|
|
11
|
+
linkPackage,
|
|
12
|
+
linkPackageDirect,
|
|
13
|
+
unlinkPackage,
|
|
14
|
+
isPackageLinked,
|
|
15
|
+
getPathInstructions,
|
|
16
|
+
isBinInPath,
|
|
17
|
+
C4AI_BIN
|
|
18
|
+
} from './link.js';
|
|
19
|
+
import type { Manifest } from './manifest.js';
|
|
20
|
+
|
|
21
|
+
describe('link', () => {
|
|
22
|
+
let tempDir: string;
|
|
23
|
+
let testBinDir: string;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
tempDir = mkdtempSync(join(tmpdir(), 'cli4ai-link-test-'));
|
|
27
|
+
testBinDir = join(tempDir, 'bin');
|
|
28
|
+
mkdirSync(testBinDir);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const createMockManifest = (name: string, version = '1.0.0'): Manifest => ({
|
|
36
|
+
name,
|
|
37
|
+
version,
|
|
38
|
+
entry: 'run.ts',
|
|
39
|
+
runtime: 'bun'
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('C4AI_BIN constant', () => {
|
|
43
|
+
test('is in home directory', () => {
|
|
44
|
+
expect(C4AI_BIN).toBe(resolve(homedir(), '.cli4ai', 'bin'));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('ensureBinDir', () => {
|
|
49
|
+
test('creates bin directory if not exists', () => {
|
|
50
|
+
// This test affects real filesystem, so we just verify function doesn't throw
|
|
51
|
+
expect(() => ensureBinDir()).not.toThrow();
|
|
52
|
+
expect(existsSync(C4AI_BIN)).toBe(true);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe('linkPackage', () => {
|
|
57
|
+
test('creates wrapper script', () => {
|
|
58
|
+
const manifest = createMockManifest('test-tool');
|
|
59
|
+
const packagePath = join(tempDir, 'test-tool');
|
|
60
|
+
|
|
61
|
+
const binPath = linkPackage(manifest, packagePath);
|
|
62
|
+
|
|
63
|
+
expect(existsSync(binPath)).toBe(true);
|
|
64
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
65
|
+
expect(content).toContain('#!/bin/sh');
|
|
66
|
+
// SECURITY: Package names are shell-escaped with single quotes
|
|
67
|
+
expect(content).toContain("cli4ai run 'test-tool'");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test('makes script executable', () => {
|
|
71
|
+
const manifest = createMockManifest('test-tool');
|
|
72
|
+
const packagePath = join(tempDir, 'test-tool');
|
|
73
|
+
|
|
74
|
+
const binPath = linkPackage(manifest, packagePath);
|
|
75
|
+
|
|
76
|
+
const stat = statSync(binPath);
|
|
77
|
+
// Check executable bit (0o755 = rwxr-xr-x)
|
|
78
|
+
const isExecutable = (stat.mode & 0o111) !== 0;
|
|
79
|
+
expect(isExecutable).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('includes version in comment', () => {
|
|
83
|
+
const manifest = createMockManifest('my-tool', '2.5.0');
|
|
84
|
+
const packagePath = join(tempDir, 'my-tool');
|
|
85
|
+
|
|
86
|
+
const binPath = linkPackage(manifest, packagePath);
|
|
87
|
+
|
|
88
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
89
|
+
// SECURITY: Version is shell-escaped in comment
|
|
90
|
+
expect(content).toContain("'my-tool'@'2.5.0'");
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('linkPackageDirect', () => {
|
|
95
|
+
test('creates direct wrapper script', () => {
|
|
96
|
+
const manifest = createMockManifest('direct-tool');
|
|
97
|
+
const packagePath = join(tempDir, 'direct-tool');
|
|
98
|
+
|
|
99
|
+
const binPath = linkPackageDirect(manifest, packagePath);
|
|
100
|
+
|
|
101
|
+
expect(existsSync(binPath)).toBe(true);
|
|
102
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
103
|
+
expect(content).toContain('#!/bin/sh');
|
|
104
|
+
expect(content).toContain('bun run');
|
|
105
|
+
expect(content).toContain('run.ts');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('uses correct runtime from manifest (node)', () => {
|
|
109
|
+
const manifest: Manifest = {
|
|
110
|
+
name: 'node-tool',
|
|
111
|
+
version: '1.0.0',
|
|
112
|
+
entry: 'index.js',
|
|
113
|
+
runtime: 'node'
|
|
114
|
+
};
|
|
115
|
+
const packagePath = join(tempDir, 'node-tool');
|
|
116
|
+
|
|
117
|
+
const binPath = linkPackageDirect(manifest, packagePath);
|
|
118
|
+
|
|
119
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
120
|
+
// Node.js doesn't use 'run' subcommand - it's just 'node <file>'
|
|
121
|
+
// SECURITY: Paths are now shell-escaped with single quotes
|
|
122
|
+
expect(content).toContain("exec node '");
|
|
123
|
+
expect(content).not.toContain('node run');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('defaults to bun runtime', () => {
|
|
127
|
+
const manifest: Manifest = {
|
|
128
|
+
name: 'no-runtime',
|
|
129
|
+
version: '1.0.0',
|
|
130
|
+
entry: 'run.ts'
|
|
131
|
+
// no runtime specified
|
|
132
|
+
};
|
|
133
|
+
const packagePath = join(tempDir, 'no-runtime');
|
|
134
|
+
|
|
135
|
+
const binPath = linkPackageDirect(manifest, packagePath);
|
|
136
|
+
|
|
137
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
138
|
+
expect(content).toContain('bun run');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('includes full path to entry', () => {
|
|
142
|
+
const manifest = createMockManifest('path-tool');
|
|
143
|
+
const packagePath = join(tempDir, 'path-tool');
|
|
144
|
+
|
|
145
|
+
const binPath = linkPackageDirect(manifest, packagePath);
|
|
146
|
+
|
|
147
|
+
const content = readFileSync(binPath, 'utf-8');
|
|
148
|
+
expect(content).toContain(resolve(packagePath, 'run.ts'));
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('unlinkPackage', () => {
|
|
153
|
+
test('removes linked package', () => {
|
|
154
|
+
const manifest = createMockManifest('unlink-test');
|
|
155
|
+
const packagePath = join(tempDir, 'unlink-test');
|
|
156
|
+
|
|
157
|
+
const binPath = linkPackage(manifest, packagePath);
|
|
158
|
+
expect(existsSync(binPath)).toBe(true);
|
|
159
|
+
|
|
160
|
+
const result = unlinkPackage('unlink-test');
|
|
161
|
+
|
|
162
|
+
expect(result).toBe(true);
|
|
163
|
+
expect(existsSync(binPath)).toBe(false);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test('returns false for non-existent package', () => {
|
|
167
|
+
const result = unlinkPackage('nonexistent-package-name');
|
|
168
|
+
expect(result).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('isPackageLinked', () => {
|
|
173
|
+
test('returns true for linked package', () => {
|
|
174
|
+
const manifest = createMockManifest('linked-tool');
|
|
175
|
+
const packagePath = join(tempDir, 'linked-tool');
|
|
176
|
+
|
|
177
|
+
linkPackage(manifest, packagePath);
|
|
178
|
+
|
|
179
|
+
expect(isPackageLinked('linked-tool')).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('returns false for unlinked package', () => {
|
|
183
|
+
expect(isPackageLinked('not-linked-tool')).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe('getPathInstructions', () => {
|
|
188
|
+
test('returns instructions string', () => {
|
|
189
|
+
const instructions = getPathInstructions();
|
|
190
|
+
|
|
191
|
+
expect(instructions).toContain('export PATH=');
|
|
192
|
+
expect(instructions).toContain(C4AI_BIN);
|
|
193
|
+
expect(instructions).toContain('source');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('detects zsh shell', () => {
|
|
197
|
+
const originalShell = process.env.SHELL;
|
|
198
|
+
process.env.SHELL = '/bin/zsh';
|
|
199
|
+
|
|
200
|
+
const instructions = getPathInstructions();
|
|
201
|
+
|
|
202
|
+
expect(instructions).toContain('.zshrc');
|
|
203
|
+
|
|
204
|
+
process.env.SHELL = originalShell;
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('defaults to bash', () => {
|
|
208
|
+
const originalShell = process.env.SHELL;
|
|
209
|
+
process.env.SHELL = '/bin/bash';
|
|
210
|
+
|
|
211
|
+
const instructions = getPathInstructions();
|
|
212
|
+
|
|
213
|
+
expect(instructions).toContain('.bashrc');
|
|
214
|
+
|
|
215
|
+
process.env.SHELL = originalShell;
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('isBinInPath', () => {
|
|
220
|
+
test('returns true when bin in PATH', () => {
|
|
221
|
+
const originalPath = process.env.PATH;
|
|
222
|
+
process.env.PATH = `${C4AI_BIN}:${originalPath}`;
|
|
223
|
+
|
|
224
|
+
expect(isBinInPath()).toBe(true);
|
|
225
|
+
|
|
226
|
+
process.env.PATH = originalPath;
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('returns false when bin not in PATH', () => {
|
|
230
|
+
const originalPath = process.env.PATH;
|
|
231
|
+
process.env.PATH = '/usr/bin:/usr/local/bin';
|
|
232
|
+
|
|
233
|
+
expect(isBinInPath()).toBe(false);
|
|
234
|
+
|
|
235
|
+
process.env.PATH = originalPath;
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
});
|
package/src/core/link.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PATH linking for global packages
|
|
3
|
+
*
|
|
4
|
+
* Creates executable symlinks in ~/.cli4ai/bin/ that can be added to PATH
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { existsSync, mkdirSync, symlinkSync, unlinkSync, writeFileSync, chmodSync } from 'fs';
|
|
8
|
+
import { resolve, join } from 'path';
|
|
9
|
+
import { homedir } from 'os';
|
|
10
|
+
import { type Manifest } from './manifest.js';
|
|
11
|
+
|
|
12
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
13
|
+
// SECURITY: Shell escaping for safe script generation
|
|
14
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Validate that a string is safe for shell script embedding.
|
|
18
|
+
* Only allows alphanumeric, hyphens, underscores, and dots.
|
|
19
|
+
* This is a strict allowlist approach for defense in depth.
|
|
20
|
+
*/
|
|
21
|
+
function isShellSafe(value: string): boolean {
|
|
22
|
+
return /^[a-zA-Z0-9._-]+$/.test(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Escape a string for safe embedding in shell scripts.
|
|
27
|
+
* Uses single quotes which prevent all shell interpretation except for single quotes themselves.
|
|
28
|
+
*/
|
|
29
|
+
function shellEscape(value: string): string {
|
|
30
|
+
// Single quotes prevent shell interpretation; escape any single quotes in the value
|
|
31
|
+
// by ending the single-quoted string, adding an escaped single quote, and starting a new single-quoted string
|
|
32
|
+
return "'" + value.replace(/'/g, "'\\''") + "'";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
36
|
+
// PATHS
|
|
37
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
38
|
+
|
|
39
|
+
export const C4AI_BIN = resolve(homedir(), '.cli4ai', 'bin');
|
|
40
|
+
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
// FUNCTIONS
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ensure bin directory exists
|
|
47
|
+
*/
|
|
48
|
+
export function ensureBinDir(): void {
|
|
49
|
+
if (!existsSync(C4AI_BIN)) {
|
|
50
|
+
mkdirSync(C4AI_BIN, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create executable wrapper script for a package
|
|
56
|
+
*
|
|
57
|
+
* Creates a shell script that invokes `cli4ai run <package> [args]`
|
|
58
|
+
*/
|
|
59
|
+
export function linkPackage(manifest: Manifest, packagePath: string): string {
|
|
60
|
+
ensureBinDir();
|
|
61
|
+
|
|
62
|
+
// SECURITY: Validate manifest values before embedding in shell script
|
|
63
|
+
if (!isShellSafe(manifest.name)) {
|
|
64
|
+
throw new Error(`Invalid package name for shell script: ${manifest.name}`);
|
|
65
|
+
}
|
|
66
|
+
if (!isShellSafe(manifest.version)) {
|
|
67
|
+
throw new Error(`Invalid package version for shell script: ${manifest.version}`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const binPath = join(C4AI_BIN, manifest.name);
|
|
71
|
+
|
|
72
|
+
// Create wrapper script with escaped values for defense in depth
|
|
73
|
+
const safeName = shellEscape(manifest.name);
|
|
74
|
+
const safeVersion = shellEscape(manifest.version);
|
|
75
|
+
|
|
76
|
+
const script = `#!/bin/sh
|
|
77
|
+
# cli4ai wrapper for ${safeName}@${safeVersion}
|
|
78
|
+
# Auto-generated - do not edit
|
|
79
|
+
|
|
80
|
+
exec cli4ai run ${safeName} "$@"
|
|
81
|
+
`;
|
|
82
|
+
|
|
83
|
+
writeFileSync(binPath, script);
|
|
84
|
+
chmodSync(binPath, 0o755);
|
|
85
|
+
|
|
86
|
+
return binPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Create direct executable wrapper that runs the tool directly
|
|
91
|
+
*
|
|
92
|
+
* This is faster than going through cli4ai run
|
|
93
|
+
*/
|
|
94
|
+
export function linkPackageDirect(manifest: Manifest, packagePath: string): string {
|
|
95
|
+
ensureBinDir();
|
|
96
|
+
|
|
97
|
+
// SECURITY: Validate manifest values before embedding in shell script
|
|
98
|
+
if (!isShellSafe(manifest.name)) {
|
|
99
|
+
throw new Error(`Invalid package name for shell script: ${manifest.name}`);
|
|
100
|
+
}
|
|
101
|
+
if (!isShellSafe(manifest.version)) {
|
|
102
|
+
throw new Error(`Invalid package version for shell script: ${manifest.version}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const binPath = join(C4AI_BIN, manifest.name);
|
|
106
|
+
const entryPath = resolve(packagePath, manifest.entry);
|
|
107
|
+
const runtime = manifest.runtime || 'bun';
|
|
108
|
+
|
|
109
|
+
// SECURITY: Shell-escape the entry path to prevent injection
|
|
110
|
+
const safeEntryPath = shellEscape(entryPath);
|
|
111
|
+
const safeName = shellEscape(manifest.name);
|
|
112
|
+
const safeVersion = shellEscape(manifest.version);
|
|
113
|
+
|
|
114
|
+
// Build runtime command based on runtime type
|
|
115
|
+
// - bun: bun run <file>
|
|
116
|
+
// - node: node <file>
|
|
117
|
+
let execCommand: string;
|
|
118
|
+
switch (runtime) {
|
|
119
|
+
case 'node':
|
|
120
|
+
execCommand = `node ${safeEntryPath}`;
|
|
121
|
+
break;
|
|
122
|
+
case 'bun':
|
|
123
|
+
default:
|
|
124
|
+
execCommand = `bun run ${safeEntryPath}`;
|
|
125
|
+
break;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Create wrapper script that runs the tool directly
|
|
129
|
+
const script = `#!/bin/sh
|
|
130
|
+
# cli4ai wrapper for ${safeName}@${safeVersion}
|
|
131
|
+
# Auto-generated - do not edit
|
|
132
|
+
|
|
133
|
+
exec ${execCommand} "$@"
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
writeFileSync(binPath, script);
|
|
137
|
+
chmodSync(binPath, 0o755);
|
|
138
|
+
|
|
139
|
+
return binPath;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Remove executable link for a package
|
|
144
|
+
*/
|
|
145
|
+
export function unlinkPackage(packageName: string): boolean {
|
|
146
|
+
const binPath = join(C4AI_BIN, packageName);
|
|
147
|
+
|
|
148
|
+
if (existsSync(binPath)) {
|
|
149
|
+
unlinkSync(binPath);
|
|
150
|
+
return true;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Check if a package is linked
|
|
158
|
+
*/
|
|
159
|
+
export function isPackageLinked(packageName: string): boolean {
|
|
160
|
+
return existsSync(join(C4AI_BIN, packageName));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Get PATH setup instructions
|
|
165
|
+
*/
|
|
166
|
+
export function getPathInstructions(): string {
|
|
167
|
+
const shell = process.env.SHELL || '/bin/bash';
|
|
168
|
+
const isZsh = shell.includes('zsh');
|
|
169
|
+
const rcFile = isZsh ? '~/.zshrc' : '~/.bashrc';
|
|
170
|
+
|
|
171
|
+
return `
|
|
172
|
+
To use globally installed cli4ai packages from anywhere, add this to your ${rcFile}:
|
|
173
|
+
|
|
174
|
+
export PATH="${C4AI_BIN}:$PATH"
|
|
175
|
+
|
|
176
|
+
Then reload your shell:
|
|
177
|
+
|
|
178
|
+
source ${rcFile}
|
|
179
|
+
|
|
180
|
+
Or start a new terminal session.
|
|
181
|
+
`.trim();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Check if bin directory is in PATH
|
|
186
|
+
*/
|
|
187
|
+
export function isBinInPath(): boolean {
|
|
188
|
+
const pathEnv = process.env.PATH || '';
|
|
189
|
+
return pathEnv.split(':').some(p => resolve(p) === C4AI_BIN);
|
|
190
|
+
}
|