@formigio/fazemos-cli 0.5.0 → 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')
@@ -653,6 +705,149 @@ invites
653
705
  process.exit(1);
654
706
  }
655
707
  });
708
+ // ── orgs notifications subcommands (F12) ────────────────────
709
+ const NOTIFICATION_EVENT_TYPES = [
710
+ 'agent_started',
711
+ 'agent_completed',
712
+ 'agent_failed',
713
+ 'human_work',
714
+ 'pipeline_complete',
715
+ ];
716
+ const notifications = orgs
717
+ .command('notifications')
718
+ .description(`Manage Slack notifications for the active org.
719
+
720
+ Notifications are sent to a Slack incoming webhook URL configured per org.
721
+ Default events: agent_failed, human_work, pipeline_complete (loud events
722
+ only). agent_started and agent_completed are off by default to keep the
723
+ channel quiet.
724
+
725
+ The webhook URL is stored on the API side and never returned by the API.
726
+ To rotate the URL, regenerate it in Slack and call set-webhook again.
727
+ Owner role required to change the webhook URL; admin can toggle events.`);
728
+ function requireActiveOrgOrExit() {
729
+ const orgId = getActiveOrgId();
730
+ if (!orgId) {
731
+ console.error(chalk.red('No active org. Run: fazemos orgs switch <slug>'));
732
+ process.exit(1);
733
+ }
734
+ return orgId;
735
+ }
736
+ notifications
737
+ .command('get')
738
+ .description('Show current notification config (events enabled, webhook source)')
739
+ .action(async () => {
740
+ try {
741
+ const orgId = requireActiveOrgOrExit();
742
+ const data = await api('GET', `/api/organizations/${orgId}/notifications/config`);
743
+ console.log(chalk.cyan('Notifications config:'));
744
+ console.log(` Webhook configured: ${data.webhook_configured ? chalk.green('yes') : chalk.gray('no')}`);
745
+ console.log(` Webhook source: ${data.webhook_source}`);
746
+ console.log(' Events:');
747
+ for (const ev of NOTIFICATION_EVENT_TYPES) {
748
+ const on = data.events_enabled[ev];
749
+ console.log(` ${on ? chalk.green('●') : chalk.gray('○')} ${ev}`);
750
+ }
751
+ }
752
+ catch (err) {
753
+ console.error(chalk.red(err.message));
754
+ process.exit(1);
755
+ }
756
+ });
757
+ notifications
758
+ .command('set-webhook')
759
+ .description('Set the Slack incoming webhook URL for the active org. Owner only.')
760
+ .requiredOption('-u, --url <url>', 'Slack incoming webhook URL (https://hooks.slack.com/services/...)')
761
+ .action(async (opts) => {
762
+ try {
763
+ const orgId = requireActiveOrgOrExit();
764
+ const data = await api('PUT', `/api/organizations/${orgId}/notifications/config`, { webhook_url: opts.url });
765
+ console.log(chalk.green('Webhook updated.'));
766
+ console.log(` Source: ${data.webhook_source}`);
767
+ }
768
+ catch (err) {
769
+ console.error(chalk.red(err.message));
770
+ process.exit(1);
771
+ }
772
+ });
773
+ notifications
774
+ .command('clear-webhook')
775
+ .description('Remove the Slack webhook URL for the active org. Notifications stop being sent. Owner only.')
776
+ .action(async () => {
777
+ try {
778
+ const orgId = requireActiveOrgOrExit();
779
+ await api('PUT', `/api/organizations/${orgId}/notifications/config`, { webhook_url: null });
780
+ console.log(chalk.green('Webhook cleared. Notifications are now silent until a new webhook is configured.'));
781
+ }
782
+ catch (err) {
783
+ console.error(chalk.red(err.message));
784
+ process.exit(1);
785
+ }
786
+ });
787
+ notifications
788
+ .command('enable')
789
+ .description('Enable a notification event type for the active org')
790
+ .argument('<event-type>', `One of: ${NOTIFICATION_EVENT_TYPES.join(', ')}`)
791
+ .action(async (eventType) => {
792
+ try {
793
+ if (!NOTIFICATION_EVENT_TYPES.includes(eventType)) {
794
+ console.error(chalk.red(`Unknown event type: ${eventType}`));
795
+ console.error(`Valid: ${NOTIFICATION_EVENT_TYPES.join(', ')}`);
796
+ process.exit(1);
797
+ }
798
+ const orgId = requireActiveOrgOrExit();
799
+ const current = await api('GET', `/api/organizations/${orgId}/notifications/config`);
800
+ const events_enabled = { ...current.events_enabled, [eventType]: true };
801
+ await api('PUT', `/api/organizations/${orgId}/notifications/config`, { events_enabled });
802
+ console.log(chalk.green(`Enabled: ${eventType}`));
803
+ }
804
+ catch (err) {
805
+ console.error(chalk.red(err.message));
806
+ process.exit(1);
807
+ }
808
+ });
809
+ notifications
810
+ .command('disable')
811
+ .description('Disable a notification event type for the active org')
812
+ .argument('<event-type>', `One of: ${NOTIFICATION_EVENT_TYPES.join(', ')}`)
813
+ .action(async (eventType) => {
814
+ try {
815
+ if (!NOTIFICATION_EVENT_TYPES.includes(eventType)) {
816
+ console.error(chalk.red(`Unknown event type: ${eventType}`));
817
+ console.error(`Valid: ${NOTIFICATION_EVENT_TYPES.join(', ')}`);
818
+ process.exit(1);
819
+ }
820
+ const orgId = requireActiveOrgOrExit();
821
+ const current = await api('GET', `/api/organizations/${orgId}/notifications/config`);
822
+ const events_enabled = { ...current.events_enabled, [eventType]: false };
823
+ await api('PUT', `/api/organizations/${orgId}/notifications/config`, { events_enabled });
824
+ console.log(chalk.green(`Disabled: ${eventType}`));
825
+ }
826
+ catch (err) {
827
+ console.error(chalk.red(err.message));
828
+ process.exit(1);
829
+ }
830
+ });
831
+ notifications
832
+ .command('test')
833
+ .description('Send a test notification to verify the configured webhook is working')
834
+ .action(async () => {
835
+ try {
836
+ const orgId = requireActiveOrgOrExit();
837
+ const result = await api('POST', `/api/organizations/${orgId}/notifications/test`, {});
838
+ if (result.success) {
839
+ console.log(chalk.green(`Test notification sent via ${result.channel}. Check your Slack channel.`));
840
+ }
841
+ else {
842
+ console.error(chalk.red(`Test failed (${result.channel}): ${result.error}`));
843
+ process.exit(1);
844
+ }
845
+ }
846
+ catch (err) {
847
+ console.error(chalk.red(err.message));
848
+ process.exit(1);
849
+ }
850
+ });
656
851
  // ── Worksheets ──────────────────────────────────────────────
657
852
  const ws = program.command('worksheets').alias('ws').description('Worksheet commands');
658
853
  ws
@@ -1727,6 +1922,16 @@ function allSteps(definition) {
1727
1922
  function findStepById(definition, stepId) {
1728
1923
  return allSteps(definition).find((s) => s.id === stepId);
1729
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
+ }
1730
1935
  const VALID_IO_TYPES = ['text', 'markdown', 'number', 'boolean', 'url', 'json', 'object', 'array'];
1731
1936
  function requireDraftStatus(template) {
1732
1937
  if (template.status !== 'draft') {
@@ -1750,6 +1955,7 @@ const templates = program.command('templates').alias('tpl').description('Pipelin
1750
1955
  ' 4. tpl add-output / add-input Wire I/O between steps\n' +
1751
1956
  ' 5. tpl validate <id> Check for errors\n' +
1752
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' +
1753
1959
  ' Use "tpl steps <id>" to list step IDs needed by --step options.\n' +
1754
1960
  ' Use "tpl show <id>" to see full structure with I/O declarations.');
1755
1961
  templates
@@ -1777,7 +1983,7 @@ templates
1777
1983
  });
1778
1984
  templates
1779
1985
  .command('show')
1780
- .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.')
1781
1987
  .argument('<id>', 'Template ID (use "tpl list" to find IDs)')
1782
1988
  .action(async (id) => {
1783
1989
  try {
@@ -1786,7 +1992,7 @@ templates
1786
1992
  console.log(chalk.cyan(t.name));
1787
1993
  console.log(` ID: ${t.id}`);
1788
1994
  console.log(` Status: ${t.status}`);
1789
- console.log(` Version: ${t.version}`);
1995
+ console.log(` Revision: ${t.version}`);
1790
1996
  // Pipeline-level inputs
1791
1997
  if (t.definition?.inputs?.length) {
1792
1998
  console.log(chalk.cyan('\n Pipeline Inputs:'));
@@ -1799,7 +2005,7 @@ templates
1799
2005
  }
1800
2006
  if (t.definition?.phases) {
1801
2007
  for (const phase of t.definition.phases) {
1802
- console.log(chalk.cyan(`\n Phase: ${phase.name}`));
2008
+ console.log(chalk.cyan(`\n Phase: ${phase.name}`) + chalk.dim(` ${phase.id}`));
1803
2009
  for (const step of phase.steps || []) {
1804
2010
  const reviewer = step.reviewer ? ` reviewer:${step.reviewer}` : '';
1805
2011
  const cycles = step.reviewer && step.max_review_cycles ? ` max-cycles:${step.max_review_cycles}` : '';
@@ -1815,8 +2021,9 @@ templates
1815
2021
  if (step.inputs?.length) {
1816
2022
  console.log(' Inputs:');
1817
2023
  for (const inp of step.inputs) {
1818
- if (inp.pipeline_input) {
1819
- console.log(` ← ${inp.name} ← pipeline.${inp.pipeline_input}`);
2024
+ const pName = getPipelineInputName(inp);
2025
+ if (pName) {
2026
+ console.log(` ← ${inp.name} ← pipeline.${pName}`);
1820
2027
  }
1821
2028
  else {
1822
2029
  const srcStep = findStepById(t.definition, inp.source_step_id);
@@ -1921,13 +2128,23 @@ templates
1921
2128
  description: s.description || '',
1922
2129
  step_type: s.executionMode === 'script' ? 'script' : (s.agent ? 'agent' : 'human'),
1923
2130
  role: s.role || s.agent || 'unassigned',
1924
- inputs: (s.inputs || []).map((inp) => ({
1925
- name: inp.name,
1926
- ...(inp.source_step_id ? { source_step_id: idMap.get(inp.source_step_id) || inp.source_step_id, source_output_name: inp.source_output_name } : {}),
1927
- ...(inp.pipeline_input ? { pipeline_input: inp.pipeline_input } : {}),
1928
- ...(inp.description ? { description: inp.description } : {}),
1929
- required: inp.required !== false,
1930
- })),
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
+ }),
1931
2148
  outputs: (s.outputs || []).map((o) => ({
1932
2149
  name: o.name,
1933
2150
  type: o.type || 'text',
@@ -1995,7 +2212,7 @@ templates
1995
2212
  // ── Template structure commands ─────────────────────────────
1996
2213
  templates
1997
2214
  .command('update')
1998
- .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.')
1999
2216
  .argument('<id>', 'Template ID')
2000
2217
  .option('-n, --name <name>', 'New name')
2001
2218
  .option('-d, --description <desc>', 'New description')
@@ -2082,9 +2299,9 @@ templates
2082
2299
  });
2083
2300
  templates
2084
2301
  .command('remove-phase')
2085
- .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.')
2086
2303
  .argument('<templateId>', 'Template ID')
2087
- .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
2304
+ .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases" or "tpl show" output)')
2088
2305
  .option('--force', 'Remove even if phase contains steps')
2089
2306
  .action(async (templateId, opts) => {
2090
2307
  try {
@@ -2113,9 +2330,9 @@ templates
2113
2330
  });
2114
2331
  templates
2115
2332
  .command('edit-phase')
2116
- .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.')
2117
2334
  .argument('<templateId>', 'Template ID')
2118
- .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl show" output)')
2335
+ .requiredOption('--phase <phaseId>', 'Phase ID (from "tpl phases" or "tpl show" output)')
2119
2336
  .option('--name <name>', 'New phase name (must be unique within the template)')
2120
2337
  .option('--description <desc>', 'New phase description')
2121
2338
  .action(async (templateId, opts) => {
@@ -2151,9 +2368,9 @@ templates
2151
2368
  });
2152
2369
  templates
2153
2370
  .command('add-step')
2154
- .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.')
2155
2372
  .argument('<templateId>', 'Template ID')
2156
- .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)')
2157
2374
  .requiredOption('--name <name>', 'Step name (must be unique within the phase)')
2158
2375
  .option('--type <type>', 'Step type: human (manual task), agent (AI agent), script (automated), gate (approval checkpoint)', 'human')
2159
2376
  .option('--role <role>', 'Role or agent name (e.g., "kate", "marco", "dev-team")')
@@ -2166,6 +2383,9 @@ templates
2166
2383
  .option('--timeout <seconds>', 'Timeout in seconds for script steps', parseNumber)
2167
2384
  .option('--working-dir <dir>', 'Working directory for script steps (absolute path)')
2168
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)
2169
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')
2170
2390
  .action(async (templateId, opts) => {
2171
2391
  try {
@@ -2206,6 +2426,9 @@ templates
2206
2426
  execConfig.env = parseJson(opts.env, '--env');
2207
2427
  }
2208
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);
2209
2432
  const step = {
2210
2433
  id: crypto.randomUUID(),
2211
2434
  name: opts.name,
@@ -2214,16 +2437,37 @@ templates
2214
2437
  role: opts.role || 'unassigned',
2215
2438
  inputs: [],
2216
2439
  outputs: [],
2217
- sections: '',
2440
+ sections: opts.sections ? resolveFileOrInline(opts.sections) : '',
2218
2441
  reviewer: opts.reviewer || null,
2219
2442
  max_review_cycles: parseInt(opts.maxReviewCycles) || 0,
2220
2443
  execution_config: execConfig,
2221
2444
  parallel_group: opts.parallelGroup || null,
2222
- sort_order: phase.steps.length,
2445
+ sort_order: maxSortOrder + 1,
2223
2446
  };
2224
2447
  if (opts.agentConfig)
2225
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
+ }
2226
2469
  phase.steps.push(step);
2470
+ phase.steps.sort((a, b) => a.sort_order - b.sort_order);
2227
2471
  await api('PUT', `/api/pipeline-templates/${templateId}`, { definition: t.definition });
2228
2472
  console.log(chalk.green(`Added step: ${opts.name} (${opts.type}) to phase ${phase.name}`));
2229
2473
  console.log(` ID: ${step.id}`);
@@ -2278,7 +2522,7 @@ templates
2278
2522
  });
2279
2523
  templates
2280
2524
  .command('edit-step')
2281
- .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.')
2282
2526
  .argument('<templateId>', 'Template ID')
2283
2527
  .requiredOption('--step <stepId>', 'Step ID (from "tpl steps" output)')
2284
2528
  .option('--name <name>', 'New step name (must be unique within the phase)')
@@ -2293,13 +2537,14 @@ templates
2293
2537
  .option('--timeout <seconds>', 'Timeout in seconds for script steps', parseNumber)
2294
2538
  .option('--working-dir <dir>', 'Working directory for script steps (absolute path)')
2295
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)')
2296
2541
  .option('--agent-config <json>', 'Per-step agent config overrides as JSON (merges with existing)')
2297
2542
  .action(async (templateId, opts) => {
2298
2543
  try {
2299
2544
  const hasUpdate = opts.name || opts.type || opts.role || opts.description != null
2300
2545
  || opts.reviewer != null || opts.maxReviewCycles != null || opts.parallelGroup != null
2301
2546
  || opts.image || opts.command || opts.timeout !== undefined || opts.workingDir || opts.env
2302
- || opts.agentConfig;
2547
+ || opts.agentConfig || opts.sections != null;
2303
2548
  if (!hasUpdate) {
2304
2549
  console.error(chalk.red('Provide at least one field to update'));
2305
2550
  process.exit(1);
@@ -2361,6 +2606,9 @@ templates
2361
2606
  step.execution_config.env = parseJson(opts.env, '--env');
2362
2607
  }
2363
2608
  }
2609
+ // sections (agent instructions)
2610
+ if (opts.sections != null)
2611
+ step.sections = resolveFileOrInline(opts.sections);
2364
2612
  // agent_config overrides (merge with existing)
2365
2613
  if (opts.agentConfig) {
2366
2614
  step.agent_config = { ...(step.agent_config || {}), ...parseJson(opts.agentConfig, '--agent-config') };
@@ -2374,6 +2622,28 @@ templates
2374
2622
  }
2375
2623
  });
2376
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
+ });
2377
2647
  templates
2378
2648
  .command('steps')
2379
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.')
@@ -2547,11 +2817,13 @@ templates
2547
2817
  }
2548
2818
  const input = { name: opts.name, required: !opts.optional };
2549
2819
  if (opts.sourceStep) {
2820
+ input.source = 'step';
2550
2821
  input.source_step_id = opts.sourceStep;
2551
2822
  input.source_output_name = opts.sourceOutput;
2552
2823
  }
2553
2824
  else {
2554
- input.pipeline_input = opts.pipelineInput;
2825
+ input.source = 'pipeline';
2826
+ input.source_pipeline_input = opts.pipelineInput;
2555
2827
  }
2556
2828
  if (opts.description)
2557
2829
  input.description = opts.description;
@@ -2656,7 +2928,7 @@ templates
2656
2928
  process.exit(1);
2657
2929
  }
2658
2930
  // Block if steps reference it unless --force
2659
- 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));
2660
2932
  if (refs.length && !opts.force) {
2661
2933
  console.error(chalk.yellow(`Pipeline input "${opts.name}" is referenced by: ${refs.map((r) => r.name).join(', ')}`));
2662
2934
  console.error(chalk.yellow('Use --force to remove anyway'));
@@ -2730,9 +3002,10 @@ templates
2730
3002
  errors++;
2731
3003
  }
2732
3004
  }
2733
- if (inp.pipeline_input) {
2734
- if (!pipelineInputs.find((p) => p.name === inp.pipeline_input)) {
2735
- 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}"`));
2736
3009
  hasUnresolved = true;
2737
3010
  errors++;
2738
3011
  }
@@ -4285,6 +4558,97 @@ agentsCmd
4285
4558
  process.exit(1);
4286
4559
  }
4287
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
+ });
4288
4652
  // ── Health ──────────────────────────────────────────────────
4289
4653
  program
4290
4654
  .command('health')