@pugi/cli 0.1.0-alpha.18 → 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.
- package/README.md +33 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +204 -0
- package/dist/core/repl/privacy-banner.js +4 -4
- package/dist/core/repl/session.js +90 -0
- package/dist/core/repl/slash-commands.js +12 -3
- package/dist/runtime/cli.js +193 -1
- package/dist/runtime/commands/config.js +136 -0
- package/package.json +2 -2
package/dist/runtime/cli.js
CHANGED
|
@@ -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.
|
|
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`)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.19",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"undici": "^8.3.0",
|
|
53
53
|
"zod": "^3.23.0",
|
|
54
54
|
"@pugi/personas": "0.1.1",
|
|
55
|
-
"@pugi/sdk": "0.1.0-alpha.
|
|
55
|
+
"@pugi/sdk": "0.1.0-alpha.19"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
58
58
|
"@types/node": "^22.0.0",
|