cli4ai 0.8.2 → 0.9.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/package.json CHANGED
@@ -1,28 +1,29 @@
1
1
  {
2
2
  "name": "cli4ai",
3
- "version": "0.8.2",
4
- "description": "The package manager for AI CLI tools - cliforai.com",
3
+ "version": "0.9.0",
4
+ "description": "The package manager for AI CLI tools - cli4ai.com",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "cli4ai": "./src/bin.ts"
8
8
  },
9
9
  "scripts": {
10
- "dev": "bun run src/bin.ts",
11
- "build": "bun build src/bin.ts --outdir dist --target node",
10
+ "dev": "npx tsx src/bin.ts",
12
11
  "typecheck": "tsc --noEmit",
13
- "test": "bun test",
14
- "test:watch": "bun test --watch",
15
- "test:coverage": "bun test --coverage"
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "test:coverage": "vitest run --coverage"
16
15
  },
17
16
  "dependencies": {
18
17
  "commander": "^14.0.0",
18
+ "cron-parser": "^4.9.0",
19
19
  "semver": "^7.6.0"
20
20
  },
21
21
  "devDependencies": {
22
- "@types/bun": "^1.3.4",
23
22
  "@types/node": "^22.0.0",
24
23
  "@types/semver": "^7.5.0",
25
- "typescript": "^5.7.0"
24
+ "tsx": "^4.0.0",
25
+ "typescript": "^5.7.0",
26
+ "vitest": "^2.0.0"
26
27
  },
27
28
  "author": "cliforai",
28
29
  "license": "MIT",
@@ -30,7 +31,7 @@
30
31
  "type": "git",
31
32
  "url": "https://github.com/cliforai/framework"
32
33
  },
33
- "homepage": "https://cliforai.com",
34
+ "homepage": "https://cli4ai.com",
34
35
  "keywords": [
35
36
  "cli",
36
37
  "ai",
package/src/bin.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  /**
3
3
  * cli4ai - The package manager for AI CLI tools
4
- * cliforai.com
4
+ * cli4ai.com
5
5
  */
6
6
 
7
7
  import { createProgram } from './cli.js';
@@ -32,13 +32,13 @@ async function showAnimation(): Promise<void> {
32
32
  const food = '·';
33
33
 
34
34
  for (let pos = 0; pos <= width; pos++) {
35
- process.stdout.write('\r\x1B[K');
35
+ process.stderr.write('\r\x1B[K');
36
36
  const frame = robot[pos % robot.length];
37
37
  const trail = ' '.repeat(pos) + food.repeat(width - pos);
38
- process.stdout.write(` ${CYAN}${frame}${RESET}${DIM}${trail}${RESET}`);
38
+ process.stderr.write(` ${CYAN}${frame}${RESET}${DIM}${trail}${RESET}`);
39
39
  await sleep(20);
40
40
  }
41
- process.stdout.write('\r\x1B[K');
41
+ process.stderr.write('\r\x1B[K');
42
42
  }
43
43
 
44
44
  /**
@@ -46,19 +46,19 @@ async function showAnimation(): Promise<void> {
46
46
  */
47
47
  async function animateRobotFace(): Promise<void> {
48
48
  const faces = ['[•_•]', '[•_•]', '[-_-]', '[•_•]', '[•_•]', '[°_°]', '[•_•]'];
49
- const line = ` ${BOLD}${CYAN}cli4ai${RESET} ${DIM}─${RESET} ${WHITE}cliforai.com${RESET}`;
49
+ const line = ` ${BOLD}${CYAN}cli4ai${RESET} ${DIM}─${RESET} ${WHITE}cli4ai.com${RESET}`;
50
50
 
51
51
  for (const face of faces) {
52
- process.stdout.write(`\r ${CYAN}${face}${RESET}${line}`);
52
+ process.stderr.write(`\r ${CYAN}${face}${RESET}${line}`);
53
53
  await sleep(120);
54
54
  }
55
- console.log('');
55
+ console.error('');
56
56
  }
57
57
 
58
58
  async function showBanner(): Promise<void> {
59
- if (!process.stdout.isTTY) return;
59
+ if (!process.stderr.isTTY) return;
60
60
 
61
- console.log('');
61
+ console.error('');
62
62
 
63
63
  // Fun robot eating animation
64
64
  await showAnimation();
@@ -66,18 +66,18 @@ async function showBanner(): Promise<void> {
66
66
  // Animated robot branding (blinking)
67
67
  await animateRobotFace();
68
68
 
69
- console.log(` ${DIM}The package manager for AI CLI tools${RESET}`);
70
- console.log(` ${DIM}v${VERSION}${RESET}`);
71
- console.log('');
72
- console.log(` ${BOLD}Commands${RESET}`);
73
- console.log(` ${DIM}${'─'.repeat(40)}${RESET}`);
74
- console.log(` ${GREEN}browse${RESET} ${DIM}Browse & install packages${RESET}`);
75
- console.log(` ${GREEN}run${RESET} ${CYAN}<pkg> <cmd>${RESET} ${DIM}Run a tool command${RESET}`);
76
- console.log(` ${GREEN}ls${RESET} ${DIM}List installed packages${RESET}`);
77
- console.log(` ${GREEN}update${RESET} ${DIM}Update all packages${RESET}`);
78
- console.log('');
79
- console.log(` ${DIM}Run${RESET} ${WHITE}cli4ai --help${RESET} ${DIM}for all commands${RESET}`);
80
- console.log('');
69
+ console.error(` ${DIM}The package manager for AI CLI tools${RESET}`);
70
+ console.error(` ${DIM}v${VERSION}${RESET}`);
71
+ console.error('');
72
+ console.error(` ${BOLD}Commands${RESET}`);
73
+ console.error(` ${DIM}${'─'.repeat(40)}${RESET}`);
74
+ console.error(` ${GREEN}browse${RESET} ${DIM}Browse & install packages${RESET}`);
75
+ console.error(` ${GREEN}run${RESET} ${CYAN}<pkg> <cmd>${RESET} ${DIM}Run a tool command${RESET}`);
76
+ console.error(` ${GREEN}ls${RESET} ${DIM}List installed packages${RESET}`);
77
+ console.error(` ${GREEN}update${RESET} ${DIM}Update all packages${RESET}`);
78
+ console.error('');
79
+ console.error(` ${DIM}Run${RESET} ${WHITE}cli4ai --help${RESET} ${DIM}for all commands${RESET}`);
80
+ console.error('');
81
81
 
82
82
  // Check for updates in background (non-blocking)
83
83
  checkUpdatesInBackground();
package/src/cli.ts CHANGED
@@ -26,6 +26,14 @@ import {
26
26
  import { browseCommand } from './commands/browse.js';
27
27
  import { updateCommand } from './commands/update.js';
28
28
  import { routinesListCommand, routinesRunCommand, routinesShowCommand, routinesCreateCommand, routinesEditCommand, routinesRemoveCommand } from './commands/routines.js';
29
+ import {
30
+ schedulerStartCommand,
31
+ schedulerStopCommand,
32
+ schedulerStatusCommand,
33
+ schedulerLogsCommand,
34
+ schedulerHistoryCommand,
35
+ schedulerRunCommand
36
+ } from './commands/scheduler.js';
29
37
 
30
38
  export function createProgram(): Command {
31
39
  const program = new Command()
@@ -49,7 +57,7 @@ export function createProgram(): Command {
49
57
  .command('init [name]')
50
58
  .description('Create a new cli4ai tool project')
51
59
  .option('-t, --template <template>', 'Template to use (basic, api, browser)', 'basic')
52
- .option('-r, --runtime <runtime>', 'Runtime (bun, node)', 'bun')
60
+ .option('-r, --runtime <runtime>', 'Runtime (node, bun)', 'node')
53
61
  .option('-y, --yes', 'Skip prompts, use defaults')
54
62
  .action(withErrorHandling(initCommand));
55
63
 
@@ -252,5 +260,47 @@ Pass-through:
252
260
  .option('--dry-run', 'Show execution plan without running')
253
261
  .action(withErrorHandling(routinesRunCommand));
254
262
 
263
+ // ═══════════════════════════════════════════════════════════════════════════
264
+ // SCHEDULER
265
+ // ═══════════════════════════════════════════════════════════════════════════
266
+
267
+ const scheduler = program
268
+ .command('scheduler')
269
+ .description('Manage scheduled routine execution');
270
+
271
+ scheduler
272
+ .command('start')
273
+ .description('Start the scheduler daemon')
274
+ .option('-f, --foreground', 'Run in foreground (blocking)')
275
+ .action(withErrorHandling(schedulerStartCommand));
276
+
277
+ scheduler
278
+ .command('stop')
279
+ .description('Stop the scheduler daemon')
280
+ .action(withErrorHandling(schedulerStopCommand));
281
+
282
+ scheduler
283
+ .command('status')
284
+ .description('Show scheduler status and upcoming runs')
285
+ .action(withErrorHandling(schedulerStatusCommand));
286
+
287
+ scheduler
288
+ .command('logs')
289
+ .description('View scheduler logs')
290
+ .option('-f, --follow', 'Follow log output')
291
+ .option('-n, --lines <n>', 'Number of lines to show', '50')
292
+ .action(withErrorHandling(schedulerLogsCommand));
293
+
294
+ scheduler
295
+ .command('history [routine]')
296
+ .description('View routine execution history')
297
+ .option('-n, --limit <n>', 'Number of records to show', '20')
298
+ .action(withErrorHandling(schedulerHistoryCommand));
299
+
300
+ scheduler
301
+ .command('run <routine>')
302
+ .description('Manually trigger a scheduled routine')
303
+ .action(withErrorHandling(schedulerRunCommand));
304
+
255
305
  return program;
256
306
  }
@@ -45,6 +45,47 @@ interface InstallPlan {
45
45
  fromNpm?: boolean;
46
46
  }
47
47
 
48
+ const PKG_NAME_PATTERN = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
49
+ const CLI4AI_SCOPED_PKG_PATTERN = /^@cli4ai\/[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
50
+ const URL_LIKE_PATTERN = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
51
+
52
+ function validatePackageSpecifier(pkg: string): void {
53
+ if (URL_LIKE_PATTERN.test(pkg)) {
54
+ let parsed: URL;
55
+ try {
56
+ parsed = new URL(pkg);
57
+ } catch {
58
+ outputError('INVALID_INPUT', 'Invalid URL', { url: pkg });
59
+ }
60
+ if (parsed.protocol !== 'https:') {
61
+ outputError('INVALID_INPUT', 'Unsupported URL protocol', {
62
+ url: pkg,
63
+ protocol: parsed.protocol,
64
+ allowed: ['https:']
65
+ });
66
+ }
67
+ outputError('INVALID_INPUT', 'Installing from URLs is not supported', {
68
+ url: pkg,
69
+ hint: 'Use a local path (./path) or a package name (e.g. github, @cli4ai/github)'
70
+ });
71
+ }
72
+
73
+ if (pkg.includes('\\') || pkg.includes('..')) {
74
+ outputError('INVALID_INPUT', 'Invalid package specifier', { package: pkg });
75
+ }
76
+
77
+ if (pkg.startsWith('@cli4ai/')) {
78
+ if (!CLI4AI_SCOPED_PKG_PATTERN.test(pkg)) {
79
+ outputError('INVALID_INPUT', 'Invalid package name', { package: pkg });
80
+ }
81
+ return;
82
+ }
83
+
84
+ if (!PKG_NAME_PATTERN.test(pkg)) {
85
+ outputError('INVALID_INPUT', 'Invalid package name', { package: pkg });
86
+ }
87
+ }
88
+
48
89
  /**
49
90
  * Prompt user for confirmation
50
91
  */
@@ -196,15 +237,13 @@ async function installNpmDependencies(pkgPath: string, dependencies: Record<stri
196
237
 
197
238
  log(`Installing npm dependencies: ${deps.join(', ')}`);
198
239
 
199
- const proc = Bun.spawn(['bun', 'add', ...deps], {
240
+ const result = spawnSync('npm', ['install', ...deps], {
200
241
  cwd: pkgPath,
201
- stdout: 'inherit',
202
- stderr: 'inherit'
242
+ stdio: 'inherit'
203
243
  });
204
244
 
205
- const exitCode = await proc.exited;
206
- if (exitCode !== 0) {
207
- throw new Error(`Failed to install dependencies (exit code: ${exitCode})`);
245
+ if (result.status !== 0) {
246
+ throw new Error(`Failed to install dependencies (exit code: ${result.status})`);
208
247
  }
209
248
  }
210
249
 
@@ -446,6 +485,8 @@ async function resolvePackage(
446
485
  return { manifest, path: absolutePath };
447
486
  }
448
487
 
488
+ validatePackageSpecifier(pkg);
489
+
449
490
  // Check local registries first
450
491
  const config = loadConfig();
451
492
  for (const registryPath of config.localRegistries) {
@@ -166,7 +166,7 @@ async function multiSelect(items: BrowseItem[]): Promise<string[]> {
166
166
  // Robot header
167
167
  log('');
168
168
  log(` ${CYAN}${BOLD}[•_•]${RESET} ${BOLD}cli4ai${RESET} ${DIM}─${RESET} ${WHITE}Package Browser${RESET}`);
169
- log(` ${DIM}cliforai.com${RESET}`);
169
+ log(` ${DIM}cli4ai.com${RESET}`);
170
170
  log('');
171
171
 
172
172
  // Box top
@@ -5,7 +5,7 @@
5
5
  import { output, outputError, log } from '../lib/cli.js';
6
6
  import {
7
7
  loadConfig,
8
- saveConfig,
8
+ updateConfig,
9
9
  addLocalRegistry,
10
10
  removeLocalRegistry,
11
11
  CLI4AI_HOME,
@@ -72,8 +72,13 @@ export async function configCommand(
72
72
 
73
73
  // Set key=value
74
74
  if (key && value) {
75
- const updated = setNestedValue(config, key, parseValue(value));
76
- saveConfig(updated);
75
+ const parsed = parseValue(value);
76
+
77
+ if (key === 'registry') {
78
+ validateRegistryUrl(parsed);
79
+ }
80
+
81
+ const updated = updateConfig((current) => setNestedValue(current, key, parsed));
77
82
  log(`Set ${key} = ${value}`);
78
83
  output({
79
84
  action: 'set',
@@ -84,6 +89,29 @@ export async function configCommand(
84
89
  }
85
90
  }
86
91
 
92
+ function validateRegistryUrl(value: unknown): void {
93
+ if (typeof value !== 'string' || value.trim().length === 0) {
94
+ outputError('INVALID_INPUT', 'Registry must be a non-empty URL string', {
95
+ got: value
96
+ });
97
+ }
98
+
99
+ let url: URL;
100
+ try {
101
+ url = new URL(value);
102
+ } catch {
103
+ outputError('INVALID_INPUT', `Invalid registry URL: ${value}`, {
104
+ hint: 'Use a valid http(s) URL, e.g. https://registry.cli4ai.com'
105
+ });
106
+ }
107
+
108
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
109
+ outputError('INVALID_INPUT', `Invalid registry URL protocol: ${url.protocol}`, {
110
+ hint: 'Registry URL must start with http:// or https://'
111
+ });
112
+ }
113
+ }
114
+
87
115
  function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
88
116
  const parts = path.split('.');
89
117
  let current: unknown = obj;
@@ -66,7 +66,7 @@ function outputPackageInfo(manifest: Manifest, path: string, installed: boolean)
66
66
  description: manifest.description,
67
67
  author: manifest.author,
68
68
  license: manifest.license,
69
- runtime: manifest.runtime || 'bun',
69
+ runtime: manifest.runtime || 'node',
70
70
  entry: manifest.entry,
71
71
  path,
72
72
  installed,
@@ -2,7 +2,7 @@
2
2
  * Integration tests for init command
3
3
  */
4
4
 
5
- import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
5
+ import { describe, test, expect, beforeEach, afterEach } from 'vitest';
6
6
  import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
7
7
  import { join } from 'path';
8
8
  import { tmpdir } from 'os';
@@ -88,11 +88,11 @@ describe('init command', () => {
88
88
  expect(manifest.version).toBe('1.0.0');
89
89
  });
90
90
 
91
- test('manifest has default runtime bun', async () => {
91
+ test('manifest has default runtime node', async () => {
92
92
  await initCommand('tool', {});
93
93
 
94
94
  const manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
95
- expect(manifest.runtime).toBe('bun');
95
+ expect(manifest.runtime).toBe('node');
96
96
  });
97
97
 
98
98
  test('manifest has entry pointing to run.ts', async () => {
@@ -110,7 +110,7 @@ describe('init command', () => {
110
110
  expect(manifest.commands?.hello?.description).toBe('Say hello');
111
111
  });
112
112
 
113
- test('manifest includes dependencies for bun runtime', async () => {
113
+ test('manifest includes dependencies', async () => {
114
114
  await initCommand('tool', {});
115
115
 
116
116
  const manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
@@ -121,11 +121,11 @@ describe('init command', () => {
121
121
  });
122
122
 
123
123
  describe('entry file content', () => {
124
- test('run.ts starts with shebang', async () => {
124
+ test('run.ts starts with tsx shebang', async () => {
125
125
  await initCommand('tool', {});
126
126
 
127
127
  const content = readFileSync(join(tempDir, 'tool', 'run.ts'), 'utf-8');
128
- expect(content.startsWith('#!/usr/bin/env bun')).toBe(true);
128
+ expect(content.startsWith('#!/usr/bin/env npx tsx')).toBe(true);
129
129
  });
130
130
 
131
131
  test('run.ts has hello command', async () => {
@@ -139,25 +139,73 @@ describe('init command', () => {
139
139
  await initCommand('tool', {});
140
140
 
141
141
  const content = readFileSync(join(tempDir, 'tool', 'run.ts'), 'utf-8');
142
- expect(content).toContain("@cli4ai/lib/cli.ts");
142
+ expect(content).toContain("@cli4ai/lib");
143
143
  expect(content).toContain('output(');
144
144
  });
145
145
  });
146
146
 
147
147
  describe('runtime option', () => {
148
148
  test('respects runtime option', async () => {
149
- await initCommand('tool', { runtime: 'node' });
149
+ await initCommand('tool', { runtime: 'bun' });
150
150
 
151
151
  const manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
152
- expect(manifest.runtime).toBe('node');
152
+ expect(manifest.runtime).toBe('bun');
153
153
  });
154
154
 
155
- test('node runtime uses run.mjs entry', async () => {
155
+ test('both runtimes use run.ts with tsx', async () => {
156
+ // Node runtime (default)
156
157
  await initCommand('tool', { runtime: 'node' });
158
+ let manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
159
+ expect(manifest.entry).toBe('run.ts');
160
+ expect(existsSync(join(tempDir, 'tool', 'run.ts'))).toBe(true);
157
161
 
158
- const manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
159
- expect(manifest.entry).toBe('run.mjs');
160
- expect(existsSync(join(tempDir, 'tool', 'run.mjs'))).toBe(true);
162
+ // Bun runtime
163
+ rmSync(join(tempDir, 'tool'), { recursive: true, force: true });
164
+ await initCommand('tool', { runtime: 'bun' });
165
+ manifest = JSON.parse(readFileSync(join(tempDir, 'tool', 'cli4ai.json'), 'utf-8'));
166
+ expect(manifest.entry).toBe('run.ts');
167
+ expect(existsSync(join(tempDir, 'tool', 'run.ts'))).toBe(true);
168
+ });
169
+ });
170
+
171
+ describe('test scaffolding', () => {
172
+ test('creates vitest.config.ts', async () => {
173
+ await initCommand('tool', {});
174
+ expect(existsSync(join(tempDir, 'tool', 'vitest.config.ts'))).toBe(true);
175
+ });
176
+
177
+ test('creates run.test.ts with vitest imports', async () => {
178
+ await initCommand('tool', {});
179
+ const content = readFileSync(join(tempDir, 'tool', 'run.test.ts'), 'utf-8');
180
+ expect(content).toContain("from 'vitest'");
181
+ expect(content).toContain('describe(');
182
+ });
183
+
184
+ test('package.json has vitest dev dependency', async () => {
185
+ await initCommand('tool', {});
186
+ const pkg = JSON.parse(readFileSync(join(tempDir, 'tool', 'package.json'), 'utf-8'));
187
+ expect(pkg.devDependencies).toBeDefined();
188
+ expect(pkg.devDependencies['vitest']).toBeDefined();
189
+ expect(pkg.devDependencies['tsx']).toBeDefined();
190
+ });
191
+
192
+ test('package.json has test script', async () => {
193
+ await initCommand('tool', {});
194
+ const pkg = JSON.parse(readFileSync(join(tempDir, 'tool', 'package.json'), 'utf-8'));
195
+ expect(pkg.scripts?.test).toBe('vitest run');
196
+ });
197
+ });
198
+
199
+ describe('tsconfig.json', () => {
200
+ test('creates tsconfig.json', async () => {
201
+ await initCommand('tool', {});
202
+ expect(existsSync(join(tempDir, 'tool', 'tsconfig.json'))).toBe(true);
203
+ });
204
+
205
+ test('tsconfig uses NodeNext module resolution', async () => {
206
+ await initCommand('tool', {});
207
+ const tsconfig = JSON.parse(readFileSync(join(tempDir, 'tool', 'tsconfig.json'), 'utf-8'));
208
+ expect(tsconfig.compilerOptions.moduleResolution).toBe('NodeNext');
161
209
  });
162
210
  });
163
211
  });