cli4ai 1.0.4 → 1.1.1

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,6 +1,6 @@
1
1
  {
2
2
  "name": "cli4ai",
3
- "version": "1.0.4",
3
+ "version": "1.1.1",
4
4
  "description": "The package manager for AI CLI tools - cli4ai.com",
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,8 @@
16
16
  "dependencies": {
17
17
  "commander": "^14.0.0",
18
18
  "cron-parser": "^4.9.0",
19
- "semver": "^7.6.0"
19
+ "semver": "^7.6.0",
20
+ "yaml": "^2.8.2"
20
21
  },
21
22
  "devDependencies": {
22
23
  "@types/node": "^22.0.0",
package/src/cli.ts CHANGED
@@ -34,6 +34,16 @@ import {
34
34
  schedulerHistoryCommand,
35
35
  schedulerRunCommand
36
36
  } from './commands/scheduler.js';
37
+ import {
38
+ remotesListCommand,
39
+ remotesAddCommand,
40
+ remotesUpdateCommand,
41
+ remotesRemoveCommand,
42
+ remotesShowCommand,
43
+ remotesTestCommand,
44
+ remotesPackagesCommand
45
+ } from './commands/remotes.js';
46
+ import { serveCommand } from './commands/serve.js';
37
47
 
38
48
  export function createProgram(): Command {
39
49
  const program = new Command()
@@ -96,6 +106,8 @@ export function createProgram(): Command {
96
106
  .option('-e, --env <vars...>', 'Environment variables (KEY=value)')
97
107
  .option('--scope <level>', 'Permission scope: read, write, or full (default: full)')
98
108
  .option('--sandbox', 'Run in sandboxed environment with restricted file system access')
109
+ .option('-r, --remote <name>', 'Execute on a remote cli4ai service')
110
+ .option('--timeout <ms>', 'Timeout in milliseconds')
99
111
  // Allow passing tool flags through (e.g. `cli4ai run chrome screenshot --full-page`)
100
112
  .allowUnknownOption(true)
101
113
  .addHelpText('after', `
@@ -105,6 +117,7 @@ Examples:
105
117
  cli4ai run chrome screenshot https://example.com --full-page
106
118
  cli4ai run github list-issues --scope read
107
119
  cli4ai run untrusted-pkg process --sandbox
120
+ cli4ai run browser screenshot --remote chrome-server
108
121
 
109
122
  Pass-through:
110
123
  Use "--" to pass flags that would otherwise be parsed by cli4ai:
@@ -115,6 +128,10 @@ Security:
115
128
  --scope write Allow write operations
116
129
  --scope full Full access (default)
117
130
  --sandbox Restrict file system access to temp directories
131
+
132
+ Remote Execution:
133
+ --remote <name> Execute on a configured remote service
134
+ Use "cli4ai remotes add" to configure remotes
118
135
  `)
119
136
  .action(withErrorHandling(runCommand));
120
137
 
@@ -246,7 +263,7 @@ Security:
246
263
  .command('create <name>')
247
264
  .description('Create a new routine')
248
265
  .option('-g, --global', 'Create routine globally')
249
- .option('--type <type>', 'Routine type (bash, json)', 'bash')
266
+ .option('--type <type>', 'Routine type (yaml, json, bash)', 'yaml')
250
267
  .action(withErrorHandling(routinesCreateCommand));
251
268
 
252
269
  routines
@@ -312,5 +329,81 @@ Security:
312
329
  .description('Manually trigger a scheduled routine')
313
330
  .action(withErrorHandling(schedulerRunCommand));
314
331
 
332
+ // ═══════════════════════════════════════════════════════════════════════════
333
+ // REMOTE EXECUTION
334
+ // ═══════════════════════════════════════════════════════════════════════════
335
+
336
+ program
337
+ .command('serve')
338
+ .description('Start cli4ai as a remote service')
339
+ .option('-p, --port <port>', 'Port to listen on', '4100')
340
+ .option('-H, --host <host>', 'Host to bind to', '0.0.0.0')
341
+ .option('-k, --api-key <key>', 'API key for authentication')
342
+ .option('--scope <levels>', 'Comma-separated allowed scopes (read,write,full)')
343
+ .option('--cwd <dir>', 'Working directory for execution')
344
+ .addHelpText('after', `
345
+
346
+ Examples:
347
+ cli4ai serve # Start on port 4100
348
+ cli4ai serve --port 8080 # Start on port 8080
349
+ cli4ai serve --api-key mysecretkey # Require API key
350
+ cli4ai serve --scope read,write # Restrict to read/write operations
351
+
352
+ The service exposes these endpoints:
353
+ GET /health - Service health check
354
+ GET /packages - List available packages
355
+ GET /packages/:name - Get package info
356
+ POST /run - Execute a tool
357
+ POST /routines/:name/run - Run a routine
358
+ `)
359
+ .action(withErrorHandling(serveCommand));
360
+
361
+ const remotes = program
362
+ .command('remotes')
363
+ .description('Manage remote cli4ai services');
364
+
365
+ remotes
366
+ .command('list')
367
+ .alias('ls')
368
+ .description('List configured remotes')
369
+ .action(withErrorHandling(remotesListCommand));
370
+
371
+ remotes
372
+ .command('add <name> <url>')
373
+ .description('Add a remote service')
374
+ .option('-k, --api-key <key>', 'API key for authentication')
375
+ .option('-d, --description <desc>', 'Description of this remote')
376
+ .option('--no-test', 'Skip connection test')
377
+ .action(withErrorHandling(remotesAddCommand));
378
+
379
+ remotes
380
+ .command('update <name>')
381
+ .description('Update a remote service')
382
+ .option('-u, --url <url>', 'New URL')
383
+ .option('-k, --api-key <key>', 'New API key')
384
+ .option('-d, --description <desc>', 'New description')
385
+ .action(withErrorHandling(remotesUpdateCommand));
386
+
387
+ remotes
388
+ .command('remove <name>')
389
+ .alias('rm')
390
+ .description('Remove a remote')
391
+ .action(withErrorHandling(remotesRemoveCommand));
392
+
393
+ remotes
394
+ .command('show <name>')
395
+ .description('Show remote details')
396
+ .action(withErrorHandling(remotesShowCommand));
397
+
398
+ remotes
399
+ .command('test <name>')
400
+ .description('Test connection to a remote')
401
+ .action(withErrorHandling(remotesTestCommand));
402
+
403
+ remotes
404
+ .command('packages <name>')
405
+ .description('List packages on a remote')
406
+ .action(withErrorHandling(remotesPackagesCommand));
407
+
315
408
  return program;
316
409
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Remote hosts management command.
3
+ *
4
+ * Manage connections to remote cli4ai instances for distributed execution.
5
+ */
6
+
7
+ import { output, outputError, log } from '../lib/cli.js';
8
+ import {
9
+ getRemotes,
10
+ getRemote,
11
+ addRemote,
12
+ updateRemote,
13
+ removeRemote,
14
+ RemoteNotFoundError,
15
+ RemoteAlreadyExistsError,
16
+ InvalidRemoteUrlError
17
+ } from '../core/remotes.js';
18
+ import { testRemoteConnection, remoteListPackages } from '../core/remote-client.js';
19
+
20
+ // ═══════════════════════════════════════════════════════════════════════════
21
+ // LIST REMOTES
22
+ // ═══════════════════════════════════════════════════════════════════════════
23
+
24
+ export async function remotesListCommand(): Promise<void> {
25
+ const remotes = getRemotes();
26
+
27
+ if (remotes.length === 0) {
28
+ output({ remotes: [], message: 'No remotes configured. Use "cli4ai remotes add <name> <url>" to add one.' });
29
+ return;
30
+ }
31
+
32
+ output({
33
+ remotes: remotes.map(r => ({
34
+ name: r.name,
35
+ url: r.url,
36
+ description: r.description,
37
+ hasApiKey: !!r.apiKey,
38
+ addedAt: r.addedAt,
39
+ lastConnected: r.lastConnected
40
+ }))
41
+ });
42
+ }
43
+
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+ // ADD REMOTE
46
+ // ═══════════════════════════════════════════════════════════════════════════
47
+
48
+ export interface AddRemoteOptions {
49
+ apiKey?: string;
50
+ description?: string;
51
+ test?: boolean;
52
+ }
53
+
54
+ export async function remotesAddCommand(
55
+ name: string,
56
+ url: string,
57
+ options: AddRemoteOptions
58
+ ): Promise<void> {
59
+ try {
60
+ const remote = addRemote(name, url, {
61
+ apiKey: options.apiKey,
62
+ description: options.description
63
+ });
64
+
65
+ log(`Added remote: ${remote.name} -> ${remote.url}`);
66
+
67
+ // Test connection if requested
68
+ if (options.test !== false) {
69
+ log('Testing connection...');
70
+ const result = await testRemoteConnection(name);
71
+
72
+ if (result.success) {
73
+ log(`Connection successful: ${result.message}`);
74
+ output({
75
+ remote: {
76
+ name: remote.name,
77
+ url: remote.url,
78
+ description: remote.description,
79
+ hasApiKey: !!remote.apiKey
80
+ },
81
+ connectionTest: result
82
+ });
83
+ } else {
84
+ log(`Warning: Connection failed - ${result.message}`);
85
+ log('The remote was added but could not be reached. Check the URL and try again.');
86
+ output({
87
+ remote: {
88
+ name: remote.name,
89
+ url: remote.url,
90
+ description: remote.description,
91
+ hasApiKey: !!remote.apiKey
92
+ },
93
+ connectionTest: result
94
+ });
95
+ }
96
+ } else {
97
+ output({
98
+ remote: {
99
+ name: remote.name,
100
+ url: remote.url,
101
+ description: remote.description,
102
+ hasApiKey: !!remote.apiKey
103
+ }
104
+ });
105
+ }
106
+ } catch (err) {
107
+ if (err instanceof RemoteAlreadyExistsError) {
108
+ outputError('ALREADY_EXISTS', `Remote "${err.remoteName}" already exists. Use "cli4ai remotes update" to modify it.`);
109
+ }
110
+ if (err instanceof InvalidRemoteUrlError) {
111
+ outputError('INVALID_INPUT', `Invalid URL "${err.url}": ${err.reason}`);
112
+ }
113
+ throw err;
114
+ }
115
+ }
116
+
117
+ // ═══════════════════════════════════════════════════════════════════════════
118
+ // UPDATE REMOTE
119
+ // ═══════════════════════════════════════════════════════════════════════════
120
+
121
+ export interface UpdateRemoteOptions {
122
+ url?: string;
123
+ apiKey?: string;
124
+ description?: string;
125
+ }
126
+
127
+ export async function remotesUpdateCommand(
128
+ name: string,
129
+ options: UpdateRemoteOptions
130
+ ): Promise<void> {
131
+ try {
132
+ // Check if any update provided
133
+ if (!options.url && !options.apiKey && !options.description) {
134
+ outputError('INVALID_INPUT', 'No updates provided. Use --url, --api-key, or --description.');
135
+ }
136
+
137
+ const remote = updateRemote(name, options);
138
+
139
+ output({
140
+ updated: true,
141
+ remote: {
142
+ name: remote.name,
143
+ url: remote.url,
144
+ description: remote.description,
145
+ hasApiKey: !!remote.apiKey
146
+ }
147
+ });
148
+ } catch (err) {
149
+ if (err instanceof RemoteNotFoundError) {
150
+ outputError('NOT_FOUND', `Remote "${err.remoteName}" not found`);
151
+ }
152
+ if (err instanceof InvalidRemoteUrlError) {
153
+ outputError('INVALID_INPUT', `Invalid URL "${err.url}": ${err.reason}`);
154
+ }
155
+ throw err;
156
+ }
157
+ }
158
+
159
+ // ═══════════════════════════════════════════════════════════════════════════
160
+ // REMOVE REMOTE
161
+ // ═══════════════════════════════════════════════════════════════════════════
162
+
163
+ export async function remotesRemoveCommand(name: string): Promise<void> {
164
+ try {
165
+ removeRemote(name);
166
+ output({ removed: true, name });
167
+ } catch (err) {
168
+ if (err instanceof RemoteNotFoundError) {
169
+ outputError('NOT_FOUND', `Remote "${err.remoteName}" not found`);
170
+ }
171
+ throw err;
172
+ }
173
+ }
174
+
175
+ // ═══════════════════════════════════════════════════════════════════════════
176
+ // SHOW REMOTE
177
+ // ═══════════════════════════════════════════════════════════════════════════
178
+
179
+ export async function remotesShowCommand(name: string): Promise<void> {
180
+ const remote = getRemote(name);
181
+
182
+ if (!remote) {
183
+ outputError('NOT_FOUND', `Remote "${name}" not found`);
184
+ }
185
+
186
+ output({
187
+ name: remote.name,
188
+ url: remote.url,
189
+ description: remote.description,
190
+ hasApiKey: !!remote.apiKey,
191
+ addedAt: remote.addedAt,
192
+ lastConnected: remote.lastConnected
193
+ });
194
+ }
195
+
196
+ // ═══════════════════════════════════════════════════════════════════════════
197
+ // TEST REMOTE
198
+ // ═══════════════════════════════════════════════════════════════════════════
199
+
200
+ export async function remotesTestCommand(name: string): Promise<void> {
201
+ const remote = getRemote(name);
202
+
203
+ if (!remote) {
204
+ outputError('NOT_FOUND', `Remote "${name}" not found`);
205
+ }
206
+
207
+ log(`Testing connection to ${remote.name} (${remote.url})...`);
208
+
209
+ const result = await testRemoteConnection(name);
210
+
211
+ if (result.success) {
212
+ log(`Connection successful!`);
213
+ output({
214
+ name,
215
+ url: remote.url,
216
+ connected: true,
217
+ ...result.details
218
+ });
219
+ } else {
220
+ log(`Connection failed: ${result.message}`);
221
+ output({
222
+ name,
223
+ url: remote.url,
224
+ connected: false,
225
+ error: result.message
226
+ });
227
+ }
228
+ }
229
+
230
+ // ═══════════════════════════════════════════════════════════════════════════
231
+ // LIST PACKAGES ON REMOTE
232
+ // ═══════════════════════════════════════════════════════════════════════════
233
+
234
+ export async function remotesPackagesCommand(name: string): Promise<void> {
235
+ const remote = getRemote(name);
236
+
237
+ if (!remote) {
238
+ outputError('NOT_FOUND', `Remote "${name}" not found`);
239
+ }
240
+
241
+ try {
242
+ const result = await remoteListPackages(name);
243
+ output({
244
+ remote: name,
245
+ packages: result.packages
246
+ });
247
+ } catch (err) {
248
+ if (err instanceof Error) {
249
+ outputError('API_ERROR', `Failed to list packages on remote "${name}": ${err.message}`);
250
+ }
251
+ throw err;
252
+ }
253
+ }
@@ -5,6 +5,7 @@
5
5
  import { spawn, spawnSync } from 'child_process';
6
6
  import { readFileSync, writeFileSync, existsSync, chmodSync, rmSync } from 'fs';
7
7
  import { resolve } from 'path';
8
+ import { stringify as stringifyYaml } from 'yaml';
8
9
  import { output, outputError, log } from '../lib/cli.js';
9
10
  import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from '../core/config.js';
10
11
  import { getGlobalRoutines, getLocalRoutines, resolveRoutine, validateRoutineName, type RoutineInfo } from '../core/routines.js';
@@ -23,7 +24,7 @@ interface RoutinesRunOptions {
23
24
 
24
25
  interface RoutinesCreateOptions {
25
26
  global?: boolean;
26
- type?: 'json' | 'bash';
27
+ type?: 'yaml' | 'json' | 'bash';
27
28
  }
28
29
 
29
30
  function ensureValidRoutineName(name: string): void {
@@ -139,22 +140,37 @@ function getTargetDir(global: boolean, projectDir: string): string {
139
140
  export async function routinesCreateCommand(name: string, options: RoutinesCreateOptions): Promise<void> {
140
141
  ensureValidRoutineName(name);
141
142
  const projectDir = process.cwd();
142
- const type = options.type ?? 'bash';
143
- if (type !== 'bash' && type !== 'json') {
143
+ const type = options.type ?? 'yaml';
144
+ if (type !== 'yaml' && type !== 'bash' && type !== 'json') {
144
145
  outputError('INVALID_INPUT', `Invalid routine type: ${type}`, {
145
- allowed: ['bash', 'json']
146
+ allowed: ['yaml', 'json', 'bash']
146
147
  });
147
148
  }
148
149
  const dir = getTargetDir(!!options.global, projectDir);
149
150
 
150
- const suffix = type === 'json' ? '.routine.json' : '.routine.sh';
151
+ const suffixMap: Record<string, string> = {
152
+ yaml: '.routine.yaml',
153
+ json: '.routine.json',
154
+ bash: '.routine.sh'
155
+ };
156
+ const suffix = suffixMap[type];
151
157
  const filePath = resolve(dir, `${name}${suffix}`);
152
158
 
153
159
  if (existsSync(filePath)) {
154
160
  outputError('INVALID_INPUT', `Routine already exists: ${name}`, { path: filePath });
155
161
  }
156
162
 
157
- if (type === 'json') {
163
+ if (type === 'yaml') {
164
+ const template = {
165
+ version: 1,
166
+ name,
167
+ description: 'TODO: describe this routine',
168
+ vars: {},
169
+ steps: [],
170
+ result: '{{steps}}'
171
+ };
172
+ writeFileSync(filePath, stringifyYaml(template));
173
+ } else if (type === 'json') {
158
174
  const template = {
159
175
  version: 1,
160
176
  name,
@@ -208,8 +224,11 @@ export async function routinesRunCommand(
208
224
 
209
225
  const vars = parseVars(options.var);
210
226
 
227
+ // Structured routines (YAML or JSON) use the routine engine
228
+ const isStructured = routine.kind === 'yaml' || routine.kind === 'json';
229
+
211
230
  if (options.dryRun) {
212
- if (routine.kind === 'json') {
231
+ if (isStructured) {
213
232
  try {
214
233
  const def = loadRoutineDefinition(routine.path);
215
234
  const plan = await dryRunRoutine(def, vars, process.cwd());
@@ -252,7 +271,7 @@ export async function routinesRunCommand(
252
271
  return;
253
272
  }
254
273
 
255
- if (routine.kind === 'json') {
274
+ if (isStructured) {
256
275
  try {
257
276
  const def = loadRoutineDefinition(routine.path);
258
277
  const summary = await runRoutine(def, vars, process.cwd());
@@ -2,13 +2,17 @@
2
2
  * cli4ai run - Execute a tool command
3
3
  */
4
4
 
5
- import { outputError } from '../lib/cli.js';
5
+ import { output, outputError, log } from '../lib/cli.js';
6
6
  import { executeTool, ExecuteToolError, type ScopeLevel } from '../core/execute.js';
7
+ import { remoteRunTool, RemoteConnectionError, RemoteApiError } from '../core/remote-client.js';
8
+ import { getRemote } from '../core/remotes.js';
7
9
 
8
10
  interface RunOptions {
9
11
  env?: string[];
10
12
  scope?: string;
11
13
  sandbox?: boolean;
14
+ remote?: string;
15
+ timeout?: string;
12
16
  }
13
17
 
14
18
  export async function runCommand(
@@ -41,6 +45,64 @@ export async function runCommand(
41
45
  scope = options.scope as ScopeLevel;
42
46
  }
43
47
 
48
+ // Parse timeout
49
+ let timeout: number | undefined;
50
+ if (options.timeout) {
51
+ timeout = parseInt(options.timeout, 10);
52
+ if (isNaN(timeout) || timeout < 0) {
53
+ outputError('INVALID_INPUT', 'Timeout must be a positive number (milliseconds)');
54
+ }
55
+ }
56
+
57
+ // Remote execution
58
+ if (options.remote) {
59
+ const remote = getRemote(options.remote);
60
+ if (!remote) {
61
+ outputError('NOT_FOUND', `Remote "${options.remote}" not found`, {
62
+ hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
63
+ });
64
+ }
65
+
66
+ log(`Executing on remote: ${remote.name} (${remote.url})`);
67
+
68
+ try {
69
+ const result = await remoteRunTool(options.remote, {
70
+ package: packageName,
71
+ command,
72
+ args,
73
+ env: Object.keys(extraEnv).length > 0 ? extraEnv : undefined,
74
+ timeout,
75
+ scope
76
+ });
77
+
78
+ // Output stdout/stderr
79
+ if (result.stdout) {
80
+ process.stdout.write(result.stdout);
81
+ }
82
+ if (result.stderr) {
83
+ process.stderr.write(result.stderr);
84
+ }
85
+
86
+ process.exitCode = result.exitCode;
87
+
88
+ if (!result.success && result.error) {
89
+ log(`Remote error: ${result.error.message}`);
90
+ }
91
+
92
+ return;
93
+ } catch (err) {
94
+ if (err instanceof RemoteConnectionError) {
95
+ outputError('NETWORK_ERROR', err.message, { remote: options.remote, url: remote.url });
96
+ }
97
+ if (err instanceof RemoteApiError) {
98
+ outputError(err.code, err.message, err.details);
99
+ }
100
+ const message = err instanceof Error ? err.message : String(err);
101
+ outputError('API_ERROR', message);
102
+ }
103
+ }
104
+
105
+ // Local execution
44
106
  try {
45
107
  const result = await executeTool({
46
108
  packageName,
@@ -49,6 +111,7 @@ export async function runCommand(
49
111
  cwd: process.cwd(),
50
112
  env: extraEnv,
51
113
  capture: 'inherit',
114
+ timeoutMs: timeout,
52
115
  scope,
53
116
  sandbox: options.sandbox ?? false
54
117
  });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Serve command - Start cli4ai as a remote service.
3
+ *
4
+ * Allows other cli4ai instances to execute tools on this machine.
5
+ */
6
+
7
+ import { output, outputError, log } from '../lib/cli.js';
8
+ import { startService, type StartServiceOptions } from '../server/service.js';
9
+ import type { ScopeLevel } from '../core/execute.js';
10
+
11
+ export interface ServeCommandOptions {
12
+ port?: string;
13
+ host?: string;
14
+ apiKey?: string;
15
+ scope?: string;
16
+ cwd?: string;
17
+ }
18
+
19
+ export async function serveCommand(options: ServeCommandOptions): Promise<void> {
20
+ const serviceOptions: StartServiceOptions = {
21
+ cwd: options.cwd ?? process.cwd()
22
+ };
23
+
24
+ // Parse port
25
+ if (options.port) {
26
+ const port = parseInt(options.port, 10);
27
+ if (isNaN(port) || port < 1 || port > 65535) {
28
+ outputError('INVALID_INPUT', 'Port must be a number between 1 and 65535');
29
+ }
30
+ serviceOptions.port = port;
31
+ }
32
+
33
+ // Parse host
34
+ if (options.host) {
35
+ serviceOptions.host = options.host;
36
+ }
37
+
38
+ // API key
39
+ if (options.apiKey) {
40
+ serviceOptions.apiKey = options.apiKey;
41
+ }
42
+
43
+ // Parse allowed scopes
44
+ if (options.scope) {
45
+ const scopes = options.scope.split(',').map(s => s.trim()) as ScopeLevel[];
46
+ const validScopes: ScopeLevel[] = ['read', 'write', 'full'];
47
+
48
+ for (const scope of scopes) {
49
+ if (!validScopes.includes(scope)) {
50
+ outputError('INVALID_INPUT', `Invalid scope "${scope}". Must be one of: read, write, full`);
51
+ }
52
+ }
53
+
54
+ serviceOptions.allowedScopes = scopes;
55
+ }
56
+
57
+ try {
58
+ await startService(serviceOptions);
59
+ output({ status: 'stopped' });
60
+ } catch (err) {
61
+ if (err instanceof Error) {
62
+ outputError('API_ERROR', err.message);
63
+ }
64
+ throw err;
65
+ }
66
+ }
@@ -349,7 +349,14 @@ export function addLocalRegistry(path: string): void {
349
349
  */
350
350
  export function removeLocalRegistry(path: string): void {
351
351
  const absolutePath = resolve(path);
352
- const safePath = validateSymlinkTarget(absolutePath) ?? absolutePath;
352
+
353
+ // SECURITY: Validate symlink target consistently with addLocalRegistry
354
+ const safePath = validateSymlinkTarget(absolutePath);
355
+ if (!safePath) {
356
+ outputError('INVALID_INPUT', `Registry path points to an unsafe location: ${absolutePath}`, {
357
+ hint: 'Symlinks must point to directories within safe locations'
358
+ });
359
+ }
353
360
 
354
361
  let removed = false;
355
362
  updateConfig((config) => {
@@ -551,7 +558,7 @@ export function getGlobalPackages(): InstalledPackage[] {
551
558
  name: manifest.name,
552
559
  version: manifest.version,
553
560
  path: safePath,
554
- source: 'local', // TODO: track actual source
561
+ source: 'registry',
555
562
  installedAt: new Date().toISOString()
556
563
  });
557
564
  } catch {