@formigio/fazemos-cli 0.5.1 → 0.6.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/dist/index.js CHANGED
@@ -5,7 +5,7 @@ import { config, getEnv, getToken, getActiveOrgId, setActiveOrgId, addEnvironmen
5
5
  import { login, signup, confirmSignup, adminLogin } from './auth.js';
6
6
  import { api, ApiError } from './api.js';
7
7
  import { execSync } from 'child_process';
8
- import { readFileSync, readdirSync } from 'fs';
8
+ import { readFileSync, readdirSync, writeFileSync, mkdirSync, existsSync, statSync } from 'fs';
9
9
  import { fileURLToPath } from 'url';
10
10
  import { dirname, resolve, basename } from 'path';
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -25,6 +25,58 @@ function parseJson(val, flag) {
25
25
  process.exit(1);
26
26
  }
27
27
  }
28
+ /** Resolve a value that may be `@filepath` (reads file contents) or inline text. */
29
+ function resolveFileOrInline(val) {
30
+ if (val.startsWith('@')) {
31
+ const filePath = resolve(val.slice(1));
32
+ try {
33
+ return readFileSync(filePath, 'utf-8');
34
+ }
35
+ catch (err) {
36
+ throw new Error(`Cannot read --sections file "${filePath}": ${err.message}`);
37
+ }
38
+ }
39
+ return val;
40
+ }
41
+ /**
42
+ * Make a string safe to use as a filename: strip path separators and other
43
+ * shell-hostile characters, reject `..` traversal. Used by `agents export` so
44
+ * an agent display_name like `foo/bar` or `..` cannot escape the output dir.
45
+ */
46
+ function safeFileName(name) {
47
+ const cleaned = name.replace(/[^\w.\- ]+/g, '_').trim();
48
+ if (!cleaned || cleaned === '.' || cleaned === '..') {
49
+ throw new Error(`Cannot derive safe filename from "${name}"`);
50
+ }
51
+ return cleaned;
52
+ }
53
+ /**
54
+ * Build the markdown content for an exported agent. With `includeFrontmatter`,
55
+ * prepends YAML frontmatter reconstructed from `agent_config`. Note: only
56
+ * `name` round-trips through `upload-all` today — the other fields are
57
+ * informational on import. String values are JSON-stringified to keep YAML
58
+ * valid even if a model name or role contains a colon.
59
+ */
60
+ function buildAgentFileContent(agent, includeFrontmatter) {
61
+ const prompt = agent.agent_config?.systemPrompt || '';
62
+ if (!includeFrontmatter)
63
+ return prompt + '\n';
64
+ const ac = agent.agent_config || {};
65
+ const fm = ['---'];
66
+ fm.push(`name: ${JSON.stringify(agent.display_name)}`);
67
+ if (ac.model)
68
+ fm.push(`model: ${JSON.stringify(ac.model)}`);
69
+ if (ac.maxBudgetUsd != null)
70
+ fm.push(`maxBudgetUsd: ${ac.maxBudgetUsd}`);
71
+ if (ac.maxTurns != null)
72
+ fm.push(`maxTurns: ${ac.maxTurns}`);
73
+ if (ac.timeoutMs != null)
74
+ fm.push(`timeoutMs: ${ac.timeoutMs}`);
75
+ if (ac.roles?.length)
76
+ fm.push(`roles: ${JSON.stringify(ac.roles)}`);
77
+ fm.push('---');
78
+ return fm.join('\n') + '\n\n' + prompt + '\n';
79
+ }
28
80
  const program = new Command();
29
81
  program
30
82
  .name('fazemos')
@@ -1870,6 +1922,16 @@ function allSteps(definition) {
1870
1922
  function findStepById(definition, stepId) {
1871
1923
  return allSteps(definition).find((s) => s.id === stepId);
1872
1924
  }
1925
+ /**
1926
+ * Pipeline-sourced step inputs went through a schema migration: the old
1927
+ * shape stored the pipeline input name under `pipeline_input`; the new shape
1928
+ * uses `source: "pipeline"` + `source_pipeline_input`. Read sites should call
1929
+ * this helper instead of touching either field directly so both shapes work.
1930
+ * Returns undefined for step-sourced inputs.
1931
+ */
1932
+ function getPipelineInputName(input) {
1933
+ return input?.source_pipeline_input ?? input?.pipeline_input;
1934
+ }
1873
1935
  const VALID_IO_TYPES = ['text', 'markdown', 'number', 'boolean', 'url', 'json', 'object', 'array'];
1874
1936
  function requireDraftStatus(template) {
1875
1937
  if (template.status !== 'draft') {
@@ -1893,6 +1955,7 @@ const templates = program.command('templates').alias('tpl').description('Pipelin
1893
1955
  ' 4. tpl add-output / add-input Wire I/O between steps\n' +
1894
1956
  ' 5. tpl validate <id> Check for errors\n' +
1895
1957
  ' 6. tpl activate <id> Make available for instances\n\n' +
1958
+ ' Use "tpl phases <id>" to list phase IDs needed by --phase options.\n' +
1896
1959
  ' Use "tpl steps <id>" to list step IDs needed by --step options.\n' +
1897
1960
  ' Use "tpl show <id>" to see full structure with I/O declarations.');
1898
1961
  templates
@@ -1920,7 +1983,7 @@ templates
1920
1983
  });
1921
1984
  templates
1922
1985
  .command('show')
1923
- .description('Show template detail including phases, steps, I/O declarations, and pipeline inputs. Use this to inspect the full structure of a template and discover phase/step IDs needed by other commands.')
1986
+ .description('Show template detail including phases, steps, I/O declarations, pipeline inputs, and revision number. Use this to inspect the full structure of a template and discover phase/step IDs needed by other commands.')
1924
1987
  .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
1925
1988
  .action(async (id) => {
1926
1989
  try {
@@ -1929,7 +1992,7 @@ templates
1929
1992
  console.log(chalk.cyan(t.name));
1930
1993
  console.log(` ID: ${t.id}`);
1931
1994
  console.log(` Status: ${t.status}`);
1932
- console.log(` Version: ${t.version}`);
1995
+ console.log(` Revision: ${t.version}`);
1933
1996
  // Pipeline-level inputs
1934
1997
  if (t.definition?.inputs?.length) {
1935
1998
  console.log(chalk.cyan('\n Pipeline Inputs:'));
@@ -1942,7 +2005,7 @@ templates
1942
2005
  }
1943
2006
  if (t.definition?.phases) {
1944
2007
  for (const phase of t.definition.phases) {
1945
- console.log(chalk.cyan(`\n Phase: ${phase.name}`));
2008
+ console.log(chalk.cyan(`\n Phase: ${phase.name}`) + chalk.dim(` ${phase.id}`));
1946
2009
  for (const step of phase.steps || []) {
1947
2010
  const reviewer = step.reviewer ? ` reviewer:${step.reviewer}` : '';
1948
2011
  const cycles = step.reviewer && step.max_review_cycles ? ` max-cycles:${step.max_review_cycles}` : '';
@@ -1958,8 +2021,9 @@ templates
1958
2021
  if (step.inputs?.length) {
1959
2022
  console.log(' Inputs:');
1960
2023
  for (const inp of step.inputs) {
1961
- if (inp.pipeline_input) {
1962
- console.log(` ← ${inp.name} ← pipeline.${inp.pipeline_input}`);
2024
+ const pName = getPipelineInputName(inp);
2025
+ if (pName) {
2026
+ console.log(` ← ${inp.name} ← pipeline.${pName}`);
1963
2027
  }
1964
2028
  else {
1965
2029
  const srcStep = findStepById(t.definition, inp.source_step_id);
@@ -2064,13 +2128,23 @@ templates
2064
2128
  description: s.description || '',
2065
2129
  step_type: s.executionMode === 'script' ? 'script' : (s.agent ? 'agent' : 'human'),
2066
2130
  role: s.role || s.agent || 'unassigned',
2067
- inputs: (s.inputs || []).map((inp) => ({
2068
- name: inp.name,
2069
- ...(inp.source_step_id ? { source_step_id: idMap.get(inp.source_step_id) || inp.source_step_id, source_output_name: inp.source_output_name } : {}),
2070
- ...(inp.pipeline_input ? { pipeline_input: inp.pipeline_input } : {}),
2071
- ...(inp.description ? { description: inp.description } : {}),
2072
- required: inp.required !== false,
2073
- })),
2131
+ inputs: (s.inputs || []).map((inp) => {
2132
+ const pipelineName = getPipelineInputName(inp);
2133
+ return {
2134
+ name: inp.name,
2135
+ ...(inp.source_step_id ? {
2136
+ source: 'step',
2137
+ source_step_id: idMap.get(inp.source_step_id) || inp.source_step_id,
2138
+ source_output_name: inp.source_output_name,
2139
+ } : {}),
2140
+ ...(pipelineName ? {
2141
+ source: 'pipeline',
2142
+ source_pipeline_input: pipelineName,
2143
+ } : {}),
2144
+ ...(inp.description ? { description: inp.description } : {}),
2145
+ required: inp.required !== false,
2146
+ };
2147
+ }),
2074
2148
  outputs: (s.outputs || []).map((o) => ({
2075
2149
  name: o.name,
2076
2150
  type: o.type || 'text',
@@ -2138,7 +2212,7 @@ templates
2138
2212
  // ── Template structure commands ─────────────────────────────
2139
2213
  templates
2140
2214
  .command('update')
2141
- .description('Update template name or description. Works on any status (draft, active, or archived). Does not modify the definition or bump version.')
2215
+ .description('Update template name or description. Works on any status (draft, active, or archived). Does not modify the definition or bump the revision counter.')
2142
2216
  .argument('<id>', 'Template ID')
2143
2217
  .option('-n, --name <name>', 'New name')
2144
2218
  .option('-d, --description <desc>', 'New description')
@@ -2225,9 +2299,9 @@ templates
2225
2299
  });
2226
2300
  templates
2227
2301
  .command('remove-phase')
2228
- .description('Remove a phase from a template. Blocked if the phase contains steps unless --force is used. Template must be in draft status. Use "tpl show" to find phase IDs.')
2302
+ .description('Remove a phase from a template. Blocked if the phase contains steps unless --force is used. Template must be in draft status. Use "tpl phases" or "tpl show" to find phase IDs.')
2229
2303
  .argument('<templateId>', 'Template ID')
2230
- .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
2304
+ .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases" or "tpl show" output)')
2231
2305
  .option('--force', 'Remove even if phase contains steps')
2232
2306
  .action(async (templateId, opts) => {
2233
2307
  try {
@@ -2256,9 +2330,9 @@ templates
2256
2330
  });
2257
2331
  templates
2258
2332
  .command('edit-phase')
2259
- .description('Edit a phase name or description. Template must be in draft status. Provide at least one of --name or --description.')
2333
+ .description('Edit a phase name or description. Template must be in draft status. Provide at least one of --name or --description. Use "tpl phases" to find phase IDs.')
2260
2334
  .argument('<templateId>', 'Template ID')
2261
- .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
2335
+ .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases" or "tpl show" output)')
2262
2336
  .option('--name <name>', 'New phase name (must be unique within the template)')
2263
2337
  .option('--description <desc>', 'New phase description')
2264
2338
  .action(async (templateId, opts) => {
@@ -2294,9 +2368,9 @@ templates
2294
2368
  });
2295
2369
  templates
2296
2370
  .command('add-step')
2297
- .description('Add a step to a phase. Template must be in draft status. Step names must be unique within their phase. Returns the generated step ID needed by I/O commands (add-output, add-input, etc.). Use "tpl show" or "tpl steps" to find existing step IDs.')
2371
+ .description('Add a step to a phase. Template must be in draft status. Step names must be unique within their phase. Returns the generated step ID needed by I/O commands (add-output, add-input, etc.). Use --sections to provide agent instructions (inline or @filepath). Use --after to control ordering. Use "tpl show" or "tpl steps" to find existing step IDs.')
2298
2372
  .argument('<templateId>', 'Template ID')
2299
- .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" or "tpl add-phase" output)')
2373
+ .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases", "tpl show", or "tpl add-phase" output)')
2300
2374
  .requiredOption('--name <name>', 'Step name (must be unique within the phase)')
2301
2375
  .option('--type <type>', 'Step type: human (manual task), agent (AI agent), script (automated), gate (approval checkpoint)', 'human')
2302
2376
  .option('--role <role>', 'Role or agent name (e.g., "kate", "marco", "dev-team")')
@@ -2309,6 +2383,9 @@ templates
2309
2383
  .option('--timeout <seconds>', 'Timeout in seconds for script steps', parseNumber)
2310
2384
  .option('--working-dir <dir>', 'Working directory for script steps (absolute path)')
2311
2385
  .option('--env <json>', 'Environment variables for script steps as JSON (e.g., \'{"KEY":"value"}\')')
2386
+ .option('--sections <text>', 'Agent instructions / step content. Use @filepath to load from a file (e.g., --sections @steps/review.md)')
2387
+ .option('--after <stepId>', 'Insert after this step within the same phase (sets sort_order to target + 1, shifts subsequent steps). Target must live in the phase named by --phase.')
2388
+ .option('--sort-order <n>', 'Set sort_order directly (lower-level escape hatch)', parseNumber)
2312
2389
  .option('--agent-config <json>', 'Per-step agent config overrides as JSON (e.g., \'{"model":"opus","maxBudgetUsd":20}\'). Overrides agent member defaults for: model, maxBudgetUsd, maxTurns, timeoutMs, cwd, repos')
2313
2390
  .action(async (templateId, opts) => {
2314
2391
  try {
@@ -2349,6 +2426,9 @@ templates
2349
2426
  execConfig.env = parseJson(opts.env, '--env');
2350
2427
  }
2351
2428
  }
2429
+ // Default sort_order: max existing + 1, so non-contiguous gaps don't
2430
+ // cause new steps to collide with existing ones (e.g. after a --after insert).
2431
+ const maxSortOrder = phase.steps.reduce((m, s) => Math.max(m, s.sort_order ?? 0), -1);
2352
2432
  const step = {
2353
2433
  id: crypto.randomUUID(),
2354
2434
  name: opts.name,
@@ -2357,16 +2437,37 @@ templates
2357
2437
  role: opts.role || 'unassigned',
2358
2438
  inputs: [],
2359
2439
  outputs: [],
2360
- sections: '',
2440
+ sections: opts.sections ? resolveFileOrInline(opts.sections) : '',
2361
2441
  reviewer: opts.reviewer || null,
2362
2442
  max_review_cycles: parseInt(opts.maxReviewCycles) || 0,
2363
2443
  execution_config: execConfig,
2364
2444
  parallel_group: opts.parallelGroup || null,
2365
- sort_order: phase.steps.length,
2445
+ sort_order: maxSortOrder + 1,
2366
2446
  };
2367
2447
  if (opts.agentConfig)
2368
2448
  step.agent_config = parseJson(opts.agentConfig, '--agent-config');
2449
+ // Positioning: --after or --sort-order
2450
+ if (opts.after && opts.sortOrder !== undefined) {
2451
+ console.error(chalk.red('Cannot use both --after and --sort-order'));
2452
+ process.exit(1);
2453
+ }
2454
+ if (opts.after) {
2455
+ const target = phase.steps.find((s) => s.id === opts.after);
2456
+ if (!target) {
2457
+ console.error(chalk.red(`Step "${opts.after}" not found in phase "${phase.name}"`));
2458
+ process.exit(1);
2459
+ }
2460
+ step.sort_order = target.sort_order + 1;
2461
+ for (const s of phase.steps) {
2462
+ if (s.sort_order >= step.sort_order)
2463
+ s.sort_order++;
2464
+ }
2465
+ }
2466
+ else if (opts.sortOrder !== undefined) {
2467
+ step.sort_order = opts.sortOrder;
2468
+ }
2369
2469
  phase.steps.push(step);
2470
+ phase.steps.sort((a, b) => a.sort_order - b.sort_order);
2370
2471
  await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
2371
2472
  console.log(chalk.green(`Added step: ${opts.name} (${opts.type}) to phase ${phase.name}`));
2372
2473
  console.log(` ID: ${step.id}`);
@@ -2421,7 +2522,7 @@ templates
2421
2522
  });
2422
2523
  templates
2423
2524
  .command('edit-step')
2424
- .description('Edit step properties. Template must be in draft status. Provide at least one field to update. Use "tpl steps" to find step IDs.')
2525
+ .description('Edit step properties including sections (agent instructions). Template must be in draft status. Provide at least one field to update. Use "tpl steps" to find step IDs.')
2425
2526
  .argument('<templateId>', 'Template ID')
2426
2527
  .requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
2427
2528
  .option('--name <name>', 'New step name (must be unique within the phase)')
@@ -2436,13 +2537,14 @@ templates
2436
2537
  .option('--timeout <seconds>', 'Timeout in seconds for script steps', parseNumber)
2437
2538
  .option('--working-dir <dir>', 'Working directory for script steps (absolute path)')
2438
2539
  .option('--env <json>', 'Environment variables for script steps as JSON')
2540
+ .option('--sections <text>', 'Agent instructions / step content. Use @filepath to load from a file (e.g., --sections @steps/review.md)')
2439
2541
  .option('--agent-config <json>', 'Per-step agent config overrides as JSON (merges with existing)')
2440
2542
  .action(async (templateId, opts) => {
2441
2543
  try {
2442
2544
  const hasUpdate = opts.name || opts.type || opts.role || opts.description != null
2443
2545
  || opts.reviewer != null || opts.maxReviewCycles != null || opts.parallelGroup != null
2444
2546
  || opts.image || opts.command || opts.timeout !== undefined || opts.workingDir || opts.env
2445
- || opts.agentConfig;
2547
+ || opts.agentConfig || opts.sections != null;
2446
2548
  if (!hasUpdate) {
2447
2549
  console.error(chalk.red('Provide at least one field to update'));
2448
2550
  process.exit(1);
@@ -2504,6 +2606,9 @@ templates
2504
2606
  step.execution_config.env = parseJson(opts.env, '--env');
2505
2607
  }
2506
2608
  }
2609
+ // sections (agent instructions)
2610
+ if (opts.sections != null)
2611
+ step.sections = resolveFileOrInline(opts.sections);
2507
2612
  // agent_config overrides (merge with existing)
2508
2613
  if (opts.agentConfig) {
2509
2614
  step.agent_config = { ...(step.agent_config || {}), ...parseJson(opts.agentConfig, '--agent-config') };
@@ -2517,6 +2622,28 @@ templates
2517
2622
  }
2518
2623
  });
2519
2624
  // ── Template I/O commands ──────────────────────────────────
2625
+ templates
2626
+ .command('phases')
2627
+ .description('List phase IDs and names in a template. Use this to discover phase IDs needed by --phase options in add-step, edit-phase, remove-phase, etc.')
2628
+ .argument('<id>', 'Template ID')
2629
+ .action(async (id) => {
2630
+ try {
2631
+ const data = await api('GET', `/api/pipeline-templates/${id}`);
2632
+ const phases = data.template.definition?.phases || [];
2633
+ if (!phases.length) {
2634
+ console.log(chalk.yellow('No phases'));
2635
+ return;
2636
+ }
2637
+ for (const p of phases) {
2638
+ const stepCount = (p.steps || []).length;
2639
+ console.log(` ${chalk.dim(p.id)} ${p.name} ${chalk.dim(`(${stepCount} steps)`)}`);
2640
+ }
2641
+ }
2642
+ catch (err) {
2643
+ console.error(chalk.red(err.message));
2644
+ process.exit(1);
2645
+ }
2646
+ });
2520
2647
  templates
2521
2648
  .command('steps')
2522
2649
  .description('List step IDs and names in a template. Use this to discover step IDs needed by --step options in add-output, add-input, remove-step, edit-step, etc.')
@@ -2690,11 +2817,13 @@ templates
2690
2817
  }
2691
2818
  const input = { name: opts.name, required: !opts.optional };
2692
2819
  if (opts.sourceStep) {
2820
+ input.source = 'step';
2693
2821
  input.source_step_id = opts.sourceStep;
2694
2822
  input.source_output_name = opts.sourceOutput;
2695
2823
  }
2696
2824
  else {
2697
- input.pipeline_input = opts.pipelineInput;
2825
+ input.source = 'pipeline';
2826
+ input.source_pipeline_input = opts.pipelineInput;
2698
2827
  }
2699
2828
  if (opts.description)
2700
2829
  input.description = opts.description;
@@ -2799,7 +2928,7 @@ templates
2799
2928
  process.exit(1);
2800
2929
  }
2801
2930
  // Block if steps reference it unless --force
2802
- const refs = allSteps(t.definition).filter((s) => (s.inputs || []).some((inp) => inp.pipeline_input === opts.name));
2931
+ const refs = allSteps(t.definition).filter((s) => (s.inputs || []).some((inp) => getPipelineInputName(inp) === opts.name));
2803
2932
  if (refs.length && !opts.force) {
2804
2933
  console.error(chalk.yellow(`Pipeline input "${opts.name}" is referenced by: ${refs.map((r) => r.name).join(', ')}`));
2805
2934
  console.error(chalk.yellow('Use --force to remove anyway'));
@@ -2873,9 +3002,10 @@ templates
2873
3002
  errors++;
2874
3003
  }
2875
3004
  }
2876
- if (inp.pipeline_input) {
2877
- if (!pipelineInputs.find((p) => p.name === inp.pipeline_input)) {
2878
- console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references missing pipeline input "${inp.pipeline_input}"`));
3005
+ const pipelineName = getPipelineInputName(inp);
3006
+ if (pipelineName) {
3007
+ if (!pipelineInputs.find((p) => p.name === pipelineName)) {
3008
+ console.log(chalk.red(` ✗ Step "${step.name}" input "${inp.name}" references missing pipeline input "${pipelineName}"`));
2879
3009
  hasUnresolved = true;
2880
3010
  errors++;
2881
3011
  }
@@ -4428,6 +4558,97 @@ agentsCmd
4428
4558
  process.exit(1);
4429
4559
  }
4430
4560
  });
4561
+ agentsCmd
4562
+ .command('export')
4563
+ .description('Export all agent system prompts to a directory. Default: body only (matches what upload-all consumes). Use --include-frontmatter to also write readable YAML frontmatter — note that upload-all currently only reads the `name:` field, so the other frontmatter fields are informational.')
4564
+ .argument('<output-dir>', 'Directory to write agent .md files to (created if needed)')
4565
+ .option('--include-frontmatter', 'Include YAML frontmatter with agent config fields (name, model, maxBudgetUsd, maxTurns, timeoutMs, roles)')
4566
+ .action(async (outputDir, opts) => {
4567
+ try {
4568
+ const orgId = getActiveOrgId();
4569
+ if (!orgId) {
4570
+ console.error(chalk.red('No active org'));
4571
+ process.exit(1);
4572
+ }
4573
+ const resolvedDir = resolve(outputDir);
4574
+ if (existsSync(resolvedDir) && statSync(resolvedDir).isFile()) {
4575
+ console.error(chalk.red(`"${resolvedDir}" is a file, not a directory`));
4576
+ process.exit(1);
4577
+ }
4578
+ mkdirSync(resolvedDir, { recursive: true });
4579
+ const membersData = await api('GET', `/api/organizations/${orgId}/members?type=agent`);
4580
+ const agents = membersData.members.filter((m) => m.member_type === 'agent');
4581
+ let exported = 0;
4582
+ let skipped = 0;
4583
+ const writtenFiles = new Set();
4584
+ for (const agent of agents) {
4585
+ const prompt = agent.agent_config?.systemPrompt;
4586
+ if (!prompt) {
4587
+ console.log(chalk.yellow(` ⊘ ${agent.display_name} — no systemPrompt`));
4588
+ skipped++;
4589
+ continue;
4590
+ }
4591
+ let fileName;
4592
+ try {
4593
+ fileName = `${safeFileName(agent.display_name)}.md`;
4594
+ }
4595
+ catch (err) {
4596
+ console.log(chalk.yellow(` ⊘ ${agent.display_name} — ${err.message}`));
4597
+ skipped++;
4598
+ continue;
4599
+ }
4600
+ if (writtenFiles.has(fileName)) {
4601
+ console.log(chalk.yellow(` ⊘ ${agent.display_name} — filename "${fileName}" already written by another agent (display name collision)`));
4602
+ skipped++;
4603
+ continue;
4604
+ }
4605
+ const content = buildAgentFileContent(agent, !!opts.includeFrontmatter);
4606
+ writeFileSync(resolve(resolvedDir, fileName), content, 'utf-8');
4607
+ writtenFiles.add(fileName);
4608
+ console.log(chalk.green(` ✓ ${agent.display_name}`));
4609
+ exported++;
4610
+ }
4611
+ console.log(`\n${exported} agents exported to ${resolvedDir}${skipped ? `, ${skipped} skipped` : ''}`);
4612
+ }
4613
+ catch (err) {
4614
+ console.error(chalk.red(err.message));
4615
+ process.exit(1);
4616
+ }
4617
+ });
4618
+ agentsCmd
4619
+ .command('export-definition')
4620
+ .description('Export a single agent system prompt to a file. Matches agent by display name (case-insensitive). Default: body only. Use --include-frontmatter to also write readable YAML frontmatter — note that upload-definition only reads the body, so the frontmatter fields are informational.')
4621
+ .argument('<name>', 'Agent display name')
4622
+ .argument('<file>', 'Output file path')
4623
+ .option('--include-frontmatter', 'Include YAML frontmatter with agent config fields (name, model, maxBudgetUsd, maxTurns, timeoutMs, roles)')
4624
+ .action(async (name, file, opts) => {
4625
+ try {
4626
+ const orgId = getActiveOrgId();
4627
+ if (!orgId) {
4628
+ console.error(chalk.red('No active org'));
4629
+ process.exit(1);
4630
+ }
4631
+ const membersData = await api('GET', `/api/organizations/${orgId}/members?type=agent`);
4632
+ const norm = (s) => s.toLowerCase().replace(/[-_\s]+/g, ' ').trim();
4633
+ const agent = membersData.members.find((m) => norm(m.display_name) === norm(name));
4634
+ if (!agent) {
4635
+ console.error(chalk.red(`Agent "${name}" not found`));
4636
+ process.exit(1);
4637
+ }
4638
+ const prompt = agent.agent_config?.systemPrompt;
4639
+ if (!prompt) {
4640
+ console.error(chalk.red(`Agent "${agent.display_name}" has no systemPrompt`));
4641
+ process.exit(1);
4642
+ }
4643
+ const content = buildAgentFileContent(agent, !!opts.includeFrontmatter);
4644
+ writeFileSync(resolve(file), content, 'utf-8');
4645
+ console.log(chalk.green(`Exported ${agent.display_name} to ${resolve(file)} (${prompt.length} chars)`));
4646
+ }
4647
+ catch (err) {
4648
+ console.error(chalk.red(err.message));
4649
+ process.exit(1);
4650
+ }
4651
+ });
4431
4652
  // ── Health ──────────────────────────────────────────────────
4432
4653
  program
4433
4654
  .command('health')