@pugi/cli 0.1.0-alpha.17 → 0.1.0-alpha.19

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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * REPL slash command registry Sprint α5.7, expanded α6.14 wave 2.
2
+ * REPL slash command registry - Sprint α5.7, expanded α6.14 wave 2.
3
3
  *
4
4
  * The REPL input box surfaces a palette of slash commands the operator
5
5
  * can run from inside a persistent session. The wave-2 expansion (CEO
@@ -15,46 +15,35 @@
15
15
  *
16
16
  * Tiering (per CEO wave-2 spec):
17
17
  *
18
- * Tier 1 wired against real state (3 + existing 6 = 9 wired):
18
+ * Tier 1 - wired against real state (3 + existing 6 = 9 wired):
19
19
  * brief, agents, stop, help, quit, web, clear, version, jobs.
20
20
  *
21
- * Tier 2 best-effort wiring against existing surfaces (3):
21
+ * Tier 2 - best-effort wiring against existing surfaces (3):
22
22
  * diff, cost, status.
23
23
  *
24
- * Tier 3 deterministic stubs ("coming in αX.Y") (8):
24
+ * Tier 3 - deterministic stubs ("coming in αX.Y") (8):
25
25
  * compact, resume, memory, config, privacy, budget, mcp, undo.
26
26
  *
27
27
  * Brand voice (brandbook §08): power words `brief / dispatch / stop /
28
28
  * agents / quit / shipped`. Tagline `Brief it. It ships.` reserved for
29
- * `/quit` confirmation and `/help` footer never inline.
29
+ * `/quit` confirmation and `/help` footer - never inline.
30
30
  */
31
31
  import { listRoles } from '../agents/registry.js';
32
32
  /**
33
33
  * Deterministic stub copy returned by the Tier 3 commands. Spec'd
34
34
  * inline so the unit test can pin the exact text without poking at
35
35
  * the help overlay. The version tag at the end maps to the sprint we
36
- * intend to land the real wiring in.
36
+ * intend to land the real wiring in. Keyed by StubSlashCommandName
37
+ * (not the full SlashCommandName union) so wired commands cannot
38
+ * silently appear here with empty placeholders.
37
39
  */
38
40
  export const SLASH_STUB_MESSAGES = Object.freeze({
39
- brief: '',
40
- agents: '',
41
- stop: '',
42
- help: '',
43
- quit: '',
44
- web: '',
45
- clear: '',
46
- version: '',
47
- jobs: '',
48
- ask: '',
49
- diff: '',
50
- cost: '',
51
- status: '',
52
- consensus: '',
53
- compact: 'Manual context compaction lands in α6.5.',
54
- resume: '',
55
- memory: 'Session memory editor lands in α6.5.',
41
+ compact: 'Manual context compaction lands in α6.5b.',
42
+ memory: 'Session memory editor lands in α6.5b.',
56
43
  config: 'Run `pugi config list` from a fresh shell for the full surface; in-REPL editor lands in α6.5.',
57
- privacy: 'Run `pugi privacy show` from a fresh shell; in-REPL toggle lands in α6.5.',
44
+ // alpha 6.13: /privacy graduated from stub; nothing reads this at
45
+ // runtime but the type record stays exhaustive.
46
+ privacy: '',
58
47
  budget: 'Run `pugi budget` from a fresh shell; in-REPL summary lands in α6.5.',
59
48
  mcp: 'Run `pugi config mcp list` from a fresh shell; in-REPL palette lands in α6.5.',
60
49
  undo: 'Run `pugi undo` from a fresh shell; in-REPL undo lands in α6.5.',
@@ -69,8 +58,9 @@ export const SLASH_COMMAND_HELP = Object.freeze([
69
58
  // Session
70
59
  { name: 'clear', args: '', gloss: 'Clear conversation pane', group: 'Session' },
71
60
  { name: 'resume', args: '', gloss: 'Pick a stored session to restore', group: 'Session' },
72
- { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5)', group: 'Session', stub: true },
73
- { name: 'memory', args: '', gloss: 'Session memory editor (α6.5)', group: 'Session', stub: true },
61
+ { name: 'context', args: '', gloss: 'Show three-tier context summary (Tier 0 skeleton + Tier 1 working set)', group: 'Session' },
62
+ { name: 'compact', args: '', gloss: 'Manual context compaction (α6.5b)', group: 'Session', stub: true },
63
+ { name: 'memory', args: '', gloss: 'Session memory editor (α6.5b)', group: 'Session', stub: true },
74
64
  // Pugi tools
75
65
  { name: 'web', args: '<url>', gloss: 'Fetch a URL into context', group: 'Pugi tools' },
76
66
  { name: 'diff', args: '', gloss: 'Show pending diff', group: 'Pugi tools' },
@@ -79,7 +69,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
79
69
  { name: 'consensus', args: '[ref]', gloss: '3-model consensus review (codex · claude · deepseek)', group: 'Pugi tools' },
80
70
  // Settings
81
71
  { name: 'config', args: '', gloss: 'Show config', group: 'Settings', stub: true },
82
- { name: 'privacy', args: '', gloss: 'Show privacy mode', group: 'Settings', stub: true },
72
+ { name: 'privacy', args: '', gloss: 'Show privacy mode + contract', group: 'Settings' },
83
73
  { name: 'budget', args: '', gloss: 'Show usage budget', group: 'Settings', stub: true },
84
74
  { name: 'mcp', args: '', gloss: 'List MCP servers', group: 'Settings', stub: true },
85
75
  { name: 'undo', args: '', gloss: 'Undo last write', group: 'Settings', stub: true },
@@ -105,7 +95,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
105
95
  * - Empty / whitespace-only input returns `noop` with the original
106
96
  * text so the REPL can ignore it without printing anything.
107
97
  * - Input that does not start with `/` is treated as an implicit
108
- * `/brief <text>` the most-common operator action.
98
+ * `/brief <text>` - the most-common operator action.
109
99
  * - `/<name> [args]` resolves the name against the registry; unknown
110
100
  * names return `error` so the REPL can render a one-line tip
111
101
  * instead of silently dropping the input.
@@ -113,7 +103,7 @@ export const SLASH_COMMAND_GROUPS = Object.freeze([
113
103
  * can render the deterministic "coming in αX.Y" copy uniformly.
114
104
  *
115
105
  * The function never throws. Bad input maps to a structured result the
116
- * REPL can render the alternative (throwing from a keystroke handler)
106
+ * REPL can render - the alternative (throwing from a keystroke handler)
117
107
  * would unmount Ink mid-frame.
118
108
  */
119
109
  export function parseSlashCommand(input) {
@@ -210,17 +200,32 @@ export function parseSlashCommand(input) {
210
200
  // so the slash-command layer stays UI-agnostic.
211
201
  return { kind: 'resume' };
212
202
  }
203
+ case 'context':
204
+ case 'ctx': {
205
+ // α6.5: surface Tier 0 + Tier 1 status. The session module
206
+ // renders the summary as system lines so the operator can see
207
+ // skeleton size + working-set utilisation at a glance.
208
+ return { kind: 'context' };
209
+ }
210
+ case 'privacy': {
211
+ // alpha 6.13: real handler - the session module prints the
212
+ // contract doc + the current mode banner. Tail is ignored (no
213
+ // sub-commands today; mode flips go through
214
+ // `pugi config set privacy=<mode>` from a fresh shell so the
215
+ // device flow + audit identity are wired correctly).
216
+ return { kind: 'privacy' };
217
+ }
213
218
  case 'compact':
214
219
  case 'memory':
215
220
  case 'config':
216
- case 'privacy':
217
221
  case 'budget':
218
222
  case 'mcp':
219
223
  case 'undo': {
224
+ const stubName = name;
220
225
  return {
221
226
  kind: 'stub',
222
- name: name,
223
- message: SLASH_STUB_MESSAGES[name],
227
+ name: stubName,
228
+ message: SLASH_STUB_MESSAGES[stubName],
224
229
  };
225
230
  }
226
231
  default: {
@@ -20,6 +20,7 @@ import { signatureForPlanReview } from '../core/repl/ask.js';
20
20
  import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
21
21
  import { PUGI_TAGLINE } from '@pugi/personas';
22
22
  import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
23
+ import { runDeployCommand } from '../commands/deploy.js';
23
24
  import { runJobsCommand } from '../commands/jobs.js';
24
25
  import { runConfigCommand } from './commands/config.js';
25
26
  import { runPrivacyCommand } from './commands/privacy.js';
@@ -31,6 +32,7 @@ import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
31
32
  import { runReviewConsensus } from './commands/review-consensus.js';
32
33
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
33
34
  import { slugForCwd } from '../core/repl/history.js';
35
+ import { dispatchEdit, } from '../core/edits/index.js';
34
36
  /**
35
37
  * CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
36
38
  *
@@ -42,7 +44,7 @@ import { slugForCwd } from '../core/repl/history.js';
42
44
  * packages/pugi-sdk/package.json); the publish workflow validates the
43
45
  * three are in lockstep.
44
46
  */
45
- const PUGI_CLI_VERSION = "0.1.0-alpha.17";
47
+ const PUGI_CLI_VERSION = "0.1.0-alpha.19";
46
48
  const handlers = {
47
49
  accounts,
48
50
  agents: dispatchAgents,
@@ -51,6 +53,7 @@ const handlers = {
51
53
  budget: dispatchBudget,
52
54
  code: runEngineTask('code'),
53
55
  config: dispatchConfig,
56
+ deploy: dispatchDeploy,
54
57
  doctor,
55
58
  explain: runEngineTask('explain'),
56
59
  fix: runEngineTask('fix'),
@@ -524,6 +527,16 @@ async function help(_args, flags, _session) {
524
527
  ' pugi ask "<question>" Surface a yes/no question modal locally.',
525
528
  ' pugi plan-review <task> Generate + present a plan-review modal.',
526
529
  '',
530
+ 'Deploy:',
531
+ ' pugi deploy --target vercel <vercelProject> --project <id>',
532
+ ' Trigger a Vercel deployment from the bound Git source.',
533
+ ' Optional: --target-env production|preview, --ref <ref>,',
534
+ ' --integration <id>.',
535
+ ' pugi deploy --target render <renderService> --project <id>',
536
+ ' Trigger a Render deployment (Sprint 2 — stub today).',
537
+ ' pugi deploy --status <id> Vendor-agnostic status snapshot.',
538
+ ' pugi deploy --logs <id> [--tail] Build-log tail. --tail polls until terminal.',
539
+ '',
527
540
  'Sync safety:',
528
541
  ' pugi sync --dry-run --privacy metadata',
529
542
  '',
@@ -2138,6 +2151,45 @@ function runEngineTask(kind) {
2138
2151
  risks: ['adapter terminated without emitting a result event'],
2139
2152
  };
2140
2153
  }
2154
+ // α6.6 diff escalation — Layer A/B/C dispatcher.
2155
+ //
2156
+ // Some models emit file edits as inline SEARCH/REPLACE markers in
2157
+ // the final response rather than through tool calls (especially
2158
+ // Gemini and o1 family, which under-use tool schemas in long
2159
+ // reasoning chains). We run the dispatcher against the model's
2160
+ // final text so those markers still land on disk. Tool-call edits
2161
+ // (Layer-A equivalent already handled by `edit`/`write` tools) are
2162
+ // unaffected — the dispatcher only fires on prose blocks that
2163
+ // happen to contain markers.
2164
+ //
2165
+ // Scope: code / fix / build / explain only. `plan` is read-only
2166
+ // (the engine refuses write tools), so even a stray marker in plan
2167
+ // output gets ignored to honour the plan-mode contract.
2168
+ //
2169
+ // Dry-run + read-only short-circuits: when the flags forbid writes
2170
+ // we dispatch with `dryRun: true` so the operator still sees what
2171
+ // WOULD have been written, but nothing touches disk.
2172
+ let dispatchResults = [];
2173
+ if (kind === 'code' || kind === 'fix' || kind === 'build_task') {
2174
+ dispatchResults = await runMarkerDispatch({
2175
+ root,
2176
+ result: {
2177
+ status: result.status,
2178
+ summary: result.summary,
2179
+ eventRefs: result.eventRefs,
2180
+ },
2181
+ dryRun: flags.dryRun,
2182
+ });
2183
+ // Merge dispatcher-touched files into `result.filesChanged` so the
2184
+ // operator-facing summary lists them alongside tool-driven edits.
2185
+ for (const dr of dispatchResults) {
2186
+ if (dr.ok && dr.absPath) {
2187
+ const rel = relative(root, dr.absPath);
2188
+ if (!result.filesChanged.includes(rel))
2189
+ result.filesChanged.push(rel);
2190
+ }
2191
+ }
2192
+ }
2141
2193
  // For `plan` we always write a plan.md artifact, regardless of
2142
2194
  // outcome. A blocked plan (budget exhausted, tool refusal) still
2143
2195
  // produces a reviewable artifact — the reason is recorded inline.
@@ -2200,6 +2252,16 @@ function runEngineTask(kind) {
2200
2252
  sessionEventsMirror: metrics.mirror,
2201
2253
  risks: result.risks,
2202
2254
  plan: planArtifact ? { path: planArtifact.relPath } : undefined,
2255
+ // α6.6 — per-edit dispatcher trace. Empty array when no inline
2256
+ // markers were detected in the model's final response.
2257
+ diffEdits: dispatchResults.map((dr) => ({
2258
+ layer: dr.layer,
2259
+ file: dr.file,
2260
+ ok: dr.ok,
2261
+ bytesWritten: dr.bytesWritten,
2262
+ reason: dr.reason,
2263
+ detail: dr.detail,
2264
+ })),
2203
2265
  // The full event stream is useful for cabinet UI replay. We surface
2204
2266
  // it in JSON mode only — text mode operators want the summary, not
2205
2267
  // 30 turn-level lines.
@@ -2220,6 +2282,19 @@ function runEngineTask(kind) {
2220
2282
  textLines.push('Files modified: none');
2221
2283
  }
2222
2284
  textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
2285
+ if (dispatchResults.length > 0) {
2286
+ const okCount = dispatchResults.filter((d) => d.ok).length;
2287
+ const failCount = dispatchResults.length - okCount;
2288
+ textLines.push(`Diff dispatch: ${okCount} applied, ${failCount} rejected (${dispatchResults.length} marker block${dispatchResults.length === 1 ? '' : 's'})`);
2289
+ for (const dr of dispatchResults) {
2290
+ if (dr.ok) {
2291
+ textLines.push(` + ${dr.layer} ${dr.file} (${dr.bytesWritten} bytes)`);
2292
+ }
2293
+ else {
2294
+ textLines.push(` ! ${dr.layer} ${dr.file}: ${dr.reason ?? 'failure'} — ${dr.detail ?? ''}`);
2295
+ }
2296
+ }
2297
+ }
2223
2298
  if (result.risks.length > 0) {
2224
2299
  textLines.push(`Risks: ${result.risks.join('; ')}`);
2225
2300
  }
@@ -2229,6 +2304,96 @@ function runEngineTask(kind) {
2229
2304
  writeOutput(flags, payload, textLines.join('\n'));
2230
2305
  };
2231
2306
  }
2307
+ // Exported for the α6.6.1 triple-review remediation spec
2308
+ // (`apps/pugi-cli/test/edits-dispatcher-gate.spec.ts`). The runtime
2309
+ // surface is not part of the public CLI API; this is a test seam.
2310
+ export async function runMarkerDispatch(input) {
2311
+ const { root, result, dryRun } = input;
2312
+ const dispatch = input.dispatchFn ?? dispatchEdit;
2313
+ // Triple-review 2026-05-25 P2 (Codex): gate the dispatcher on the
2314
+ // engine's terminal status. A `blocked` (budget_exhausted, plan-mode
2315
+ // refusal) or `failed` result may still carry markers in the
2316
+ // partial `summary` text — applying them would mutate files the
2317
+ // CLI then exits non-zero on, leaving the workspace in an
2318
+ // unexpected state with no operator signal that "blocked but with
2319
+ // side effects" happened. Only `done` is allowed to write.
2320
+ if (result.status !== 'done')
2321
+ return [];
2322
+ // Strip the engine's status prefixes (`[budget_exhausted] `, etc.)
2323
+ // from the body before scanning. The prefixes start with `[`; the
2324
+ // dispatcher tolerates leading prose but the cleaner the input the
2325
+ // less chance of accidental marker matches.
2326
+ const body = result.summary;
2327
+ if (!hasAnyMarkerSignal(body))
2328
+ return [];
2329
+ const modelTag = extractModelTag(result.eventRefs);
2330
+ try {
2331
+ return await dispatch(body, {
2332
+ modelTag,
2333
+ cwd: root,
2334
+ dryRun,
2335
+ });
2336
+ }
2337
+ catch (error) {
2338
+ // Triple-review 2026-05-25 P2 (Claude): the previous `catch {}`
2339
+ // swallowed parser/applicator crashes silently — the operator
2340
+ // saw a clean "0 applied" rather than the actual stack trace,
2341
+ // and the bug only surfaced when someone manually `pugi resume`-d
2342
+ // a session and noticed the missing edits. Surface the failure
2343
+ // both to stderr (so live operators see it) and as a synthetic
2344
+ // DispatchResult (so JSON consumers and the audit log record it).
2345
+ //
2346
+ // R2 triple-review 2026-05-25 P2 (Claude): the earlier remediation
2347
+ // returned `detail: message` — i.e. the raw `Error.message`. That
2348
+ // string is constructed by whatever code path threw (parser,
2349
+ // applicator, fs layer, etc.) and may contain absolute paths,
2350
+ // secret fragments echoed in `oldString` context, or other
2351
+ // stack-bearing internals. The audit log and any JSON consumer
2352
+ // that surfaces `detail` to the operator (or worse, to a remote
2353
+ // monitoring pipe) would leak them. Stack already goes to stderr
2354
+ // for live diagnosis; the returned result must carry a safe,
2355
+ // static string so consumers can still detect "dispatcher
2356
+ // crashed" without re-rendering the underlying exception.
2357
+ const message = error instanceof Error ? error.message : String(error);
2358
+ const stack = error instanceof Error && error.stack ? error.stack : message;
2359
+ process.stderr.write(`pugi diff-dispatch: internal crash in dispatchEdit (${message}); see stack:\n${stack}\n`);
2360
+ return [
2361
+ {
2362
+ layer: 'layer-a',
2363
+ file: '',
2364
+ ok: false,
2365
+ bytesWritten: 0,
2366
+ reason: 'dispatcher_crash',
2367
+ detail: 'dispatcher crashed - see stderr for stack trace',
2368
+ },
2369
+ ];
2370
+ }
2371
+ }
2372
+ /**
2373
+ * Quick pre-filter: does the body contain ANY of the marker
2374
+ * signatures the dispatcher knows about? Saves a full parse on every
2375
+ * model response (most responses are pure prose and would otherwise
2376
+ * round-trip through the parser pointlessly).
2377
+ */
2378
+ function hasAnyMarkerSignal(body) {
2379
+ return (body.includes('+++ NEW') ||
2380
+ body.includes('<<<<<<< SEARCH') ||
2381
+ body.includes('@@@ REWRITE') ||
2382
+ body.includes('@@@ AST') ||
2383
+ /^--- a\//m.test(body));
2384
+ }
2385
+ /**
2386
+ * Extract `model=<tag>` from eventRefs if the adapter emitted it.
2387
+ * Returns undefined when missing; dispatchEdit then auto-detects from
2388
+ * the payload itself.
2389
+ */
2390
+ function extractModelTag(refs) {
2391
+ for (const ref of refs) {
2392
+ if (ref.startsWith('model='))
2393
+ return ref.slice('model='.length);
2394
+ }
2395
+ return undefined;
2396
+ }
2232
2397
  /**
2233
2398
  * Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
2234
2399
  * already emits the canonical strings (`tool_calls=N`, `turns=N`,
@@ -3502,6 +3667,33 @@ async function jobs(args, flags, session) {
3502
3667
  process.exitCode = exitCode;
3503
3668
  }
3504
3669
  }
3670
+ /**
3671
+ * `pugi deploy` — Wave 3 P2 (Task #34, 2026-05-25). Thin shim into
3672
+ * `src/commands/deploy.ts`. The shim adapts the global `CliFlags` shape
3673
+ * to the deploy-specific flag set + exposes the credential store via
3674
+ * `resolveRuntimeConfig` so the deploy module stays decoupled from the
3675
+ * CLI's auth bootstrap.
3676
+ */
3677
+ async function dispatchDeploy(args, flags, _session) {
3678
+ // Triple-review #391 P2: the global `parseArgs` in this file consumes
3679
+ // `--json` before the per-command args reach us, so `runDeployCommand`'s
3680
+ // internal parser never sees it and the JSON envelope path is silently
3681
+ // skipped. Re-inject the flag so downstream parsing surfaces the JSON
3682
+ // output contract the operator asked for. Idempotent: if the user wrote
3683
+ // `pugi deploy --json ...` the global parser stripped it; if they wrote
3684
+ // `pugi --json deploy ...` ditto. Either way the global flag is the
3685
+ // single source of truth and we forward it verbatim.
3686
+ const forwardedArgs = flags.json && !args.includes('--json') ? [...args, '--json'] : args;
3687
+ const exitCode = await runDeployCommand(forwardedArgs, {
3688
+ write: (text) => process.stdout.write(text),
3689
+ writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
3690
+ }, {
3691
+ resolveConfig: () => resolveRuntimeConfig(),
3692
+ });
3693
+ if (exitCode !== 0) {
3694
+ process.exitCode = exitCode;
3695
+ }
3696
+ }
3505
3697
  function notImplemented(command) {
3506
3698
  return async (_args, flags) => {
3507
3699
  const payload = {
@@ -58,6 +58,8 @@ export async function runConfigCommand(args, ctx) {
58
58
  'pugi config get routing',
59
59
  'pugi config set routing.<tag>.<budget>=<model>',
60
60
  'pugi config unset routing.<tag>.<budget>',
61
+ 'pugi config get privacy',
62
+ 'pugi config set privacy=strict|balanced|permissive',
61
63
  ],
62
64
  }, [
63
65
  'Usage:',
@@ -71,6 +73,8 @@ export async function runConfigCommand(args, ctx) {
71
73
  ' pugi config get routing Show effective routing table (defaults + tenant overrides).',
72
74
  ' pugi config set routing.<tag>.<budget>=<model> Override the model for one (tag, budget) lane.',
73
75
  ' pugi config unset routing.<tag>.<budget> Remove a routing override (revert to default).',
76
+ ' pugi config get privacy Show current tenant privacy mode + last-flip metadata.',
77
+ ' pugi config set privacy=<mode> Flip privacy mode (strict | balanced | permissive).',
74
78
  ].join('\n'));
75
79
  return;
76
80
  }
@@ -81,6 +85,13 @@ export async function runConfigCommand(args, ctx) {
81
85
  if (args[1] === 'routing') {
82
86
  return runRoutingGet(ctx);
83
87
  }
88
+ // alpha 6.13: `pugi config get privacy` hits the admin-api
89
+ // /api/admin/privacy/mode surface. The privacy mode is a tenant-
90
+ // scoped server-side setting (so the Anvil filter can enforce it),
91
+ // not a local-only preference.
92
+ if (args[1] === 'privacy') {
93
+ return runPrivacyGet(ctx);
94
+ }
84
95
  return runConfigGet(args.slice(1), ctx);
85
96
  case 'set':
86
97
  // Special form: `pugi config set routing.<tag>.<budget>=<model>` hits
@@ -88,6 +99,40 @@ export async function runConfigCommand(args, ctx) {
88
99
  if (args[1] && args[1].startsWith('routing.')) {
89
100
  return runRoutingSet(args.slice(1), ctx);
90
101
  }
102
+ // alpha 6.13: `pugi config set privacy=<mode>` (or `privacy <mode>`)
103
+ // flips the tenant-scoped server-side mode IFF <mode> is one of
104
+ // the new closed set (strict | balanced | permissive). Legacy
105
+ // values (local-only | metadata | full) still hit the local
106
+ // config schema via runConfigSet so existing operators do not
107
+ // get a surprise 4xx when their playbook still uses the old
108
+ // names. The unit spec for config.ts has a regression test for
109
+ // both code paths.
110
+ //
111
+ // Triple-review P2 fix (2026-05-25): the prior disambiguation
112
+ // only excluded the bare form (`privacy local-only`) - the `=`
113
+ // form (`privacy=local-only`) still routed to runPrivacySet and
114
+ // 4xx'd. We now check the value AFTER `=` and route legacy local
115
+ // values to runConfigSet; new-style values + unknown values
116
+ // continue to runPrivacySet so its client-side validator surfaces
117
+ // a structured "Unknown privacy mode" error (preserves the
118
+ // existing UX for typos like `privacy=paranoid`).
119
+ if (args[1]) {
120
+ const equalsForm = args[1].startsWith('privacy=');
121
+ const bareForm = args[1] === 'privacy';
122
+ if (equalsForm) {
123
+ const valueAfterEquals = args[1].slice('privacy='.length);
124
+ if (isLegacyLocalPrivacyValue(valueAfterEquals)) {
125
+ // Legacy local form (`privacy=local-only|metadata|full`) -
126
+ // split into `['privacy', '<value>']` so runConfigSet sees
127
+ // the same shape it would for the bare form.
128
+ return runConfigSet(['privacy', valueAfterEquals], ctx);
129
+ }
130
+ return runPrivacySet(args.slice(1), ctx);
131
+ }
132
+ if (bareForm && isNewPrivacyModeValue(args[2])) {
133
+ return runPrivacySet(args.slice(1), ctx);
134
+ }
135
+ }
91
136
  return runConfigSet(args.slice(1), ctx);
92
137
  case 'unset':
93
138
  if (args[1] && args[1].startsWith('routing.')) {
@@ -393,6 +438,97 @@ async function runRoutingUnset(args, ctx) {
393
438
  ? `routing.${tag}.${budget} reverted to default.`
394
439
  : `routing.${tag}.${budget} had no override (nothing to remove).`);
395
440
  }
441
+ /* ------------------------------------------------------------------ */
442
+ /* alpha 6.13 privacy 3-mode - config.privacy.* subcommands */
443
+ /* ------------------------------------------------------------------ */
444
+ /**
445
+ * Closed mirror of the server-side PRIVACY_MODES enum
446
+ * (apps/admin-api/src/privacy/privacy-mode.ts). Pinning the literal
447
+ * set here lets the CLI reject typos client-side before the round-
448
+ * trip to admin-api (better UX, smaller blast radius on a flaky
449
+ * network).
450
+ */
451
+ const PRIVACY_MODES = ['strict', 'balanced', 'permissive'];
452
+ function isNewPrivacyModeValue(value) {
453
+ return typeof value === 'string' && PRIVACY_MODES.includes(value);
454
+ }
455
+ function isPrivacyMode(value) {
456
+ return PRIVACY_MODES.includes(value);
457
+ }
458
+ /**
459
+ * Legacy local-config privacy values from before alpha 6.13. Kept so
460
+ * `pugi config set privacy=local-only` continues to write to the local
461
+ * config file (matching the bare-form behaviour). Triple-review P2 fix
462
+ * (2026-05-25): the prior disambiguation only excluded the bare form;
463
+ * the `=` form routed to runPrivacySet and 4xx'd on the unknown mode.
464
+ */
465
+ const LEGACY_LOCAL_PRIVACY_VALUES = [
466
+ 'local-only',
467
+ 'metadata',
468
+ 'full',
469
+ ];
470
+ function isLegacyLocalPrivacyValue(value) {
471
+ return (typeof value === 'string' &&
472
+ LEGACY_LOCAL_PRIVACY_VALUES.includes(value));
473
+ }
474
+ /**
475
+ * `pugi config get privacy` - fetch the current privacy mode snapshot
476
+ * from /api/admin/privacy/mode + render it in human-readable form.
477
+ */
478
+ async function runPrivacyGet(ctx) {
479
+ const { apiUrl, apiKey } = resolveAdminApi();
480
+ const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey);
481
+ const lastUpdatedLine = snapshot.lastUpdated
482
+ ? `(last set by ${snapshot.lastUpdatedBy ?? 'unknown'} on ${snapshot.lastUpdated})`
483
+ : '(no flips recorded; on implicit default)';
484
+ const text = [
485
+ `privacy.mode = ${snapshot.mode}`,
486
+ `privacy.defaultMode = ${snapshot.defaultMode}`,
487
+ lastUpdatedLine,
488
+ ].join('\n');
489
+ ctx.writeOutput({
490
+ command: 'config.privacy.get',
491
+ apiUrl,
492
+ snapshot,
493
+ }, text);
494
+ }
495
+ /**
496
+ * `pugi config set privacy=<mode>` - PUT to /api/admin/privacy/mode.
497
+ * Validates the mode client-side against the closed set before
498
+ * round-tripping.
499
+ *
500
+ * Accepts both `privacy <mode>` and `privacy=<mode>` argument forms so
501
+ * the operator can type it either way.
502
+ */
503
+ async function runPrivacySet(args, ctx) {
504
+ const raw = args.join(' ').trim();
505
+ let mode;
506
+ if (raw.startsWith('privacy=')) {
507
+ mode = raw.slice('privacy='.length).trim();
508
+ }
509
+ else if (raw === 'privacy') {
510
+ throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
511
+ }
512
+ else if (raw.startsWith('privacy ')) {
513
+ mode = raw.slice('privacy '.length).trim();
514
+ }
515
+ else {
516
+ throw new Error(`pugi config set privacy: unrecognised argument "${raw}". Try: pugi config set privacy=balanced`);
517
+ }
518
+ if (mode.length === 0) {
519
+ throw new Error('pugi config set privacy requires a mode. Try: pugi config set privacy=balanced');
520
+ }
521
+ if (!isPrivacyMode(mode)) {
522
+ throw new Error(`Unknown privacy mode "${mode}". Allowed: ${PRIVACY_MODES.join(', ')}.`);
523
+ }
524
+ const { apiUrl, apiKey } = resolveAdminApi();
525
+ const snapshot = await fetchJson(`${apiUrl}/api/admin/privacy/mode`, apiKey, { method: 'PUT', body: { mode } });
526
+ ctx.writeOutput({
527
+ command: 'config.privacy.set',
528
+ mode: snapshot.mode,
529
+ snapshot,
530
+ }, `privacy.mode = ${snapshot.mode}`);
531
+ }
396
532
  /**
397
533
  * Thin authenticated fetch helper. Adds the bearer token + accepts JSON +
398
534
  * surfaces structured errors. Uses undici `request` (not native `fetch`)