@formigio/fazemos-cli 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +989 -44
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -552,6 +552,7 @@ orgs
|
|
|
552
552
|
}
|
|
553
553
|
const data = await api('POST', `/api/organizations/${orgId}/members`, {
|
|
554
554
|
displayName: opts.name,
|
|
555
|
+
memberType: 'agent',
|
|
555
556
|
role: opts.role,
|
|
556
557
|
});
|
|
557
558
|
console.log(chalk.green(`Created agent: ${data.member.display_name}`));
|
|
@@ -5088,39 +5089,260 @@ agentsCmd
|
|
|
5088
5089
|
process.exit(1);
|
|
5089
5090
|
}
|
|
5090
5091
|
});
|
|
5092
|
+
// ── F17 — Project-scoped agents ─────────────────────────────────
|
|
5093
|
+
// `agents list` / `agents show` become Project-scoped by default. The
|
|
5094
|
+
// pre-F17 Org-level list is still reachable via `--org-only`. Execution
|
|
5095
|
+
// status, heartbeats, metrics, budget, and events are unchanged — they
|
|
5096
|
+
// live on their own subcommands (agents metrics / work / events / budget /
|
|
5097
|
+
// heartbeat) and are not Project-scoped.
|
|
5098
|
+
//
|
|
5099
|
+
// Spec: specs/tech/platform/F17-project-scoped-agent-resolution-tech-spec.md §8
|
|
5100
|
+
// UX: features/platform/F17-project-scoped-agent-resolution-ux.md §6
|
|
5101
|
+
/**
|
|
5102
|
+
* F17 §8.1 — "No active Project AND no override flag" error block.
|
|
5103
|
+
* Q1 ruling (2026-04-20, tech spec §15 item 12): fail with an actionable
|
|
5104
|
+
* multi-line message, exit code 1. NO agents roster is rendered.
|
|
5105
|
+
*
|
|
5106
|
+
* The exact copy is locked in by the tech spec and must match verbatim —
|
|
5107
|
+
* keep this the single call site so it stays canonical.
|
|
5108
|
+
*/
|
|
5109
|
+
function exitNoActiveProjectForAgentsList() {
|
|
5110
|
+
console.error(chalk.red('Error: No active Project.'));
|
|
5111
|
+
console.error(' Set one with: fazemos projects switch <slug>');
|
|
5112
|
+
console.error(' Or override: fazemos agents list --all-projects');
|
|
5113
|
+
console.error(' fazemos agents list --org-only');
|
|
5114
|
+
process.exit(1);
|
|
5115
|
+
}
|
|
5116
|
+
/**
|
|
5117
|
+
* Render one row of the project-agents table. Shared between the default
|
|
5118
|
+
* (active-Project) and `--all-projects` paths so column widths and source
|
|
5119
|
+
* token formatting stay identical.
|
|
5120
|
+
*
|
|
5121
|
+
* Source tokens: the API returns `project_override` / `project` / `org`;
|
|
5122
|
+
* display strips the underscore (tech spec §8.1 "Source column uses the
|
|
5123
|
+
* exact tokens from the API `source` field", but the illustrative output
|
|
5124
|
+
* in §8.1 prints `project-override` hyphenated — matching the UX §6.1
|
|
5125
|
+
* display).
|
|
5126
|
+
*/
|
|
5127
|
+
function formatAgentSource(source) {
|
|
5128
|
+
if (source === 'project_override')
|
|
5129
|
+
return 'project-override';
|
|
5130
|
+
return source;
|
|
5131
|
+
}
|
|
5132
|
+
/**
|
|
5133
|
+
* Column-align a table — pad the first N-1 cells to the widest value in
|
|
5134
|
+
* each column. Last column isn't padded (no trailing whitespace). Keeps
|
|
5135
|
+
* the output readable without a dep on an ASCII-table library.
|
|
5136
|
+
*/
|
|
5137
|
+
function padTable(rows) {
|
|
5138
|
+
if (!rows.length)
|
|
5139
|
+
return [];
|
|
5140
|
+
const cols = rows[0].length;
|
|
5141
|
+
const widths = new Array(cols).fill(0);
|
|
5142
|
+
for (const row of rows) {
|
|
5143
|
+
for (let c = 0; c < cols; c++) {
|
|
5144
|
+
if (row[c].length > widths[c])
|
|
5145
|
+
widths[c] = row[c].length;
|
|
5146
|
+
}
|
|
5147
|
+
}
|
|
5148
|
+
return rows.map((row) => row
|
|
5149
|
+
.map((cell, c) => (c === cols - 1 ? cell : cell.padEnd(widths[c])))
|
|
5150
|
+
.join(' '));
|
|
5151
|
+
}
|
|
5091
5152
|
agentsCmd
|
|
5092
5153
|
.command('list')
|
|
5093
|
-
.description('List
|
|
5094
|
-
.option('
|
|
5154
|
+
.description('List agents available to the active Project (org baseline + Project overrides/additions). Use --org-only for the Org-level roster, --all-projects to see all Projects side-by-side.')
|
|
5155
|
+
.option('--project <slug>', 'Override active project for this call')
|
|
5156
|
+
.option('--all-projects', 'List agents across every Project in the active Org', false)
|
|
5157
|
+
.option('--org-only', 'Show only the Org-level agent roster (pre-F17 behavior)', false)
|
|
5095
5158
|
.action(async (opts) => {
|
|
5096
5159
|
try {
|
|
5097
|
-
const
|
|
5098
|
-
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5160
|
+
const orgId = requireActiveOrgOrExit();
|
|
5161
|
+
// --org-only: pre-F17 behavior. Calls /api/agents directly; no
|
|
5162
|
+
// Project context. Useful for admins auditing the Org baseline.
|
|
5163
|
+
if (opts.orgOnly) {
|
|
5164
|
+
const data = await api('GET', '/api/agents', undefined, { noProjectHeader: true });
|
|
5165
|
+
if (!data.agents?.length) {
|
|
5166
|
+
console.log(chalk.yellow('No agents registered at the Org level.'));
|
|
5167
|
+
return;
|
|
5168
|
+
}
|
|
5169
|
+
const org = findOrgById(orgId);
|
|
5170
|
+
console.log(chalk.cyan(`Org agents: ${org?.name ?? orgId}`));
|
|
5171
|
+
console.log('');
|
|
5172
|
+
const rows = [['NAME', 'STATUS', 'MODEL']];
|
|
5173
|
+
for (const a of data.agents) {
|
|
5174
|
+
rows.push([
|
|
5175
|
+
a.display_name,
|
|
5176
|
+
a.agent_status ?? 'unknown',
|
|
5177
|
+
a.agent_config?.model ?? '',
|
|
5178
|
+
]);
|
|
5179
|
+
}
|
|
5180
|
+
for (const line of padTable(rows))
|
|
5181
|
+
console.log(line);
|
|
5104
5182
|
return;
|
|
5105
5183
|
}
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
const
|
|
5112
|
-
const
|
|
5113
|
-
|
|
5184
|
+
// --all-projects: iterate Projects in the active Org, render a
|
|
5185
|
+
// flat table with project_slug column.
|
|
5186
|
+
if (opts.allProjects) {
|
|
5187
|
+
// Refresh /auth/me so newly-created Projects are visible.
|
|
5188
|
+
const me = await refreshAuthMeCache();
|
|
5189
|
+
const meOrg = me?.orgs.find((o) => o.id === orgId) ?? findOrgById(orgId);
|
|
5190
|
+
const projects = (meOrg?.projects ?? []).filter((p) => p.status === 'active');
|
|
5191
|
+
if (!projects.length) {
|
|
5192
|
+
console.log(chalk.yellow('No active projects in this organization.'));
|
|
5193
|
+
return;
|
|
5194
|
+
}
|
|
5195
|
+
const rows = [['PROJECT', 'NAME', 'SOURCE', 'STATUS', 'MODEL']];
|
|
5196
|
+
const excludedRows = [];
|
|
5197
|
+
for (const p of projects) {
|
|
5198
|
+
try {
|
|
5199
|
+
const data = await api('GET', `/api/organizations/${orgId}/projects/${p.id}/agents`, undefined, { noProjectHeader: true });
|
|
5200
|
+
for (const a of data.agents ?? []) {
|
|
5201
|
+
rows.push([
|
|
5202
|
+
p.slug,
|
|
5203
|
+
a.name,
|
|
5204
|
+
formatAgentSource(a.source),
|
|
5205
|
+
a.status ?? 'active',
|
|
5206
|
+
a.effective_config?.model ?? '',
|
|
5207
|
+
]);
|
|
5208
|
+
}
|
|
5209
|
+
for (const ex of data.excluded ?? []) {
|
|
5210
|
+
excludedRows.push([
|
|
5211
|
+
p.slug,
|
|
5212
|
+
ex.name,
|
|
5213
|
+
'excluded',
|
|
5214
|
+
'—',
|
|
5215
|
+
'—',
|
|
5216
|
+
]);
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
catch (err) {
|
|
5220
|
+
// Don't break the whole list on one Project's fetch failure —
|
|
5221
|
+
// log it inline and continue. Matches the pattern of
|
|
5222
|
+
// `projects list` with stats errors.
|
|
5223
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5224
|
+
console.error(chalk.yellow(` (warning) ${p.slug}: ${msg}`));
|
|
5225
|
+
}
|
|
5226
|
+
}
|
|
5227
|
+
if (rows.length === 1 && !excludedRows.length) {
|
|
5228
|
+
console.log(chalk.yellow('No agents resolved for any Project.'));
|
|
5229
|
+
return;
|
|
5230
|
+
}
|
|
5231
|
+
// Merge active + excluded rows into one alignment pass so the
|
|
5232
|
+
// separator row lines up with the main table.
|
|
5233
|
+
const activeCount = rows.length - 1;
|
|
5234
|
+
const allRows = [...rows, ...excludedRows];
|
|
5235
|
+
const formatted = padTable(allRows);
|
|
5236
|
+
for (let i = 0; i <= activeCount; i++)
|
|
5237
|
+
console.log(formatted[i]);
|
|
5238
|
+
if (excludedRows.length) {
|
|
5239
|
+
console.log('-----');
|
|
5240
|
+
for (let i = activeCount + 1; i < formatted.length; i++)
|
|
5241
|
+
console.log(formatted[i]);
|
|
5242
|
+
}
|
|
5243
|
+
return;
|
|
5244
|
+
}
|
|
5245
|
+
// Default: Project-scoped. Require an active Project OR --project
|
|
5246
|
+
// override; else emit the Q1 error and exit 1.
|
|
5247
|
+
const projectId = await resolveProjectIdForAgentsList(opts.project);
|
|
5248
|
+
if (!projectId)
|
|
5249
|
+
exitNoActiveProjectForAgentsList();
|
|
5250
|
+
const proj = findProjectById(orgId, projectId);
|
|
5251
|
+
const org = findOrgById(orgId);
|
|
5252
|
+
const data = await api('GET', `/api/organizations/${orgId}/projects/${projectId}/agents`, undefined, { noProjectHeader: true });
|
|
5253
|
+
const projName = proj?.name ?? proj?.slug ?? projectId;
|
|
5254
|
+
const orgName = org?.name ?? orgId;
|
|
5255
|
+
console.log(chalk.cyan(`Project: ${projName} (${orgName})`));
|
|
5256
|
+
console.log('');
|
|
5257
|
+
if (!data.agents?.length && !data.excluded?.length) {
|
|
5258
|
+
console.log(chalk.yellow('No agents available in this Project.'));
|
|
5259
|
+
if (data.org_agent_count === 0) {
|
|
5260
|
+
console.log(chalk.gray('Your Org has no agents yet. Create one with: fazemos agents register --name <name>'));
|
|
5261
|
+
}
|
|
5262
|
+
return;
|
|
5263
|
+
}
|
|
5264
|
+
// Build one unified rows array so header + active rows + excluded
|
|
5265
|
+
// rows share column widths. Excluded rows render below a dashed
|
|
5266
|
+
// separator per tech spec §8.1.
|
|
5267
|
+
const rows = [['NAME', 'SOURCE', 'STATUS', 'MODEL']];
|
|
5268
|
+
const activeCount = data.agents?.length ?? 0;
|
|
5269
|
+
for (const a of data.agents ?? []) {
|
|
5270
|
+
rows.push([
|
|
5271
|
+
a.name,
|
|
5272
|
+
formatAgentSource(a.source),
|
|
5273
|
+
a.status ?? 'active',
|
|
5274
|
+
a.effective_config?.model ?? '',
|
|
5275
|
+
]);
|
|
5276
|
+
}
|
|
5277
|
+
for (const ex of data.excluded ?? []) {
|
|
5278
|
+
rows.push([ex.name, 'excluded', '—', '—']);
|
|
5279
|
+
}
|
|
5280
|
+
const formatted = padTable(rows);
|
|
5281
|
+
// Header + active rows first.
|
|
5282
|
+
for (let i = 0; i <= activeCount; i++)
|
|
5283
|
+
console.log(formatted[i]);
|
|
5284
|
+
if ((data.excluded?.length ?? 0) > 0) {
|
|
5285
|
+
console.log('-----');
|
|
5286
|
+
for (let i = activeCount + 1; i < formatted.length; i++)
|
|
5287
|
+
console.log(formatted[i]);
|
|
5114
5288
|
}
|
|
5115
5289
|
}
|
|
5116
5290
|
catch (err) {
|
|
5117
|
-
|
|
5118
|
-
process.exit(1);
|
|
5291
|
+
handleScopedError(err);
|
|
5119
5292
|
}
|
|
5120
5293
|
});
|
|
5294
|
+
/**
|
|
5295
|
+
* Resolve the projectId to use for Project-scoped `agents` subcommands.
|
|
5296
|
+
* Returns the active projectId (from config) or the `--project <slug>`
|
|
5297
|
+
* override resolved through the /auth/me cache. Returns `null` when no
|
|
5298
|
+
* scope is available — the caller decides what error message to emit
|
|
5299
|
+
* (agents-list uses the Q1 multi-line copy; other subcommands use
|
|
5300
|
+
* handleScopedError via the shared MISSING_PROJECT_CONTEXT path).
|
|
5301
|
+
*/
|
|
5302
|
+
async function resolveProjectIdForAgentsList(slugOverride) {
|
|
5303
|
+
if (slugOverride) {
|
|
5304
|
+
const orgId = getActiveOrgId();
|
|
5305
|
+
if (!orgId)
|
|
5306
|
+
return null;
|
|
5307
|
+
let project = findProjectBySlug(orgId, slugOverride);
|
|
5308
|
+
if (!project) {
|
|
5309
|
+
await refreshAuthMeCache();
|
|
5310
|
+
project = findProjectBySlug(orgId, slugOverride);
|
|
5311
|
+
}
|
|
5312
|
+
if (!project) {
|
|
5313
|
+
console.error(chalk.red(`Unknown project: ${slugOverride}`));
|
|
5314
|
+
console.error(chalk.gray('Run: fazemos projects list'));
|
|
5315
|
+
process.exit(1);
|
|
5316
|
+
}
|
|
5317
|
+
return project.id;
|
|
5318
|
+
}
|
|
5319
|
+
return getActiveProjectId();
|
|
5320
|
+
}
|
|
5321
|
+
/**
|
|
5322
|
+
* Require a Project scope for a Project-scoped `agents` subcommand
|
|
5323
|
+
* (override/exclude/add/revert/remove). Resolves --project override or
|
|
5324
|
+
* active Project; exits 1 with the uniform "requirement missing: project"
|
|
5325
|
+
* error (matches F15 KD9) if neither is set.
|
|
5326
|
+
*/
|
|
5327
|
+
async function requireProjectForAgents(slugOverride) {
|
|
5328
|
+
const orgId = requireActiveOrgOrExit();
|
|
5329
|
+
const projectId = await resolveProjectIdForAgentsList(slugOverride);
|
|
5330
|
+
if (!projectId) {
|
|
5331
|
+
console.error(chalk.red('Error: requirement missing: project'));
|
|
5332
|
+
console.error('');
|
|
5333
|
+
console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
|
|
5334
|
+
console.error(chalk.gray('Or pass: --project <slug>'));
|
|
5335
|
+
process.exit(1);
|
|
5336
|
+
}
|
|
5337
|
+
return { orgId, projectId };
|
|
5338
|
+
}
|
|
5339
|
+
// F17 — `agents show <name>` is redefined to show the effective Project
|
|
5340
|
+
// config + merge-source breakdown (tech spec §8.2). The pre-F17 "agent
|
|
5341
|
+
// runtime status / current execution" view lives on `agents status` so
|
|
5342
|
+
// operators who want heartbeat + running execution info still have it.
|
|
5121
5343
|
agentsCmd
|
|
5122
|
-
.command('
|
|
5123
|
-
.description('Show agent status
|
|
5344
|
+
.command('status')
|
|
5345
|
+
.description('Show agent runtime status (heartbeat, current execution, total cost). Pre-F17 behavior; see `agents show` for Project-scoped effective config.')
|
|
5124
5346
|
.argument('<id>', 'Agent name or member ID')
|
|
5125
5347
|
.action(async (id) => {
|
|
5126
5348
|
try {
|
|
@@ -5150,6 +5372,605 @@ agentsCmd
|
|
|
5150
5372
|
process.exit(1);
|
|
5151
5373
|
}
|
|
5152
5374
|
});
|
|
5375
|
+
/**
|
|
5376
|
+
* F17 §8.2 — `agents show <name>`: effective Project config with
|
|
5377
|
+
* merge-source breakdown. Renders:
|
|
5378
|
+
* - Agent name + display_name + source (one of: org / project /
|
|
5379
|
+
* project-override)
|
|
5380
|
+
* - EFFECTIVE CONFIG block with the merged, ready-to-use config
|
|
5381
|
+
* - SOURCE BREAKDOWN block (for 'project_override' only) listing
|
|
5382
|
+
* which fields came from the override vs inherited from Org
|
|
5383
|
+
* - Timestamp footer (Org baseline updated + override created)
|
|
5384
|
+
* - F18 forward-compat: `Role doc: <path>` line when role_doc_path
|
|
5385
|
+
* is populated; omitted when null (v1 behavior)
|
|
5386
|
+
*/
|
|
5387
|
+
agentsCmd
|
|
5388
|
+
.command('show')
|
|
5389
|
+
.description('Show the effective agent config for this Project, including merge-source breakdown when the agent has a Project override.')
|
|
5390
|
+
.argument('<name>', 'Agent name (e.g., marco, kate)')
|
|
5391
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5392
|
+
.action(async (name, opts) => {
|
|
5393
|
+
try {
|
|
5394
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5395
|
+
const data = await api('GET', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, undefined, { noProjectHeader: true });
|
|
5396
|
+
const a = data.agent;
|
|
5397
|
+
const proj = findProjectById(orgId, projectId);
|
|
5398
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5399
|
+
const sourceLabel = formatAgentSource(a.source);
|
|
5400
|
+
console.log(chalk.cyan(`Agent: ${a.name} (${a.display_name})`));
|
|
5401
|
+
console.log(`Project: ${projSlug} · Source: ${sourceLabel}`);
|
|
5402
|
+
console.log('');
|
|
5403
|
+
// EFFECTIVE CONFIG
|
|
5404
|
+
console.log(chalk.cyan('EFFECTIVE CONFIG'));
|
|
5405
|
+
const eff = (a.effective_config ?? {});
|
|
5406
|
+
for (const key of Object.keys(eff).sort()) {
|
|
5407
|
+
const v = eff[key];
|
|
5408
|
+
if (typeof v === 'string') {
|
|
5409
|
+
if (v.length > 120) {
|
|
5410
|
+
// Pretty-print long system prompts with soft wrap preserved.
|
|
5411
|
+
console.log(` ${key}: ${JSON.stringify(v.slice(0, 120) + '...')}`);
|
|
5412
|
+
}
|
|
5413
|
+
else {
|
|
5414
|
+
console.log(` ${key}: ${JSON.stringify(v)}`);
|
|
5415
|
+
}
|
|
5416
|
+
}
|
|
5417
|
+
else {
|
|
5418
|
+
console.log(` ${key}: ${JSON.stringify(v)}`);
|
|
5419
|
+
}
|
|
5420
|
+
}
|
|
5421
|
+
// SOURCE BREAKDOWN
|
|
5422
|
+
console.log('');
|
|
5423
|
+
if (a.source === 'project_override') {
|
|
5424
|
+
console.log(chalk.cyan('SOURCE BREAKDOWN'));
|
|
5425
|
+
const overrideFields = new Set(a.override_fields ?? []);
|
|
5426
|
+
const allFields = new Set([
|
|
5427
|
+
...Object.keys(eff),
|
|
5428
|
+
...overrideFields,
|
|
5429
|
+
]);
|
|
5430
|
+
const padKey = Math.max(...Array.from(allFields).map((k) => k.length), 4);
|
|
5431
|
+
for (const key of Array.from(allFields).sort()) {
|
|
5432
|
+
const src = overrideFields.has(key)
|
|
5433
|
+
? 'Project override (replaces Org value)'
|
|
5434
|
+
: 'Org (inherited)';
|
|
5435
|
+
console.log(` ${key.padEnd(padKey)} ← ${src}`);
|
|
5436
|
+
}
|
|
5437
|
+
}
|
|
5438
|
+
else if (a.source === 'project') {
|
|
5439
|
+
console.log(chalk.gray('Project-only — not available in other Projects.'));
|
|
5440
|
+
}
|
|
5441
|
+
else {
|
|
5442
|
+
console.log(chalk.gray('Using Org-level config — no Project override active.'));
|
|
5443
|
+
}
|
|
5444
|
+
// Timestamps
|
|
5445
|
+
console.log('');
|
|
5446
|
+
if (a.org_baseline_updated_at) {
|
|
5447
|
+
console.log(chalk.gray(`Org baseline last updated: ${new Date(a.org_baseline_updated_at).toISOString().slice(0, 10)}`));
|
|
5448
|
+
}
|
|
5449
|
+
if (a.override_updated_at && a.source !== 'org') {
|
|
5450
|
+
const label = a.source === 'project_override' ? 'Override created' : 'Project-only created';
|
|
5451
|
+
console.log(chalk.gray(`${label}: ${new Date(a.override_updated_at).toISOString().slice(0, 10)}`));
|
|
5452
|
+
}
|
|
5453
|
+
// F18 forward-compat: role doc line only when populated.
|
|
5454
|
+
if (a.role_doc_path) {
|
|
5455
|
+
const level = a.role_doc_level ? ` (${a.role_doc_level})` : '';
|
|
5456
|
+
console.log('');
|
|
5457
|
+
console.log(chalk.gray(`Role doc: ${a.role_doc_path}${level}`));
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
catch (err) {
|
|
5461
|
+
if (err instanceof ApiError && err.code === 'AGENT_NOT_FOUND') {
|
|
5462
|
+
console.error(chalk.red(`Agent '${name}' not found in this Project.`));
|
|
5463
|
+
console.error(chalk.gray('Run: fazemos agents list'));
|
|
5464
|
+
process.exit(1);
|
|
5465
|
+
}
|
|
5466
|
+
handleScopedError(err);
|
|
5467
|
+
}
|
|
5468
|
+
});
|
|
5469
|
+
// ── F17 §8.3-8.6 — Project-scoped write commands ────────────────────────
|
|
5470
|
+
//
|
|
5471
|
+
// Shared helpers:
|
|
5472
|
+
// resolveConfigArg — accept a JSON literal OR a file path (ends in
|
|
5473
|
+
// .json, OR has no leading '{'). Matches the spec's
|
|
5474
|
+
// "<json-or-file>" value shape.
|
|
5475
|
+
// confirmOrAbort — prompt unless --yes. Returns on accept, exits on
|
|
5476
|
+
// reject (non-destructive; exit code 0).
|
|
5477
|
+
// dryRunLabel — chalk tag for [DRY RUN] prefix on log lines.
|
|
5478
|
+
/**
|
|
5479
|
+
* Accept either inline JSON or a file path as the `--config` value. Matches
|
|
5480
|
+
* the pattern in `agents upload-all` and the UX §6.3 "if flag is a file
|
|
5481
|
+
* path ... reads JSON from that file" contract.
|
|
5482
|
+
*
|
|
5483
|
+
* Heuristic: leading `{` or `[` → parse as JSON literal. Otherwise treat
|
|
5484
|
+
* as a file path, read, and parse. Errors are surfaced with a clear
|
|
5485
|
+
* message identifying which input failed.
|
|
5486
|
+
*/
|
|
5487
|
+
function resolveConfigArg(val, flag) {
|
|
5488
|
+
const trimmed = val.trim();
|
|
5489
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
5490
|
+
try {
|
|
5491
|
+
return JSON.parse(trimmed);
|
|
5492
|
+
}
|
|
5493
|
+
catch (err) {
|
|
5494
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5495
|
+
console.error(chalk.red(`Invalid JSON for ${flag}: ${msg}`));
|
|
5496
|
+
process.exit(1);
|
|
5497
|
+
}
|
|
5498
|
+
}
|
|
5499
|
+
// File path. Resolve relative to cwd.
|
|
5500
|
+
const filePath = resolve(trimmed);
|
|
5501
|
+
let raw;
|
|
5502
|
+
try {
|
|
5503
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
5504
|
+
}
|
|
5505
|
+
catch (err) {
|
|
5506
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5507
|
+
console.error(chalk.red(`Cannot read ${flag} file "${filePath}": ${msg}`));
|
|
5508
|
+
process.exit(1);
|
|
5509
|
+
}
|
|
5510
|
+
try {
|
|
5511
|
+
return JSON.parse(raw);
|
|
5512
|
+
}
|
|
5513
|
+
catch (err) {
|
|
5514
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5515
|
+
console.error(chalk.red(`Invalid JSON in ${filePath}: ${msg}`));
|
|
5516
|
+
process.exit(1);
|
|
5517
|
+
}
|
|
5518
|
+
}
|
|
5519
|
+
/**
|
|
5520
|
+
* Y/N confirmation prompt. If `yes` is truthy, skips the prompt and
|
|
5521
|
+
* returns. Otherwise prints the prompt and accepts only a y/yes response;
|
|
5522
|
+
* anything else cancels with a "Cancelled." message and exits 0.
|
|
5523
|
+
*/
|
|
5524
|
+
async function confirmOrAbort(prompt, yes) {
|
|
5525
|
+
if (yes)
|
|
5526
|
+
return;
|
|
5527
|
+
const answer = await promptLine(prompt);
|
|
5528
|
+
if (!answer || !/^y(es)?$/i.test(answer.trim())) {
|
|
5529
|
+
console.log(chalk.gray('Cancelled.'));
|
|
5530
|
+
process.exit(0);
|
|
5531
|
+
}
|
|
5532
|
+
}
|
|
5533
|
+
/**
|
|
5534
|
+
* F17 Phase 3 Dex SB-2 — pre-check that the DELETE command matches the
|
|
5535
|
+
* agent's actual source before firing the destructive call.
|
|
5536
|
+
*
|
|
5537
|
+
* Without this: `agents revert` on a Project-only agent DELETEs the row
|
|
5538
|
+
* first, then the CLI detects `removed='project_only'` and tells the user
|
|
5539
|
+
* to use `remove` — but the agent is already gone. Non-idempotent.
|
|
5540
|
+
*
|
|
5541
|
+
* With this: GET the agent, compare source, bail out BEFORE the DELETE
|
|
5542
|
+
* if the caller used the wrong command.
|
|
5543
|
+
*
|
|
5544
|
+
* Returns silently when source matches; prints error + exits 1 otherwise.
|
|
5545
|
+
* If the agent doesn't exist (404), lets the caller's error-handler chain
|
|
5546
|
+
* surface the usual `AGENT_NOT_FOUND` copy.
|
|
5547
|
+
*/
|
|
5548
|
+
async function preCheckAgentSourceForDelete(opts) {
|
|
5549
|
+
let data;
|
|
5550
|
+
try {
|
|
5551
|
+
data = await api('GET', `/api/organizations/${opts.orgId}/projects/${opts.projectId}/agents/${encodeURIComponent(opts.name)}`, undefined, { noProjectHeader: true });
|
|
5552
|
+
}
|
|
5553
|
+
catch (err) {
|
|
5554
|
+
// Let the caller's error chain handle 404 / auth / etc.
|
|
5555
|
+
throw err;
|
|
5556
|
+
}
|
|
5557
|
+
const actual = data.agent.source;
|
|
5558
|
+
if (actual === opts.expectedSource)
|
|
5559
|
+
return;
|
|
5560
|
+
// Plain-English source label for the error.
|
|
5561
|
+
const actualLabel = actual === 'project' ? 'a Project-only agent' :
|
|
5562
|
+
actual === 'project_override' ? 'an Org agent with a Project override' :
|
|
5563
|
+
'an Org agent (no Project customization)';
|
|
5564
|
+
console.error(chalk.red(`${opts.name} is ${actualLabel}.`));
|
|
5565
|
+
console.error(chalk.gray(`Use: ${opts.wrongCommandHint} ${opts.name}`));
|
|
5566
|
+
process.exit(1);
|
|
5567
|
+
}
|
|
5568
|
+
/**
|
|
5569
|
+
* F17 §8.3 — `agents override <name>`: create/update a Project override
|
|
5570
|
+
* for the named Org agent. PATCH with `{override: <parsed>}`.
|
|
5571
|
+
*
|
|
5572
|
+
* --dry-run prints the shallow-merge diff and exits without calling the
|
|
5573
|
+
* API (Kate Q2). --yes bypasses the confirmation prompt.
|
|
5574
|
+
*/
|
|
5575
|
+
agentsCmd
|
|
5576
|
+
.command('override')
|
|
5577
|
+
.description('Create or update a Project-level override for an Org agent. Shallow-merge: each top-level field in --config replaces the Org baseline field entirely.')
|
|
5578
|
+
.argument('<name>', 'Agent name (e.g., marco)')
|
|
5579
|
+
.requiredOption('--config <json-or-file>', 'Override config as inline JSON or a file path')
|
|
5580
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5581
|
+
.option('--dry-run', 'Print the diff without calling the API', false)
|
|
5582
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
5583
|
+
.action(async (name, opts) => {
|
|
5584
|
+
try {
|
|
5585
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5586
|
+
const override = resolveConfigArg(opts.config, '--config');
|
|
5587
|
+
if (typeof override !== 'object' || override === null || Array.isArray(override)) {
|
|
5588
|
+
console.error(chalk.red('--config must be a JSON object, not an array or scalar.'));
|
|
5589
|
+
process.exit(1);
|
|
5590
|
+
}
|
|
5591
|
+
// Fetch current state so we can print a diff and confirm.
|
|
5592
|
+
let current;
|
|
5593
|
+
try {
|
|
5594
|
+
current = await api('GET', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, undefined, { noProjectHeader: true });
|
|
5595
|
+
}
|
|
5596
|
+
catch (err) {
|
|
5597
|
+
if (err instanceof ApiError && err.code === 'AGENT_NOT_FOUND') {
|
|
5598
|
+
console.error(chalk.red(`Agent '${name}' not found in this Project.`));
|
|
5599
|
+
console.error(chalk.gray('Use `fazemos agents add` to create a Project-only agent.'));
|
|
5600
|
+
process.exit(1);
|
|
5601
|
+
}
|
|
5602
|
+
throw err;
|
|
5603
|
+
}
|
|
5604
|
+
const existing = current.agent;
|
|
5605
|
+
const proj = findProjectById(orgId, projectId);
|
|
5606
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5607
|
+
const fieldsToUpdate = Object.keys(override);
|
|
5608
|
+
console.log(chalk.cyan(`Overriding ${name} for project: ${projSlug}`));
|
|
5609
|
+
if (existing.source === 'org') {
|
|
5610
|
+
console.log(`Source: org agent → project-override`);
|
|
5611
|
+
}
|
|
5612
|
+
else if (existing.source === 'project_override') {
|
|
5613
|
+
console.log(`Source: project-override → project-override (updating)`);
|
|
5614
|
+
}
|
|
5615
|
+
else if (existing.source === 'project') {
|
|
5616
|
+
console.log(chalk.yellow(`Warning: ${name} is a Project-only agent — this will be rejected by the API.`));
|
|
5617
|
+
console.log(chalk.gray('Use `fazemos agents add` (update) or PATCH via the UI.'));
|
|
5618
|
+
}
|
|
5619
|
+
console.log(`Fields to update: ${fieldsToUpdate.join(', ')}`);
|
|
5620
|
+
console.log('');
|
|
5621
|
+
console.log(chalk.gray('Shallow merge: each field you set replaces that field in full.'));
|
|
5622
|
+
console.log(chalk.gray('Arrays are not merged — they replace.'));
|
|
5623
|
+
console.log('');
|
|
5624
|
+
console.log(chalk.gray('Diff (Org baseline → Project override):'));
|
|
5625
|
+
const baseline = (existing.org_baseline_config ?? {});
|
|
5626
|
+
for (const k of fieldsToUpdate) {
|
|
5627
|
+
const before = baseline[k];
|
|
5628
|
+
const after = override[k];
|
|
5629
|
+
const beforeStr = before === undefined ? chalk.gray('(unset)') : JSON.stringify(before).slice(0, 80);
|
|
5630
|
+
const afterStr = JSON.stringify(after).slice(0, 80);
|
|
5631
|
+
console.log(` ${k}:`);
|
|
5632
|
+
console.log(` − ${beforeStr}`);
|
|
5633
|
+
console.log(` + ${afterStr}`);
|
|
5634
|
+
}
|
|
5635
|
+
console.log('');
|
|
5636
|
+
if (opts.dryRun) {
|
|
5637
|
+
console.log(chalk.yellow('[dry run] No changes sent to the API.'));
|
|
5638
|
+
return;
|
|
5639
|
+
}
|
|
5640
|
+
if (existing.source === 'org' || existing.source === 'project_override') {
|
|
5641
|
+
console.log(`This will diverge ${name}'s config in ${projSlug} from the Org baseline.`);
|
|
5642
|
+
console.log(`Future Org-level changes to these fields will not propagate.`);
|
|
5643
|
+
await confirmOrAbort('Proceed? [y/N]: ', !!opts.yes);
|
|
5644
|
+
}
|
|
5645
|
+
const result = await api('PATCH', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, { override }, { noProjectHeader: true });
|
|
5646
|
+
console.log(chalk.green(`${result.agent.name} override saved.`));
|
|
5647
|
+
}
|
|
5648
|
+
catch (err) {
|
|
5649
|
+
if (err instanceof ApiError && err.code === 'ORG_AGENT_NOT_FOUND') {
|
|
5650
|
+
console.error(chalk.red(err.message));
|
|
5651
|
+
console.error(chalk.gray('Use `fazemos agents add` to create a Project-only agent.'));
|
|
5652
|
+
process.exit(1);
|
|
5653
|
+
}
|
|
5654
|
+
if (err instanceof ApiError && err.code === 'AGENT_EXCLUDED_USE_REINCLUDE_FIRST') {
|
|
5655
|
+
console.error(chalk.red(err.message));
|
|
5656
|
+
console.error(chalk.gray('Run: fazemos agents list (to see excluded agents)'));
|
|
5657
|
+
console.error(chalk.gray('Then: PATCH via the UI or a future CLI re-include subcommand.'));
|
|
5658
|
+
process.exit(1);
|
|
5659
|
+
}
|
|
5660
|
+
handleScopedError(err);
|
|
5661
|
+
}
|
|
5662
|
+
});
|
|
5663
|
+
/**
|
|
5664
|
+
* F17 §8.4 — `agents exclude <name>`: PATCH with `{excluded: true}`.
|
|
5665
|
+
*
|
|
5666
|
+
* Before writing, fetches templates for the active Project and greps each
|
|
5667
|
+
* one's definition for the agent name to produce the impact count in the
|
|
5668
|
+
* confirmation prompt. --dry-run prints the impact and exits; --yes
|
|
5669
|
+
* bypasses confirmation.
|
|
5670
|
+
*
|
|
5671
|
+
* Q6 (tech spec §15 item 13): shadow-on-excluded refuse behavior is
|
|
5672
|
+
* server-side. CLI doesn't need special handling.
|
|
5673
|
+
*/
|
|
5674
|
+
agentsCmd
|
|
5675
|
+
.command('exclude')
|
|
5676
|
+
.description('Exclude an Org agent from the active Project. Pipelines that reference this agent in this Project will fail.')
|
|
5677
|
+
.argument('<name>', 'Agent name (e.g., atlas)')
|
|
5678
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5679
|
+
.option('--dry-run', 'Print impact without calling the API', false)
|
|
5680
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
5681
|
+
.action(async (name, opts) => {
|
|
5682
|
+
try {
|
|
5683
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5684
|
+
const proj = findProjectById(orgId, projectId);
|
|
5685
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5686
|
+
// Preflight: fetch templates in this Project, grep for agent name in
|
|
5687
|
+
// each template's definition.phases[].steps[].role. Per spec §8.4.
|
|
5688
|
+
//
|
|
5689
|
+
// This is N+1 (list + detail per template), but Projects typically
|
|
5690
|
+
// have 3-10 templates so the latency is fine. If that grows we can
|
|
5691
|
+
// switch to a server-side `?ref_agent=<name>` query — not needed in v1.
|
|
5692
|
+
const tplList = await api('GET', `/api/pipeline-templates`, undefined, { noProjectHeader: false } // uses active Project header
|
|
5693
|
+
);
|
|
5694
|
+
const refs = [];
|
|
5695
|
+
const targetName = name.toLowerCase();
|
|
5696
|
+
for (const t of tplList.templates ?? []) {
|
|
5697
|
+
try {
|
|
5698
|
+
const detail = await api('GET', `/api/pipeline-templates/${t.id}`, undefined, { noProjectHeader: true });
|
|
5699
|
+
const phases = detail.template?.definition?.phases ?? [];
|
|
5700
|
+
let matched = false;
|
|
5701
|
+
for (const phase of phases) {
|
|
5702
|
+
for (const step of phase.steps ?? []) {
|
|
5703
|
+
if ((step.role ?? '').toLowerCase() === targetName || (step.agent ?? '').toLowerCase() === targetName) {
|
|
5704
|
+
matched = true;
|
|
5705
|
+
break;
|
|
5706
|
+
}
|
|
5707
|
+
}
|
|
5708
|
+
if (matched)
|
|
5709
|
+
break;
|
|
5710
|
+
}
|
|
5711
|
+
if (matched)
|
|
5712
|
+
refs.push({ id: t.id, name: t.name });
|
|
5713
|
+
}
|
|
5714
|
+
catch {
|
|
5715
|
+
// Ignore individual template fetch errors in preflight — the
|
|
5716
|
+
// user will still see the main exclude message.
|
|
5717
|
+
}
|
|
5718
|
+
}
|
|
5719
|
+
console.log(chalk.cyan(`Excluding ${name} from project: ${projSlug}`));
|
|
5720
|
+
console.log('');
|
|
5721
|
+
console.log(`${name} will not appear in this project's effective roster.`);
|
|
5722
|
+
console.log(`Pipelines that reference ${name} will fail if run in this project.`);
|
|
5723
|
+
console.log('');
|
|
5724
|
+
console.log(`Pipelines referencing ${name} in this project: ${refs.length}`);
|
|
5725
|
+
for (const r of refs)
|
|
5726
|
+
console.log(` - ${r.name}`);
|
|
5727
|
+
if (refs.length > 0) {
|
|
5728
|
+
console.log('');
|
|
5729
|
+
console.log(chalk.yellow(`Warning: ${refs.length} pipeline template${refs.length === 1 ? '' : 's'} reference ${name} and will fail.`));
|
|
5730
|
+
}
|
|
5731
|
+
console.log('');
|
|
5732
|
+
if (opts.dryRun) {
|
|
5733
|
+
console.log(chalk.yellow('[dry run] No changes sent to the API.'));
|
|
5734
|
+
return;
|
|
5735
|
+
}
|
|
5736
|
+
await confirmOrAbort('Confirm? [y/N]: ', !!opts.yes);
|
|
5737
|
+
await api('PATCH', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, { excluded: true }, { noProjectHeader: true });
|
|
5738
|
+
console.log(chalk.green(`${name} excluded from ${projSlug}.`));
|
|
5739
|
+
}
|
|
5740
|
+
catch (err) {
|
|
5741
|
+
if (err instanceof ApiError && err.code === 'AGENT_HAS_OVERRIDE_REVERT_FIRST') {
|
|
5742
|
+
console.error(chalk.red(err.message));
|
|
5743
|
+
console.error(chalk.gray(`Run: fazemos agents revert ${name}`));
|
|
5744
|
+
process.exit(1);
|
|
5745
|
+
}
|
|
5746
|
+
if (err instanceof ApiError && err.code === 'ORG_AGENT_NOT_FOUND') {
|
|
5747
|
+
console.error(chalk.red(err.message));
|
|
5748
|
+
process.exit(1);
|
|
5749
|
+
}
|
|
5750
|
+
handleScopedError(err);
|
|
5751
|
+
}
|
|
5752
|
+
});
|
|
5753
|
+
/**
|
|
5754
|
+
* F17 §8.5 — `agents add <name>`: POST a Project-only agent to the
|
|
5755
|
+
* active Project. Required config keys: display_name (or displayName),
|
|
5756
|
+
* systemPrompt, model. Optional: tools, plus anything else the Org
|
|
5757
|
+
* schema supports.
|
|
5758
|
+
*
|
|
5759
|
+
* Shadow guard: API returns 409 AGENT_SHADOWS_ORG when the name collides
|
|
5760
|
+
* with an active Org agent. CLI re-attempts with `allow_shadow=true`
|
|
5761
|
+
* only if `--force-shadow` is passed. Without it (and without --yes),
|
|
5762
|
+
* the CLI prompts; with --yes and no --force-shadow it errors.
|
|
5763
|
+
*/
|
|
5764
|
+
agentsCmd
|
|
5765
|
+
.command('add')
|
|
5766
|
+
.description('Add a Project-only agent to the active Project. The agent is scoped to this Project — it does not appear in other Projects.')
|
|
5767
|
+
.argument('<name>', 'Agent name (lowercase, alphanumeric + hyphens, 1-64 chars)')
|
|
5768
|
+
.requiredOption('--config <json-or-file>', 'Agent config as inline JSON or a file path. Required keys: display_name, systemPrompt, model.')
|
|
5769
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5770
|
+
.option('--force-shadow', 'Force creation even if an Org agent with the same name exists (use with care — creates two agents with the same name).', false)
|
|
5771
|
+
.option('--dry-run', 'Print the intended POST without calling the API', false)
|
|
5772
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
5773
|
+
.action(async (name, opts) => {
|
|
5774
|
+
try {
|
|
5775
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5776
|
+
const parsed = resolveConfigArg(opts.config, '--config');
|
|
5777
|
+
// display_name can live in the config blob (as `displayName` or
|
|
5778
|
+
// `display_name`) or be extracted to the top-level payload.
|
|
5779
|
+
const displayName = (typeof parsed.display_name === 'string' && parsed.display_name) ||
|
|
5780
|
+
(typeof parsed.displayName === 'string' && parsed.displayName) ||
|
|
5781
|
+
undefined;
|
|
5782
|
+
const configBlob = { ...parsed };
|
|
5783
|
+
delete configBlob.display_name;
|
|
5784
|
+
delete configBlob.displayName;
|
|
5785
|
+
// Light client-side validation matching spec §4.2. The API
|
|
5786
|
+
// enforces these authoritatively, but catching typos here is a
|
|
5787
|
+
// better UX than a round-trip.
|
|
5788
|
+
if (!displayName) {
|
|
5789
|
+
console.error(chalk.red('--config must include `display_name` (or `displayName`).'));
|
|
5790
|
+
process.exit(1);
|
|
5791
|
+
}
|
|
5792
|
+
if (!configBlob.systemPrompt || typeof configBlob.systemPrompt !== 'string') {
|
|
5793
|
+
console.error(chalk.red('--config must include `systemPrompt` (string).'));
|
|
5794
|
+
process.exit(1);
|
|
5795
|
+
}
|
|
5796
|
+
if (!configBlob.model || typeof configBlob.model !== 'string') {
|
|
5797
|
+
console.error(chalk.red('--config must include `model` (string).'));
|
|
5798
|
+
process.exit(1);
|
|
5799
|
+
}
|
|
5800
|
+
const proj = findProjectById(orgId, projectId);
|
|
5801
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5802
|
+
console.log(chalk.cyan(`Adding Project-only agent to: ${projSlug}`));
|
|
5803
|
+
console.log('');
|
|
5804
|
+
console.log(`Name: ${name}`);
|
|
5805
|
+
console.log(`Display: ${displayName}`);
|
|
5806
|
+
console.log(`Model: ${configBlob.model}`);
|
|
5807
|
+
console.log(`Source: project-only (will not appear in other projects)`);
|
|
5808
|
+
console.log('');
|
|
5809
|
+
if (opts.dryRun) {
|
|
5810
|
+
console.log(chalk.yellow('[dry run] No changes sent to the API.'));
|
|
5811
|
+
console.log(chalk.gray('Body:'));
|
|
5812
|
+
console.log(chalk.gray(JSON.stringify({ name, display_name: displayName, config: configBlob, allow_shadow: !!opts.forceShadow }, null, 2)));
|
|
5813
|
+
return;
|
|
5814
|
+
}
|
|
5815
|
+
await confirmOrAbort('Create? [y/N]: ', !!opts.yes);
|
|
5816
|
+
const body = {
|
|
5817
|
+
name,
|
|
5818
|
+
display_name: displayName,
|
|
5819
|
+
config: configBlob,
|
|
5820
|
+
};
|
|
5821
|
+
if (opts.forceShadow)
|
|
5822
|
+
body.allow_shadow = true;
|
|
5823
|
+
try {
|
|
5824
|
+
const result = await api('POST', `/api/organizations/${orgId}/projects/${projectId}/agents`, body, { noProjectHeader: true });
|
|
5825
|
+
console.log(chalk.green(`${result.agent.name} created in ${projSlug}.`));
|
|
5826
|
+
}
|
|
5827
|
+
catch (err) {
|
|
5828
|
+
if (err instanceof ApiError && err.code === 'AGENT_SHADOWS_ORG') {
|
|
5829
|
+
if (opts.forceShadow)
|
|
5830
|
+
throw err; // should be impossible
|
|
5831
|
+
console.error(chalk.yellow(`This name collides with Org agent '${name}'.`));
|
|
5832
|
+
console.error(chalk.gray(`Options:`));
|
|
5833
|
+
console.error(chalk.gray(` - Pick a different name`));
|
|
5834
|
+
console.error(chalk.gray(` - Customize the Org agent instead: fazemos agents override ${name} --config ...`));
|
|
5835
|
+
console.error(chalk.gray(` - Force shadow (creates two agents with the same name): add --force-shadow`));
|
|
5836
|
+
process.exit(1);
|
|
5837
|
+
}
|
|
5838
|
+
if (err instanceof ApiError && err.code === 'AGENT_EXCLUDED_IN_PROJECT') {
|
|
5839
|
+
console.error(chalk.red(err.message));
|
|
5840
|
+
console.error(chalk.gray('The Org agent is excluded in this Project. Re-include it first via the UI or a future CLI re-include subcommand.'));
|
|
5841
|
+
process.exit(1);
|
|
5842
|
+
}
|
|
5843
|
+
if (err instanceof ApiError && err.code === 'AGENT_ALREADY_EXISTS') {
|
|
5844
|
+
console.error(chalk.red(err.message));
|
|
5845
|
+
console.error(chalk.gray(`Run: fazemos agents show ${name} (to see the current config)`));
|
|
5846
|
+
console.error(chalk.gray(`Or: fazemos agents remove ${name} (if you want to replace it)`));
|
|
5847
|
+
process.exit(1);
|
|
5848
|
+
}
|
|
5849
|
+
throw err;
|
|
5850
|
+
}
|
|
5851
|
+
}
|
|
5852
|
+
catch (err) {
|
|
5853
|
+
handleScopedError(err);
|
|
5854
|
+
}
|
|
5855
|
+
});
|
|
5856
|
+
/**
|
|
5857
|
+
* F17 §8.6 — `agents revert <name>`: clear a Project override,
|
|
5858
|
+
* reverting to the Org baseline. DELETE.
|
|
5859
|
+
*
|
|
5860
|
+
* Server distinguishes project_only vs override in the response. We
|
|
5861
|
+
* assert the response is `removed=override` — if it came back as
|
|
5862
|
+
* `removed=project_only`, we error with the spec's guidance to use
|
|
5863
|
+
* `agents remove` instead.
|
|
5864
|
+
*/
|
|
5865
|
+
agentsCmd
|
|
5866
|
+
.command('revert')
|
|
5867
|
+
.description('Revert a Project override. The agent goes back to using the Org-level config.')
|
|
5868
|
+
.argument('<name>', 'Agent name (e.g., marco)')
|
|
5869
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5870
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
5871
|
+
.action(async (name, opts) => {
|
|
5872
|
+
try {
|
|
5873
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5874
|
+
const proj = findProjectById(orgId, projectId);
|
|
5875
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5876
|
+
console.log(chalk.cyan(`Reverting ${name} to Org default in project: ${projSlug}`));
|
|
5877
|
+
console.log('');
|
|
5878
|
+
console.log(`Removes Project override. ${name} will use the Org-level config.`);
|
|
5879
|
+
console.log('');
|
|
5880
|
+
// F17 Phase 3 Dex SB-2 — pre-check BEFORE the destructive DELETE so
|
|
5881
|
+
// running `revert` on a Project-only agent errors out with guidance
|
|
5882
|
+
// instead of silently removing the agent.
|
|
5883
|
+
await preCheckAgentSourceForDelete({
|
|
5884
|
+
orgId,
|
|
5885
|
+
projectId,
|
|
5886
|
+
name,
|
|
5887
|
+
expectedSource: 'project_override',
|
|
5888
|
+
wrongCommandHint: 'fazemos agents remove',
|
|
5889
|
+
});
|
|
5890
|
+
await confirmOrAbort('Confirm? [y/N]: ', !!opts.yes);
|
|
5891
|
+
const result = await api('DELETE', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, undefined, { noProjectHeader: true });
|
|
5892
|
+
if (result.removed === 'project_only') {
|
|
5893
|
+
// Should be unreachable after the preCheck, but keep as
|
|
5894
|
+
// defense-in-depth against a race (user concurrently toggles the
|
|
5895
|
+
// agent source between GET and DELETE).
|
|
5896
|
+
console.error(chalk.red(`${name} was removed as Project-only, not reverted.`));
|
|
5897
|
+
console.error(chalk.gray('(Concurrent change detected — the agent may have been swapped between `revert` pre-check and the DELETE. Re-create with `fazemos agents add` if needed.)'));
|
|
5898
|
+
process.exit(1);
|
|
5899
|
+
}
|
|
5900
|
+
console.log(chalk.green(`${name} reverted to Org default.`));
|
|
5901
|
+
}
|
|
5902
|
+
catch (err) {
|
|
5903
|
+
if (err instanceof ApiError && err.code === 'NO_OVERRIDE_TO_REMOVE') {
|
|
5904
|
+
console.error(chalk.red(`${name} has no Project override to revert.`));
|
|
5905
|
+
console.error(chalk.gray('The agent is already using the Org-level config.'));
|
|
5906
|
+
process.exit(1);
|
|
5907
|
+
}
|
|
5908
|
+
if (err instanceof ApiError && err.code === 'USE_PATCH_TO_REINCLUDE') {
|
|
5909
|
+
console.error(chalk.red(err.message));
|
|
5910
|
+
console.error(chalk.gray('To re-include an excluded agent, use the UI or PATCH with `{excluded:false}`.'));
|
|
5911
|
+
process.exit(1);
|
|
5912
|
+
}
|
|
5913
|
+
handleScopedError(err);
|
|
5914
|
+
}
|
|
5915
|
+
});
|
|
5916
|
+
/**
|
|
5917
|
+
* F17 §8.6 — `agents remove <name>`: DELETE a Project-only agent. This
|
|
5918
|
+
* is actual removal — the agent disappears from the Project. Not the same
|
|
5919
|
+
* as reverting an override.
|
|
5920
|
+
*
|
|
5921
|
+
* Guards against accidental use on an Org override: asserts the server's
|
|
5922
|
+
* `removed` field is `project_only`. If it came back as `override`, we
|
|
5923
|
+
* surface the spec's guidance to use `agents revert` instead.
|
|
5924
|
+
*/
|
|
5925
|
+
agentsCmd
|
|
5926
|
+
.command('remove')
|
|
5927
|
+
.description('Remove a Project-only agent from the active Project. The agent is permanently removed.')
|
|
5928
|
+
.argument('<name>', 'Agent name (e.g., gus)')
|
|
5929
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
5930
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
5931
|
+
.action(async (name, opts) => {
|
|
5932
|
+
try {
|
|
5933
|
+
const { orgId, projectId } = await requireProjectForAgents(opts.project);
|
|
5934
|
+
const proj = findProjectById(orgId, projectId);
|
|
5935
|
+
const projSlug = proj?.slug ?? projectId;
|
|
5936
|
+
console.log(chalk.cyan(`Removing Project-only agent ${name} from: ${projSlug}`));
|
|
5937
|
+
console.log('');
|
|
5938
|
+
console.log(`This agent exists only in this project. It will be permanently removed.`);
|
|
5939
|
+
console.log(`Pipelines referencing ${name} will fail if run in this project.`);
|
|
5940
|
+
console.log('');
|
|
5941
|
+
// F17 Phase 3 Dex SB-2 (symmetric) — pre-check BEFORE DELETE so
|
|
5942
|
+
// running `remove` on an Org override errors out with guidance
|
|
5943
|
+
// instead of silently clearing the override.
|
|
5944
|
+
await preCheckAgentSourceForDelete({
|
|
5945
|
+
orgId,
|
|
5946
|
+
projectId,
|
|
5947
|
+
name,
|
|
5948
|
+
expectedSource: 'project',
|
|
5949
|
+
wrongCommandHint: 'fazemos agents revert',
|
|
5950
|
+
});
|
|
5951
|
+
await confirmOrAbort('Confirm? [y/N]: ', !!opts.yes);
|
|
5952
|
+
const result = await api('DELETE', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(name)}`, undefined, { noProjectHeader: true });
|
|
5953
|
+
if (result.removed === 'override') {
|
|
5954
|
+
console.error(chalk.red(`${name} was cleared as an override, not removed as Project-only.`));
|
|
5955
|
+
console.error(chalk.gray('(Concurrent change detected — the agent may have been swapped between `remove` pre-check and the DELETE. Re-apply with `fazemos agents override` if needed.)'));
|
|
5956
|
+
process.exit(1);
|
|
5957
|
+
}
|
|
5958
|
+
console.log(chalk.green(`${name} removed from ${projSlug}.`));
|
|
5959
|
+
}
|
|
5960
|
+
catch (err) {
|
|
5961
|
+
if (err instanceof ApiError && err.code === 'NO_OVERRIDE_TO_REMOVE') {
|
|
5962
|
+
console.error(chalk.red(`${name} is not a Project-only agent in this Project.`));
|
|
5963
|
+
console.error(chalk.gray(`Run: fazemos agents list (to see what's available)`));
|
|
5964
|
+
process.exit(1);
|
|
5965
|
+
}
|
|
5966
|
+
if (err instanceof ApiError && err.code === 'USE_PATCH_TO_REINCLUDE') {
|
|
5967
|
+
console.error(chalk.red(err.message));
|
|
5968
|
+
console.error(chalk.gray('To re-include an excluded agent, use the UI or PATCH with `{excluded:false}`.'));
|
|
5969
|
+
process.exit(1);
|
|
5970
|
+
}
|
|
5971
|
+
handleScopedError(err);
|
|
5972
|
+
}
|
|
5973
|
+
});
|
|
5153
5974
|
agentsCmd
|
|
5154
5975
|
.command('metrics')
|
|
5155
5976
|
.description('Show agent execution metrics')
|
|
@@ -5365,49 +6186,173 @@ agentsCmd
|
|
|
5365
6186
|
});
|
|
5366
6187
|
agentsCmd
|
|
5367
6188
|
.command('upload-all')
|
|
5368
|
-
.description('Upload all agent definitions from a directory')
|
|
6189
|
+
.description('Upload all agent definitions from a directory. Default: Org-level (pre-F17 behavior, uploads systemPrompt only). With --project <slug>: uploads as Project overrides (agents that exist at Org level) or skips (names without an Org baseline — use `fazemos agents add` for Project-only creation).')
|
|
5369
6190
|
.argument('<dir>', 'Directory containing .md agent definition files')
|
|
5370
|
-
.
|
|
6191
|
+
.option('--project <slug>', 'Upload to a Project as overrides/additions instead of Org baseline')
|
|
6192
|
+
.option('--dry-run', 'Print the plan without calling the API', false)
|
|
6193
|
+
.option('--yes', 'Skip the confirmation prompt', false)
|
|
6194
|
+
.action(async (dir, opts) => {
|
|
5371
6195
|
try {
|
|
5372
|
-
const orgId =
|
|
5373
|
-
if (!orgId) {
|
|
5374
|
-
console.error(chalk.red('No active org'));
|
|
5375
|
-
process.exit(1);
|
|
5376
|
-
}
|
|
5377
|
-
const membersData = await api('GET', `/api/organizations/${orgId}/members?type=agent`);
|
|
5378
|
-
const agents = membersData.members.filter((m) => m.member_type === 'agent');
|
|
6196
|
+
const orgId = requireActiveOrgOrExit();
|
|
5379
6197
|
const resolvedDir = resolve(dir);
|
|
5380
6198
|
const files = readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
|
|
5381
6199
|
if (!files.length) {
|
|
5382
6200
|
console.log(chalk.yellow(`No .md files found in ${resolvedDir}`));
|
|
5383
6201
|
return;
|
|
5384
6202
|
}
|
|
5385
|
-
let uploaded = 0;
|
|
5386
|
-
let skipped = 0;
|
|
5387
6203
|
const norm = (s) => s.toLowerCase().replace(/[-_\s]+/g, ' ').trim();
|
|
6204
|
+
// ── Pre-F17 path: Org-level upload ────────────────────────────────
|
|
6205
|
+
//
|
|
6206
|
+
// --project not set. Kept bit-for-bit compatible with the original
|
|
6207
|
+
// command: same log format, same PATCH path, same skip-on-missing-
|
|
6208
|
+
// agent behavior. --dry-run/--yes are new and optional; they are
|
|
6209
|
+
// inert on this path because the existing behavior doesn't prompt.
|
|
6210
|
+
if (!opts.project) {
|
|
6211
|
+
const membersData = await api('GET', `/api/organizations/${orgId}/members?type=agent`);
|
|
6212
|
+
const agents = membersData.members.filter((m) => m.member_type === 'agent');
|
|
6213
|
+
let uploaded = 0;
|
|
6214
|
+
let skipped = 0;
|
|
6215
|
+
for (const file of files) {
|
|
6216
|
+
const raw = readFileSync(resolve(resolvedDir, file), 'utf-8');
|
|
6217
|
+
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
6218
|
+
const frontmatter = frontmatterMatch ? frontmatterMatch[1] : '';
|
|
6219
|
+
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
6220
|
+
const name = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
|
|
6221
|
+
const agent = agents.find((a) => norm(a.display_name) === norm(name));
|
|
6222
|
+
if (!agent) {
|
|
6223
|
+
console.log(chalk.yellow(` ⊘ ${name} — no matching agent`));
|
|
6224
|
+
skipped++;
|
|
6225
|
+
continue;
|
|
6226
|
+
}
|
|
6227
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
|
|
6228
|
+
if (opts.dryRun) {
|
|
6229
|
+
console.log(chalk.yellow(` [dry run] ${agent.display_name} (${body.length} chars) → Org systemPrompt`));
|
|
6230
|
+
}
|
|
6231
|
+
else {
|
|
6232
|
+
await api('PATCH', `/api/members/${agent.id}/agent-config`, { systemPrompt: body });
|
|
6233
|
+
console.log(chalk.green(` ✓ ${agent.display_name} (${body.length} chars)`));
|
|
6234
|
+
}
|
|
6235
|
+
uploaded++;
|
|
6236
|
+
}
|
|
6237
|
+
const summaryVerb = opts.dryRun ? 'planned' : 'uploaded';
|
|
6238
|
+
console.log(`\n${uploaded} ${summaryVerb}, ${skipped} skipped`);
|
|
6239
|
+
return;
|
|
6240
|
+
}
|
|
6241
|
+
// ── F17 §8.7 — --project path: upload as Project overrides ────────
|
|
6242
|
+
//
|
|
6243
|
+
// HP-4 (Dex): today's upload-all parses ONLY `name:` from frontmatter
|
|
6244
|
+
// and uploads the body as `systemPrompt`. The --project variant
|
|
6245
|
+
// inherits that narrowness — it reads name + body, nothing else.
|
|
6246
|
+
//
|
|
6247
|
+
// Plan (per file):
|
|
6248
|
+
// - If the Project already has an effective agent at this name AND
|
|
6249
|
+
// the Org baseline exists: upload as a Project override via
|
|
6250
|
+
// PATCH /api/organizations/:orgId/projects/:projectId/agents/:name
|
|
6251
|
+
// with { override: { systemPrompt } }.
|
|
6252
|
+
// - If the body matches the current systemPrompt (Org baseline OR
|
|
6253
|
+
// existing override): skip (no change).
|
|
6254
|
+
// - If no Org agent with that name exists: SKIP with a warning
|
|
6255
|
+
// pointing the operator at `fazemos agents add`. Project-only
|
|
6256
|
+
// creation requires display_name + model which upload-all does
|
|
6257
|
+
// not parse today.
|
|
6258
|
+
//
|
|
6259
|
+
// Full-frontmatter upload-all is explicitly deferred (tech spec §8.7
|
|
6260
|
+
// "Expanded-scope path explicitly deferred." — CLI-IMP-3 in backlog).
|
|
6261
|
+
const { projectId } = await requireProjectForAgents(opts.project);
|
|
6262
|
+
const proj = findProjectById(orgId, projectId);
|
|
6263
|
+
const projSlug = proj?.slug ?? projectId;
|
|
6264
|
+
// Resolve the Project's effective roster — gives us current
|
|
6265
|
+
// systemPrompt values to diff against AND tells us which names have
|
|
6266
|
+
// an Org baseline (source != 'project' — either 'org' or
|
|
6267
|
+
// 'project_override') so we know what can be uploaded as an override.
|
|
6268
|
+
const roster = await api('GET', `/api/organizations/${orgId}/projects/${projectId}/agents`, undefined, { noProjectHeader: true });
|
|
6269
|
+
const byName = new Map();
|
|
6270
|
+
for (const a of roster.agents ?? [])
|
|
6271
|
+
byName.set(norm(a.name), a);
|
|
6272
|
+
console.log(chalk.cyan(`Uploading agents to project: ${projSlug} (not Org level)`));
|
|
6273
|
+
console.log('');
|
|
6274
|
+
const plan = [];
|
|
5388
6275
|
for (const file of files) {
|
|
5389
6276
|
const raw = readFileSync(resolve(resolvedDir, file), 'utf-8');
|
|
5390
|
-
// Extract name from frontmatter (name: field), fall back to filename
|
|
5391
6277
|
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
5392
6278
|
const frontmatter = frontmatterMatch ? frontmatterMatch[1] : '';
|
|
5393
6279
|
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
5394
6280
|
const name = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
|
|
5395
|
-
const
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
6281
|
+
const body = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '').trim();
|
|
6282
|
+
const existing = byName.get(norm(name));
|
|
6283
|
+
if (!existing) {
|
|
6284
|
+
plan.push({ file, name, kind: 'skip-no-org-baseline' });
|
|
5399
6285
|
continue;
|
|
5400
6286
|
}
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
6287
|
+
if (existing.source === 'project') {
|
|
6288
|
+
// Project-only agent already exists at that name. upload-all
|
|
6289
|
+
// doesn't touch Project-only agents in v1 — operator uses
|
|
6290
|
+
// `agents override`-ish path, which for project-only is a PATCH
|
|
6291
|
+
// with `{config: ...}`. That needs display_name/model which
|
|
6292
|
+
// upload-all doesn't parse. Skip with a clear message.
|
|
6293
|
+
plan.push({ file, name, kind: 'skip-is-project-only' });
|
|
6294
|
+
continue;
|
|
6295
|
+
}
|
|
6296
|
+
const currentPrompt = existing.effective_config?.systemPrompt ?? '';
|
|
6297
|
+
if (currentPrompt.trim() === body.trim()) {
|
|
6298
|
+
plan.push({ file, name, kind: 'no-change' });
|
|
6299
|
+
continue;
|
|
6300
|
+
}
|
|
6301
|
+
plan.push({ file, name, kind: 'override-update', body, before: currentPrompt });
|
|
6302
|
+
}
|
|
6303
|
+
// Print the plan.
|
|
6304
|
+
for (const item of plan) {
|
|
6305
|
+
const label = ` ${item.file.padEnd(24)} →`;
|
|
6306
|
+
switch (item.kind) {
|
|
6307
|
+
case 'override-update':
|
|
6308
|
+
console.log(`${label} Project override (systemPrompt diverges from Org baseline)`);
|
|
6309
|
+
break;
|
|
6310
|
+
case 'no-change':
|
|
6311
|
+
console.log(chalk.gray(`${label} No change (Project body matches Org systemPrompt)`));
|
|
6312
|
+
break;
|
|
6313
|
+
case 'skip-no-org-baseline':
|
|
6314
|
+
console.log(chalk.yellow(`${label} SKIPPED: no Org agent '${item.name}'; use 'fazemos agents add' for Project-only`));
|
|
6315
|
+
break;
|
|
6316
|
+
case 'skip-is-project-only':
|
|
6317
|
+
console.log(chalk.yellow(`${label} SKIPPED: '${item.name}' is Project-only; upload-all only updates overrides on Org agents`));
|
|
6318
|
+
break;
|
|
6319
|
+
}
|
|
6320
|
+
}
|
|
6321
|
+
const toUpload = plan.filter((p) => p.kind === 'override-update');
|
|
6322
|
+
console.log('');
|
|
6323
|
+
console.log(`Plan: ${toUpload.length} override${toUpload.length === 1 ? '' : 's'} to write, ${plan.filter(p => p.kind === 'no-change').length} unchanged, ${plan.filter(p => p.kind.startsWith('skip-')).length} skipped.`);
|
|
6324
|
+
if (opts.dryRun) {
|
|
6325
|
+
console.log('');
|
|
6326
|
+
console.log(chalk.yellow('[dry run] No changes sent to the API.'));
|
|
6327
|
+
return;
|
|
6328
|
+
}
|
|
6329
|
+
if (toUpload.length === 0) {
|
|
6330
|
+
console.log(chalk.gray('Nothing to upload.'));
|
|
6331
|
+
return;
|
|
6332
|
+
}
|
|
6333
|
+
console.log('');
|
|
6334
|
+
console.log('Uploading as Project overrides. Org-level agents are unchanged.');
|
|
6335
|
+
console.log(`This will diverge these agents' config in ${projSlug} from the Org baseline.`);
|
|
6336
|
+
console.log(`Future Org-level changes to systemPrompt will not propagate to this Project`);
|
|
6337
|
+
console.log(`for overridden fields.`);
|
|
6338
|
+
console.log('');
|
|
6339
|
+
await confirmOrAbort('Proceed? [y/N]: ', !!opts.yes);
|
|
6340
|
+
let uploaded = 0;
|
|
6341
|
+
for (const item of toUpload) {
|
|
6342
|
+
try {
|
|
6343
|
+
await api('PATCH', `/api/organizations/${orgId}/projects/${projectId}/agents/${encodeURIComponent(item.name)}`, { override: { systemPrompt: item.body } }, { noProjectHeader: true });
|
|
6344
|
+
console.log(chalk.green(` ✓ ${item.name} override saved (${item.body.length} chars)`));
|
|
6345
|
+
uploaded++;
|
|
6346
|
+
}
|
|
6347
|
+
catch (err) {
|
|
6348
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
6349
|
+
console.error(chalk.red(` ✗ ${item.name}: ${msg}`));
|
|
6350
|
+
}
|
|
5405
6351
|
}
|
|
5406
|
-
console.log(`\n${uploaded}
|
|
6352
|
+
console.log(`\n${uploaded} of ${toUpload.length} uploaded.`);
|
|
5407
6353
|
}
|
|
5408
6354
|
catch (err) {
|
|
5409
|
-
|
|
5410
|
-
process.exit(1);
|
|
6355
|
+
handleScopedError(err);
|
|
5411
6356
|
}
|
|
5412
6357
|
});
|
|
5413
6358
|
agentsCmd
|