cli4ai 1.0.3 → 1.1.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.
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Remote host configuration for distributed cli4ai execution.
3
+ *
4
+ * Manages named remote hosts that can execute cli4ai commands.
5
+ * Each remote is a cli4ai instance running `cli4ai serve`.
6
+ */
7
+
8
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
9
+ import { resolve } from 'path';
10
+ import { CLI4AI_HOME, ensureCli4aiHome } from './config.js';
11
+
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+ // TYPES
14
+ // ═══════════════════════════════════════════════════════════════════════════
15
+
16
+ export interface RemoteHost {
17
+ /** Unique name for this remote (e.g., "chrome-server", "gpu-box") */
18
+ name: string;
19
+ /** Base URL of the remote cli4ai service (e.g., "http://192.168.1.50:4100") */
20
+ url: string;
21
+ /** Optional API key for authentication */
22
+ apiKey?: string;
23
+ /** Optional description */
24
+ description?: string;
25
+ /** When this remote was added */
26
+ addedAt: string;
27
+ /** Last successful connection time */
28
+ lastConnected?: string;
29
+ }
30
+
31
+ export interface RemotesConfig {
32
+ version: 1;
33
+ remotes: RemoteHost[];
34
+ }
35
+
36
+ // ═══════════════════════════════════════════════════════════════════════════
37
+ // PATHS
38
+ // ═══════════════════════════════════════════════════════════════════════════
39
+
40
+ const REMOTES_FILE = resolve(CLI4AI_HOME, 'remotes.json');
41
+
42
+ // ═══════════════════════════════════════════════════════════════════════════
43
+ // LOADING & SAVING
44
+ // ═══════════════════════════════════════════════════════════════════════════
45
+
46
+ const DEFAULT_REMOTES_CONFIG: RemotesConfig = {
47
+ version: 1,
48
+ remotes: []
49
+ };
50
+
51
+ /**
52
+ * Load remotes configuration
53
+ */
54
+ export function loadRemotesConfig(): RemotesConfig {
55
+ ensureCli4aiHome();
56
+
57
+ if (!existsSync(REMOTES_FILE)) {
58
+ return { ...DEFAULT_REMOTES_CONFIG };
59
+ }
60
+
61
+ try {
62
+ const content = readFileSync(REMOTES_FILE, 'utf-8');
63
+ const data = JSON.parse(content);
64
+
65
+ // Validate and migrate if needed
66
+ if (!data.version || data.version !== 1) {
67
+ return { ...DEFAULT_REMOTES_CONFIG };
68
+ }
69
+
70
+ return data as RemotesConfig;
71
+ } catch {
72
+ return { ...DEFAULT_REMOTES_CONFIG };
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Save remotes configuration
78
+ */
79
+ export function saveRemotesConfig(config: RemotesConfig): void {
80
+ ensureCli4aiHome();
81
+ const content = JSON.stringify(config, null, 2) + '\n';
82
+ writeFileSync(REMOTES_FILE, content, { mode: 0o600 });
83
+ }
84
+
85
+ // ═══════════════════════════════════════════════════════════════════════════
86
+ // REMOTE MANAGEMENT
87
+ // ═══════════════════════════════════════════════════════════════════════════
88
+
89
+ export class RemoteNotFoundError extends Error {
90
+ constructor(public remoteName: string) {
91
+ super(`Remote not found: ${remoteName}`);
92
+ this.name = 'RemoteNotFoundError';
93
+ }
94
+ }
95
+
96
+ export class RemoteAlreadyExistsError extends Error {
97
+ constructor(public remoteName: string) {
98
+ super(`Remote already exists: ${remoteName}`);
99
+ this.name = 'RemoteAlreadyExistsError';
100
+ }
101
+ }
102
+
103
+ export class InvalidRemoteUrlError extends Error {
104
+ constructor(public url: string, public reason: string) {
105
+ super(`Invalid remote URL "${url}": ${reason}`);
106
+ this.name = 'InvalidRemoteUrlError';
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Validate and normalize a remote URL
112
+ */
113
+ export function validateRemoteUrl(url: string): string {
114
+ // Basic URL validation
115
+ let parsed: URL;
116
+ try {
117
+ parsed = new URL(url);
118
+ } catch {
119
+ throw new InvalidRemoteUrlError(url, 'Not a valid URL');
120
+ }
121
+
122
+ // Only allow http and https
123
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
124
+ throw new InvalidRemoteUrlError(url, 'Must use http or https protocol');
125
+ }
126
+
127
+ // Remove trailing slash for consistency
128
+ let normalized = parsed.origin;
129
+ if (parsed.pathname && parsed.pathname !== '/') {
130
+ normalized += parsed.pathname.replace(/\/$/, '');
131
+ }
132
+
133
+ return normalized;
134
+ }
135
+
136
+ /**
137
+ * Validate remote name (alphanumeric, dashes, underscores)
138
+ */
139
+ export function validateRemoteName(name: string): void {
140
+ if (!name || name.length === 0) {
141
+ throw new Error('Remote name cannot be empty');
142
+ }
143
+
144
+ if (!/^[a-zA-Z][a-zA-Z0-9_-]*$/.test(name)) {
145
+ throw new Error('Remote name must start with a letter and contain only letters, numbers, dashes, and underscores');
146
+ }
147
+
148
+ if (name.length > 64) {
149
+ throw new Error('Remote name must be 64 characters or less');
150
+ }
151
+ }
152
+
153
+ /**
154
+ * Get all configured remotes
155
+ */
156
+ export function getRemotes(): RemoteHost[] {
157
+ const config = loadRemotesConfig();
158
+ return config.remotes;
159
+ }
160
+
161
+ /**
162
+ * Get a remote by name
163
+ */
164
+ export function getRemote(name: string): RemoteHost | null {
165
+ const config = loadRemotesConfig();
166
+ return config.remotes.find(r => r.name === name) ?? null;
167
+ }
168
+
169
+ /**
170
+ * Get a remote by name, throwing if not found
171
+ */
172
+ export function getRemoteOrThrow(name: string): RemoteHost {
173
+ const remote = getRemote(name);
174
+ if (!remote) {
175
+ throw new RemoteNotFoundError(name);
176
+ }
177
+ return remote;
178
+ }
179
+
180
+ /**
181
+ * Add a new remote
182
+ */
183
+ export function addRemote(
184
+ name: string,
185
+ url: string,
186
+ options?: { apiKey?: string; description?: string }
187
+ ): RemoteHost {
188
+ validateRemoteName(name);
189
+ const normalizedUrl = validateRemoteUrl(url);
190
+
191
+ const config = loadRemotesConfig();
192
+
193
+ // Check for duplicate name
194
+ if (config.remotes.some(r => r.name === name)) {
195
+ throw new RemoteAlreadyExistsError(name);
196
+ }
197
+
198
+ const remote: RemoteHost = {
199
+ name,
200
+ url: normalizedUrl,
201
+ apiKey: options?.apiKey,
202
+ description: options?.description,
203
+ addedAt: new Date().toISOString()
204
+ };
205
+
206
+ config.remotes.push(remote);
207
+ saveRemotesConfig(config);
208
+
209
+ return remote;
210
+ }
211
+
212
+ /**
213
+ * Update an existing remote
214
+ */
215
+ export function updateRemote(
216
+ name: string,
217
+ updates: { url?: string; apiKey?: string; description?: string }
218
+ ): RemoteHost {
219
+ const config = loadRemotesConfig();
220
+ const index = config.remotes.findIndex(r => r.name === name);
221
+
222
+ if (index === -1) {
223
+ throw new RemoteNotFoundError(name);
224
+ }
225
+
226
+ const remote = config.remotes[index];
227
+
228
+ if (updates.url !== undefined) {
229
+ remote.url = validateRemoteUrl(updates.url);
230
+ }
231
+ if (updates.apiKey !== undefined) {
232
+ remote.apiKey = updates.apiKey || undefined;
233
+ }
234
+ if (updates.description !== undefined) {
235
+ remote.description = updates.description || undefined;
236
+ }
237
+
238
+ saveRemotesConfig(config);
239
+ return remote;
240
+ }
241
+
242
+ /**
243
+ * Remove a remote
244
+ */
245
+ export function removeRemote(name: string): void {
246
+ const config = loadRemotesConfig();
247
+ const index = config.remotes.findIndex(r => r.name === name);
248
+
249
+ if (index === -1) {
250
+ throw new RemoteNotFoundError(name);
251
+ }
252
+
253
+ config.remotes.splice(index, 1);
254
+ saveRemotesConfig(config);
255
+ }
256
+
257
+ /**
258
+ * Update last connected time for a remote
259
+ */
260
+ export function updateRemoteLastConnected(name: string): void {
261
+ const config = loadRemotesConfig();
262
+ const remote = config.remotes.find(r => r.name === name);
263
+
264
+ if (remote) {
265
+ remote.lastConnected = new Date().toISOString();
266
+ saveRemotesConfig(config);
267
+ }
268
+ }
@@ -4,7 +4,10 @@
4
4
 
5
5
  import { readFileSync } from 'fs';
6
6
  import { spawn } from 'child_process';
7
+ import { parse as parseYaml } from 'yaml';
7
8
  import { executeTool, ExecuteToolError } from './execute.js';
9
+ import { remoteRunTool, RemoteConnectionError, RemoteApiError } from './remote-client.js';
10
+ import { getRemote } from './remotes.js';
8
11
 
9
12
  export class RoutineParseError extends Error {
10
13
  constructor(
@@ -78,6 +81,8 @@ export interface RoutineC4aiStep extends RoutineBaseStep {
78
81
  env?: Record<string, string>;
79
82
  stdin?: string;
80
83
  capture?: StepCapture;
84
+ /** Name of a configured remote to execute on (optional) */
85
+ remote?: string;
81
86
  }
82
87
 
83
88
  export interface RoutineSetStep extends RoutineBaseStep {
@@ -153,11 +158,13 @@ export function loadRoutineDefinition(path: string): RoutineDefinition {
153
158
  throw new RoutineParseError(path, `Failed to read: ${err instanceof Error ? err.message : String(err)}`);
154
159
  }
155
160
 
161
+ const isYaml = path.endsWith('.yaml') || path.endsWith('.yml');
156
162
  let data: unknown;
157
163
  try {
158
- data = JSON.parse(content);
159
- } catch {
160
- throw new RoutineParseError(path, 'Invalid JSON');
164
+ data = isYaml ? parseYaml(content) : JSON.parse(content);
165
+ } catch (err) {
166
+ const format = isYaml ? 'YAML' : 'JSON';
167
+ throw new RoutineParseError(path, `Invalid ${format}: ${err instanceof Error ? err.message : String(err)}`);
161
168
  }
162
169
 
163
170
  return validateRoutineDefinition(data, path);
@@ -318,6 +325,9 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
318
325
  if (s.capture !== undefined && !['inherit', 'text', 'json'].includes(String(s.capture))) {
319
326
  throw new RoutineValidationError(`Step "${s.id}" has invalid "capture"`, { source, id: s.id, got: s.capture });
320
327
  }
328
+ if (s.remote !== undefined && typeof s.remote !== 'string') {
329
+ throw new RoutineValidationError(`Step "${s.id}" has invalid "remote" (must be string)`, { source, id: s.id, got: s.remote });
330
+ }
321
331
  }
322
332
 
323
333
  if (s.type === 'set') {
@@ -718,39 +728,91 @@ export async function runRoutine(def: RoutineDefinition, vars: Record<string, st
718
728
  const args = renderStringArray(step.args, ctx, { stepId: step.id, field: 'args' });
719
729
  const env = renderEnv(step.env, ctx, { stepId: step.id, field: 'env' });
720
730
  const stdin = step.stdin !== undefined ? renderString(step.stdin, ctx, { stepId: step.id, field: 'stdin' }) : undefined;
731
+ const remoteName = step.remote !== undefined ? renderScalarString(step.remote, ctx, { stepId: step.id, field: 'remote' }) : undefined;
721
732
 
722
733
  const capture = step.capture ?? 'text';
723
734
 
724
735
  try {
725
- const execRes = await executeTool({
726
- packageName: pkg,
727
- command: cmd,
728
- args,
729
- cwd: invocationDir,
730
- env,
731
- stdin,
732
- capture: 'pipe',
733
- timeoutMs: step.timeout,
734
- teeStderr: true
735
- });
736
+ let execResult: { exitCode: number; durationMs: number; stdout?: string; stderr?: string };
737
+
738
+ if (remoteName) {
739
+ // Remote execution
740
+ const remote = getRemote(remoteName);
741
+ if (!remote) {
742
+ throw new ExecuteToolError('NOT_FOUND', `Remote "${remoteName}" not found`, {
743
+ step: step.id,
744
+ hint: 'Use "cli4ai remotes add <name> <url>" to configure a remote'
745
+ });
746
+ }
747
+
748
+ const remoteRes = await remoteRunTool(remoteName, {
749
+ package: pkg,
750
+ command: cmd,
751
+ args,
752
+ env: Object.keys(env).length > 0 ? env : undefined,
753
+ stdin,
754
+ timeout: step.timeout
755
+ });
756
+
757
+ execResult = {
758
+ exitCode: remoteRes.exitCode,
759
+ durationMs: remoteRes.durationMs,
760
+ stdout: remoteRes.stdout,
761
+ stderr: remoteRes.stderr
762
+ };
763
+
764
+ // Log stderr from remote
765
+ if (remoteRes.stderr) {
766
+ process.stderr.write(remoteRes.stderr);
767
+ }
768
+
769
+ // Handle remote-level errors
770
+ if (remoteRes.error && !remoteRes.success) {
771
+ throw new ExecuteToolError(
772
+ remoteRes.error.code,
773
+ remoteRes.error.message,
774
+ remoteRes.error.details
775
+ );
776
+ }
777
+ } else {
778
+ // Local execution
779
+ const localRes = await executeTool({
780
+ packageName: pkg,
781
+ command: cmd,
782
+ args,
783
+ cwd: invocationDir,
784
+ env,
785
+ stdin,
786
+ capture: 'pipe',
787
+ timeoutMs: step.timeout,
788
+ teeStderr: true
789
+ });
790
+
791
+ execResult = {
792
+ exitCode: localRes.exitCode,
793
+ durationMs: localRes.durationMs,
794
+ stdout: localRes.stdout,
795
+ stderr: localRes.stderr
796
+ };
797
+ }
736
798
 
737
799
  const res: StepRunResult = {
738
800
  id: step.id,
739
801
  type: step.type,
740
- status: execRes.exitCode === 0 ? 'success' : 'failed',
741
- exitCode: execRes.exitCode,
742
- durationMs: execRes.durationMs,
743
- stdout: execRes.stdout,
744
- stderr: execRes.stderr
802
+ status: execResult.exitCode === 0 ? 'success' : 'failed',
803
+ exitCode: execResult.exitCode,
804
+ durationMs: execResult.durationMs,
805
+ stdout: execResult.stdout,
806
+ stderr: execResult.stderr
745
807
  };
746
808
 
747
809
  if (capture === 'json') {
748
810
  try {
749
- res.json = execRes.stdout ? JSON.parse(execRes.stdout) : null;
811
+ res.json = execResult.stdout ? JSON.parse(execResult.stdout) : null;
750
812
  } catch {
751
813
  throw new RoutineTemplateError(`JSON parse error in step "${step.id}": stdout is not valid JSON`, {
752
814
  step: step.id,
753
- stdout: (execRes.stdout ?? '').slice(0, 200)
815
+ stdout: (execResult.stdout ?? '').slice(0, 200)
754
816
  });
755
817
  }
756
818
  }
@@ -758,8 +820,8 @@ export async function runRoutine(def: RoutineDefinition, vars: Record<string, st
758
820
  stepsById[step.id] = res;
759
821
  steps.push(res);
760
822
 
761
- if (execRes.exitCode !== 0 && !step.continueOnError) {
762
- return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execRes.exitCode);
823
+ if (execResult.exitCode !== 0 && !step.continueOnError) {
824
+ return finalizeFailure(def, ctxVars, steps, Date.now() - startTime, execResult.exitCode);
763
825
  }
764
826
  continue;
765
827
  } catch (err) {
@@ -799,6 +861,12 @@ function normalizeError(err: unknown): { code: string; message: string; details?
799
861
  if (err instanceof ExecuteToolError) {
800
862
  return { code: err.code, message: err.message, details: err.details };
801
863
  }
864
+ if (err instanceof RemoteConnectionError) {
865
+ return { code: 'NETWORK_ERROR', message: err.message, details: { remote: err.remoteName, url: err.url } };
866
+ }
867
+ if (err instanceof RemoteApiError) {
868
+ return { code: err.code, message: err.message, details: err.details };
869
+ }
802
870
  if (err instanceof RoutineTemplateError) {
803
871
  return { code: 'INVALID_INPUT', message: err.message, details: err.details };
804
872
  }
@@ -7,15 +7,16 @@
7
7
  *
8
8
  * Resolution order:
9
9
  * - local before global (unless globalOnly)
10
- * - within a scope: .routine.json before .routine.sh
10
+ * - within a scope: .routine.yaml > .routine.yml > .routine.json > .routine.sh
11
11
  */
12
12
 
13
13
  import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
14
14
  import { resolve } from 'path';
15
+ import { parse as parseYaml } from 'yaml';
15
16
  import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from './config.js';
16
17
  import { validateScheduleConfig, type RoutineSchedule } from './routine-engine.js';
17
18
 
18
- export type RoutineKind = 'json' | 'bash';
19
+ export type RoutineKind = 'yaml' | 'json' | 'bash';
19
20
  export type RoutineScope = 'local' | 'global';
20
21
 
21
22
  export interface RoutineInfo {
@@ -30,6 +31,8 @@ export interface ResolveRoutineOptions {
30
31
  }
31
32
 
32
33
  const ROUTINE_FILES = [
34
+ { kind: 'yaml' as const, suffix: '.routine.yaml' },
35
+ { kind: 'yaml' as const, suffix: '.routine.yml' },
33
36
  { kind: 'json' as const, suffix: '.routine.json' },
34
37
  { kind: 'bash' as const, suffix: '.routine.sh' }
35
38
  ] as const;
@@ -127,13 +130,13 @@ export function getScheduledRoutines(projectDir?: string): ScheduledRoutineInfo[
127
130
  const results: ScheduledRoutineInfo[] = [];
128
131
  const seen = new Set<string>();
129
132
 
130
- // Collect all JSON routines
133
+ // Collect all structured routines (YAML and JSON - bash scripts cannot have schedules)
131
134
  const allRoutines: RoutineInfo[] = [];
132
135
 
133
136
  if (projectDir) {
134
- allRoutines.push(...getLocalRoutines(projectDir).filter(r => r.kind === 'json'));
137
+ allRoutines.push(...getLocalRoutines(projectDir).filter(r => r.kind === 'yaml' || r.kind === 'json'));
135
138
  }
136
- allRoutines.push(...getGlobalRoutines().filter(r => r.kind === 'json'));
139
+ allRoutines.push(...getGlobalRoutines().filter(r => r.kind === 'yaml' || r.kind === 'json'));
137
140
 
138
141
  for (const routine of allRoutines) {
139
142
  // Skip if we've already processed a routine with this name (local takes precedence)
@@ -142,7 +145,7 @@ export function getScheduledRoutines(projectDir?: string): ScheduledRoutineInfo[
142
145
 
143
146
  try {
144
147
  const content = readFileSync(routine.path, 'utf-8');
145
- const data = JSON.parse(content);
148
+ const data = routine.kind === 'yaml' ? parseYaml(content) : JSON.parse(content);
146
149
 
147
150
  if (data.schedule && typeof data.schedule === 'object') {
148
151
  // Check if schedule is enabled (defaults to true)
@@ -11,7 +11,7 @@
11
11
  * └── scheduler.log # Daemon logs
12
12
  */
13
13
 
14
- import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
14
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, appendFileSync } from 'fs';
15
15
  import { resolve, join } from 'path';
16
16
  import parser from 'cron-parser';
17
17
  import { SCHEDULER_DIR, ensureCli4aiHome } from './config.js';
@@ -282,7 +282,7 @@ export function appendSchedulerLog(level: LogLevel, message: string): void {
282
282
  ensureSchedulerDirs();
283
283
  const timestamp = new Date().toISOString();
284
284
  const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
285
- const fd = require('fs').appendFileSync(SCHEDULER_LOG_FILE, line);
285
+ appendFileSync(SCHEDULER_LOG_FILE, line);
286
286
  }
287
287
 
288
288
  // ═══════════════════════════════════════════════════════════════════════════
package/src/mcp/server.ts CHANGED
@@ -17,6 +17,7 @@ import { homedir } from 'os';
17
17
  import { type Manifest } from '../core/manifest.js';
18
18
  import { manifestToMcpTools, type McpTool } from './adapter.js';
19
19
  import { getSecret } from '../core/secrets.js';
20
+ import { loadConfig } from '../core/config.js';
20
21
 
21
22
  // ═══════════════════════════════════════════════════════════════════════════
22
23
  // SECURITY: Audit logging and rate limiting
@@ -33,6 +34,7 @@ interface RateLimitEntry {
33
34
 
34
35
  /**
35
36
  * Audit log an MCP tool call for security tracking
37
+ * Can be disabled via `cli4ai config set audit.enabled false`
36
38
  */
37
39
  function auditLog(
38
40
  packageName: string,
@@ -42,6 +44,12 @@ function auditLog(
42
44
  errorMessage?: string
43
45
  ): void {
44
46
  try {
47
+ // Check if audit logging is enabled
48
+ const config = loadConfig();
49
+ if (!config.audit?.enabled) {
50
+ return;
51
+ }
52
+
45
53
  if (!existsSync(AUDIT_LOG_DIR)) {
46
54
  mkdirSync(AUDIT_LOG_DIR, { recursive: true });
47
55
  }