@formigio/fazemos-cli 0.8.1 → 0.10.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 +1543 -44
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5089,39 +5089,260 @@ agentsCmd
|
|
|
5089
5089
|
process.exit(1);
|
|
5090
5090
|
}
|
|
5091
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
|
+
}
|
|
5092
5152
|
agentsCmd
|
|
5093
5153
|
.command('list')
|
|
5094
|
-
.description('List
|
|
5095
|
-
.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)
|
|
5096
5158
|
.action(async (opts) => {
|
|
5097
5159
|
try {
|
|
5098
|
-
const
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
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);
|
|
5105
5182
|
return;
|
|
5106
5183
|
}
|
|
5107
|
-
|
|
5108
|
-
|
|
5109
|
-
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
const
|
|
5113
|
-
const
|
|
5114
|
-
|
|
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]);
|
|
5115
5288
|
}
|
|
5116
5289
|
}
|
|
5117
5290
|
catch (err) {
|
|
5118
|
-
|
|
5119
|
-
process.exit(1);
|
|
5291
|
+
handleScopedError(err);
|
|
5120
5292
|
}
|
|
5121
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.
|
|
5122
5343
|
agentsCmd
|
|
5123
|
-
.command('
|
|
5124
|
-
.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.')
|
|
5125
5346
|
.argument('<id>', 'Agent name or member ID')
|
|
5126
5347
|
.action(async (id) => {
|
|
5127
5348
|
try {
|
|
@@ -5151,6 +5372,605 @@ agentsCmd
|
|
|
5151
5372
|
process.exit(1);
|
|
5152
5373
|
}
|
|
5153
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
|
+
});
|
|
5154
5974
|
agentsCmd
|
|
5155
5975
|
.command('metrics')
|
|
5156
5976
|
.description('Show agent execution metrics')
|
|
@@ -5366,49 +6186,173 @@ agentsCmd
|
|
|
5366
6186
|
});
|
|
5367
6187
|
agentsCmd
|
|
5368
6188
|
.command('upload-all')
|
|
5369
|
-
.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).')
|
|
5370
6190
|
.argument('<dir>', 'Directory containing .md agent definition files')
|
|
5371
|
-
.
|
|
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) => {
|
|
5372
6195
|
try {
|
|
5373
|
-
const orgId =
|
|
5374
|
-
if (!orgId) {
|
|
5375
|
-
console.error(chalk.red('No active org'));
|
|
5376
|
-
process.exit(1);
|
|
5377
|
-
}
|
|
5378
|
-
const membersData = await api('GET', `/api/organizations/${orgId}/members?type=agent`);
|
|
5379
|
-
const agents = membersData.members.filter((m) => m.member_type === 'agent');
|
|
6196
|
+
const orgId = requireActiveOrgOrExit();
|
|
5380
6197
|
const resolvedDir = resolve(dir);
|
|
5381
6198
|
const files = readdirSync(resolvedDir).filter(f => f.endsWith('.md'));
|
|
5382
6199
|
if (!files.length) {
|
|
5383
6200
|
console.log(chalk.yellow(`No .md files found in ${resolvedDir}`));
|
|
5384
6201
|
return;
|
|
5385
6202
|
}
|
|
5386
|
-
let uploaded = 0;
|
|
5387
|
-
let skipped = 0;
|
|
5388
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 = [];
|
|
5389
6275
|
for (const file of files) {
|
|
5390
6276
|
const raw = readFileSync(resolve(resolvedDir, file), 'utf-8');
|
|
5391
|
-
// Extract name from frontmatter (name: field), fall back to filename
|
|
5392
6277
|
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
5393
6278
|
const frontmatter = frontmatterMatch ? frontmatterMatch[1] : '';
|
|
5394
6279
|
const nameMatch = frontmatter.match(/^name:\s*(.+)$/m);
|
|
5395
6280
|
const name = nameMatch ? nameMatch[1].trim() : basename(file, '.md');
|
|
5396
|
-
const
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
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' });
|
|
5400
6285
|
continue;
|
|
5401
6286
|
}
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
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
|
+
}
|
|
5406
6320
|
}
|
|
5407
|
-
|
|
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
|
+
}
|
|
6351
|
+
}
|
|
6352
|
+
console.log(`\n${uploaded} of ${toUpload.length} uploaded.`);
|
|
5408
6353
|
}
|
|
5409
6354
|
catch (err) {
|
|
5410
|
-
|
|
5411
|
-
process.exit(1);
|
|
6355
|
+
handleScopedError(err);
|
|
5412
6356
|
}
|
|
5413
6357
|
});
|
|
5414
6358
|
agentsCmd
|
|
@@ -5702,5 +6646,560 @@ ops
|
|
|
5702
6646
|
process.exit(1);
|
|
5703
6647
|
}
|
|
5704
6648
|
});
|
|
6649
|
+
// ── F18 — Project-scoped docs surface ────────────────────────────────
|
|
6650
|
+
// Spec: specs/tech/platform/F18-project-scoped-docs-surface-tech-spec.md §8
|
|
6651
|
+
//
|
|
6652
|
+
// Five subcommands expose the Project docs tree + individual file reads
|
|
6653
|
+
// that F17 + F18 API ship. Active-Project-aware (F15): resolve active
|
|
6654
|
+
// Project from config; `--project <slug>` overrides. No active Project +
|
|
6655
|
+
// no override → exit 1 with the uniform "requirement missing: project"
|
|
6656
|
+
// block (matches handleScopedError + the F17 agents subcommands).
|
|
6657
|
+
//
|
|
6658
|
+
// Server-side F18 /docs/file currently returns a flat
|
|
6659
|
+
// { path, file_type, content, sha, cached_at, etag }
|
|
6660
|
+
// envelope (see fazemos-api src/services/projectDocsService.ts). The tech
|
|
6661
|
+
// spec §4.2 also describes `frontmatter`, `body`, and `source` fields on
|
|
6662
|
+
// the response — those are Phase 3 web-surface additions not present in
|
|
6663
|
+
// the Phase 2 D2 API. The CLI renders the header line from the response's
|
|
6664
|
+
// `file_type` and the composed `<docs_root>/<path>` string (since the
|
|
6665
|
+
// source.path field doesn't exist yet on the wire). This is called out in
|
|
6666
|
+
// the handoff report as an API contract gap.
|
|
6667
|
+
//
|
|
6668
|
+
// Step-doc 422 handling (Phase 2 revision R2): when the server returns
|
|
6669
|
+
// 422 with code STEP_DOC_* and a `validation_errors` array, `docs cat`
|
|
6670
|
+
// prints the errors to stderr and exits 2 (distinct from the generic
|
|
6671
|
+
// error exit 1 so scripts can branch on "malformed doc" vs other failures).
|
|
6672
|
+
const docs = program.command('docs').description('Project docs browser. Read the Project-scoped tree and individual files that F18 exposes (Project agents, steps, and general docs) without opening the web UI.');
|
|
6673
|
+
/**
|
|
6674
|
+
* F18 — resolve {orgId, projectId, projectSlug} for a docs subcommand.
|
|
6675
|
+
* Mirrors F17's `requireProjectForAgents` but also surfaces the project
|
|
6676
|
+
* slug for display purposes (header lines, GitHub URLs, local-path probe).
|
|
6677
|
+
*
|
|
6678
|
+
* On no-active-Project + no-override: emits the uniform F15 "requirement
|
|
6679
|
+
* missing: project" block and exits 1. Reuses the wording from
|
|
6680
|
+
* handleScopedError — the KD9 convention says the CLI always prints this
|
|
6681
|
+
* as a triple-hint block (switch / --project / --all-projects), but
|
|
6682
|
+
* --all-projects has no meaning for docs subcommands (each Project has
|
|
6683
|
+
* its own docs tree — "all-projects" would be N API calls with no unified
|
|
6684
|
+
* output). We omit the --all-projects hint here on purpose.
|
|
6685
|
+
*/
|
|
6686
|
+
async function requireProjectForDocs(slugOverride) {
|
|
6687
|
+
const orgId = requireActiveOrgOrExit();
|
|
6688
|
+
let projectId = null;
|
|
6689
|
+
let projectSlug = null;
|
|
6690
|
+
if (slugOverride) {
|
|
6691
|
+
let project = findProjectBySlug(orgId, slugOverride);
|
|
6692
|
+
if (!project) {
|
|
6693
|
+
await refreshAuthMeCache();
|
|
6694
|
+
project = findProjectBySlug(orgId, slugOverride);
|
|
6695
|
+
}
|
|
6696
|
+
if (!project) {
|
|
6697
|
+
console.error(chalk.red(`Unknown project: ${slugOverride}`));
|
|
6698
|
+
console.error(chalk.gray('Run: fazemos projects list'));
|
|
6699
|
+
process.exit(1);
|
|
6700
|
+
}
|
|
6701
|
+
projectId = project.id;
|
|
6702
|
+
projectSlug = project.slug;
|
|
6703
|
+
}
|
|
6704
|
+
else {
|
|
6705
|
+
projectId = getActiveProjectId();
|
|
6706
|
+
if (projectId) {
|
|
6707
|
+
const proj = findProjectById(orgId, projectId);
|
|
6708
|
+
projectSlug = proj?.slug ?? null;
|
|
6709
|
+
}
|
|
6710
|
+
}
|
|
6711
|
+
if (!projectId) {
|
|
6712
|
+
console.error(chalk.red('Error: requirement missing: project'));
|
|
6713
|
+
console.error('');
|
|
6714
|
+
console.error(chalk.gray('Set one with: fazemos projects switch <slug>'));
|
|
6715
|
+
console.error(chalk.gray('Or pass: --project <slug>'));
|
|
6716
|
+
process.exit(1);
|
|
6717
|
+
}
|
|
6718
|
+
// projectSlug can still be null if the cache is empty and we went the
|
|
6719
|
+
// active-project path — refresh once so the smoke-test path of "I just
|
|
6720
|
+
// authed in another shell" doesn't print UUIDs in headers.
|
|
6721
|
+
if (!projectSlug) {
|
|
6722
|
+
await refreshAuthMeCache();
|
|
6723
|
+
const proj = findProjectById(orgId, projectId);
|
|
6724
|
+
projectSlug = proj?.slug ?? projectId; // fall back to UUID if still unknown
|
|
6725
|
+
}
|
|
6726
|
+
return { orgId, projectId, projectSlug };
|
|
6727
|
+
}
|
|
6728
|
+
/**
|
|
6729
|
+
* Render the `source` block as a short header label for `docs cat`.
|
|
6730
|
+
* Tech spec §4.2 discriminator:
|
|
6731
|
+
* file → "file"
|
|
6732
|
+
* file + level='org' → "file (org)"
|
|
6733
|
+
* system_config → "system config"
|
|
6734
|
+
* template → "template"
|
|
6735
|
+
* Falls back to "file" when the response pre-dates Phase 4b and omits
|
|
6736
|
+
* the `source` block entirely (defensive — the API always ships it now).
|
|
6737
|
+
*/
|
|
6738
|
+
function renderOriginLabel(source) {
|
|
6739
|
+
if (!source)
|
|
6740
|
+
return 'file';
|
|
6741
|
+
if (source.type === 'system_config')
|
|
6742
|
+
return 'system config';
|
|
6743
|
+
if (source.type === 'template')
|
|
6744
|
+
return 'template';
|
|
6745
|
+
if (source.level === 'org' || source.level === 'org_db')
|
|
6746
|
+
return 'file (org)';
|
|
6747
|
+
return 'file';
|
|
6748
|
+
}
|
|
6749
|
+
/**
|
|
6750
|
+
* Fetch a Project's docs tree. Returns the raw payload. Swallows nothing —
|
|
6751
|
+
* callers that want empty-state friendly output should branch on
|
|
6752
|
+
* `payload.empty` + `payload.reason`.
|
|
6753
|
+
*/
|
|
6754
|
+
async function fetchDocsTree(orgId, projectId) {
|
|
6755
|
+
return (await api('GET', `/api/organizations/${orgId}/projects/${projectId}/docs/tree`, undefined, { noProjectHeader: true }));
|
|
6756
|
+
}
|
|
6757
|
+
/**
|
|
6758
|
+
* Render a flat list of tree entries as an indented text tree. The API
|
|
6759
|
+
* returns paths with embedded slashes ("docs/product/fazemos-concept.md")
|
|
6760
|
+
* and entries for both files and directories. We build a directory map
|
|
6761
|
+
* from the entries and walk it alphabetically for stable output.
|
|
6762
|
+
*
|
|
6763
|
+
* Output format matches tech spec §8.2 — directory names end with `/`,
|
|
6764
|
+
* files get 2-space indent per nesting level, and a file-type badge is
|
|
6765
|
+
* appended for `agent` / `step` files (not `doc` — those are the default
|
|
6766
|
+
* and badging every markdown file would be noisy). Size is NOT printed
|
|
6767
|
+
* (noisy at list scale). Directory file-counts are computed locally.
|
|
6768
|
+
*/
|
|
6769
|
+
function renderDocsTree(entries) {
|
|
6770
|
+
const root = { name: '', fullPath: '', isDir: true, children: new Map() };
|
|
6771
|
+
function insert(path, entry, forceDir = false) {
|
|
6772
|
+
const parts = path.split('/');
|
|
6773
|
+
let cur = root;
|
|
6774
|
+
for (let i = 0; i < parts.length; i++) {
|
|
6775
|
+
const part = parts[i];
|
|
6776
|
+
const isLast = i === parts.length - 1;
|
|
6777
|
+
const isDir = !isLast || forceDir || entry?.type === 'dir';
|
|
6778
|
+
let child = cur.children.get(part);
|
|
6779
|
+
if (!child) {
|
|
6780
|
+
child = {
|
|
6781
|
+
name: part,
|
|
6782
|
+
fullPath: parts.slice(0, i + 1).join('/'),
|
|
6783
|
+
isDir,
|
|
6784
|
+
children: new Map(),
|
|
6785
|
+
};
|
|
6786
|
+
cur.children.set(part, child);
|
|
6787
|
+
}
|
|
6788
|
+
if (isLast) {
|
|
6789
|
+
child.entry = entry;
|
|
6790
|
+
// Once marked as dir (by its own entry), don't downgrade.
|
|
6791
|
+
if (isDir)
|
|
6792
|
+
child.isDir = true;
|
|
6793
|
+
}
|
|
6794
|
+
cur = child;
|
|
6795
|
+
}
|
|
6796
|
+
}
|
|
6797
|
+
for (const e of entries) {
|
|
6798
|
+
insert(e.path, e, e.type === 'dir');
|
|
6799
|
+
}
|
|
6800
|
+
function countFiles(node) {
|
|
6801
|
+
if (!node.isDir)
|
|
6802
|
+
return 1;
|
|
6803
|
+
let n = 0;
|
|
6804
|
+
for (const c of node.children.values())
|
|
6805
|
+
n += countFiles(c);
|
|
6806
|
+
return n;
|
|
6807
|
+
}
|
|
6808
|
+
const lines = [];
|
|
6809
|
+
function walk(node, depth) {
|
|
6810
|
+
const kids = Array.from(node.children.values()).sort((a, b) => {
|
|
6811
|
+
// Directories first, then files, both alphabetical.
|
|
6812
|
+
if (a.isDir !== b.isDir)
|
|
6813
|
+
return a.isDir ? -1 : 1;
|
|
6814
|
+
return a.name.localeCompare(b.name);
|
|
6815
|
+
});
|
|
6816
|
+
for (const c of kids) {
|
|
6817
|
+
const indent = ' '.repeat(depth);
|
|
6818
|
+
if (c.isDir) {
|
|
6819
|
+
const count = countFiles(c);
|
|
6820
|
+
const tag = count ? chalk.gray(` (${count} ${count === 1 ? 'file' : 'files'})`) : '';
|
|
6821
|
+
lines.push(`${indent}${c.name}/${tag}`);
|
|
6822
|
+
walk(c, depth + 1);
|
|
6823
|
+
}
|
|
6824
|
+
else {
|
|
6825
|
+
const ft = c.entry?.file_type;
|
|
6826
|
+
const badge = ft === 'agent'
|
|
6827
|
+
? chalk.cyan(' [agent]')
|
|
6828
|
+
: ft === 'step'
|
|
6829
|
+
? chalk.magenta(' [step]')
|
|
6830
|
+
: '';
|
|
6831
|
+
lines.push(`${indent}${c.name}${badge}`);
|
|
6832
|
+
}
|
|
6833
|
+
}
|
|
6834
|
+
}
|
|
6835
|
+
walk(root, 0);
|
|
6836
|
+
return lines;
|
|
6837
|
+
}
|
|
6838
|
+
/**
|
|
6839
|
+
* Format the "Synced N ago" sub-header from a cached_at ISO string. Keep
|
|
6840
|
+
* wording identical to UX §5.1 example ("Synced just now") when <30s.
|
|
6841
|
+
*/
|
|
6842
|
+
function formatSyncedLine(cachedAtIso) {
|
|
6843
|
+
try {
|
|
6844
|
+
const cached = new Date(cachedAtIso).getTime();
|
|
6845
|
+
const now = Date.now();
|
|
6846
|
+
const seconds = Math.max(0, Math.floor((now - cached) / 1000));
|
|
6847
|
+
if (seconds < 30)
|
|
6848
|
+
return 'Synced just now';
|
|
6849
|
+
if (seconds < 90)
|
|
6850
|
+
return 'Synced 1 minute ago';
|
|
6851
|
+
if (seconds < 60 * 60)
|
|
6852
|
+
return `Synced ${Math.floor(seconds / 60)} minutes ago`;
|
|
6853
|
+
if (seconds < 2 * 60 * 60)
|
|
6854
|
+
return 'Synced 1 hour ago';
|
|
6855
|
+
return `Synced ${Math.floor(seconds / 3600)} hours ago`;
|
|
6856
|
+
}
|
|
6857
|
+
catch {
|
|
6858
|
+
return `Synced at ${cachedAtIso}`;
|
|
6859
|
+
}
|
|
6860
|
+
}
|
|
6861
|
+
// ── docs tree ────────────────────────────────────────────────────────
|
|
6862
|
+
docs
|
|
6863
|
+
.command('tree')
|
|
6864
|
+
.description('Show the Project\'s docs tree (agents/, steps/, docs/, plus the Project CLAUDE.md). Output is stable text suitable for piping.')
|
|
6865
|
+
.option('--include-shared', 'Also print Org-shared docs (under `docs/` and root CLAUDE.md) as a second section', false)
|
|
6866
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
6867
|
+
.action(async (opts) => {
|
|
6868
|
+
try {
|
|
6869
|
+
const { orgId, projectId, projectSlug } = await requireProjectForDocs(opts.project);
|
|
6870
|
+
const payload = await fetchDocsTree(orgId, projectId);
|
|
6871
|
+
const repoBadge = payload.repo ? `${payload.repo} at ${payload.root ?? ''}` : '(no repo configured)';
|
|
6872
|
+
console.log(chalk.cyan(`Docs — ${projectSlug} (${repoBadge})`));
|
|
6873
|
+
console.log(chalk.gray(formatSyncedLine(payload.cached_at)));
|
|
6874
|
+
console.log('');
|
|
6875
|
+
if (payload.empty) {
|
|
6876
|
+
if (payload.reason === 'no_docs_repo') {
|
|
6877
|
+
console.log(chalk.yellow('No docs repository configured.'));
|
|
6878
|
+
console.log(chalk.gray(' Set one with: fazemos projects update <slug> --docs-repo <owner/name> --docs-root <path>'));
|
|
6879
|
+
}
|
|
6880
|
+
else if (payload.reason === 'no_connection') {
|
|
6881
|
+
console.log(chalk.yellow('Project has no active GitHub Connection.'));
|
|
6882
|
+
console.log(chalk.gray(' Set one up with: fazemos connections ...'));
|
|
6883
|
+
}
|
|
6884
|
+
else {
|
|
6885
|
+
console.log(chalk.yellow('Docs tree is empty.'));
|
|
6886
|
+
}
|
|
6887
|
+
return;
|
|
6888
|
+
}
|
|
6889
|
+
for (const line of renderDocsTree(payload.tree))
|
|
6890
|
+
console.log(line);
|
|
6891
|
+
if (opts.includeShared) {
|
|
6892
|
+
console.log('');
|
|
6893
|
+
console.log(chalk.gray('── Org shared ─────────────────────────────────────────'));
|
|
6894
|
+
if (payload.org_shared && payload.org_shared.length) {
|
|
6895
|
+
for (const line of renderDocsTree(payload.org_shared))
|
|
6896
|
+
console.log(line);
|
|
6897
|
+
}
|
|
6898
|
+
else {
|
|
6899
|
+
// Phase 2 API does not yet populate org_shared (see the file-level
|
|
6900
|
+
// note at the top of this block). Tell the operator what we see.
|
|
6901
|
+
console.log(chalk.gray('(no org-shared docs returned by the API — Phase 2 ships Project-only)'));
|
|
6902
|
+
}
|
|
6903
|
+
}
|
|
6904
|
+
}
|
|
6905
|
+
catch (err) {
|
|
6906
|
+
handleScopedError(err);
|
|
6907
|
+
}
|
|
6908
|
+
});
|
|
6909
|
+
// ── docs cat ─────────────────────────────────────────────────────────
|
|
6910
|
+
/**
|
|
6911
|
+
* F18 §8 — step-doc validation failure exit code. Distinct from the
|
|
6912
|
+
* generic error exit 1 so operator scripts can branch on "file is
|
|
6913
|
+
* structurally invalid" vs "server 5xx / auth / other".
|
|
6914
|
+
*/
|
|
6915
|
+
const DOCS_STEP_VALIDATION_EXIT = 2;
|
|
6916
|
+
docs
|
|
6917
|
+
.command('cat')
|
|
6918
|
+
.description('Print a Project doc to stdout. Adds a one-line header showing the file type and source path. On malformed step docs, prints validation errors to stderr and exits 2.')
|
|
6919
|
+
.argument('<path>', 'Relative path under docs_root (e.g., agents/marco.md, steps/feature-pipeline/tech-spec.md)')
|
|
6920
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
6921
|
+
.action(async (relPath, opts) => {
|
|
6922
|
+
try {
|
|
6923
|
+
const { orgId, projectId } = await requireProjectForDocs(opts.project);
|
|
6924
|
+
// Phase 4b: the API now ships `source.path` on every /docs/file
|
|
6925
|
+
// response (tech spec §4.2), so the Phase-3b tree pre-fetch that
|
|
6926
|
+
// compensated for the missing field is gone. `source.path` is the
|
|
6927
|
+
// authoritative repo-relative path. `tree` is only needed for the
|
|
6928
|
+
// Org-shared fallback below (kept for resilience — if the server
|
|
6929
|
+
// ever omits `source`, this still degrades to a helpful header).
|
|
6930
|
+
let payload;
|
|
6931
|
+
try {
|
|
6932
|
+
payload = (await api('GET', `/api/organizations/${orgId}/projects/${projectId}/docs/file?path=${encodeURIComponent(relPath)}`, undefined, { noProjectHeader: true }));
|
|
6933
|
+
}
|
|
6934
|
+
catch (err) {
|
|
6935
|
+
// Phase 2 revision R2: 422 on malformed step docs. The API
|
|
6936
|
+
// returns `{ error, code: STEP_DOC_*, details: { path, validation_errors: [...] } }`.
|
|
6937
|
+
if (err instanceof ApiError &&
|
|
6938
|
+
err.status === 422 &&
|
|
6939
|
+
typeof err.code === 'string' &&
|
|
6940
|
+
err.code.startsWith('STEP_DOC_')) {
|
|
6941
|
+
const body = (err.body ?? {});
|
|
6942
|
+
console.error(chalk.red(`Error: ${err.message}`));
|
|
6943
|
+
console.error(chalk.red(` Code: ${err.code}`));
|
|
6944
|
+
const ve = body.details?.validation_errors ?? [];
|
|
6945
|
+
if (ve.length) {
|
|
6946
|
+
console.error(chalk.red(' Validation errors:'));
|
|
6947
|
+
for (const v of ve) {
|
|
6948
|
+
const field = v.field ? `${v.field}: ` : '';
|
|
6949
|
+
console.error(chalk.red(` - ${field}${v.message ?? v.code ?? 'unknown'}`));
|
|
6950
|
+
}
|
|
6951
|
+
}
|
|
6952
|
+
process.exit(DOCS_STEP_VALIDATION_EXIT);
|
|
6953
|
+
}
|
|
6954
|
+
throw err;
|
|
6955
|
+
}
|
|
6956
|
+
// Header: file type + source discriminator + repo-relative path.
|
|
6957
|
+
// Phase 4b: `source.path` / `source.type` / `source.level` are
|
|
6958
|
+
// always present on a 200. The header reflects the §4.2 source
|
|
6959
|
+
// block so an operator piping `docs cat` to a reviewer sees
|
|
6960
|
+
// exactly where the bytes came from (file vs. system_config vs.
|
|
6961
|
+
// template-inline).
|
|
6962
|
+
const sourcePath = payload.source?.path ?? payload.path;
|
|
6963
|
+
const typeLabel = payload.file_type === 'agent'
|
|
6964
|
+
? chalk.cyan('agent')
|
|
6965
|
+
: payload.file_type === 'step'
|
|
6966
|
+
? chalk.magenta('step')
|
|
6967
|
+
: chalk.gray('doc');
|
|
6968
|
+
const originLabel = renderOriginLabel(payload.source);
|
|
6969
|
+
console.log(chalk.gray(`# ${typeLabel} ${chalk.gray('·')} ${originLabel}: ${sourcePath}`));
|
|
6970
|
+
console.log('');
|
|
6971
|
+
if (payload.content == null) {
|
|
6972
|
+
// DB-sourced response (system_config / template) per tech spec §4.2.
|
|
6973
|
+
// content is null; the callable body is in `body` (Phase 3+).
|
|
6974
|
+
if (payload.body) {
|
|
6975
|
+
console.log(payload.body);
|
|
6976
|
+
}
|
|
6977
|
+
else {
|
|
6978
|
+
console.log(chalk.gray('(no file content — this doc is sourced from system config / template inline; body field not yet exposed)'));
|
|
6979
|
+
}
|
|
6980
|
+
return;
|
|
6981
|
+
}
|
|
6982
|
+
// Trim trailing newlines; re-emit exactly one so `| grep` output
|
|
6983
|
+
// doesn't end with multiple blank lines.
|
|
6984
|
+
process.stdout.write(payload.content);
|
|
6985
|
+
if (!payload.content.endsWith('\n'))
|
|
6986
|
+
process.stdout.write('\n');
|
|
6987
|
+
}
|
|
6988
|
+
catch (err) {
|
|
6989
|
+
handleScopedError(err);
|
|
6990
|
+
}
|
|
6991
|
+
});
|
|
6992
|
+
docs
|
|
6993
|
+
.command('search')
|
|
6994
|
+
.description('Search the Project docs tree by filename/path (case-insensitive). Phase 3: path-match only; server-side full-text search is deferred per spec §8.6.')
|
|
6995
|
+
.argument('<query>', 'Substring to search for in file paths')
|
|
6996
|
+
.option('--scope <scope>', 'Scope: this-project (default) | agents | steps | all', 'this-project')
|
|
6997
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
6998
|
+
.action(async (query, opts) => {
|
|
6999
|
+
const scope = (opts.scope ?? 'this-project');
|
|
7000
|
+
if (!['this-project', 'agents', 'steps', 'all'].includes(scope)) {
|
|
7001
|
+
console.error(chalk.red(`Invalid --scope "${opts.scope}". Allowed: this-project, agents, steps, all`));
|
|
7002
|
+
process.exit(1);
|
|
7003
|
+
}
|
|
7004
|
+
try {
|
|
7005
|
+
const { orgId, projectId } = await requireProjectForDocs(opts.project);
|
|
7006
|
+
const payload = await fetchDocsTree(orgId, projectId);
|
|
7007
|
+
const pool = [];
|
|
7008
|
+
for (const e of payload.tree)
|
|
7009
|
+
if (e.type === 'file')
|
|
7010
|
+
pool.push(e);
|
|
7011
|
+
if (scope === 'all') {
|
|
7012
|
+
for (const e of payload.org_shared ?? [])
|
|
7013
|
+
if (e.type === 'file')
|
|
7014
|
+
pool.push(e);
|
|
7015
|
+
}
|
|
7016
|
+
const filtered = pool.filter((e) => {
|
|
7017
|
+
if (scope === 'agents')
|
|
7018
|
+
return e.file_type === 'agent';
|
|
7019
|
+
if (scope === 'steps')
|
|
7020
|
+
return e.file_type === 'step';
|
|
7021
|
+
return true; // this-project | all
|
|
7022
|
+
});
|
|
7023
|
+
const q = query.toLowerCase();
|
|
7024
|
+
const matches = filtered.filter((e) => e.path.toLowerCase().includes(q));
|
|
7025
|
+
if (!matches.length) {
|
|
7026
|
+
console.log(chalk.yellow(`No matches for "${query}" in scope "${scope}".`));
|
|
7027
|
+
return;
|
|
7028
|
+
}
|
|
7029
|
+
// Highlight the matched substring. chalk.yellow the match so it's
|
|
7030
|
+
// visible even in monochrome terminals (dim doesn't show well on
|
|
7031
|
+
// light backgrounds).
|
|
7032
|
+
for (const e of matches) {
|
|
7033
|
+
const low = e.path.toLowerCase();
|
|
7034
|
+
let out = '';
|
|
7035
|
+
let i = 0;
|
|
7036
|
+
while (i < e.path.length) {
|
|
7037
|
+
const at = low.indexOf(q, i);
|
|
7038
|
+
if (at < 0) {
|
|
7039
|
+
out += e.path.slice(i);
|
|
7040
|
+
break;
|
|
7041
|
+
}
|
|
7042
|
+
out += e.path.slice(i, at);
|
|
7043
|
+
out += chalk.yellow(e.path.slice(at, at + q.length));
|
|
7044
|
+
i = at + q.length;
|
|
7045
|
+
}
|
|
7046
|
+
const badge = e.file_type === 'agent'
|
|
7047
|
+
? chalk.cyan(' [agent]')
|
|
7048
|
+
: e.file_type === 'step'
|
|
7049
|
+
? chalk.magenta(' [step]')
|
|
7050
|
+
: '';
|
|
7051
|
+
console.log(` ${out}${badge}`);
|
|
7052
|
+
}
|
|
7053
|
+
console.log(chalk.gray(`\n${matches.length} ${matches.length === 1 ? 'match' : 'matches'} in scope "${scope}".`));
|
|
7054
|
+
}
|
|
7055
|
+
catch (err) {
|
|
7056
|
+
handleScopedError(err);
|
|
7057
|
+
}
|
|
7058
|
+
});
|
|
7059
|
+
// ── docs path ────────────────────────────────────────────────────────
|
|
7060
|
+
/**
|
|
7061
|
+
* F18 §8.5 — print the local workspace clone path for this Project,
|
|
7062
|
+
* falling back to the GitHub URL when no local clone is detected.
|
|
7063
|
+
*
|
|
7064
|
+
* Local detection heuristic:
|
|
7065
|
+
* 1. $FAZEMOS_WORKSPACE_PATH env var, if set + dir exists.
|
|
7066
|
+
* 2. `~/development/<repo-name>/` (the Fazemos convention) + dir exists.
|
|
7067
|
+
* If either matches, the full path is `<workspace>/<docs_root>`.
|
|
7068
|
+
*
|
|
7069
|
+
* When neither matches, we print the GitHub tree URL
|
|
7070
|
+
* (https://github.com/<owner>/<repo>/tree/<branch>/<root>) so `open $(fazemos docs path)`
|
|
7071
|
+
* on a fresh machine gracefully routes to the web UI instead of failing.
|
|
7072
|
+
*/
|
|
7073
|
+
docs
|
|
7074
|
+
.command('path')
|
|
7075
|
+
.description('Print the local filesystem path to this Project\'s docs root, or the GitHub URL if no local workspace clone is detected. Useful for `cd $(fazemos docs path)`.')
|
|
7076
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
7077
|
+
.action(async (opts) => {
|
|
7078
|
+
try {
|
|
7079
|
+
const { orgId, projectId } = await requireProjectForDocs(opts.project);
|
|
7080
|
+
const payload = await fetchDocsTree(orgId, projectId);
|
|
7081
|
+
if (!payload.repo) {
|
|
7082
|
+
console.error(chalk.red('Project has no docs repository configured.'));
|
|
7083
|
+
process.exit(1);
|
|
7084
|
+
}
|
|
7085
|
+
const repo = payload.repo;
|
|
7086
|
+
const root = payload.root ?? '';
|
|
7087
|
+
const branch = payload.branch ?? 'main';
|
|
7088
|
+
// Local probe.
|
|
7089
|
+
const candidates = [];
|
|
7090
|
+
if (process.env.FAZEMOS_WORKSPACE_PATH)
|
|
7091
|
+
candidates.push(process.env.FAZEMOS_WORKSPACE_PATH);
|
|
7092
|
+
const home = process.env.HOME;
|
|
7093
|
+
const repoName = repo.split('/').pop() ?? '';
|
|
7094
|
+
if (home && repoName)
|
|
7095
|
+
candidates.push(`${home}/development/${repoName}`);
|
|
7096
|
+
for (const c of candidates) {
|
|
7097
|
+
try {
|
|
7098
|
+
if (existsSync(c) && statSync(c).isDirectory()) {
|
|
7099
|
+
// Emit to stdout only — the whole point is that `cd $(fazemos docs path)` works.
|
|
7100
|
+
console.log(root ? `${c}/${root}` : c);
|
|
7101
|
+
return;
|
|
7102
|
+
}
|
|
7103
|
+
}
|
|
7104
|
+
catch {
|
|
7105
|
+
// ignore
|
|
7106
|
+
}
|
|
7107
|
+
}
|
|
7108
|
+
// No local clone — print the GitHub URL.
|
|
7109
|
+
const url = root
|
|
7110
|
+
? `https://github.com/${repo}/tree/${branch}/${root}`
|
|
7111
|
+
: `https://github.com/${repo}/tree/${branch}`;
|
|
7112
|
+
console.log(url);
|
|
7113
|
+
}
|
|
7114
|
+
catch (err) {
|
|
7115
|
+
handleScopedError(err);
|
|
7116
|
+
}
|
|
7117
|
+
});
|
|
7118
|
+
// ── docs open ────────────────────────────────────────────────────────
|
|
7119
|
+
/**
|
|
7120
|
+
* F18 §8 — open a doc. If the workspace repo is cloned locally AND we're
|
|
7121
|
+
* currently inside that clone (heuristic: cwd's git-root has a remote
|
|
7122
|
+
* matching the Project's configured docs_repo), launch $EDITOR. Otherwise
|
|
7123
|
+
* print the GitHub blob URL so operators on a fresh machine get a sensible
|
|
7124
|
+
* fallback.
|
|
7125
|
+
*
|
|
7126
|
+
* Why the "cwd is the git clone" heuristic rather than just "any clone
|
|
7127
|
+
* exists anywhere on disk"? Operators routinely run the CLI from inside
|
|
7128
|
+
* workspace shells (that's where the Fazemos CLI lives in the workflow)
|
|
7129
|
+
* and expect `fazemos docs open` to touch the same clone they're editing.
|
|
7130
|
+
* If they're in a different shell, opening GitHub in a browser is the
|
|
7131
|
+
* safer action than editing a clone they didn't ask about.
|
|
7132
|
+
*/
|
|
7133
|
+
docs
|
|
7134
|
+
.command('open')
|
|
7135
|
+
.description('Open a Project doc. If you\'re inside the workspace repo clone, launches $EDITOR on the file; otherwise prints the GitHub blob URL.')
|
|
7136
|
+
.argument('<path>', 'Relative path under docs_root (e.g., agents/marco.md)')
|
|
7137
|
+
.option('--project <slug>', 'Override active Project for this call')
|
|
7138
|
+
.action(async (relPath, opts) => {
|
|
7139
|
+
try {
|
|
7140
|
+
const { orgId, projectId } = await requireProjectForDocs(opts.project);
|
|
7141
|
+
const payload = await fetchDocsTree(orgId, projectId);
|
|
7142
|
+
if (!payload.repo) {
|
|
7143
|
+
console.error(chalk.red('Project has no docs repository configured.'));
|
|
7144
|
+
process.exit(1);
|
|
7145
|
+
}
|
|
7146
|
+
const repo = payload.repo;
|
|
7147
|
+
const root = payload.root ?? '';
|
|
7148
|
+
const branch = payload.branch ?? 'main';
|
|
7149
|
+
// Heuristic: are we inside a git clone whose origin matches `repo`?
|
|
7150
|
+
let localWorkspacePath = null;
|
|
7151
|
+
try {
|
|
7152
|
+
const gitRoot = execSync('git rev-parse --show-toplevel', {
|
|
7153
|
+
encoding: 'utf-8',
|
|
7154
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
7155
|
+
}).trim();
|
|
7156
|
+
if (gitRoot) {
|
|
7157
|
+
const originUrl = execSync('git config --get remote.origin.url', {
|
|
7158
|
+
encoding: 'utf-8',
|
|
7159
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
7160
|
+
cwd: gitRoot,
|
|
7161
|
+
}).trim();
|
|
7162
|
+
// Accept any URL form that ends with the repo's `owner/name`
|
|
7163
|
+
// (with optional `.git` suffix). Covers https, ssh, and git://.
|
|
7164
|
+
const re = new RegExp(`[:/]${repo.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&')}(?:\\.git)?$`);
|
|
7165
|
+
if (re.test(originUrl)) {
|
|
7166
|
+
localWorkspacePath = gitRoot;
|
|
7167
|
+
}
|
|
7168
|
+
}
|
|
7169
|
+
}
|
|
7170
|
+
catch {
|
|
7171
|
+
// Not a git repo, or git not available — fall through to URL.
|
|
7172
|
+
}
|
|
7173
|
+
if (localWorkspacePath) {
|
|
7174
|
+
const fullPath = root
|
|
7175
|
+
? `${localWorkspacePath}/${root}/${relPath}`
|
|
7176
|
+
: `${localWorkspacePath}/${relPath}`;
|
|
7177
|
+
const editor = process.env.EDITOR || process.env.VISUAL;
|
|
7178
|
+
if (!editor) {
|
|
7179
|
+
console.error(chalk.red('No $EDITOR or $VISUAL configured. Set one (e.g., `export EDITOR=code`) or re-run from outside the workspace clone to get the GitHub URL.'));
|
|
7180
|
+
// Still print the full local path so the operator can open it manually.
|
|
7181
|
+
console.log(fullPath);
|
|
7182
|
+
process.exit(1);
|
|
7183
|
+
}
|
|
7184
|
+
try {
|
|
7185
|
+
execSync(`${editor} "${fullPath}"`, { stdio: 'inherit' });
|
|
7186
|
+
}
|
|
7187
|
+
catch (err) {
|
|
7188
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
7189
|
+
console.error(chalk.red(`Failed to launch editor: ${msg}`));
|
|
7190
|
+
process.exit(1);
|
|
7191
|
+
}
|
|
7192
|
+
return;
|
|
7193
|
+
}
|
|
7194
|
+
// Not inside the workspace clone — print the GitHub blob URL.
|
|
7195
|
+
const url = root
|
|
7196
|
+
? `https://github.com/${repo}/blob/${branch}/${root}/${relPath}`
|
|
7197
|
+
: `https://github.com/${repo}/blob/${branch}/${relPath}`;
|
|
7198
|
+
console.log(url);
|
|
7199
|
+
}
|
|
7200
|
+
catch (err) {
|
|
7201
|
+
handleScopedError(err);
|
|
7202
|
+
}
|
|
7203
|
+
});
|
|
5705
7204
|
program.parse();
|
|
5706
7205
|
//# sourceMappingURL=index.js.map
|