@pugi/cli 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
|
@@ -0,0 +1,3405 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { statSync } from 'node:fs';
|
|
5
|
+
import { dirname, relative, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { AnvilEngineLoopClient } from '../core/engine/anvil-client.js';
|
|
8
|
+
import { NoopEngineAdapter } from '../core/engine/noop.js';
|
|
9
|
+
import { NativePugiEngineAdapter } from '../core/engine/native-pugi.js';
|
|
10
|
+
import { decidePermission } from '../core/permission.js';
|
|
11
|
+
import { openSession, recordCommandCompleted, recordCommandStarted, recordToolCall, recordToolResult, } from '../core/session.js';
|
|
12
|
+
import { loadSettings } from '../core/settings.js';
|
|
13
|
+
import { FileReadCache } from '../core/file-cache.js';
|
|
14
|
+
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
15
|
+
import { globTool, grepTool, readTool } from '../tools/file-tools.js';
|
|
16
|
+
import { toolRegistry, toolSchemaBundleHashInput } from '../tools/registry.js';
|
|
17
|
+
import { webFetchTool } from '../tools/web-fetch.js';
|
|
18
|
+
import { emptyIndex, rebuildIndex, readIndex, upsertArtifact, writeIndex, } from '../core/index-store.js';
|
|
19
|
+
import { buildRuntimeConfig, loadRuntimeConfig, pollDeviceFlow, pugiHandoffBundleSchema, pugiSyncDryRunPlanSchema, pugiSyncPrivacyModeSchema, pugiSyncRequestSchema, pugiSyncUploadPlanSchema, pugiTripleReviewRequestSchema, startDeviceFlow, submitSync, submitTripleReview, } from '@pugi/sdk';
|
|
20
|
+
import { PUGI_TAGLINE } from '@pugi/personas';
|
|
21
|
+
import { clearApiKey, DEFAULT_API_URL, listStoredCredentials, maskApiKey, normalizeApiUrl, purgeAllCredentials, readCredentialsFile, resolveActiveCredential, storeApiKey, switchActiveAccount, } from '../core/credentials.js';
|
|
22
|
+
import { runJobsCommand } from '../commands/jobs.js';
|
|
23
|
+
import { runConfigCommand } from './commands/config.js';
|
|
24
|
+
import { runPrivacyCommand } from './commands/privacy.js';
|
|
25
|
+
import { runUndoCommand } from './commands/undo.js';
|
|
26
|
+
import { runBudgetCommand } from './commands/budget.js';
|
|
27
|
+
import { runSkillsCommand } from './commands/skills.js';
|
|
28
|
+
import { runAgentsCommand } from './commands/agents.js';
|
|
29
|
+
/**
|
|
30
|
+
* CLI version shown by `pugi version` and embedded in `pugi doctor --json`.
|
|
31
|
+
*
|
|
32
|
+
* Kept as a single hard-coded string (rather than reading package.json at
|
|
33
|
+
* runtime) because the CLI ships compiled JS via npm — the `.tgz` does not
|
|
34
|
+
* include package.json in a location relative to the compiled bundle, and
|
|
35
|
+
* the build does not run `--resolveJsonModule`. Bumping CLI version is a
|
|
36
|
+
* three-line ritual (this constant + apps/pugi-cli/package.json +
|
|
37
|
+
* packages/pugi-sdk/package.json); the publish workflow validates the
|
|
38
|
+
* three are in lockstep.
|
|
39
|
+
*/
|
|
40
|
+
const PUGI_CLI_VERSION = '0.1.0-alpha.10';
|
|
41
|
+
const handlers = {
|
|
42
|
+
accounts,
|
|
43
|
+
agents: dispatchAgents,
|
|
44
|
+
build: runEngineTask('build_task'),
|
|
45
|
+
budget: dispatchBudget,
|
|
46
|
+
code: runEngineTask('code'),
|
|
47
|
+
config: dispatchConfig,
|
|
48
|
+
doctor,
|
|
49
|
+
explain: runEngineTask('explain'),
|
|
50
|
+
fix: runEngineTask('fix'),
|
|
51
|
+
handoff,
|
|
52
|
+
help,
|
|
53
|
+
idea,
|
|
54
|
+
init,
|
|
55
|
+
jobs,
|
|
56
|
+
login,
|
|
57
|
+
logout,
|
|
58
|
+
plan: runEngineTask('plan'),
|
|
59
|
+
privacy: dispatchPrivacy,
|
|
60
|
+
review,
|
|
61
|
+
resume,
|
|
62
|
+
sessions,
|
|
63
|
+
skills: dispatchSkills,
|
|
64
|
+
sync,
|
|
65
|
+
undo: dispatchUndo,
|
|
66
|
+
version,
|
|
67
|
+
web: dispatchWeb,
|
|
68
|
+
whoami,
|
|
69
|
+
};
|
|
70
|
+
async function dispatchConfig(args, flags, _session) {
|
|
71
|
+
await runConfigCommand(args, {
|
|
72
|
+
workspaceRoot: process.cwd(),
|
|
73
|
+
json: flags.json,
|
|
74
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
async function dispatchPrivacy(args, flags, _session) {
|
|
78
|
+
await runPrivacyCommand(args, {
|
|
79
|
+
json: flags.json,
|
|
80
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
async function dispatchUndo(args, flags, session) {
|
|
84
|
+
await runUndoCommand(args, {
|
|
85
|
+
workspaceRoot: process.cwd(),
|
|
86
|
+
session,
|
|
87
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
async function dispatchBudget(args, flags, _session) {
|
|
91
|
+
await runBudgetCommand(args, {
|
|
92
|
+
workspaceRoot: process.cwd(),
|
|
93
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function dispatchSkills(args, flags, _session) {
|
|
97
|
+
await runSkillsCommand(args, {
|
|
98
|
+
workspaceRoot: process.cwd(),
|
|
99
|
+
json: flags.json,
|
|
100
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
async function dispatchAgents(args, flags, _session) {
|
|
104
|
+
await runAgentsCommand(args, {
|
|
105
|
+
workspaceRoot: process.cwd(),
|
|
106
|
+
json: flags.json,
|
|
107
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* `pugi web <url>` — Sprint α6.15 Phase 1 quick-win subcommand.
|
|
112
|
+
*
|
|
113
|
+
* One-shot fetch + Markdown convert + print to stdout. The REPL slash
|
|
114
|
+
* `/web <url>` goes through ReplSession; this surface is for non-REPL
|
|
115
|
+
* pipelines (CI, scripts, `pugi web ... | pbcopy`). Gated by either
|
|
116
|
+
* `--allow-fetch` on this invocation or `web.fetch.enabled` in
|
|
117
|
+
* `.pugi/settings.json`.
|
|
118
|
+
*/
|
|
119
|
+
async function dispatchWeb(args, flags, _session) {
|
|
120
|
+
const url = args[0];
|
|
121
|
+
if (!url) {
|
|
122
|
+
writeOutput(flags, { ok: false, error: 'Usage: pugi web <url> [--allow-fetch]' }, 'Usage: pugi web <url> [--allow-fetch]');
|
|
123
|
+
process.exitCode = 2;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Malformed `.pugi/settings.json` must not crash the dispatch — the
|
|
127
|
+
// fetch is gated default-off so we fail safe: refuse with a clear
|
|
128
|
+
// error and let the operator repair the file.
|
|
129
|
+
let settings;
|
|
130
|
+
try {
|
|
131
|
+
settings = loadSettings(process.cwd());
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
135
|
+
const result = {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: `Failed to load .pugi/settings.json (${message}). web_fetch refused.`,
|
|
138
|
+
};
|
|
139
|
+
writeOutput(flags, result, `web_fetch refused: ${result.error}`);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const result = await webFetchTool({ url }, { settings, allowFetch: flags.allowFetch });
|
|
144
|
+
if (!result.ok) {
|
|
145
|
+
writeOutput(flags, result, `web_fetch refused: ${result.error}`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
|
|
150
|
+
}
|
|
151
|
+
export async function runCli(argv) {
|
|
152
|
+
const { command, args, flags, isBareInvocation } = parseArgs(argv);
|
|
153
|
+
// Bare `pugi` on a TTY enters the REPL-by-default agentic session
|
|
154
|
+
// (Sprint α5.7, ADR-0056). The REPL is the customer-facing surface
|
|
155
|
+
// that brings Pugi to parity with Claude Code / Codex CLI. When the
|
|
156
|
+
// operator has no credentials yet, we fall back to the α5.0 splash
|
|
157
|
+
// so the install-time `pugi` surface still shows the wordmark +
|
|
158
|
+
// quick-start hints. Non-TTY (CI, pipes, `--no-tty`) also falls
|
|
159
|
+
// through to the splash because the REPL needs raw input and SSE.
|
|
160
|
+
if (isBareInvocation && isInteractive(flags)) {
|
|
161
|
+
const runtimeConfig = resolveRuntimeConfig();
|
|
162
|
+
if (runtimeConfig) {
|
|
163
|
+
// The REPL session reads PUGI_ALLOW_FETCH from env to decide
|
|
164
|
+
// whether to honor `/web <url>` without the settings flag.
|
|
165
|
+
// Propagating via env keeps the session module transport-free.
|
|
166
|
+
if (flags.allowFetch)
|
|
167
|
+
process.env.PUGI_ALLOW_FETCH = '1';
|
|
168
|
+
// α6.2: peek the npm registry for a newer @pugi/cli before
|
|
169
|
+
// mounting Ink. Wrapped in a try/catch belt-and-braces even
|
|
170
|
+
// though `checkForUpdate` already swallows every failure mode —
|
|
171
|
+
// a thrown bug here must never block REPL startup.
|
|
172
|
+
const { checkForUpdate } = await import('./update-check.js');
|
|
173
|
+
let updateBanner = null;
|
|
174
|
+
try {
|
|
175
|
+
updateBanner = await checkForUpdate({
|
|
176
|
+
installed: PUGI_CLI_VERSION,
|
|
177
|
+
cliSkip: flags.noUpdateCheck,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
updateBanner = null;
|
|
182
|
+
}
|
|
183
|
+
const { renderRepl } = await import('../tui/repl-render.js');
|
|
184
|
+
await renderRepl({
|
|
185
|
+
apiUrl: runtimeConfig.apiUrl,
|
|
186
|
+
apiKey: runtimeConfig.apiKey,
|
|
187
|
+
workspaceLabel: workspaceLabel(process.cwd()),
|
|
188
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
189
|
+
updateBanner,
|
|
190
|
+
skipSplash: flags.noSplash,
|
|
191
|
+
});
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
const { renderSplash } = await import('../tui/render.js');
|
|
195
|
+
await renderSplash(PUGI_CLI_VERSION);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const handler = handlers[command] ?? help;
|
|
199
|
+
const session = openSession(process.cwd());
|
|
200
|
+
recordCommandStarted(session, command);
|
|
201
|
+
try {
|
|
202
|
+
await handler(args, flags, session);
|
|
203
|
+
// Handlers can signal a failed gate by setting `process.exitCode`
|
|
204
|
+
// (e.g. BLOCK verdict, auth_missing, rate_limited) instead of
|
|
205
|
+
// throwing. Treat any non-zero exit as 'error' so the session log
|
|
206
|
+
// and `.pugi/index.json` agree with the actual process outcome.
|
|
207
|
+
const status = process.exitCode && process.exitCode !== 0 ? 'error' : 'success';
|
|
208
|
+
recordCommandCompleted(session, command, status);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
recordCommandCompleted(session, command, 'error');
|
|
212
|
+
throw error;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
function parseArgs(argv) {
|
|
216
|
+
const flags = {
|
|
217
|
+
json: false,
|
|
218
|
+
remote: false,
|
|
219
|
+
web: false,
|
|
220
|
+
dryRun: false,
|
|
221
|
+
triple: false,
|
|
222
|
+
offline: false,
|
|
223
|
+
noTty: false,
|
|
224
|
+
allowFetch: false,
|
|
225
|
+
noUpdateCheck: false,
|
|
226
|
+
noSplash: process.env.PUGI_SKIP_SPLASH === '1',
|
|
227
|
+
};
|
|
228
|
+
const args = [];
|
|
229
|
+
// Sprint 2E: `pugi --version` / `-v` are universal install-test conventions
|
|
230
|
+
// (npm uses --version on every published bin, Homebrew formula uses it in
|
|
231
|
+
// the test block). Normalize them to the `version` command so users can
|
|
232
|
+
// discover the CLI works without knowing our subcommand grammar.
|
|
233
|
+
if (argv[0] === '--version' || argv[0] === '-v') {
|
|
234
|
+
return { command: 'version', args: [], flags, isBareInvocation: false };
|
|
235
|
+
}
|
|
236
|
+
if (argv[0] === '--help' || argv[0] === '-h') {
|
|
237
|
+
return { command: 'help', args: [], flags, isBareInvocation: false };
|
|
238
|
+
}
|
|
239
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
240
|
+
const arg = argv[index] ?? '';
|
|
241
|
+
if (arg === '--json') {
|
|
242
|
+
flags.json = true;
|
|
243
|
+
}
|
|
244
|
+
else if (arg === '--remote') {
|
|
245
|
+
flags.remote = true;
|
|
246
|
+
}
|
|
247
|
+
else if (arg === '--web') {
|
|
248
|
+
flags.web = true;
|
|
249
|
+
}
|
|
250
|
+
else if (arg === '--dry-run') {
|
|
251
|
+
flags.dryRun = true;
|
|
252
|
+
}
|
|
253
|
+
else if (arg === '--triple' || arg === '--consensus') {
|
|
254
|
+
flags.triple = true;
|
|
255
|
+
}
|
|
256
|
+
else if (arg === '--offline') {
|
|
257
|
+
flags.offline = true;
|
|
258
|
+
}
|
|
259
|
+
else if (arg === '--no-tty') {
|
|
260
|
+
flags.noTty = true;
|
|
261
|
+
}
|
|
262
|
+
else if (arg === '--allow-fetch') {
|
|
263
|
+
flags.allowFetch = true;
|
|
264
|
+
}
|
|
265
|
+
else if (arg === '--no-update-check') {
|
|
266
|
+
flags.noUpdateCheck = true;
|
|
267
|
+
}
|
|
268
|
+
else if (arg === '--no-splash') {
|
|
269
|
+
flags.noSplash = true;
|
|
270
|
+
}
|
|
271
|
+
else if (arg.startsWith('--privacy=')) {
|
|
272
|
+
flags.privacy = parsePrivacyMode(arg.slice('--privacy='.length));
|
|
273
|
+
}
|
|
274
|
+
else if (arg === '--privacy') {
|
|
275
|
+
const next = argv[index + 1];
|
|
276
|
+
if (!next)
|
|
277
|
+
throw new Error('--privacy requires a mode');
|
|
278
|
+
flags.privacy = parsePrivacyMode(next);
|
|
279
|
+
index += 1;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
args.push(arg);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const isBareInvocation = args.length === 0;
|
|
286
|
+
return {
|
|
287
|
+
command: args.shift() ?? 'help',
|
|
288
|
+
args,
|
|
289
|
+
flags,
|
|
290
|
+
isBareInvocation,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
async function version(_args, flags, _session) {
|
|
294
|
+
const payload = {
|
|
295
|
+
name: 'pugi',
|
|
296
|
+
version: PUGI_CLI_VERSION,
|
|
297
|
+
};
|
|
298
|
+
writeOutput(flags, payload, `pugi ${payload.version}`);
|
|
299
|
+
}
|
|
300
|
+
async function help(_args, flags, _session) {
|
|
301
|
+
const commands = Object.keys(handlers).sort();
|
|
302
|
+
writeOutput(flags, { commands }, [
|
|
303
|
+
'Pugi CLI',
|
|
304
|
+
'',
|
|
305
|
+
'Usage: pugi <command> [--json] [--web] [--remote] [--no-tty]',
|
|
306
|
+
'',
|
|
307
|
+
'Commands:',
|
|
308
|
+
...commands.map((command) => ` ${command}`),
|
|
309
|
+
'',
|
|
310
|
+
'Authentication:',
|
|
311
|
+
' pugi login Interactive picker (browser OAuth / PAT / env).',
|
|
312
|
+
' pugi login --provider device|token|env Non-interactive variant.',
|
|
313
|
+
' pugi whoami Show active credential, JWT account, plan tier.',
|
|
314
|
+
' pugi accounts list All stored accounts across multiple endpoints.',
|
|
315
|
+
' pugi accounts switch <label> Re-point the active account.',
|
|
316
|
+
' pugi accounts remove <label> Delete a stored credential.',
|
|
317
|
+
'',
|
|
318
|
+
'Review gate:',
|
|
319
|
+
' pugi review --triple Prepare the Anvil-backed triple-review gate.',
|
|
320
|
+
'',
|
|
321
|
+
'Skills + agents marketplace:',
|
|
322
|
+
' pugi skills list All installed skills.',
|
|
323
|
+
' pugi skills install <source> [--yes] Fetch + trust + install a skill.',
|
|
324
|
+
' pugi skills info <name> Metadata + body preview.',
|
|
325
|
+
' pugi agents list All installed sub-agents.',
|
|
326
|
+
' pugi agents install <source> [--yes] Fetch + trust + install an agent.',
|
|
327
|
+
'',
|
|
328
|
+
'Sync safety:',
|
|
329
|
+
' pugi sync --dry-run --privacy metadata',
|
|
330
|
+
'',
|
|
331
|
+
'Interactivity:',
|
|
332
|
+
' --no-tty Force the line-buffered output path (CI, pipes,',
|
|
333
|
+
' recording flows, dumb terminals).',
|
|
334
|
+
' --no-update-check Silence the REPL startup update banner. Pairs',
|
|
335
|
+
' with PUGI_SKIP_UPDATE_BANNER=1.',
|
|
336
|
+
' --no-splash Skip the REPL boot splash. Pairs with',
|
|
337
|
+
' PUGI_SKIP_SPLASH=1.',
|
|
338
|
+
'',
|
|
339
|
+
PUGI_TAGLINE,
|
|
340
|
+
'Execution defaults to local. Use --remote or --web to create a handoff bundle.',
|
|
341
|
+
].join('\n'));
|
|
342
|
+
}
|
|
343
|
+
async function doctor(_args, flags, _session) {
|
|
344
|
+
const cwd = process.cwd();
|
|
345
|
+
const settings = loadSettings(cwd);
|
|
346
|
+
// `doctor` reports adapter capabilities only; we pass a no-op client
|
|
347
|
+
// so we do not require an Anvil endpoint to run `pugi doctor`. The
|
|
348
|
+
// adapter never invokes `client.send()` from inside `capabilities()`.
|
|
349
|
+
const inertClient = {
|
|
350
|
+
async send() {
|
|
351
|
+
return {
|
|
352
|
+
stop: 'error',
|
|
353
|
+
code: 'failed',
|
|
354
|
+
message: 'doctor: inert client',
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
const adapters = [
|
|
359
|
+
new NoopEngineAdapter(),
|
|
360
|
+
new NativePugiEngineAdapter({ client: inertClient }),
|
|
361
|
+
];
|
|
362
|
+
const capabilities = await Promise.all(adapters.map(async (adapter) => ({
|
|
363
|
+
name: adapter.name,
|
|
364
|
+
capabilities: await adapter.capabilities(),
|
|
365
|
+
})));
|
|
366
|
+
const payload = {
|
|
367
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
368
|
+
nodeVersion: process.version,
|
|
369
|
+
workspaceRoot: cwd,
|
|
370
|
+
pugiMode: existsSync(resolve(cwd, 'CLAUDE.md')),
|
|
371
|
+
pugiDir: existsSync(resolve(cwd, '.pugi')),
|
|
372
|
+
eventLog: existsSync(resolve(cwd, '.pugi/events.jsonl')),
|
|
373
|
+
permissionMode: settings.permissions.mode,
|
|
374
|
+
approvals: settings.workflow.approvals,
|
|
375
|
+
notAutomatic: [...settings.workflow.notAutomatic, ...settings.permissions.notAutomatic],
|
|
376
|
+
protectedFileCheck: decidePermission({ tool: 'doctor', kind: 'edit', target: '.env' }, settings, cwd),
|
|
377
|
+
protectedFileSafety: 'configured-in-m1',
|
|
378
|
+
mcpTrust: 'not-configured',
|
|
379
|
+
releaseGuard: 'scaffolded',
|
|
380
|
+
tools: toolRegistry,
|
|
381
|
+
engineAdapters: capabilities,
|
|
382
|
+
schemaBundleHash: createHash('sha256')
|
|
383
|
+
.update(toolSchemaBundleHashInput())
|
|
384
|
+
.digest('hex'),
|
|
385
|
+
};
|
|
386
|
+
writeOutput(flags, payload, [
|
|
387
|
+
'Pugi doctor',
|
|
388
|
+
`CLI: ${payload.cliVersion}`,
|
|
389
|
+
`Node: ${payload.nodeVersion}`,
|
|
390
|
+
`Workspace: ${payload.workspaceRoot}`,
|
|
391
|
+
`Pugi mode: ${payload.pugiMode ? 'detected' : 'not detected'}`,
|
|
392
|
+
`Pugi dir: ${payload.pugiDir ? 'present' : 'missing'}`,
|
|
393
|
+
`Event log: ${payload.eventLog ? 'present' : 'missing'}`,
|
|
394
|
+
`Permission mode: ${payload.permissionMode}`,
|
|
395
|
+
`Approvals: ${payload.approvals}`,
|
|
396
|
+
`Release guard: ${payload.releaseGuard}`,
|
|
397
|
+
].join('\n'));
|
|
398
|
+
}
|
|
399
|
+
async function init(_args, flags, _session) {
|
|
400
|
+
const cwd = process.cwd();
|
|
401
|
+
const pugiDir = resolve(cwd, '.pugi');
|
|
402
|
+
const created = [];
|
|
403
|
+
const skipped = [];
|
|
404
|
+
ensureDir(pugiDir, created, skipped);
|
|
405
|
+
ensureDir(resolve(pugiDir, 'artifacts'), created, skipped);
|
|
406
|
+
ensureDir(resolve(pugiDir, 'sessions'), created, skipped);
|
|
407
|
+
writeJsonIfMissing(resolve(pugiDir, 'settings.json'), {
|
|
408
|
+
schema: 1,
|
|
409
|
+
workflow: {
|
|
410
|
+
brand: 'pugi',
|
|
411
|
+
legacyName: 'codeforge',
|
|
412
|
+
approvals: 'auto',
|
|
413
|
+
notAutomatic: [],
|
|
414
|
+
defaultBaseBranch: 'dev',
|
|
415
|
+
branchPrefixes: ['feature', 'fix', 'refactor', 'chore'],
|
|
416
|
+
aiCoAuthorTrailers: false,
|
|
417
|
+
},
|
|
418
|
+
permissions: {
|
|
419
|
+
mode: 'auto',
|
|
420
|
+
allow: [],
|
|
421
|
+
deny: [],
|
|
422
|
+
notAutomatic: [],
|
|
423
|
+
},
|
|
424
|
+
privacy: {
|
|
425
|
+
mode: 'balanced',
|
|
426
|
+
telemetry: 'off',
|
|
427
|
+
},
|
|
428
|
+
artifacts: {
|
|
429
|
+
defaultPath: '.pugi/artifacts',
|
|
430
|
+
promoteExplicitly: true,
|
|
431
|
+
},
|
|
432
|
+
}, created, skipped);
|
|
433
|
+
writeJsonIfMissing(resolve(pugiDir, 'mcp.json'), {
|
|
434
|
+
schema: 1,
|
|
435
|
+
servers: [],
|
|
436
|
+
}, created, skipped);
|
|
437
|
+
writeJsonIfMissing(resolve(pugiDir, 'index.json'), emptyIndex(), created, skipped);
|
|
438
|
+
writeTextIfMissing(resolve(pugiDir, 'PUGI.md'), [
|
|
439
|
+
'# Pugi Project Context',
|
|
440
|
+
'',
|
|
441
|
+
'## Product Workflow',
|
|
442
|
+
'',
|
|
443
|
+
'- Public product name: Pugi',
|
|
444
|
+
'- Default flow: idea -> build -> review',
|
|
445
|
+
'- Approvals are automatic by default until a repo, environment, workflow, or action is marked notAutomatic.',
|
|
446
|
+
'- Do not add AI Co-Authored-By trailers.',
|
|
447
|
+
'- Generated code, comments, commits, PR text, and technical docs default to English.',
|
|
448
|
+
'',
|
|
449
|
+
'## Project Notes',
|
|
450
|
+
'',
|
|
451
|
+
'- Add repo-specific architecture, commands, and business rules here.',
|
|
452
|
+
'- Do not store secrets, real IPs, private key paths, tokens, or credentials here.',
|
|
453
|
+
'',
|
|
454
|
+
].join('\n'), created, skipped);
|
|
455
|
+
writeTextIfMissing(resolve(cwd, '.pugiignore'), [
|
|
456
|
+
'# Pugi ignore rules',
|
|
457
|
+
'.env',
|
|
458
|
+
'.env.*',
|
|
459
|
+
'!.env.example',
|
|
460
|
+
'node_modules/',
|
|
461
|
+
'dist/',
|
|
462
|
+
'.next/',
|
|
463
|
+
'coverage/',
|
|
464
|
+
'*.log',
|
|
465
|
+
'*.pem',
|
|
466
|
+
'*.key',
|
|
467
|
+
'*.crt',
|
|
468
|
+
'*.p12',
|
|
469
|
+
'*.sql',
|
|
470
|
+
'*.dump',
|
|
471
|
+
'',
|
|
472
|
+
].join('\n'), created, skipped);
|
|
473
|
+
// Ensure `.pugi/` is git-ignored so users do not accidentally commit
|
|
474
|
+
// local audit logs, artifacts, or triple-review request payloads.
|
|
475
|
+
ensurePugiGitIgnore(cwd, created, skipped);
|
|
476
|
+
const payload = {
|
|
477
|
+
status: 'initialized',
|
|
478
|
+
root: cwd,
|
|
479
|
+
created,
|
|
480
|
+
skipped,
|
|
481
|
+
};
|
|
482
|
+
writeOutput(flags, payload, [
|
|
483
|
+
'Pugi initialized',
|
|
484
|
+
`Root: ${cwd}`,
|
|
485
|
+
created.length ? `Created:\n${created.map((path) => ` ${path}`).join('\n')}` : 'Created: none',
|
|
486
|
+
skipped.length ? `Already present:\n${skipped.map((path) => ` ${path}`).join('\n')}` : 'Already present: none',
|
|
487
|
+
].join('\n'));
|
|
488
|
+
}
|
|
489
|
+
async function idea(args, flags, session) {
|
|
490
|
+
const prompt = args.join(' ').trim();
|
|
491
|
+
if (!prompt) {
|
|
492
|
+
throw new Error('pugi idea requires a prompt');
|
|
493
|
+
}
|
|
494
|
+
const root = process.cwd();
|
|
495
|
+
const pugiDir = resolve(root, '.pugi');
|
|
496
|
+
if (!existsSync(pugiDir)) {
|
|
497
|
+
throw new Error('Run pugi init before pugi idea');
|
|
498
|
+
}
|
|
499
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(prompt)}`;
|
|
500
|
+
const artifactDir = resolve(pugiDir, 'artifacts', id);
|
|
501
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
502
|
+
const toolCallId = recordToolCall(session, 'artifact:idea', prompt);
|
|
503
|
+
const briefPath = resolve(artifactDir, 'brief.md');
|
|
504
|
+
const graphPath = resolve(artifactDir, 'execution-graph.json');
|
|
505
|
+
const criteriaPath = resolve(artifactDir, 'acceptance-criteria.md');
|
|
506
|
+
const brief = [
|
|
507
|
+
'# Pugi Idea Brief',
|
|
508
|
+
'',
|
|
509
|
+
`Prompt: ${prompt}`,
|
|
510
|
+
'',
|
|
511
|
+
'## Forcing Questions',
|
|
512
|
+
'',
|
|
513
|
+
'- Who exactly has this pain?',
|
|
514
|
+
'- What do they do now?',
|
|
515
|
+
'- What breaks if this stays unsolved?',
|
|
516
|
+
'- What is the narrowest useful wedge?',
|
|
517
|
+
'- What is the current status quo cost in time or money?',
|
|
518
|
+
'- Who is the first design partner?',
|
|
519
|
+
'',
|
|
520
|
+
'## Initial Positioning',
|
|
521
|
+
'',
|
|
522
|
+
'Pugi should turn this idea into a reviewed execution graph before code is written.',
|
|
523
|
+
'',
|
|
524
|
+
].join('\n');
|
|
525
|
+
const executionGraph = {
|
|
526
|
+
id,
|
|
527
|
+
title: prompt,
|
|
528
|
+
nodes: [
|
|
529
|
+
{
|
|
530
|
+
id: 'brief',
|
|
531
|
+
title: 'Clarify founder intent',
|
|
532
|
+
kind: 'plan',
|
|
533
|
+
dependsOn: [],
|
|
534
|
+
status: 'pending',
|
|
535
|
+
acceptanceCriteria: ['Forcing questions are answered or explicitly deferred.'],
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: 'scope',
|
|
539
|
+
title: 'Define narrowest wedge',
|
|
540
|
+
kind: 'plan',
|
|
541
|
+
dependsOn: ['brief'],
|
|
542
|
+
status: 'pending',
|
|
543
|
+
acceptanceCriteria: ['One bounded MVP path is selected.'],
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
id: 'build',
|
|
547
|
+
title: 'Implement approved wedge',
|
|
548
|
+
kind: 'code',
|
|
549
|
+
dependsOn: ['scope'],
|
|
550
|
+
status: 'pending',
|
|
551
|
+
acceptanceCriteria: ['Working diff is produced with tests or explicit test gap.'],
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
id: 'review',
|
|
555
|
+
title: 'Run Pugi review gate',
|
|
556
|
+
kind: 'review',
|
|
557
|
+
dependsOn: ['build'],
|
|
558
|
+
status: 'pending',
|
|
559
|
+
acceptanceCriteria: ['Review returns PASS or BLOCK with findings.'],
|
|
560
|
+
},
|
|
561
|
+
],
|
|
562
|
+
artifacts: [
|
|
563
|
+
{ id: 'brief', kind: 'brief', path: relative(root, briefPath), title: 'Idea brief', createdAt: new Date().toISOString() },
|
|
564
|
+
{
|
|
565
|
+
id: 'execution_graph',
|
|
566
|
+
kind: 'execution_graph',
|
|
567
|
+
path: relative(root, graphPath),
|
|
568
|
+
title: 'Execution graph',
|
|
569
|
+
createdAt: new Date().toISOString(),
|
|
570
|
+
},
|
|
571
|
+
],
|
|
572
|
+
};
|
|
573
|
+
const criteria = [
|
|
574
|
+
'# Acceptance Criteria',
|
|
575
|
+
'',
|
|
576
|
+
'- The target user and pain are explicit.',
|
|
577
|
+
'- The first wedge is narrow enough for a supervised build.',
|
|
578
|
+
'- The execution graph can be reviewed before implementation.',
|
|
579
|
+
'- The review gate can block incomplete or unsafe output.',
|
|
580
|
+
'',
|
|
581
|
+
].join('\n');
|
|
582
|
+
writeFileSync(briefPath, brief, { encoding: 'utf8', mode: 0o600 });
|
|
583
|
+
writeFileSync(graphPath, `${JSON.stringify(executionGraph, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
584
|
+
writeFileSync(criteriaPath, criteria, { encoding: 'utf8', mode: 0o600 });
|
|
585
|
+
registerArtifact(root, {
|
|
586
|
+
id,
|
|
587
|
+
kind: 'idea',
|
|
588
|
+
path: relative(root, artifactDir),
|
|
589
|
+
sessionId: session.id,
|
|
590
|
+
createdAt: new Date().toISOString(),
|
|
591
|
+
files: ['brief.md', 'execution-graph.json', 'acceptance-criteria.md'],
|
|
592
|
+
});
|
|
593
|
+
recordToolResult(session, toolCallId, 'success', `Created idea artifacts ${id}`);
|
|
594
|
+
const payload = {
|
|
595
|
+
status: 'created',
|
|
596
|
+
id,
|
|
597
|
+
artifacts: {
|
|
598
|
+
brief: relative(root, briefPath),
|
|
599
|
+
executionGraph: relative(root, graphPath),
|
|
600
|
+
acceptanceCriteria: relative(root, criteriaPath),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
writeOutput(flags, payload, [
|
|
604
|
+
'Pugi idea artifacts created',
|
|
605
|
+
`ID: ${id}`,
|
|
606
|
+
`Brief: ${payload.artifacts.brief}`,
|
|
607
|
+
`Execution graph: ${payload.artifacts.executionGraph}`,
|
|
608
|
+
`Acceptance criteria: ${payload.artifacts.acceptanceCriteria}`,
|
|
609
|
+
].join('\n'));
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Sprint 2 Track A offline fallbacks.
|
|
613
|
+
*
|
|
614
|
+
* `pugi plan` / `pugi build` / `pugi explain` are now engine-driven by
|
|
615
|
+
* default (`runEngineTask` above). When the operator has no credential
|
|
616
|
+
* configured (`pugi login` never run, no PUGI_API_KEY env) OR the
|
|
617
|
+
* `--offline` flag is set, the engine path is skipped and these
|
|
618
|
+
* helpers produce the same local-first artifacts the pre-Sprint-2 CLI
|
|
619
|
+
* shipped. This preserves the local-first invariant proven by
|
|
620
|
+
* `test/local-first-invariants.spec.ts` — a brand-new repo with no
|
|
621
|
+
* credential can still run `init → idea → plan → build → review`.
|
|
622
|
+
*
|
|
623
|
+
* The offline implementations are intentionally identical to the
|
|
624
|
+
* pre-Sprint-2 handlers (modulo the rename); behaviour drift between
|
|
625
|
+
* the two paths is a P1 — keep them aligned when extending either.
|
|
626
|
+
*/
|
|
627
|
+
async function offlinePlan(args, flags, session) {
|
|
628
|
+
const root = process.cwd();
|
|
629
|
+
ensureInitialized(root);
|
|
630
|
+
const prompt = args.join(' ').trim();
|
|
631
|
+
const latestIdea = latestArtifactDir(root);
|
|
632
|
+
// Code Reviewer P2 retro 2026-05-23: both engine and offline `plan`
|
|
633
|
+
// accept an empty prompt ONLY when a previous artifact set exists
|
|
634
|
+
// to plan against. Without one the operator has handed us nothing
|
|
635
|
+
// to plan, so we fail loudly instead of producing an empty
|
|
636
|
+
// skeleton-of-a-plan artifact.
|
|
637
|
+
if (!prompt && !latestIdea) {
|
|
638
|
+
throw new Error('pugi plan requires a prompt or a previous artifact in .pugi/artifacts/');
|
|
639
|
+
}
|
|
640
|
+
const artifactDir = createArtifactDir(root, prompt || 'plan');
|
|
641
|
+
const planPath = resolve(artifactDir, 'plan.md');
|
|
642
|
+
const graphPath = resolve(artifactDir, 'execution-graph.json');
|
|
643
|
+
const toolCallId = recordToolCall(session, 'artifact:plan', prompt || 'latest local context');
|
|
644
|
+
const planText = [
|
|
645
|
+
'# Pugi Execution Plan (offline)',
|
|
646
|
+
'',
|
|
647
|
+
prompt ? `Prompt: ${prompt}` : 'Prompt: continue from local artifacts/context',
|
|
648
|
+
latestIdea ? `Previous artifact set: ${relative(root, latestIdea)}` : 'Previous artifact set: none',
|
|
649
|
+
'',
|
|
650
|
+
'## Mode',
|
|
651
|
+
'',
|
|
652
|
+
'- Offline plan: no Pugi credential configured or --offline flag set.',
|
|
653
|
+
'- To run the engine-driven plan, set PUGI_API_KEY or run `pugi login`.',
|
|
654
|
+
'',
|
|
655
|
+
'## Local-First Contract',
|
|
656
|
+
'',
|
|
657
|
+
'- CLI works with the repository locally by default.',
|
|
658
|
+
'- Remote/web execution is opt-in and represented by a handoff bundle.',
|
|
659
|
+
'- Generated artifacts are reviewable before code execution.',
|
|
660
|
+
'',
|
|
661
|
+
'## Steps',
|
|
662
|
+
'',
|
|
663
|
+
'1. Confirm scope and acceptance criteria.',
|
|
664
|
+
'2. Inspect repository context.',
|
|
665
|
+
'3. Produce or update the implementation diff locally.',
|
|
666
|
+
'4. Run available checks.',
|
|
667
|
+
'5. Run review gate before PR/deploy.',
|
|
668
|
+
'',
|
|
669
|
+
'## Open Questions',
|
|
670
|
+
'',
|
|
671
|
+
'- Which files/modules are in scope?',
|
|
672
|
+
'- Which command proves the change works?',
|
|
673
|
+
'- Should this continue locally, in web, or on a remote runner?',
|
|
674
|
+
'',
|
|
675
|
+
].join('\n');
|
|
676
|
+
const graph = {
|
|
677
|
+
id: artifactIdFromDir(artifactDir),
|
|
678
|
+
title: prompt || 'Local execution plan',
|
|
679
|
+
mode: flags.remote ? 'remote' : flags.web ? 'web' : 'local',
|
|
680
|
+
nodes: [
|
|
681
|
+
{ id: 'scope', title: 'Confirm scope', kind: 'plan', dependsOn: [], status: 'pending' },
|
|
682
|
+
{ id: 'inspect', title: 'Inspect repository', kind: 'research', dependsOn: ['scope'], status: 'pending' },
|
|
683
|
+
{ id: 'implement', title: 'Implement locally or hand off', kind: 'code', dependsOn: ['inspect'], status: 'pending' },
|
|
684
|
+
{ id: 'verify', title: 'Run checks', kind: 'test', dependsOn: ['implement'], status: 'pending' },
|
|
685
|
+
{ id: 'review', title: 'Review result', kind: 'review', dependsOn: ['verify'], status: 'pending' },
|
|
686
|
+
],
|
|
687
|
+
};
|
|
688
|
+
writeFileSync(planPath, planText, { encoding: 'utf8', mode: 0o600 });
|
|
689
|
+
writeFileSync(graphPath, `${JSON.stringify(graph, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
690
|
+
registerArtifact(root, {
|
|
691
|
+
id: artifactIdFromDir(artifactDir),
|
|
692
|
+
kind: 'plan',
|
|
693
|
+
path: relative(root, artifactDir),
|
|
694
|
+
sessionId: session.id,
|
|
695
|
+
createdAt: new Date().toISOString(),
|
|
696
|
+
files: ['plan.md', 'execution-graph.json'],
|
|
697
|
+
});
|
|
698
|
+
recordToolResult(session, toolCallId, 'success', `Created offline plan ${relative(root, planPath)}`);
|
|
699
|
+
writeOutput(flags, { status: 'planned', mode: 'offline', plan: relative(root, planPath), executionGraph: relative(root, graphPath) }, [`Pugi plan created (offline)`, `Plan: ${relative(root, planPath)}`, `Execution graph: ${relative(root, graphPath)}`].join('\n'));
|
|
700
|
+
}
|
|
701
|
+
async function offlineBuild(args, flags, session) {
|
|
702
|
+
const root = process.cwd();
|
|
703
|
+
ensureInitialized(root);
|
|
704
|
+
const prompt = args.join(' ').trim();
|
|
705
|
+
if (!prompt) {
|
|
706
|
+
throw new Error('pugi build requires a prompt');
|
|
707
|
+
}
|
|
708
|
+
if (flags.remote || flags.web) {
|
|
709
|
+
const bundle = createHandoffBundle(root, session, flags.remote ? 'remote_build' : 'web_continue', prompt);
|
|
710
|
+
writeOutput(flags, bundle, [
|
|
711
|
+
'Pugi build handoff created',
|
|
712
|
+
`Bundle: ${bundle.path}`,
|
|
713
|
+
flags.remote ? 'Next: remote runner can claim this job.' : 'Next: web can import this bundle and continue.',
|
|
714
|
+
].join('\n'));
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const artifactDir = createArtifactDir(root, prompt);
|
|
718
|
+
const buildPath = resolve(artifactDir, 'build.md');
|
|
719
|
+
const toolCallId = recordToolCall(session, 'artifact:build', prompt);
|
|
720
|
+
const text = [
|
|
721
|
+
'# Pugi Local Build Request (offline)',
|
|
722
|
+
'',
|
|
723
|
+
`Prompt: ${prompt}`,
|
|
724
|
+
'',
|
|
725
|
+
'Status: pending local implementation',
|
|
726
|
+
'',
|
|
727
|
+
'## Mode',
|
|
728
|
+
'',
|
|
729
|
+
'- Offline build: no Pugi credential configured or --offline flag set.',
|
|
730
|
+
'- To run the engine-driven build, set PUGI_API_KEY or run `pugi login`.',
|
|
731
|
+
'',
|
|
732
|
+
'## Contract',
|
|
733
|
+
'',
|
|
734
|
+
'- This command is local-first.',
|
|
735
|
+
'- Code execution should happen in this repository unless --remote or --web is used.',
|
|
736
|
+
'- Before mutating files, the engine must inspect relevant files and respect permissions.',
|
|
737
|
+
'',
|
|
738
|
+
'## Suggested Next Checks',
|
|
739
|
+
'',
|
|
740
|
+
'- `git status --short`',
|
|
741
|
+
'- project-specific test/lint/build command',
|
|
742
|
+
'- `pugi review`',
|
|
743
|
+
'',
|
|
744
|
+
].join('\n');
|
|
745
|
+
writeFileSync(buildPath, text, { encoding: 'utf8', mode: 0o600 });
|
|
746
|
+
registerArtifact(root, {
|
|
747
|
+
id: artifactIdFromDir(artifactDir),
|
|
748
|
+
kind: 'build',
|
|
749
|
+
path: relative(root, artifactDir),
|
|
750
|
+
sessionId: session.id,
|
|
751
|
+
createdAt: new Date().toISOString(),
|
|
752
|
+
files: ['build.md'],
|
|
753
|
+
});
|
|
754
|
+
recordToolResult(session, toolCallId, 'success', `Created offline build request ${relative(root, buildPath)}`);
|
|
755
|
+
writeOutput(flags, { status: 'created', mode: 'offline', build: relative(root, buildPath) }, [
|
|
756
|
+
'Pugi local build request created (offline)',
|
|
757
|
+
`Build artifact: ${relative(root, buildPath)}`,
|
|
758
|
+
'Configure PUGI_API_KEY or run `pugi login` to use the engine-driven build loop.',
|
|
759
|
+
].join('\n'));
|
|
760
|
+
}
|
|
761
|
+
async function offlineExplain(args, flags, session) {
|
|
762
|
+
const target = args[0];
|
|
763
|
+
if (!target) {
|
|
764
|
+
throw new Error('pugi explain requires a file or directory path');
|
|
765
|
+
}
|
|
766
|
+
const root = process.cwd();
|
|
767
|
+
const settings = loadSettings(root);
|
|
768
|
+
const ctx = {
|
|
769
|
+
root,
|
|
770
|
+
settings,
|
|
771
|
+
session,
|
|
772
|
+
readCache: new FileReadCache(),
|
|
773
|
+
};
|
|
774
|
+
// Validate the target stays inside the workspace BEFORE statting it.
|
|
775
|
+
// `resolveWorkspacePath` follows symlinks at both the parent and the
|
|
776
|
+
// target itself, so `pugi explain etc-link` (a symlink to /etc) and
|
|
777
|
+
// `pugi explain /etc` are both refused here before any directory walk
|
|
778
|
+
// or file read fans out.
|
|
779
|
+
const resolvedTarget = resolveWorkspacePath(root, target);
|
|
780
|
+
const stat = statSync(resolvedTarget);
|
|
781
|
+
if (stat.isDirectory()) {
|
|
782
|
+
const paths = globTool(ctx, `${target.replace(/\/$/, '')}/**/*`).slice(0, 80);
|
|
783
|
+
const matches = grepTool(ctx, 'TODO').slice(0, 20);
|
|
784
|
+
writeOutput(flags, { target, kind: 'directory', mode: 'offline', paths, todoMatches: matches }, [`Directory: ${target}`, `Files: ${paths.length}`, ...paths.map((path) => ` ${path}`)].join('\n'));
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
const content = readTool(ctx, target);
|
|
788
|
+
const lines = content.split('\n');
|
|
789
|
+
const excerpt = lines.slice(0, 120).join('\n');
|
|
790
|
+
writeOutput(flags, {
|
|
791
|
+
target,
|
|
792
|
+
kind: 'file',
|
|
793
|
+
mode: 'offline',
|
|
794
|
+
lineCount: lines.length,
|
|
795
|
+
excerpt,
|
|
796
|
+
}, [`File: ${target}`, `Lines: ${lines.length}`, '', excerpt].join('\n'));
|
|
797
|
+
}
|
|
798
|
+
async function review(args, flags, session) {
|
|
799
|
+
const root = process.cwd();
|
|
800
|
+
ensureInitialized(root);
|
|
801
|
+
const prompt = args.join(' ').trim();
|
|
802
|
+
if (flags.triple && flags.remote) {
|
|
803
|
+
await performRemoteTripleReview(root, session, flags, prompt);
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (flags.remote || flags.web) {
|
|
807
|
+
const bundle = createHandoffBundle(root, session, flags.remote ? 'remote_review' : 'web_review', prompt || 'review local diff');
|
|
808
|
+
writeOutput(flags, bundle, ['Pugi review handoff created', `Bundle: ${bundle.path}`].join('\n'));
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const artifactDir = createArtifactDir(root, prompt || 'review');
|
|
812
|
+
const reviewPath = resolve(artifactDir, flags.triple ? 'triple-review.md' : 'review.md');
|
|
813
|
+
const toolCallId = recordToolCall(session, 'artifact:review', prompt || 'local diff');
|
|
814
|
+
const status = safeGit(root, ['status', '--short']);
|
|
815
|
+
const diffStat = safeGit(root, ['diff', '--stat']);
|
|
816
|
+
const currentBranch = safeGit(root, ['branch', '--show-current']).trim() || 'unknown';
|
|
817
|
+
const mergeBase = safeGit(root, ['merge-base', 'HEAD', 'origin/main']).trim();
|
|
818
|
+
const branchDiffStat = mergeBase ? safeGit(root, ['diff', '--stat', `${mergeBase}..HEAD`]) : '';
|
|
819
|
+
const branchNameStatus = mergeBase ? safeGit(root, ['diff', '--name-status', `${mergeBase}..HEAD`]) : '';
|
|
820
|
+
const text = [
|
|
821
|
+
flags.triple ? '# Pugi Anvil Triple Review Request' : '# Pugi Local Review',
|
|
822
|
+
'',
|
|
823
|
+
prompt ? `Prompt: ${prompt}` : 'Prompt: review current local state',
|
|
824
|
+
flags.triple ? 'Gate: Anvil-backed triple-review for main merge readiness' : 'Gate: local review',
|
|
825
|
+
`Branch: ${currentBranch}`,
|
|
826
|
+
'',
|
|
827
|
+
'## Git Status',
|
|
828
|
+
'',
|
|
829
|
+
'```text',
|
|
830
|
+
status || 'clean',
|
|
831
|
+
'```',
|
|
832
|
+
'',
|
|
833
|
+
'## Diff Stat',
|
|
834
|
+
'',
|
|
835
|
+
'```text',
|
|
836
|
+
diffStat || 'no unstaged diff',
|
|
837
|
+
'```',
|
|
838
|
+
'',
|
|
839
|
+
...(flags.triple
|
|
840
|
+
? [
|
|
841
|
+
'## Branch Diff Against origin/main',
|
|
842
|
+
'',
|
|
843
|
+
'```text',
|
|
844
|
+
branchDiffStat || 'origin/main merge-base unavailable or no branch diff',
|
|
845
|
+
'```',
|
|
846
|
+
'',
|
|
847
|
+
'## Files Changed',
|
|
848
|
+
'',
|
|
849
|
+
'```text',
|
|
850
|
+
branchNameStatus || 'origin/main merge-base unavailable or no branch diff',
|
|
851
|
+
'```',
|
|
852
|
+
'',
|
|
853
|
+
'## Anvil Contract',
|
|
854
|
+
'',
|
|
855
|
+
'- Pugi CLI prepares evidence locally; Anvil runs the reviewer fan-out.',
|
|
856
|
+
'- Do not invoke Claude dev-only `/triple-review` from product runtime.',
|
|
857
|
+
'- Runtime review uses the same deterministic rubric as the Claude skill and OES MCP triple_review tool.',
|
|
858
|
+
'- Any P0 = BLOCK.',
|
|
859
|
+
'- P1 from two or more reviewers = BLOCK.',
|
|
860
|
+
'- P1 from one reviewer = WARN.',
|
|
861
|
+
'- No P0/P1 = PASS.',
|
|
862
|
+
'',
|
|
863
|
+
'## Reviewer Shape',
|
|
864
|
+
'',
|
|
865
|
+
'- Architecture reviewer: scope, module boundaries, migrations, contracts, blast radius.',
|
|
866
|
+
'- Security/reliability reviewer: auth, secrets, permissions, destructive ops, data loss, deploy risk.',
|
|
867
|
+
'- QA/regression reviewer: commands run, smoke path, acceptance criteria, rollback path.',
|
|
868
|
+
'',
|
|
869
|
+
'## Future Transport',
|
|
870
|
+
'',
|
|
871
|
+
'- Send this evidence bundle to Anvil review endpoint once CLI auth/transport is wired.',
|
|
872
|
+
'- Persist result in local `.pugi/artifacts` and server ledger.',
|
|
873
|
+
'- Feed merge readiness into the existing agent merge gate instead of trusting a client-side PASS claim.',
|
|
874
|
+
'',
|
|
875
|
+
]
|
|
876
|
+
: []),
|
|
877
|
+
'## Review Checklist',
|
|
878
|
+
'',
|
|
879
|
+
'- Are acceptance criteria explicit?',
|
|
880
|
+
'- Are generated artifacts present?',
|
|
881
|
+
'- Were tests/lint/build run or explicitly deferred?',
|
|
882
|
+
'- Are secrets, env files, and destructive ops untouched?',
|
|
883
|
+
'- Is remote/web handoff needed for longer execution?',
|
|
884
|
+
'',
|
|
885
|
+
flags.triple
|
|
886
|
+
? 'Status: block until Anvil triple-review returns PASS or a WARN is explicitly accepted.'
|
|
887
|
+
: 'Status: block until a model-backed or human review fills findings.',
|
|
888
|
+
'',
|
|
889
|
+
].join('\n');
|
|
890
|
+
writeFileSync(reviewPath, text, { encoding: 'utf8', mode: 0o600 });
|
|
891
|
+
registerArtifact(root, {
|
|
892
|
+
id: artifactIdFromDir(artifactDir),
|
|
893
|
+
kind: flags.triple ? 'triple-review' : 'review',
|
|
894
|
+
path: relative(root, artifactDir),
|
|
895
|
+
sessionId: session.id,
|
|
896
|
+
createdAt: new Date().toISOString(),
|
|
897
|
+
files: [flags.triple ? 'triple-review.md' : 'review.md'],
|
|
898
|
+
});
|
|
899
|
+
recordToolResult(session, toolCallId, 'success', `Created review ${relative(root, reviewPath)}`);
|
|
900
|
+
writeOutput(flags, { status: 'created', review: relative(root, reviewPath) }, [
|
|
901
|
+
flags.triple ? 'Pugi Anvil triple-review request created' : 'Pugi local review created',
|
|
902
|
+
`Review: ${relative(root, reviewPath)}`,
|
|
903
|
+
].join('\n'));
|
|
904
|
+
}
|
|
905
|
+
async function sync(_args, flags, session) {
|
|
906
|
+
const root = process.cwd();
|
|
907
|
+
ensureInitialized(root);
|
|
908
|
+
const settings = loadSettings(root);
|
|
909
|
+
const mode = flags.privacy ?? privacyModeFromSettings(settings.privacy.mode);
|
|
910
|
+
const createdAt = new Date().toISOString();
|
|
911
|
+
const dryRunPlan = pugiSyncDryRunPlanSchema.parse({
|
|
912
|
+
schema: 1,
|
|
913
|
+
createdAt,
|
|
914
|
+
mode,
|
|
915
|
+
uploadEnabled: false,
|
|
916
|
+
workspace: workspaceSnapshot(root),
|
|
917
|
+
items: buildSyncDryRunItems(root, mode),
|
|
918
|
+
exclusions: [
|
|
919
|
+
'.env',
|
|
920
|
+
'.env.*',
|
|
921
|
+
'*.pem',
|
|
922
|
+
'*.key',
|
|
923
|
+
'*.p12',
|
|
924
|
+
'*.sql',
|
|
925
|
+
'*.dump',
|
|
926
|
+
'node_modules/',
|
|
927
|
+
'dist/',
|
|
928
|
+
'.next/',
|
|
929
|
+
'coverage/',
|
|
930
|
+
],
|
|
931
|
+
notes: [
|
|
932
|
+
'Local repository and .pugi/ remain the source of truth (ADR-0037).',
|
|
933
|
+
'Server sync is explicit continuation, not default storage.',
|
|
934
|
+
mode === 'full-sync'
|
|
935
|
+
? 'full-sync is a future explicit opt-in and is not upload-enabled in this scaffold.'
|
|
936
|
+
: 'Raw file contents are excluded by default.',
|
|
937
|
+
],
|
|
938
|
+
});
|
|
939
|
+
if (flags.dryRun) {
|
|
940
|
+
writeOutput(flags, { status: 'dry_run', plan: dryRunPlan }, [
|
|
941
|
+
'Pugi sync dry-run',
|
|
942
|
+
`Mode: ${dryRunPlan.mode}`,
|
|
943
|
+
'Upload: disabled (dry-run)',
|
|
944
|
+
'',
|
|
945
|
+
...dryRunPlan.items.map((item) => ` ${item.action.toUpperCase()} ${item.kind} ${item.path} (${item.bytes} bytes) - ${item.reason}`),
|
|
946
|
+
'',
|
|
947
|
+
'No upload performed.',
|
|
948
|
+
].join('\n'));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
await performRemoteSync(root, session, flags, dryRunPlan, createdAt, mode);
|
|
952
|
+
}
|
|
953
|
+
async function performRemoteSync(root, session, flags, dryRunPlan, createdAt, mode) {
|
|
954
|
+
const toolCallId = recordToolCall(session, 'sync:upload', `mode=${mode}`);
|
|
955
|
+
const config = resolveRuntimeConfig();
|
|
956
|
+
if (!config) {
|
|
957
|
+
recordToolResult(session, toolCallId, 'error', 'pugi sync requires login (PUGI_API_KEY or `pugi login`)');
|
|
958
|
+
writeOutput(flags, { status: 'unauthenticated', reason: 'no_credentials', plan: dryRunPlan }, [
|
|
959
|
+
'Pugi sync requires credentials.',
|
|
960
|
+
'Run `pugi login --token=<api-key>` or set PUGI_API_URL + PUGI_API_KEY in your environment.',
|
|
961
|
+
'Run `pugi sync --dry-run` to inspect the plan offline.',
|
|
962
|
+
].join('\n'));
|
|
963
|
+
process.exitCode = 5;
|
|
964
|
+
return;
|
|
965
|
+
}
|
|
966
|
+
// Build the handoff bundle in-memory (no `.pugi/handoffs/*.json`
|
|
967
|
+
// side-effect — the server record IS the durable handoff). The
|
|
968
|
+
// operator can call `pugi handoff` separately for a local-only
|
|
969
|
+
// snapshot.
|
|
970
|
+
//
|
|
971
|
+
// Mix a randomUUID() suffix into the id so two `pugi sync`
|
|
972
|
+
// invocations in the same millisecond (CI parallelism, scripted
|
|
973
|
+
// retries) produce distinct `bundle.id` values — the
|
|
974
|
+
// server-side `clientId` column relies on them being unique
|
|
975
|
+
// for the "which server records correspond to my local
|
|
976
|
+
// handoff #foo" affordance.
|
|
977
|
+
const id = `${createdAt.replace(/[:.]/g, '-')}-sync-${randomUUID().slice(0, 8)}`;
|
|
978
|
+
const latest = latestArtifactDir(root);
|
|
979
|
+
const bundle = pugiHandoffBundleSchema.parse({
|
|
980
|
+
schema: 1,
|
|
981
|
+
id,
|
|
982
|
+
reason: `pugi sync (mode=${mode})`,
|
|
983
|
+
prompt: `Continue from the most recent Pugi session on ${workspaceSnapshot(root).rootName}.`,
|
|
984
|
+
createdAt,
|
|
985
|
+
workspace: workspaceSnapshot(root),
|
|
986
|
+
session: {
|
|
987
|
+
id: session.id,
|
|
988
|
+
eventsPath: relative(root, session.eventsPath),
|
|
989
|
+
},
|
|
990
|
+
artifacts: {
|
|
991
|
+
latest: latest ? relative(root, latest) : null,
|
|
992
|
+
},
|
|
993
|
+
privacy: {
|
|
994
|
+
includesFileContents: false,
|
|
995
|
+
includesSecrets: false,
|
|
996
|
+
},
|
|
997
|
+
});
|
|
998
|
+
const uploadPlan = pugiSyncUploadPlanSchema.parse({
|
|
999
|
+
...dryRunPlan,
|
|
1000
|
+
uploadEnabled: true,
|
|
1001
|
+
});
|
|
1002
|
+
const request = pugiSyncRequestSchema.parse({
|
|
1003
|
+
schema: 1,
|
|
1004
|
+
bundle,
|
|
1005
|
+
plan: uploadPlan,
|
|
1006
|
+
});
|
|
1007
|
+
const result = await submitSync(config, request);
|
|
1008
|
+
if (result.status === 'ok') {
|
|
1009
|
+
recordToolResult(session, toolCallId, 'success', `sync ${result.response.syncId} status=${result.response.status}`);
|
|
1010
|
+
writeOutput(flags, {
|
|
1011
|
+
status: 'completed',
|
|
1012
|
+
syncId: result.response.syncId,
|
|
1013
|
+
syncStatus: result.response.status,
|
|
1014
|
+
receivedAt: result.response.receivedAt,
|
|
1015
|
+
}, [
|
|
1016
|
+
`Pugi sync uploaded — server id ${result.response.syncId}`,
|
|
1017
|
+
`Status: ${result.response.status} at ${result.response.receivedAt}`,
|
|
1018
|
+
'Local repository and .pugi/ remain the source of truth (ADR-0037).',
|
|
1019
|
+
].join('\n'));
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const outcome = describeSyncFailure(result);
|
|
1023
|
+
// SECURITY: do NOT persist the raw upstream body into the local
|
|
1024
|
+
// session events log. If the runtime ever echoes the submitted
|
|
1025
|
+
// prompt / reason / bundle fragment back in its 4xx/5xx body
|
|
1026
|
+
// (NestJS BadRequestException echoes failing field values by
|
|
1027
|
+
// default), this would leak the customer's prompt text to disk
|
|
1028
|
+
// under `.pugi/sessions/.../events.jsonl`. The class-only
|
|
1029
|
+
// `recordedMessage` carries the status class + HTTP code so the
|
|
1030
|
+
// session log still proves the failure happened without exposing
|
|
1031
|
+
// the upstream body. The verbose `message` goes to stdout/JSON
|
|
1032
|
+
// output only, where it is operator-visible and ephemeral.
|
|
1033
|
+
recordToolResult(session, toolCallId, 'error', outcome.recordedMessage);
|
|
1034
|
+
writeOutput(flags, {
|
|
1035
|
+
status: result.status,
|
|
1036
|
+
code: 'code' in result ? result.code : undefined,
|
|
1037
|
+
message: outcome.message,
|
|
1038
|
+
plan: dryRunPlan,
|
|
1039
|
+
}, [outcome.headline, outcome.next ? `Next: ${outcome.next}` : '']
|
|
1040
|
+
.filter(Boolean)
|
|
1041
|
+
.join('\n'));
|
|
1042
|
+
process.exitCode = outcome.exitCode;
|
|
1043
|
+
}
|
|
1044
|
+
function describeSyncFailure(result) {
|
|
1045
|
+
switch (result.status) {
|
|
1046
|
+
case 'endpoint_missing':
|
|
1047
|
+
return {
|
|
1048
|
+
headline: 'Pugi runtime sync endpoint not deployed on this Anvil instance.',
|
|
1049
|
+
message: result.message,
|
|
1050
|
+
recordedMessage: 'sync: endpoint_missing (HTTP 404)',
|
|
1051
|
+
next: 'Operator must deploy POST /api/pugi/sync on api.pugi.io (Phase 1 of explicit continuation).',
|
|
1052
|
+
exitCode: 6,
|
|
1053
|
+
};
|
|
1054
|
+
case 'unauthenticated':
|
|
1055
|
+
return {
|
|
1056
|
+
headline: `Pugi runtime rejected credentials (HTTP ${result.code}).`,
|
|
1057
|
+
message: result.message,
|
|
1058
|
+
recordedMessage: `sync: unauthenticated (HTTP ${result.code})`,
|
|
1059
|
+
next: 'Rotate PUGI_API_KEY or check tenant entitlement.',
|
|
1060
|
+
exitCode: 5,
|
|
1061
|
+
};
|
|
1062
|
+
case 'rate_limited':
|
|
1063
|
+
return {
|
|
1064
|
+
headline: `Pugi runtime rate-limited the sync request (HTTP ${result.code}).`,
|
|
1065
|
+
message: result.message,
|
|
1066
|
+
recordedMessage: `sync: rate_limited (HTTP ${result.code})`,
|
|
1067
|
+
next: `Retry after ${Math.round(result.retryAfterMs / 1000)}s.`,
|
|
1068
|
+
exitCode: 7,
|
|
1069
|
+
};
|
|
1070
|
+
case 'failed':
|
|
1071
|
+
default:
|
|
1072
|
+
return {
|
|
1073
|
+
headline: 'Pugi runtime sync failed.',
|
|
1074
|
+
message: result.message,
|
|
1075
|
+
// `code === 0` denotes a transport-layer error (network /
|
|
1076
|
+
// AbortError); keep it out of the recorded line so the
|
|
1077
|
+
// session log normalises to "sync: failed".
|
|
1078
|
+
recordedMessage: `sync: failed${'code' in result && result.code > 0 ? ` (HTTP ${result.code})` : ''}`,
|
|
1079
|
+
next: 'Run `pugi sync --dry-run` to inspect the plan and retry.',
|
|
1080
|
+
exitCode: 8,
|
|
1081
|
+
};
|
|
1082
|
+
}
|
|
1083
|
+
}
|
|
1084
|
+
function resolveRuntimeConfig() {
|
|
1085
|
+
// Prefer env-only path first so CI use cases keep their fast path.
|
|
1086
|
+
const envConfig = loadRuntimeConfig();
|
|
1087
|
+
if (envConfig)
|
|
1088
|
+
return envConfig;
|
|
1089
|
+
// Fall back to the local credentials store written by `pugi login`.
|
|
1090
|
+
const credential = resolveActiveCredential();
|
|
1091
|
+
if (!credential)
|
|
1092
|
+
return null;
|
|
1093
|
+
return buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey });
|
|
1094
|
+
}
|
|
1095
|
+
async function performRemoteTripleReview(root, session, flags, prompt) {
|
|
1096
|
+
const config = resolveRuntimeConfig();
|
|
1097
|
+
const artifactDir = createArtifactDir(root, prompt || 'triple-review');
|
|
1098
|
+
const requestPath = resolve(artifactDir, 'triple-review-request.json');
|
|
1099
|
+
const resultPath = resolve(artifactDir, 'triple-review-result.json');
|
|
1100
|
+
const summaryPath = resolve(artifactDir, 'triple-review.md');
|
|
1101
|
+
const toolCallId = recordToolCall(session, 'review:triple-remote', prompt || 'review branch diff');
|
|
1102
|
+
const settings = loadSettings(root);
|
|
1103
|
+
const baseRef = resolveBaseRef(root, settings);
|
|
1104
|
+
// Compute the diff against the merge-base SHA so the review covers BOTH
|
|
1105
|
+
// committed-since-base AND uncommitted (staged + working tree) changes.
|
|
1106
|
+
// The previous shape `base...HEAD` used triple-dot range but only sent
|
|
1107
|
+
// committed-since-base, hiding pre-commit edits in the most common
|
|
1108
|
+
// review case ("review what I'm about to commit").
|
|
1109
|
+
const mergeBaseSha = baseRef ? safeGit(root, ['merge-base', baseRef, 'HEAD']).trim() : '';
|
|
1110
|
+
const diffRange = mergeBaseSha || 'HEAD';
|
|
1111
|
+
// Exclude protected paths from the patch at the git layer so we cannot
|
|
1112
|
+
// accidentally POST a tracked `.env` / `*.key` / `*.sql` to the runtime.
|
|
1113
|
+
const diffArgs = ['diff', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
1114
|
+
const diffStatArgs = ['diff', '--shortstat', diffRange, '--', '.', ...PROTECTED_DIFF_EXCLUDES];
|
|
1115
|
+
const diffPatch = safeGit(root, diffArgs);
|
|
1116
|
+
const diffStats = parseDiffStats(safeGit(root, diffStatArgs));
|
|
1117
|
+
// Untracked files are not in `git diff`. Include their paths (NOT contents)
|
|
1118
|
+
// so reviewers know the diff is incomplete. Protected basenames/suffixes
|
|
1119
|
+
// are stripped from the list before egress.
|
|
1120
|
+
const untracked = collectUntrackedSummary(root);
|
|
1121
|
+
// Append an untracked summary so reviewers see the path of any new file
|
|
1122
|
+
// that is dirty-but-not-yet-staged. Contents are never included; only
|
|
1123
|
+
// path counts and a capped list.
|
|
1124
|
+
const augmentedDiff = untracked.paths.length === 0 && untracked.excludedProtected === 0
|
|
1125
|
+
? diffPatch
|
|
1126
|
+
: [
|
|
1127
|
+
diffPatch,
|
|
1128
|
+
'',
|
|
1129
|
+
'## Untracked files (paths only, contents withheld)',
|
|
1130
|
+
`Total visible: ${untracked.paths.length}`,
|
|
1131
|
+
`Protected paths excluded: ${untracked.excludedProtected}`,
|
|
1132
|
+
...untracked.paths.map((p) => `- ${p}`),
|
|
1133
|
+
'',
|
|
1134
|
+
].join('\n');
|
|
1135
|
+
const requestBody = pugiTripleReviewRequestSchema.parse({
|
|
1136
|
+
schema: 1,
|
|
1137
|
+
workspace: {
|
|
1138
|
+
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
1139
|
+
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
1140
|
+
gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
|
|
1141
|
+
baseRef,
|
|
1142
|
+
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
1143
|
+
},
|
|
1144
|
+
diffPatch: augmentedDiff,
|
|
1145
|
+
diffStats,
|
|
1146
|
+
prompt: prompt || undefined,
|
|
1147
|
+
locale: 'en-US',
|
|
1148
|
+
reviewerPersona: 'oes-dev',
|
|
1149
|
+
});
|
|
1150
|
+
writeFileSync(requestPath, `${JSON.stringify(requestBody, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1151
|
+
registerArtifact(root, {
|
|
1152
|
+
id: artifactIdFromDir(artifactDir),
|
|
1153
|
+
kind: 'triple-review',
|
|
1154
|
+
path: relative(root, artifactDir),
|
|
1155
|
+
sessionId: session.id,
|
|
1156
|
+
createdAt: new Date().toISOString(),
|
|
1157
|
+
files: ['triple-review-request.json'],
|
|
1158
|
+
});
|
|
1159
|
+
if (!config) {
|
|
1160
|
+
const reason = 'No active Pugi credentials. Run `pugi login --token <PAT>` or set PUGI_API_KEY for CI use.';
|
|
1161
|
+
recordToolResult(session, toolCallId, 'error', reason);
|
|
1162
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
1163
|
+
prompt,
|
|
1164
|
+
requestPath: relative(root, requestPath),
|
|
1165
|
+
verdict: null,
|
|
1166
|
+
reason,
|
|
1167
|
+
response: null,
|
|
1168
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
1169
|
+
writeOutput(flags, {
|
|
1170
|
+
status: 'auth_missing',
|
|
1171
|
+
request: relative(root, requestPath),
|
|
1172
|
+
summary: relative(root, summaryPath),
|
|
1173
|
+
}, [
|
|
1174
|
+
'Pugi triple-review request prepared but not sent — no active credentials.',
|
|
1175
|
+
`Request: ${relative(root, requestPath)}`,
|
|
1176
|
+
`Run \`pugi login --token <PAT>\` (or export PUGI_API_KEY for CI) then retry \`pugi review --triple --remote\`.`,
|
|
1177
|
+
].join('\n'));
|
|
1178
|
+
process.exitCode = 5;
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
const submitResult = await submitTripleReview(config, requestBody);
|
|
1182
|
+
if (submitResult.status === 'ok') {
|
|
1183
|
+
persistTripleReviewResult(resultPath, submitResult.response);
|
|
1184
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
1185
|
+
prompt,
|
|
1186
|
+
requestPath: relative(root, requestPath),
|
|
1187
|
+
verdict: submitResult.response.verdict,
|
|
1188
|
+
reason: submitResult.response.reason,
|
|
1189
|
+
response: submitResult.response,
|
|
1190
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
1191
|
+
recordToolResult(session, toolCallId, submitResult.response.verdict === 'BLOCK' ? 'error' : 'success', `Verdict: ${submitResult.response.verdict} (${submitResult.response.reason})`);
|
|
1192
|
+
writeOutput(flags, {
|
|
1193
|
+
status: 'completed',
|
|
1194
|
+
verdict: submitResult.response.verdict,
|
|
1195
|
+
reason: submitResult.response.reason,
|
|
1196
|
+
counts: submitResult.response.counts,
|
|
1197
|
+
reviewerCount: submitResult.response.reviewerCount,
|
|
1198
|
+
effectiveTier: submitResult.response.effectiveTier,
|
|
1199
|
+
result: relative(root, resultPath),
|
|
1200
|
+
summary: relative(root, summaryPath),
|
|
1201
|
+
}, [
|
|
1202
|
+
`Pugi triple-review ${submitResult.response.verdict}: ${submitResult.response.reason}`,
|
|
1203
|
+
`Reviewers: ${submitResult.response.reviewerCount} (tier ${submitResult.response.effectiveTier})`,
|
|
1204
|
+
`Findings: P0=${submitResult.response.counts.P0} P1=${submitResult.response.counts.P1} P2=${submitResult.response.counts.P2} P3=${submitResult.response.counts.P3}`,
|
|
1205
|
+
`Result: ${relative(root, resultPath)}`,
|
|
1206
|
+
`Summary: ${relative(root, summaryPath)}`,
|
|
1207
|
+
].join('\n'));
|
|
1208
|
+
if (submitResult.response.verdict === 'BLOCK') {
|
|
1209
|
+
process.exitCode = 9;
|
|
1210
|
+
}
|
|
1211
|
+
return;
|
|
1212
|
+
}
|
|
1213
|
+
// Non-OK paths: persist local artifact noting outcome, surface actionable error.
|
|
1214
|
+
const outcome = describeSubmitFailure(submitResult);
|
|
1215
|
+
writeFileSync(summaryPath, buildTripleReviewMarkdown({
|
|
1216
|
+
prompt,
|
|
1217
|
+
requestPath: relative(root, requestPath),
|
|
1218
|
+
verdict: null,
|
|
1219
|
+
reason: outcome.message,
|
|
1220
|
+
response: null,
|
|
1221
|
+
}), { encoding: 'utf8', mode: 0o600 });
|
|
1222
|
+
recordToolResult(session, toolCallId, 'error', outcome.message);
|
|
1223
|
+
writeOutput(flags, {
|
|
1224
|
+
status: submitResult.status,
|
|
1225
|
+
code: submitResult.code,
|
|
1226
|
+
message: outcome.message,
|
|
1227
|
+
request: relative(root, requestPath),
|
|
1228
|
+
summary: relative(root, summaryPath),
|
|
1229
|
+
}, [
|
|
1230
|
+
outcome.headline,
|
|
1231
|
+
`Request: ${relative(root, requestPath)}`,
|
|
1232
|
+
`Summary: ${relative(root, summaryPath)}`,
|
|
1233
|
+
outcome.next ? `Next: ${outcome.next}` : '',
|
|
1234
|
+
]
|
|
1235
|
+
.filter(Boolean)
|
|
1236
|
+
.join('\n'));
|
|
1237
|
+
process.exitCode = outcome.exitCode;
|
|
1238
|
+
}
|
|
1239
|
+
function describeSubmitFailure(result) {
|
|
1240
|
+
switch (result.status) {
|
|
1241
|
+
case 'endpoint_missing':
|
|
1242
|
+
return {
|
|
1243
|
+
headline: 'Pugi runtime triple-review endpoint not deployed on this Anvil instance.',
|
|
1244
|
+
message: result.message,
|
|
1245
|
+
next: 'Operator must deploy POST /api/pugi/triple-review on api.pugi.io (proxies to AnvilBridgeService.askPersona with the configured reviewer persona).',
|
|
1246
|
+
exitCode: 6,
|
|
1247
|
+
};
|
|
1248
|
+
case 'unauthenticated':
|
|
1249
|
+
return {
|
|
1250
|
+
headline: `Pugi runtime rejected credentials (HTTP ${result.code}).`,
|
|
1251
|
+
message: result.message,
|
|
1252
|
+
next: 'Rotate PUGI_API_KEY or check tenant entitlement.',
|
|
1253
|
+
exitCode: 5,
|
|
1254
|
+
};
|
|
1255
|
+
case 'rate_limited':
|
|
1256
|
+
return {
|
|
1257
|
+
headline: `Pugi runtime rate-limited the triple-review request (HTTP ${result.code}).`,
|
|
1258
|
+
message: result.message,
|
|
1259
|
+
next: `Retry after ${Math.round(result.retryAfterMs / 1000)}s or upgrade the tenant tier.`,
|
|
1260
|
+
exitCode: 7,
|
|
1261
|
+
};
|
|
1262
|
+
case 'failed':
|
|
1263
|
+
return {
|
|
1264
|
+
headline: `Pugi runtime triple-review failed (HTTP ${result.code}).`,
|
|
1265
|
+
message: result.message,
|
|
1266
|
+
next: 'Inspect server logs and retry once the runtime stabilises.',
|
|
1267
|
+
exitCode: 6,
|
|
1268
|
+
};
|
|
1269
|
+
case 'ok':
|
|
1270
|
+
throw new Error('describeSubmitFailure called with ok status');
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
function persistTripleReviewResult(path, response) {
|
|
1274
|
+
writeFileSync(path, `${JSON.stringify(response, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
1275
|
+
}
|
|
1276
|
+
function buildTripleReviewMarkdown(input) {
|
|
1277
|
+
const { prompt, requestPath, verdict, reason, response } = input;
|
|
1278
|
+
const header = verdict
|
|
1279
|
+
? `# Pugi Triple Review — ${verdict}`
|
|
1280
|
+
: '# Pugi Triple Review — request prepared';
|
|
1281
|
+
const lines = [
|
|
1282
|
+
header,
|
|
1283
|
+
'',
|
|
1284
|
+
prompt ? `Prompt: ${prompt}` : 'Prompt: review branch diff vs origin/main',
|
|
1285
|
+
`Reason: ${reason}`,
|
|
1286
|
+
`Request: ${requestPath}`,
|
|
1287
|
+
'',
|
|
1288
|
+
];
|
|
1289
|
+
if (response) {
|
|
1290
|
+
lines.push(`Reviewers: ${response.reviewerCount} (effective tier ${response.effectiveTier}${response.draft ? ', draft' : ''})`, `Findings: P0=${response.counts.P0} P1=${response.counts.P1} P2=${response.counts.P2} P3=${response.counts.P3}`, '', '## Findings', '');
|
|
1291
|
+
if (response.findings.length === 0) {
|
|
1292
|
+
lines.push('- No findings reported.');
|
|
1293
|
+
}
|
|
1294
|
+
else {
|
|
1295
|
+
for (const finding of response.findings) {
|
|
1296
|
+
const location = [
|
|
1297
|
+
finding.path ? finding.path : null,
|
|
1298
|
+
finding.line ? `line ${finding.line}` : null,
|
|
1299
|
+
]
|
|
1300
|
+
.filter(Boolean)
|
|
1301
|
+
.join(' ');
|
|
1302
|
+
const fix = finding.fix ? ` Fix: ${finding.fix}` : '';
|
|
1303
|
+
lines.push(`- [${finding.severity}] ${finding.reviewer}${location ? ` — ${location}` : ''} — ${finding.issue}${fix}`);
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
lines.push('', '## Reviewer Verdicts', '');
|
|
1307
|
+
for (const reviewer of response.reviewers) {
|
|
1308
|
+
const declared = reviewer.declaredVerdict ?? 'unknown';
|
|
1309
|
+
lines.push(`- ${reviewer.model}: ${declared}${reviewer.error ? ` (error: ${reviewer.error})` : ''}`);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
lines.push('## Outcome', '', '- Local request artifact preserved.', '- Runtime call did not return a verdict.');
|
|
1314
|
+
}
|
|
1315
|
+
lines.push('');
|
|
1316
|
+
return lines.join('\n');
|
|
1317
|
+
}
|
|
1318
|
+
function resolveBaseRef(root, settings) {
|
|
1319
|
+
// Honor `workflow.defaultBaseBranch` from `.pugi/settings.json` (defaults
|
|
1320
|
+
// to `dev` per `pugi init`). Without this the resolver only checks
|
|
1321
|
+
// `origin/main`/`master` and silently submits an empty patch for any
|
|
1322
|
+
// repo on a non-main integration branch.
|
|
1323
|
+
const configured = settings?.workflow.defaultBaseBranch;
|
|
1324
|
+
const candidates = [
|
|
1325
|
+
configured ? `origin/${configured}` : null,
|
|
1326
|
+
configured ?? null,
|
|
1327
|
+
'origin/main',
|
|
1328
|
+
'origin/master',
|
|
1329
|
+
'main',
|
|
1330
|
+
'master',
|
|
1331
|
+
].filter((value) => Boolean(value));
|
|
1332
|
+
const seen = new Set();
|
|
1333
|
+
for (const candidate of candidates) {
|
|
1334
|
+
if (seen.has(candidate))
|
|
1335
|
+
continue;
|
|
1336
|
+
seen.add(candidate);
|
|
1337
|
+
const mergeBase = safeGit(root, ['merge-base', 'HEAD', candidate]).trim();
|
|
1338
|
+
if (mergeBase)
|
|
1339
|
+
return candidate;
|
|
1340
|
+
}
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
function parseDiffStats(raw) {
|
|
1344
|
+
const stats = { filesChanged: 0, insertions: 0, deletions: 0 };
|
|
1345
|
+
const filesMatch = raw.match(/(\d+)\s+files?\s+changed/);
|
|
1346
|
+
if (filesMatch?.[1])
|
|
1347
|
+
stats.filesChanged = Number.parseInt(filesMatch[1], 10);
|
|
1348
|
+
const insMatch = raw.match(/(\d+)\s+insertions?/);
|
|
1349
|
+
if (insMatch?.[1])
|
|
1350
|
+
stats.insertions = Number.parseInt(insMatch[1], 10);
|
|
1351
|
+
const delMatch = raw.match(/(\d+)\s+deletions?/);
|
|
1352
|
+
if (delMatch?.[1])
|
|
1353
|
+
stats.deletions = Number.parseInt(delMatch[1], 10);
|
|
1354
|
+
return stats;
|
|
1355
|
+
}
|
|
1356
|
+
async function handoff(args, flags, session) {
|
|
1357
|
+
const root = process.cwd();
|
|
1358
|
+
ensureInitialized(root);
|
|
1359
|
+
const reason = args[0] || 'web_continue';
|
|
1360
|
+
const prompt = args.slice(1).join(' ').trim() || 'continue local Pugi session';
|
|
1361
|
+
const bundle = createHandoffBundle(root, session, reason, prompt);
|
|
1362
|
+
writeOutput(flags, bundle, ['Pugi handoff bundle created', `Bundle: ${bundle.path}`].join('\n'));
|
|
1363
|
+
}
|
|
1364
|
+
async function sessions(args, flags, _session) {
|
|
1365
|
+
const root = process.cwd();
|
|
1366
|
+
ensureInitialized(root);
|
|
1367
|
+
const rebuild = args.includes('--rebuild');
|
|
1368
|
+
let index = rebuild ? null : readIndex(root);
|
|
1369
|
+
if (!index) {
|
|
1370
|
+
index = rebuildIndex(root);
|
|
1371
|
+
writeIndex(root, index);
|
|
1372
|
+
}
|
|
1373
|
+
else if (hasStubSession(index)) {
|
|
1374
|
+
// `registerArtifact` writes minimal session placeholders to keep the
|
|
1375
|
+
// hot path O(1). If any are still stubs (no commands recorded yet),
|
|
1376
|
+
// opportunistically materialize them from `events.jsonl` so the
|
|
1377
|
+
// default `pugi sessions` output isn't misleadingly empty. Users no
|
|
1378
|
+
// longer need to know to pass `--rebuild`.
|
|
1379
|
+
index = rebuildIndex(root);
|
|
1380
|
+
writeIndex(root, index);
|
|
1381
|
+
}
|
|
1382
|
+
const sessionsView = index.sessions.slice(0, 10).map((session) => {
|
|
1383
|
+
const artifactsForSession = index.artifacts.filter((artifact) => artifact.sessionId === session.id);
|
|
1384
|
+
const lastCommand = session.commands.at(-1) ?? null;
|
|
1385
|
+
return {
|
|
1386
|
+
id: session.id,
|
|
1387
|
+
startedAt: session.startedAt,
|
|
1388
|
+
endedAt: session.endedAt,
|
|
1389
|
+
commandCount: session.commandCount,
|
|
1390
|
+
lastCommand: lastCommand
|
|
1391
|
+
? { command: lastCommand.command, status: lastCommand.status, timestamp: lastCommand.timestamp }
|
|
1392
|
+
: null,
|
|
1393
|
+
artifactCount: artifactsForSession.length,
|
|
1394
|
+
latestArtifact: artifactsForSession[0]?.path ?? null,
|
|
1395
|
+
};
|
|
1396
|
+
});
|
|
1397
|
+
const orphans = index.artifacts.filter((artifact) => !artifact.sessionId);
|
|
1398
|
+
const handoffs = index.artifacts.filter((artifact) => artifact.kind === 'handoff');
|
|
1399
|
+
const payload = {
|
|
1400
|
+
root,
|
|
1401
|
+
indexPath: '.pugi/index.json',
|
|
1402
|
+
updatedAt: index.updatedAt,
|
|
1403
|
+
sessions: sessionsView,
|
|
1404
|
+
artifacts: index.artifacts.slice(0, 20),
|
|
1405
|
+
orphanArtifacts: orphans.slice(0, 10),
|
|
1406
|
+
handoffs: handoffs.slice(0, 10),
|
|
1407
|
+
latestSession: sessionsView[0] ?? null,
|
|
1408
|
+
latestArtifact: index.artifacts[0] ?? null,
|
|
1409
|
+
latestHandoff: handoffs[0] ?? null,
|
|
1410
|
+
};
|
|
1411
|
+
writeOutput(flags, payload, [
|
|
1412
|
+
'Pugi local sessions',
|
|
1413
|
+
`Index: ${payload.indexPath} (updated ${payload.updatedAt})`,
|
|
1414
|
+
'',
|
|
1415
|
+
'Sessions:',
|
|
1416
|
+
...(sessionsView.length
|
|
1417
|
+
? sessionsView.map((session) => {
|
|
1418
|
+
const lastCmd = session.lastCommand
|
|
1419
|
+
? `${session.lastCommand.command} (${session.lastCommand.status})`
|
|
1420
|
+
: 'none';
|
|
1421
|
+
return ` ${session.id} cmds=${session.commandCount} last=${lastCmd} artifacts=${session.artifactCount}`;
|
|
1422
|
+
})
|
|
1423
|
+
: [' none']),
|
|
1424
|
+
'',
|
|
1425
|
+
'Artifacts (latest 10):',
|
|
1426
|
+
...(index.artifacts.length
|
|
1427
|
+
? index.artifacts.slice(0, 10).map((artifact) => ` ${artifact.id} kind=${artifact.kind} session=${artifact.sessionId ?? 'orphan'} files=${artifact.files.length}`)
|
|
1428
|
+
: [' none']),
|
|
1429
|
+
'',
|
|
1430
|
+
'Handoffs:',
|
|
1431
|
+
...(handoffs.length
|
|
1432
|
+
? handoffs.slice(0, 10).map((handoff) => ` ${handoff.id} path=${handoff.path}`)
|
|
1433
|
+
: [' none']),
|
|
1434
|
+
].join('\n'));
|
|
1435
|
+
}
|
|
1436
|
+
function hasStubSession(index) {
|
|
1437
|
+
return index.sessions.some((session) => session.commandCount === 0 && session.commands.length === 0);
|
|
1438
|
+
}
|
|
1439
|
+
function registerArtifact(root, artifact) {
|
|
1440
|
+
// Hot path on every artifact-producing command. Avoid `rebuildIndex` here —
|
|
1441
|
+
// that walks the entire `.pugi/artifacts/` tree and re-parses `events.jsonl`
|
|
1442
|
+
// which is O(N) per command. Instead we read the existing index and inject
|
|
1443
|
+
// a minimal session placeholder if needed; the next `pugi sessions` call
|
|
1444
|
+
// (or `pugi sessions --rebuild`) materialises the full session record from
|
|
1445
|
+
// `events.jsonl`.
|
|
1446
|
+
const current = readIndex(root) ?? emptyIndex();
|
|
1447
|
+
const needsSessionStub = Boolean(artifact.sessionId) &&
|
|
1448
|
+
!current.sessions.some((session) => session.id === artifact.sessionId);
|
|
1449
|
+
const withSession = needsSessionStub
|
|
1450
|
+
? {
|
|
1451
|
+
...current,
|
|
1452
|
+
sessions: [
|
|
1453
|
+
...current.sessions,
|
|
1454
|
+
{
|
|
1455
|
+
id: artifact.sessionId,
|
|
1456
|
+
startedAt: new Date().toISOString(),
|
|
1457
|
+
endedAt: null,
|
|
1458
|
+
commandCount: 0,
|
|
1459
|
+
commands: [],
|
|
1460
|
+
artifactIds: [],
|
|
1461
|
+
},
|
|
1462
|
+
],
|
|
1463
|
+
}
|
|
1464
|
+
: current;
|
|
1465
|
+
const updated = upsertArtifact(withSession, artifact);
|
|
1466
|
+
writeIndex(root, updated);
|
|
1467
|
+
return updated;
|
|
1468
|
+
}
|
|
1469
|
+
async function resume(args, flags, session) {
|
|
1470
|
+
const root = process.cwd();
|
|
1471
|
+
ensureInitialized(root);
|
|
1472
|
+
const target = args[0];
|
|
1473
|
+
const artifacts = listArtifactSets(root);
|
|
1474
|
+
const selected = target
|
|
1475
|
+
? artifacts.find((artifact) => artifact.id === target || artifact.path.endsWith(target))
|
|
1476
|
+
: artifacts[0];
|
|
1477
|
+
if (!selected) {
|
|
1478
|
+
throw new Error('No local Pugi artifact session found');
|
|
1479
|
+
}
|
|
1480
|
+
const resumeDir = createArtifactDir(root, `resume-${selected.id}`);
|
|
1481
|
+
const resumePath = resolve(resumeDir, 'resume.md');
|
|
1482
|
+
const toolCallId = recordToolCall(session, 'artifact:resume', selected.id);
|
|
1483
|
+
const handoffHint = listHandoffBundles(root)[0] ?? null;
|
|
1484
|
+
const text = [
|
|
1485
|
+
'# Pugi Resume',
|
|
1486
|
+
'',
|
|
1487
|
+
`Resuming: ${selected.path}`,
|
|
1488
|
+
handoffHint ? `Latest handoff: ${handoffHint.path}` : 'Latest handoff: none',
|
|
1489
|
+
'',
|
|
1490
|
+
'## Available Files',
|
|
1491
|
+
'',
|
|
1492
|
+
...selected.files.map((file) => `- ${file}`),
|
|
1493
|
+
'',
|
|
1494
|
+
'## Next Local Actions',
|
|
1495
|
+
'',
|
|
1496
|
+
'- Inspect the selected artifact set.',
|
|
1497
|
+
'- Continue with `pugi build`, `pugi review`, or `pugi handoff --web`.',
|
|
1498
|
+
'- Use `pugi build --remote` only when local execution is insufficient.',
|
|
1499
|
+
'',
|
|
1500
|
+
].join('\n');
|
|
1501
|
+
writeFileSync(resumePath, text, { encoding: 'utf8', mode: 0o600 });
|
|
1502
|
+
registerArtifact(root, {
|
|
1503
|
+
id: artifactIdFromDir(resumeDir),
|
|
1504
|
+
kind: 'resume',
|
|
1505
|
+
path: relative(root, resumeDir),
|
|
1506
|
+
sessionId: session.id,
|
|
1507
|
+
createdAt: new Date().toISOString(),
|
|
1508
|
+
files: ['resume.md'],
|
|
1509
|
+
});
|
|
1510
|
+
recordToolResult(session, toolCallId, 'success', `Created resume ${relative(root, resumePath)}`);
|
|
1511
|
+
writeOutput(flags, { status: 'resumed', source: selected.path, resume: relative(root, resumePath) }, ['Pugi resume created', `Source: ${selected.path}`, `Resume: ${relative(root, resumePath)}`].join('\n'));
|
|
1512
|
+
}
|
|
1513
|
+
/**
|
|
1514
|
+
* Per-command exit code map. Surfaced to the operator so shell scripts
|
|
1515
|
+
* can branch on the engine outcome:
|
|
1516
|
+
* - 0 = done (model returned a final answer)
|
|
1517
|
+
* - 8 = failed (transport / runtime / unhandled adapter error)
|
|
1518
|
+
* - 9 = blocked (budget exhausted, plan-mode refusal, abort)
|
|
1519
|
+
*
|
|
1520
|
+
* 1 is reserved for the credential gate (engine_unavailable) so
|
|
1521
|
+
* existing shell wrappers that branch on "any non-zero" still work.
|
|
1522
|
+
*/
|
|
1523
|
+
const ENGINE_EXIT_CODES = {
|
|
1524
|
+
done: 0,
|
|
1525
|
+
failed: 8,
|
|
1526
|
+
blocked: 9,
|
|
1527
|
+
engine_unavailable: 1,
|
|
1528
|
+
};
|
|
1529
|
+
function commandLabel(kind) {
|
|
1530
|
+
return kind === 'build_task' ? 'build' : kind;
|
|
1531
|
+
}
|
|
1532
|
+
/**
|
|
1533
|
+
* Sprint 2 Track A: wire `pugi code/explain/fix/plan/build` to the real
|
|
1534
|
+
* `NativePugiEngineAdapter`. Each command:
|
|
1535
|
+
*
|
|
1536
|
+
* 1. Resolves the active credential (falls back to PUGI_API_KEY env).
|
|
1537
|
+
* 2. Builds an `AnvilEngineLoopClient` against the resolved API URL.
|
|
1538
|
+
* 3. Runs the adapter to completion; collects status + result events.
|
|
1539
|
+
* 4. For `plan`: writes a `.pugi/artifacts/<slug>/plan.md` artifact
|
|
1540
|
+
* (and registers it in the local index) so the operator gets a
|
|
1541
|
+
* reviewable file, not just stdout.
|
|
1542
|
+
* 5. Prints a summary (files modified, tool calls, token usage) and
|
|
1543
|
+
* sets `process.exitCode` per the table above.
|
|
1544
|
+
*
|
|
1545
|
+
* `--json` flag swaps the text summary for a JSON envelope that wraps
|
|
1546
|
+
* every event + the headline metrics, so downstream tooling (cabinet UI,
|
|
1547
|
+
* cron pipelines) can consume the run directly.
|
|
1548
|
+
*
|
|
1549
|
+
* Per memory `feedback_decide_defaults_proactively` the explicit `code`/
|
|
1550
|
+
* `fix`/`build` commands require a prompt; `explain` accepts either a
|
|
1551
|
+
* path or a prompt and `plan` accepts an empty prompt only when there is
|
|
1552
|
+
* a previous artifact in `.pugi/artifacts/` to plan against.
|
|
1553
|
+
*/
|
|
1554
|
+
/**
|
|
1555
|
+
* Test seam: when this module-scoped factory is set, every engine task
|
|
1556
|
+
* invocation uses it to build the client instead of constructing an
|
|
1557
|
+
* `AnvilEngineLoopClient` directly. The CLI exports `setEngineClientFactory`
|
|
1558
|
+
* so unit tests can inject a `FixtureClient`; production code never
|
|
1559
|
+
* sets it.
|
|
1560
|
+
*/
|
|
1561
|
+
let engineClientFactory = null;
|
|
1562
|
+
export function setEngineClientFactory(factory) {
|
|
1563
|
+
engineClientFactory = factory;
|
|
1564
|
+
}
|
|
1565
|
+
function runEngineTask(kind) {
|
|
1566
|
+
return async (args, flags, session) => {
|
|
1567
|
+
const label = commandLabel(kind);
|
|
1568
|
+
const root = process.cwd();
|
|
1569
|
+
// `.pugi/` is created by `pugi init`. The engine writes the per-
|
|
1570
|
+
// session events mirror under it, so we fail fast here instead of
|
|
1571
|
+
// silently no-op'ing the mirror inside the adapter.
|
|
1572
|
+
ensureInitialized(root);
|
|
1573
|
+
const credential = resolveActiveCredential();
|
|
1574
|
+
const envConfig = loadRuntimeConfig();
|
|
1575
|
+
const config = credential
|
|
1576
|
+
? buildRuntimeConfig({ apiUrl: credential.apiUrl, apiKey: credential.apiKey })
|
|
1577
|
+
: envConfig;
|
|
1578
|
+
// Offline fallback: preserves the local-first invariant. `plan` /
|
|
1579
|
+
// `build` / `explain` drop back to their pre-Sprint-2 stub
|
|
1580
|
+
// behaviour so an operator without an API key (or with --offline)
|
|
1581
|
+
// still gets reviewable artifacts. `code` and `fix` reject the
|
|
1582
|
+
// offline path because the value proposition IS the engine —
|
|
1583
|
+
// attempting them offline would produce nothing useful.
|
|
1584
|
+
const isExplicitlyOffline = flags.offline;
|
|
1585
|
+
const isImplicitlyOffline = !config && !flags.offline;
|
|
1586
|
+
if (isExplicitlyOffline || isImplicitlyOffline) {
|
|
1587
|
+
if (kind === 'code' || kind === 'fix') {
|
|
1588
|
+
const reason = isExplicitlyOffline
|
|
1589
|
+
? `pugi ${label} requires the engine — drop --offline or run \`pugi ${label}\` with credentials configured`
|
|
1590
|
+
: 'no Pugi credential configured — run `pugi login` or set PUGI_API_KEY before invoking the engine';
|
|
1591
|
+
writeOutput(flags, { command: label, status: 'engine_unavailable', reason }, [`Pugi engine unavailable`, `Reason: ${reason}`].join('\n'));
|
|
1592
|
+
process.exitCode = ENGINE_EXIT_CODES.engine_unavailable;
|
|
1593
|
+
return;
|
|
1594
|
+
}
|
|
1595
|
+
if (kind === 'plan')
|
|
1596
|
+
return offlinePlan(args, flags, session);
|
|
1597
|
+
if (kind === 'build_task')
|
|
1598
|
+
return offlineBuild(args, flags, session);
|
|
1599
|
+
if (kind === 'explain')
|
|
1600
|
+
return offlineExplain(args, flags, session);
|
|
1601
|
+
}
|
|
1602
|
+
// Engine path prompt gate. (Offline `explain` accepts a path as
|
|
1603
|
+
// its first positional arg — that branch returned above before
|
|
1604
|
+
// we reach this gate.)
|
|
1605
|
+
//
|
|
1606
|
+
// Code Reviewer P2 retro 2026-05-23: previously `pugi plan` with
|
|
1607
|
+
// an empty prompt threw here (engine path) but the offline path
|
|
1608
|
+
// accepted the empty prompt and synthesised a plan from the
|
|
1609
|
+
// latest `.pugi/artifacts/` entry. The handler docstring
|
|
1610
|
+
// documents the offline behaviour as canonical — engine should
|
|
1611
|
+
// mirror it. For `code/fix/build/explain` an empty prompt is
|
|
1612
|
+
// still an error (nothing to act on), but for `plan` we resynth
|
|
1613
|
+
// the prompt from the most recent artifact if one exists,
|
|
1614
|
+
// keeping the two paths aligned.
|
|
1615
|
+
let prompt = args.join(' ').trim();
|
|
1616
|
+
if (!prompt) {
|
|
1617
|
+
if (kind === 'plan') {
|
|
1618
|
+
const latest = latestArtifactDir(root);
|
|
1619
|
+
if (latest) {
|
|
1620
|
+
prompt = `Continue planning from the previous artifact set at ${relative(root, latest)}`;
|
|
1621
|
+
}
|
|
1622
|
+
else {
|
|
1623
|
+
throw new Error('pugi plan requires a prompt or a previous artifact in .pugi/artifacts/');
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
else {
|
|
1627
|
+
throw new Error(`pugi ${label} requires a prompt`);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
// Narrow `config` for the type checker — the offline branches above
|
|
1631
|
+
// return whenever `config` is null, so by this point it must be set.
|
|
1632
|
+
if (!config) {
|
|
1633
|
+
throw new Error('internal: engine config missing after offline gate');
|
|
1634
|
+
}
|
|
1635
|
+
const client = engineClientFactory ? engineClientFactory(config) : new AnvilEngineLoopClient(config);
|
|
1636
|
+
const adapter = new NativePugiEngineAdapter({ client, session });
|
|
1637
|
+
const toolCallId = recordToolCall(session, `engine:${adapter.name}`, `${label}: ${prompt}`);
|
|
1638
|
+
const taskId = `${kind}-${Date.now()}`;
|
|
1639
|
+
const events = adapter.run({
|
|
1640
|
+
id: taskId,
|
|
1641
|
+
kind,
|
|
1642
|
+
prompt,
|
|
1643
|
+
workspaceRoot: root,
|
|
1644
|
+
allowedPaths: [root],
|
|
1645
|
+
deniedPaths: [],
|
|
1646
|
+
artifacts: [],
|
|
1647
|
+
// plan mode is enforced inside the tool-bridge (read-only schema +
|
|
1648
|
+
// executor refusal sentinel). The permission mode here is the
|
|
1649
|
+
// workspace-level toggle and is unchanged from interactive default.
|
|
1650
|
+
permissionMode: 'auto',
|
|
1651
|
+
}, { sessionId: session.id });
|
|
1652
|
+
const statusEvents = [];
|
|
1653
|
+
let result = null;
|
|
1654
|
+
for await (const event of events) {
|
|
1655
|
+
if (event.type === 'status') {
|
|
1656
|
+
statusEvents.push(event.message);
|
|
1657
|
+
// For `explain` the spec wants status events on stderr so the
|
|
1658
|
+
// final summary on stdout is grep-able. Other commands keep the
|
|
1659
|
+
// events on stdout-via-final-text so the operator sees the
|
|
1660
|
+
// chronological trace.
|
|
1661
|
+
if (kind === 'explain' && !flags.json) {
|
|
1662
|
+
process.stderr.write(`${event.message}\n`);
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
result = {
|
|
1667
|
+
status: event.result.status,
|
|
1668
|
+
summary: event.result.summary,
|
|
1669
|
+
filesChanged: event.result.filesChanged,
|
|
1670
|
+
eventRefs: event.result.eventRefs,
|
|
1671
|
+
risks: event.result.risks,
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
if (!result) {
|
|
1676
|
+
// Adapter MUST emit a terminal result event. Treat the empty
|
|
1677
|
+
// outcome as a failure so the CLI surfaces a clear error rather
|
|
1678
|
+
// than exiting 0 with no output.
|
|
1679
|
+
result = {
|
|
1680
|
+
status: 'failed',
|
|
1681
|
+
summary: 'engine adapter returned no result',
|
|
1682
|
+
filesChanged: [],
|
|
1683
|
+
eventRefs: [],
|
|
1684
|
+
risks: ['adapter terminated without emitting a result event'],
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
// For `plan` we always write a plan.md artifact, regardless of
|
|
1688
|
+
// outcome. A blocked plan (budget exhausted, tool refusal) still
|
|
1689
|
+
// produces a reviewable artifact — the reason is recorded inline.
|
|
1690
|
+
let planArtifact = null;
|
|
1691
|
+
if (kind === 'plan') {
|
|
1692
|
+
planArtifact = writePlanArtifact({
|
|
1693
|
+
root,
|
|
1694
|
+
session,
|
|
1695
|
+
prompt,
|
|
1696
|
+
result,
|
|
1697
|
+
statusEvents,
|
|
1698
|
+
});
|
|
1699
|
+
}
|
|
1700
|
+
// Pull the headline metrics out of `eventRefs` so the summary and
|
|
1701
|
+
// JSON envelope match without re-parsing strings in two places.
|
|
1702
|
+
const metrics = parseEventRefs(result.eventRefs);
|
|
1703
|
+
const finalStatus = result.status === 'failed' ? 'error' : 'success';
|
|
1704
|
+
recordToolResult(session, toolCallId, finalStatus, result.summary);
|
|
1705
|
+
// Exit code policy (spec §1-§5):
|
|
1706
|
+
// code/fix/build → 0 done, 8 failed, 9 blocked
|
|
1707
|
+
// explain → same triple; read-only blocked = budget exhaustion
|
|
1708
|
+
// plan → 0 on done OR plan-mode refusal (refusal is a
|
|
1709
|
+
// SUCCESS for plan: the gate worked); 8 on failed
|
|
1710
|
+
// transport; 9 on budget exhaustion.
|
|
1711
|
+
//
|
|
1712
|
+
// Code Reviewer P2 retro 2026-05-23: previously `plan` masked
|
|
1713
|
+
// `budget_exhausted` as exit 0, so a CI loop with a token budget
|
|
1714
|
+
// hit looked identical to a successful plan. We now distinguish
|
|
1715
|
+
// via the adapter's `outcome=<status>` echo on `eventRefs` so
|
|
1716
|
+
// shell wrappers can branch on the real cause.
|
|
1717
|
+
if (kind === 'plan') {
|
|
1718
|
+
if (result.status === 'failed') {
|
|
1719
|
+
process.exitCode = ENGINE_EXIT_CODES.failed;
|
|
1720
|
+
}
|
|
1721
|
+
else if (result.status === 'blocked' &&
|
|
1722
|
+
metrics.outcome === 'budget_exhausted') {
|
|
1723
|
+
process.exitCode = ENGINE_EXIT_CODES.blocked;
|
|
1724
|
+
}
|
|
1725
|
+
else {
|
|
1726
|
+
// `done`, or `blocked` with outcome=tool_refused (= the plan-mode
|
|
1727
|
+
// gate fired, which is the contract working as designed), or
|
|
1728
|
+
// `blocked` with no outcome echo (legacy adapter — preserve the
|
|
1729
|
+
// pre-retro 0 behaviour to avoid breaking external scripts).
|
|
1730
|
+
process.exitCode = 0;
|
|
1731
|
+
}
|
|
1732
|
+
}
|
|
1733
|
+
else {
|
|
1734
|
+
process.exitCode = ENGINE_EXIT_CODES[result.status];
|
|
1735
|
+
}
|
|
1736
|
+
const payload = {
|
|
1737
|
+
command: label,
|
|
1738
|
+
taskId,
|
|
1739
|
+
status: result.status,
|
|
1740
|
+
summary: result.summary,
|
|
1741
|
+
filesChanged: result.filesChanged,
|
|
1742
|
+
toolCalls: metrics.toolCalls,
|
|
1743
|
+
turns: metrics.turns,
|
|
1744
|
+
tokens: metrics.tokens,
|
|
1745
|
+
sessionId: session.id,
|
|
1746
|
+
sessionEventsMirror: metrics.mirror,
|
|
1747
|
+
risks: result.risks,
|
|
1748
|
+
plan: planArtifact ? { path: planArtifact.relPath } : undefined,
|
|
1749
|
+
// The full event stream is useful for cabinet UI replay. We surface
|
|
1750
|
+
// it in JSON mode only — text mode operators want the summary, not
|
|
1751
|
+
// 30 turn-level lines.
|
|
1752
|
+
events: flags.json ? statusEvents : undefined,
|
|
1753
|
+
};
|
|
1754
|
+
const textLines = [];
|
|
1755
|
+
if (kind === 'plan' && planArtifact) {
|
|
1756
|
+
textLines.push(`Pugi plan written to ${planArtifact.relPath}`);
|
|
1757
|
+
}
|
|
1758
|
+
textLines.push(`Pugi ${label}: ${result.status}`);
|
|
1759
|
+
textLines.push(`Summary: ${result.summary}`);
|
|
1760
|
+
if (result.filesChanged.length > 0) {
|
|
1761
|
+
textLines.push(`Files modified (${result.filesChanged.length}):`);
|
|
1762
|
+
for (const file of result.filesChanged)
|
|
1763
|
+
textLines.push(` - ${file}`);
|
|
1764
|
+
}
|
|
1765
|
+
else if (kind !== 'explain' && kind !== 'plan') {
|
|
1766
|
+
textLines.push('Files modified: none');
|
|
1767
|
+
}
|
|
1768
|
+
textLines.push(`Tool calls: ${metrics.toolCalls} · Turns: ${metrics.turns} · Tokens: ${metrics.tokens}`);
|
|
1769
|
+
if (result.risks.length > 0) {
|
|
1770
|
+
textLines.push(`Risks: ${result.risks.join('; ')}`);
|
|
1771
|
+
}
|
|
1772
|
+
textLines.push(`Session: ${session.id}`);
|
|
1773
|
+
if (metrics.mirror)
|
|
1774
|
+
textLines.push(`Events mirror: ${metrics.mirror}`);
|
|
1775
|
+
writeOutput(flags, payload, textLines.join('\n'));
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
/**
|
|
1779
|
+
* Extract `key=value` metrics from `EngineResult.eventRefs`. The adapter
|
|
1780
|
+
* already emits the canonical strings (`tool_calls=N`, `turns=N`,
|
|
1781
|
+
* `tokens=N`, `mirror=<path>`); parsing back to a typed object keeps the
|
|
1782
|
+
* CLI from sprinkling regex-on-eventRefs across the handler.
|
|
1783
|
+
*/
|
|
1784
|
+
function parseEventRefs(refs) {
|
|
1785
|
+
const out = {
|
|
1786
|
+
toolCalls: 0,
|
|
1787
|
+
turns: 0,
|
|
1788
|
+
tokens: 0,
|
|
1789
|
+
mirror: null,
|
|
1790
|
+
outcome: null,
|
|
1791
|
+
};
|
|
1792
|
+
for (const ref of refs) {
|
|
1793
|
+
const idx = ref.indexOf('=');
|
|
1794
|
+
if (idx <= 0)
|
|
1795
|
+
continue;
|
|
1796
|
+
const key = ref.slice(0, idx);
|
|
1797
|
+
const value = ref.slice(idx + 1);
|
|
1798
|
+
if (key === 'tool_calls')
|
|
1799
|
+
out.toolCalls = Number(value) || 0;
|
|
1800
|
+
else if (key === 'turns')
|
|
1801
|
+
out.turns = Number(value) || 0;
|
|
1802
|
+
else if (key === 'tokens')
|
|
1803
|
+
out.tokens = Number(value) || 0;
|
|
1804
|
+
else if (key === 'mirror')
|
|
1805
|
+
out.mirror = value || null;
|
|
1806
|
+
else if (key === 'outcome')
|
|
1807
|
+
out.outcome = value || null;
|
|
1808
|
+
}
|
|
1809
|
+
return out;
|
|
1810
|
+
}
|
|
1811
|
+
/**
|
|
1812
|
+
* Write the `plan.md` artifact for `pugi plan`. The plan body wraps the
|
|
1813
|
+
* model's final answer (or the refusal reason when the model attempted
|
|
1814
|
+
* to write/edit/bash from plan mode) so the operator gets a single file
|
|
1815
|
+
* to review, share, or attach to a handoff bundle.
|
|
1816
|
+
*/
|
|
1817
|
+
function writePlanArtifact(input) {
|
|
1818
|
+
const { root, session, prompt, result, statusEvents } = input;
|
|
1819
|
+
const artifactDir = createArtifactDir(root, prompt);
|
|
1820
|
+
const planPath = resolve(artifactDir, 'plan.md');
|
|
1821
|
+
const metrics = parseEventRefs(result.eventRefs);
|
|
1822
|
+
const lines = [
|
|
1823
|
+
'# Pugi Plan',
|
|
1824
|
+
'',
|
|
1825
|
+
`Prompt: ${prompt}`,
|
|
1826
|
+
`Status: ${result.status}`,
|
|
1827
|
+
`Generated: ${new Date().toISOString()}`,
|
|
1828
|
+
'',
|
|
1829
|
+
'## Plan',
|
|
1830
|
+
'',
|
|
1831
|
+
result.summary || '_(empty)_',
|
|
1832
|
+
'',
|
|
1833
|
+
'## Metrics',
|
|
1834
|
+
'',
|
|
1835
|
+
`- Tool calls: ${metrics.toolCalls}`,
|
|
1836
|
+
`- Turns: ${metrics.turns}`,
|
|
1837
|
+
`- Tokens: ${metrics.tokens}`,
|
|
1838
|
+
`- Session: ${session.id}`,
|
|
1839
|
+
'',
|
|
1840
|
+
];
|
|
1841
|
+
if (result.risks.length > 0) {
|
|
1842
|
+
lines.push('## Risks', '');
|
|
1843
|
+
for (const risk of result.risks)
|
|
1844
|
+
lines.push(`- ${risk}`);
|
|
1845
|
+
lines.push('');
|
|
1846
|
+
}
|
|
1847
|
+
if (statusEvents.length > 0) {
|
|
1848
|
+
lines.push('## Engine trace', '', '```text');
|
|
1849
|
+
for (const event of statusEvents)
|
|
1850
|
+
lines.push(event);
|
|
1851
|
+
lines.push('```', '');
|
|
1852
|
+
}
|
|
1853
|
+
writeFileSync(planPath, lines.join('\n'), { encoding: 'utf8', mode: 0o600 });
|
|
1854
|
+
registerArtifact(root, {
|
|
1855
|
+
id: artifactIdFromDir(artifactDir),
|
|
1856
|
+
kind: 'plan',
|
|
1857
|
+
path: relative(root, artifactDir),
|
|
1858
|
+
sessionId: session.id,
|
|
1859
|
+
createdAt: new Date().toISOString(),
|
|
1860
|
+
files: ['plan.md'],
|
|
1861
|
+
});
|
|
1862
|
+
return { path: planPath, relPath: relative(root, planPath) };
|
|
1863
|
+
}
|
|
1864
|
+
// `explain` previously walked the workspace locally with read/grep/glob
|
|
1865
|
+
// only. Sprint 2 Track A retired it; the engine adapter now drives the
|
|
1866
|
+
// read-only loop (`runEngineTask('explain')`) with the same tool subset.
|
|
1867
|
+
/**
|
|
1868
|
+
* The three login providers `pugi login` knows how to dispatch to.
|
|
1869
|
+
*
|
|
1870
|
+
* - `device` — RFC 8628 device authorization grant against the cabinet.
|
|
1871
|
+
* - `token` — paste a PAT (or pipe it via `--token-stdin`).
|
|
1872
|
+
* - `env` — promote `PUGI_API_KEY` from the current environment into
|
|
1873
|
+
* the persistent store so subsequent commands work without
|
|
1874
|
+
* re-exporting.
|
|
1875
|
+
*/
|
|
1876
|
+
const PUGI_LOGIN_PROVIDERS = ['device', 'token', 'env'];
|
|
1877
|
+
function parseProviderFlag(args) {
|
|
1878
|
+
const raw = extractNamedFlagValue(args, 'provider');
|
|
1879
|
+
if (raw === undefined)
|
|
1880
|
+
return undefined;
|
|
1881
|
+
const lower = raw.toLowerCase();
|
|
1882
|
+
if (!PUGI_LOGIN_PROVIDERS.includes(lower)) {
|
|
1883
|
+
throw new Error(`pugi login --provider must be one of ${PUGI_LOGIN_PROVIDERS.join('|')}; got "${raw}".`);
|
|
1884
|
+
}
|
|
1885
|
+
return lower;
|
|
1886
|
+
}
|
|
1887
|
+
/**
|
|
1888
|
+
* Returns true when BOTH stdin and stdout are attached to a TTY AND
|
|
1889
|
+
* `--json` / `--no-tty` / CI markers were not supplied. We only
|
|
1890
|
+
* prompt or render Ink surfaces when a human is plausibly watching
|
|
1891
|
+
* the screen. The multi-condition gate matches Claude Code, gh CLI,
|
|
1892
|
+
* Codex CLI, and the npm CLI conventions.
|
|
1893
|
+
*/
|
|
1894
|
+
function isInteractive(flags) {
|
|
1895
|
+
if (flags.json)
|
|
1896
|
+
return false;
|
|
1897
|
+
if (flags.noTty)
|
|
1898
|
+
return false;
|
|
1899
|
+
// Common CI / scripted-context markers. CI is set by every major
|
|
1900
|
+
// provider (GitHub Actions, GitLab, CircleCI, Travis, Buildkite).
|
|
1901
|
+
if (process.env.CI)
|
|
1902
|
+
return false;
|
|
1903
|
+
if (process.env.PUGI_NO_TTY)
|
|
1904
|
+
return false;
|
|
1905
|
+
// `process.stdin.isTTY` / `process.stdout.isTTY` are `undefined`
|
|
1906
|
+
// when the stream is a pipe and `true` when attached to a real
|
|
1907
|
+
// terminal. Require both so a `pugi | tee` invocation falls back
|
|
1908
|
+
// to the line-buffered output path.
|
|
1909
|
+
const stdinTty = Boolean(process.stdin.isTTY);
|
|
1910
|
+
const stdoutTty = Boolean(process.stdout.isTTY);
|
|
1911
|
+
return stdinTty && stdoutTty;
|
|
1912
|
+
}
|
|
1913
|
+
async function login(args, flags, _session) {
|
|
1914
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
1915
|
+
writeOutput(flags, {
|
|
1916
|
+
command: 'login',
|
|
1917
|
+
usage: 'pugi login [--provider device|token|env] [--token <PAT>] [--token-stdin] [--label <name>] [--api-url <url>]',
|
|
1918
|
+
}, [
|
|
1919
|
+
'Usage: pugi login [options]',
|
|
1920
|
+
'',
|
|
1921
|
+
'Authenticate Pugi CLI against an Anvil instance.',
|
|
1922
|
+
'',
|
|
1923
|
+
'When run on a TTY with no arguments, Pugi shows an interactive picker',
|
|
1924
|
+
'with three variants (browser OAuth, paste a key, use PUGI_API_KEY).',
|
|
1925
|
+
'',
|
|
1926
|
+
'Non-interactive options:',
|
|
1927
|
+
' --provider device Run the device-flow login (recommended).',
|
|
1928
|
+
' --provider token Store an API key passed via --token / --token-stdin / PUGI_LOGIN_TOKEN.',
|
|
1929
|
+
' --provider env Promote PUGI_API_KEY from the environment into the store.',
|
|
1930
|
+
' --token <PAT> Inline API key (visible in `ps`).',
|
|
1931
|
+
' --token-stdin Read API key from stdin (gh-CLI style).',
|
|
1932
|
+
' --label <name> Short label surfaced in `pugi accounts list`.',
|
|
1933
|
+
' --api-url <url> Override the Anvil endpoint (self-hosted).',
|
|
1934
|
+
' --no-device-flow Refuse the device flow; fail fast in CI without a token.',
|
|
1935
|
+
'',
|
|
1936
|
+
'Examples:',
|
|
1937
|
+
' pugi login # interactive picker on a TTY',
|
|
1938
|
+
' pugi login --provider device # explicit browser OAuth',
|
|
1939
|
+
' pugi login --provider token --token sk-xx # paste in a key',
|
|
1940
|
+
' echo $TOKEN | pugi login --provider token --token-stdin',
|
|
1941
|
+
' PUGI_API_KEY=sk-xx pugi login --provider env',
|
|
1942
|
+
].join('\n'));
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
// Login dispatch (highest precedence first):
|
|
1946
|
+
// 1. `--provider device|token|env` — explicit non-interactive choice
|
|
1947
|
+
// for scripts. Bypasses both the menu and the env-only shortcut.
|
|
1948
|
+
// 2. `--token <PAT>` / `--token-stdin` / PUGI_LOGIN_TOKEN — paste-token
|
|
1949
|
+
// fast path, identical to gh CLI's `gh auth login --with-token`.
|
|
1950
|
+
// 3. Auto-mode: PUGI_LOGIN_TOKEN or PUGI_API_KEY is set AND stdin is
|
|
1951
|
+
// not a TTY (CI). Promote the env credential into the store
|
|
1952
|
+
// without prompting.
|
|
1953
|
+
// 4. Interactive: stdin is a TTY → render a 3-item menu and let the
|
|
1954
|
+
// user pick. Default selection is browser-based device flow.
|
|
1955
|
+
// 5. Non-interactive without a token (rare; e.g. nohup, no env) →
|
|
1956
|
+
// fall back to device flow unless `--no-device-flow` is set, in
|
|
1957
|
+
// which case raise a deterministic error.
|
|
1958
|
+
const tokenFromArgs = extractTokenFlag(args);
|
|
1959
|
+
const tokenStdinFlag = args.includes('--token-stdin');
|
|
1960
|
+
const noDeviceFlow = args.includes('--no-device-flow');
|
|
1961
|
+
const apiUrlOverride = extractApiUrlFlag(args);
|
|
1962
|
+
const labelFlag = extractLabelFlag(args);
|
|
1963
|
+
const provider = parseProviderFlag(args);
|
|
1964
|
+
const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? DEFAULT_API_URL);
|
|
1965
|
+
// Path 1: explicit --provider trumps everything else.
|
|
1966
|
+
if (provider) {
|
|
1967
|
+
await dispatchLoginProvider(provider, {
|
|
1968
|
+
apiUrl,
|
|
1969
|
+
flags,
|
|
1970
|
+
label: labelFlag,
|
|
1971
|
+
explicitToken: tokenFromArgs,
|
|
1972
|
+
tokenStdinFlag,
|
|
1973
|
+
noDeviceFlow,
|
|
1974
|
+
});
|
|
1975
|
+
return;
|
|
1976
|
+
}
|
|
1977
|
+
// Path 2: token paste-in (CLI flag / stdin / env).
|
|
1978
|
+
let explicitToken = tokenFromArgs ?? process.env.PUGI_LOGIN_TOKEN;
|
|
1979
|
+
if (!explicitToken && tokenStdinFlag) {
|
|
1980
|
+
explicitToken = readFileSync(0, 'utf8').trim();
|
|
1981
|
+
}
|
|
1982
|
+
if (explicitToken) {
|
|
1983
|
+
storeAndAnnounceToken({
|
|
1984
|
+
apiUrl,
|
|
1985
|
+
apiKey: explicitToken,
|
|
1986
|
+
label: labelFlag,
|
|
1987
|
+
source: 'token',
|
|
1988
|
+
flags,
|
|
1989
|
+
});
|
|
1990
|
+
return;
|
|
1991
|
+
}
|
|
1992
|
+
// Path 3: auto-mode (env primed + no TTY) — promote the env key into
|
|
1993
|
+
// the store. This keeps CI deterministic and avoids stalling on the
|
|
1994
|
+
// menu when there is no human at the terminal.
|
|
1995
|
+
if (!isInteractive(flags) && process.env.PUGI_API_KEY) {
|
|
1996
|
+
storeAndAnnounceToken({
|
|
1997
|
+
apiUrl,
|
|
1998
|
+
apiKey: process.env.PUGI_API_KEY,
|
|
1999
|
+
label: labelFlag,
|
|
2000
|
+
source: 'env',
|
|
2001
|
+
flags,
|
|
2002
|
+
});
|
|
2003
|
+
return;
|
|
2004
|
+
}
|
|
2005
|
+
// Path 4: interactive menu (TTY, not --json, no token args).
|
|
2006
|
+
if (isInteractive(flags)) {
|
|
2007
|
+
const choice = await promptLoginVariant(apiUrl);
|
|
2008
|
+
if (choice === null) {
|
|
2009
|
+
// User dismissed the picker via Esc / q. Use exit 130, the
|
|
2010
|
+
// standard "terminated by user signal" exit code (gh CLI,
|
|
2011
|
+
// codex, ssh, vim all use this).
|
|
2012
|
+
writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
|
|
2013
|
+
process.exitCode = 130;
|
|
2014
|
+
return;
|
|
2015
|
+
}
|
|
2016
|
+
await dispatchLoginProvider(choice, {
|
|
2017
|
+
apiUrl,
|
|
2018
|
+
flags,
|
|
2019
|
+
label: labelFlag,
|
|
2020
|
+
noDeviceFlow,
|
|
2021
|
+
});
|
|
2022
|
+
return;
|
|
2023
|
+
}
|
|
2024
|
+
// Path 5: no token, no TTY → previously fell through to a silent
|
|
2025
|
+
// device flow that nobody could answer. The Ink-TUI work refuses
|
|
2026
|
+
// that branch and raises a deterministic error so CI surfaces a
|
|
2027
|
+
// failed login immediately. The message lists every escape hatch.
|
|
2028
|
+
throw new Error('pugi login requires a token in non-interactive mode. Pass `--provider device|token|env`, `--token <PAT>`, pipe via `--token-stdin`, set PUGI_LOGIN_TOKEN, or use `--provider env` with PUGI_API_KEY exported.');
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Render the interactive Ink picker shown when `pugi login` runs on
|
|
2032
|
+
* a TTY with no token args. Returns the chosen provider, or `null`
|
|
2033
|
+
* when the user dismisses the picker via Esc / q. Mirrors the
|
|
2034
|
+
* Claude Code / Codex CLI auth picker UX.
|
|
2035
|
+
*
|
|
2036
|
+
* The Ink import is dynamic so a non-interactive `pugi <anything>`
|
|
2037
|
+
* never pays the React+Ink module-load cost. ESM dynamic-import is
|
|
2038
|
+
* cached after first call (same as require).
|
|
2039
|
+
*/
|
|
2040
|
+
async function promptLoginVariant(apiUrl) {
|
|
2041
|
+
const { renderLoginPicker, LoginCancelledError } = await import('../tui/render.js');
|
|
2042
|
+
try {
|
|
2043
|
+
return await renderLoginPicker(apiUrl);
|
|
2044
|
+
}
|
|
2045
|
+
catch (error) {
|
|
2046
|
+
if (error instanceof LoginCancelledError)
|
|
2047
|
+
return null;
|
|
2048
|
+
throw error;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
/**
|
|
2052
|
+
* Carry-over buffer between successive `readSingleChoice` calls. When
|
|
2053
|
+
* the user pastes two prompt answers into stdin at once
|
|
2054
|
+
* ("2\nsk-token-xyz\n") the first invocation consumes only up to the
|
|
2055
|
+
* first newline; the rest survives here so the next call returns
|
|
2056
|
+
* "sk-token-xyz" instead of stalling on a closed pipe.
|
|
2057
|
+
*/
|
|
2058
|
+
let stdinReadBuffer = '';
|
|
2059
|
+
/**
|
|
2060
|
+
* Single-line prompt → trimmed answer. Used for menu picks where we
|
|
2061
|
+
* just need a digit (or empty for the default). Reads directly from
|
|
2062
|
+
* stdin so the surrounding `--json` mode (which would short-circuit
|
|
2063
|
+
* `isInteractive`) cannot accidentally consume the prompt.
|
|
2064
|
+
*
|
|
2065
|
+
* Keeps the implementation dependency-free by reading bytes from fd 0
|
|
2066
|
+
* until the first newline. Avoids pulling in `readline` to keep the
|
|
2067
|
+
* CLI startup cheap.
|
|
2068
|
+
*
|
|
2069
|
+
* If the carry-over buffer (`stdinReadBuffer`) already contains a
|
|
2070
|
+
* newline we resolve synchronously — that handles piped multi-answer
|
|
2071
|
+
* input correctly (`printf '2\\nsk-xyz\\n' | pugi login`).
|
|
2072
|
+
*/
|
|
2073
|
+
async function readSingleChoice(prompt) {
|
|
2074
|
+
process.stderr.write(prompt);
|
|
2075
|
+
// Drain any leftover bytes from a previous prompt before re-attaching
|
|
2076
|
+
// a listener. This keeps the CLI usable from `here-doc` style scripts.
|
|
2077
|
+
const existingNewline = stdinReadBuffer.indexOf('\n');
|
|
2078
|
+
if (existingNewline >= 0) {
|
|
2079
|
+
const answer = stdinReadBuffer.slice(0, existingNewline).trim();
|
|
2080
|
+
stdinReadBuffer = stdinReadBuffer.slice(existingNewline + 1);
|
|
2081
|
+
return answer;
|
|
2082
|
+
}
|
|
2083
|
+
return new Promise((resolveLine, rejectLine) => {
|
|
2084
|
+
const onData = (chunk) => {
|
|
2085
|
+
const text = chunk.toString('utf8');
|
|
2086
|
+
stdinReadBuffer += text;
|
|
2087
|
+
const newlineIndex = stdinReadBuffer.indexOf('\n');
|
|
2088
|
+
if (newlineIndex >= 0) {
|
|
2089
|
+
const answer = stdinReadBuffer.slice(0, newlineIndex).trim();
|
|
2090
|
+
stdinReadBuffer = stdinReadBuffer.slice(newlineIndex + 1);
|
|
2091
|
+
cleanup();
|
|
2092
|
+
resolveLine(answer);
|
|
2093
|
+
}
|
|
2094
|
+
};
|
|
2095
|
+
const onError = (err) => {
|
|
2096
|
+
cleanup();
|
|
2097
|
+
rejectLine(err);
|
|
2098
|
+
};
|
|
2099
|
+
const onEnd = () => {
|
|
2100
|
+
cleanup();
|
|
2101
|
+
// EOF reached mid-prompt — treat the carry-over as the final
|
|
2102
|
+
// answer so a script that omits the trailing newline still works.
|
|
2103
|
+
const tail = stdinReadBuffer;
|
|
2104
|
+
stdinReadBuffer = '';
|
|
2105
|
+
resolveLine(tail.trim());
|
|
2106
|
+
};
|
|
2107
|
+
const cleanup = () => {
|
|
2108
|
+
process.stdin.removeListener('data', onData);
|
|
2109
|
+
process.stdin.removeListener('error', onError);
|
|
2110
|
+
process.stdin.removeListener('end', onEnd);
|
|
2111
|
+
// Stop reading so the parent process does not hold stdin open.
|
|
2112
|
+
if (typeof process.stdin.pause === 'function') {
|
|
2113
|
+
process.stdin.pause();
|
|
2114
|
+
}
|
|
2115
|
+
};
|
|
2116
|
+
process.stdin.on('data', onData);
|
|
2117
|
+
process.stdin.on('error', onError);
|
|
2118
|
+
process.stdin.on('end', onEnd);
|
|
2119
|
+
if (typeof process.stdin.resume === 'function') {
|
|
2120
|
+
process.stdin.resume();
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
/**
|
|
2125
|
+
* No-echo TTY read for a single secret line. Used by `pugi login` to
|
|
2126
|
+
* prompt for an API key without leaking it into shell scrollback /
|
|
2127
|
+
* terminal recordings. Falls back to `readSingleChoice` (echoing) when
|
|
2128
|
+
* stdin is not a TTY — pipe sources have no echo to suppress.
|
|
2129
|
+
*
|
|
2130
|
+
* Implementation pinned to Node's `tty.ReadStream.setRawMode` which is
|
|
2131
|
+
* stable across Node 20+. We toggle raw mode + isRaw, read bytes
|
|
2132
|
+
* char-by-char until \n, and emit `*` per byte for visual progress.
|
|
2133
|
+
* Pasted multi-char chunks are masked uniformly.
|
|
2134
|
+
*/
|
|
2135
|
+
async function readSecretLine(prompt) {
|
|
2136
|
+
const stdin = process.stdin;
|
|
2137
|
+
if (!stdin.isTTY || typeof stdin.setRawMode !== 'function') {
|
|
2138
|
+
// Non-TTY (test harness, piped input). Fall back to the
|
|
2139
|
+
// line-buffered reader; the absence of a real terminal means
|
|
2140
|
+
// there is no echo to suppress in the first place.
|
|
2141
|
+
return readSingleChoice(prompt);
|
|
2142
|
+
}
|
|
2143
|
+
process.stderr.write(prompt);
|
|
2144
|
+
return new Promise((resolveLine, rejectLine) => {
|
|
2145
|
+
let collected = '';
|
|
2146
|
+
const onData = (chunk) => {
|
|
2147
|
+
const text = chunk.toString('utf8');
|
|
2148
|
+
for (const ch of text) {
|
|
2149
|
+
const code = ch.charCodeAt(0);
|
|
2150
|
+
if (ch === '\n' || ch === '\r') {
|
|
2151
|
+
process.stderr.write('\n');
|
|
2152
|
+
cleanup();
|
|
2153
|
+
resolveLine(collected.trim());
|
|
2154
|
+
return;
|
|
2155
|
+
}
|
|
2156
|
+
if (code === 0x03) {
|
|
2157
|
+
// Ctrl-C — abort.
|
|
2158
|
+
cleanup();
|
|
2159
|
+
process.stderr.write('\n');
|
|
2160
|
+
rejectLine(new Error('pugi login: aborted by user'));
|
|
2161
|
+
return;
|
|
2162
|
+
}
|
|
2163
|
+
if (code === 0x7f || code === 0x08) {
|
|
2164
|
+
// Backspace / DEL — drop last char.
|
|
2165
|
+
if (collected.length > 0) {
|
|
2166
|
+
collected = collected.slice(0, -1);
|
|
2167
|
+
process.stderr.write('\b \b');
|
|
2168
|
+
}
|
|
2169
|
+
continue;
|
|
2170
|
+
}
|
|
2171
|
+
if (code < 0x20) {
|
|
2172
|
+
// Other control codes — ignore.
|
|
2173
|
+
continue;
|
|
2174
|
+
}
|
|
2175
|
+
collected += ch;
|
|
2176
|
+
process.stderr.write('*');
|
|
2177
|
+
}
|
|
2178
|
+
};
|
|
2179
|
+
const onError = (err) => {
|
|
2180
|
+
cleanup();
|
|
2181
|
+
rejectLine(err);
|
|
2182
|
+
};
|
|
2183
|
+
const cleanup = () => {
|
|
2184
|
+
stdin.removeListener('data', onData);
|
|
2185
|
+
stdin.removeListener('error', onError);
|
|
2186
|
+
stdin.setRawMode?.(false);
|
|
2187
|
+
stdin.pause?.();
|
|
2188
|
+
};
|
|
2189
|
+
stdin.setRawMode?.(true);
|
|
2190
|
+
stdin.resume?.();
|
|
2191
|
+
stdin.on('data', onData);
|
|
2192
|
+
stdin.on('error', onError);
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
async function dispatchLoginProvider(provider, ctx) {
|
|
2196
|
+
switch (provider) {
|
|
2197
|
+
case 'device': {
|
|
2198
|
+
if (ctx.noDeviceFlow) {
|
|
2199
|
+
throw new Error('pugi login --provider device conflicts with --no-device-flow. Drop one of the two.');
|
|
2200
|
+
}
|
|
2201
|
+
await performDeviceFlowLogin(ctx.apiUrl, ctx.flags, ctx.label);
|
|
2202
|
+
return;
|
|
2203
|
+
}
|
|
2204
|
+
case 'token': {
|
|
2205
|
+
// P2 fix Code Reviewer 2026-05-23: an explicit interactive
|
|
2206
|
+
// `--provider token` should NOT silently inherit
|
|
2207
|
+
// `PUGI_LOGIN_TOKEN` from env — the operator who typed the
|
|
2208
|
+
// flag wants the prompt. Only honour the env var on the
|
|
2209
|
+
// non-interactive path (CI shells) where there is no prompt
|
|
2210
|
+
// surface to override.
|
|
2211
|
+
const isInteractiveToken = isInteractive(ctx.flags);
|
|
2212
|
+
let token = ctx.explicitToken ?? (isInteractiveToken ? undefined : process.env.PUGI_LOGIN_TOKEN);
|
|
2213
|
+
if (!token && ctx.tokenStdinFlag) {
|
|
2214
|
+
token = readFileSync(0, 'utf8').trim();
|
|
2215
|
+
}
|
|
2216
|
+
if (!token) {
|
|
2217
|
+
// No-echo TTY read for the PAT prompt. Pasted secret never
|
|
2218
|
+
// lands in shell scrollback / terminal recordings. Code
|
|
2219
|
+
// Reviewer P1 2026-05-23: previous echoing prompt was a
|
|
2220
|
+
// secret-handling regression vs gh CLI / npm login / aws
|
|
2221
|
+
// configure.
|
|
2222
|
+
if (isInteractiveToken) {
|
|
2223
|
+
token = await readSecretLine('Paste your Pugi API key (PAT): ');
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
if (!token) {
|
|
2227
|
+
throw new Error('pugi login --provider token requires a key. Pass `--token <PAT>`, pipe via `--token-stdin`, or set PUGI_LOGIN_TOKEN.');
|
|
2228
|
+
}
|
|
2229
|
+
storeAndAnnounceToken({
|
|
2230
|
+
apiUrl: ctx.apiUrl,
|
|
2231
|
+
apiKey: token,
|
|
2232
|
+
label: ctx.label,
|
|
2233
|
+
source: 'token',
|
|
2234
|
+
flags: ctx.flags,
|
|
2235
|
+
});
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
case 'env': {
|
|
2239
|
+
const envKey = process.env.PUGI_API_KEY;
|
|
2240
|
+
if (!envKey) {
|
|
2241
|
+
throw new Error('pugi login --provider env requires PUGI_API_KEY to be exported in the current shell.');
|
|
2242
|
+
}
|
|
2243
|
+
storeAndAnnounceToken({
|
|
2244
|
+
apiUrl: ctx.apiUrl,
|
|
2245
|
+
apiKey: envKey,
|
|
2246
|
+
label: ctx.label,
|
|
2247
|
+
source: 'env',
|
|
2248
|
+
flags: ctx.flags,
|
|
2249
|
+
});
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
default: {
|
|
2253
|
+
// Type-level exhaustiveness check — adding a new provider without
|
|
2254
|
+
// updating this switch becomes a compile error.
|
|
2255
|
+
const exhaustive = provider;
|
|
2256
|
+
throw new Error(`Unhandled login provider: ${String(exhaustive)}`);
|
|
2257
|
+
}
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
function storeAndAnnounceToken(input) {
|
|
2261
|
+
const record = storeApiKey({
|
|
2262
|
+
apiUrl: input.apiUrl,
|
|
2263
|
+
apiKey: input.apiKey,
|
|
2264
|
+
label: input.label,
|
|
2265
|
+
source: input.source,
|
|
2266
|
+
});
|
|
2267
|
+
writeOutput(input.flags, {
|
|
2268
|
+
status: 'logged_in',
|
|
2269
|
+
apiUrl: record.apiUrl,
|
|
2270
|
+
apiKeyMasked: maskApiKey(record.apiKey),
|
|
2271
|
+
label: record.label ?? null,
|
|
2272
|
+
createdAt: record.createdAt,
|
|
2273
|
+
source: input.source,
|
|
2274
|
+
}, [
|
|
2275
|
+
`Pugi logged in for ${record.apiUrl}`,
|
|
2276
|
+
`Method: ${input.source}${record.label ? ` (${record.label})` : ''}`,
|
|
2277
|
+
`Token: ${maskApiKey(record.apiKey)}`,
|
|
2278
|
+
'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
|
|
2279
|
+
].join('\n'));
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* OAuth 2.0 Device Authorization Grant client (RFC 8628). Renders
|
|
2283
|
+
* progress to stderr so the user sees the verification URL and code
|
|
2284
|
+
* even when `--json` redirects stdout to a file. Polls every
|
|
2285
|
+
* `interval` seconds until the runtime returns authorized / denied /
|
|
2286
|
+
* expired. Stores the issued JWT into the credentials store on
|
|
2287
|
+
* success.
|
|
2288
|
+
*
|
|
2289
|
+
* Local-first contract: device flow only runs when the user invokes
|
|
2290
|
+
* `pugi login` without a token AND without `--no-device-flow`. The
|
|
2291
|
+
* CLI itself never reads files during this flow (the JWT and the
|
|
2292
|
+
* verification URL are the only state). Polling is wall-clock,
|
|
2293
|
+
* not retries-on-error; transient HTTP failures abort with a
|
|
2294
|
+
* clear error.
|
|
2295
|
+
*/
|
|
2296
|
+
async function performDeviceFlowLogin(apiUrl, flags, label) {
|
|
2297
|
+
process.stderr.write(`Pugi device-flow login at ${apiUrl}\n`);
|
|
2298
|
+
const startResult = await startDeviceFlow(apiUrl);
|
|
2299
|
+
if (startResult.status !== 'ok') {
|
|
2300
|
+
const failure = describeDeviceFlowFailure(startResult, 'start');
|
|
2301
|
+
writeOutput(flags, {
|
|
2302
|
+
status: failure.status,
|
|
2303
|
+
code: 'code' in startResult ? startResult.code : undefined,
|
|
2304
|
+
message: failure.message,
|
|
2305
|
+
}, [failure.headline, failure.next ? `Next: ${failure.next}` : '']
|
|
2306
|
+
.filter(Boolean)
|
|
2307
|
+
.join('\n'));
|
|
2308
|
+
process.exitCode = failure.exitCode;
|
|
2309
|
+
return;
|
|
2310
|
+
}
|
|
2311
|
+
const start = startResult.response;
|
|
2312
|
+
// Two render strategies share the same poll loop:
|
|
2313
|
+
// - TTY (Ink): auto-opens the browser, renders the Claude Code
|
|
2314
|
+
// parity device-flow screen, drives the spinner, transitions to
|
|
2315
|
+
// a clean success/failure frame, returns on Enter / Esc.
|
|
2316
|
+
// - non-TTY (stdout): the legacy line-buffered output kept
|
|
2317
|
+
// verbatim for `--json`, `--no-tty`, CI and piped contexts so
|
|
2318
|
+
// scripts that parse stdout are not broken.
|
|
2319
|
+
if (isInteractive(flags) && !flags.json) {
|
|
2320
|
+
await runDeviceFlowLoginInk(apiUrl, flags, label, start);
|
|
2321
|
+
return;
|
|
2322
|
+
}
|
|
2323
|
+
await runDeviceFlowLoginStdout(apiUrl, flags, label, start);
|
|
2324
|
+
}
|
|
2325
|
+
/**
|
|
2326
|
+
* Legacy stdout polling path. Kept verbatim from the pre-parity
|
|
2327
|
+
* implementation so `--json`, `--no-tty`, CI scripts, and piped
|
|
2328
|
+
* invocations see exactly the same output they did before. Anything
|
|
2329
|
+
* a downstream tool parsed (the `Polling every Ns` line, the
|
|
2330
|
+
* `Pugi logged in for ...` block, the JSON shape) is unchanged.
|
|
2331
|
+
*/
|
|
2332
|
+
async function runDeviceFlowLoginStdout(apiUrl, flags, label, start) {
|
|
2333
|
+
// Surface the user-visible parts to stderr so JSON consumers on
|
|
2334
|
+
// stdout are unaffected. The deviceCode itself is NEVER printed —
|
|
2335
|
+
// it is the secret poll handle.
|
|
2336
|
+
process.stderr.write([
|
|
2337
|
+
'',
|
|
2338
|
+
`1. Open this URL in your browser: ${start.verification_uri}`,
|
|
2339
|
+
`2. Enter this code: ${start.user_code}`,
|
|
2340
|
+
` (or use the complete link: ${start.verification_uri_complete})`,
|
|
2341
|
+
`3. Approve the request when prompted.`,
|
|
2342
|
+
'',
|
|
2343
|
+
`Polling every ${start.interval}s, expires in ${start.expires_in}s...`,
|
|
2344
|
+
'',
|
|
2345
|
+
].join('\n'));
|
|
2346
|
+
const outcome = await pollDeviceFlowUntilTerminal(apiUrl, start);
|
|
2347
|
+
emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
|
|
2348
|
+
}
|
|
2349
|
+
/**
|
|
2350
|
+
* TTY device-flow path — Claude Code parity. Auto-opens the browser,
|
|
2351
|
+
* mounts the Ink device-flow component, drives status transitions as
|
|
2352
|
+
* the poll loop advances, and resolves on Enter (success) or Esc
|
|
2353
|
+
* (cancel). On non-TTY this entry point is never called.
|
|
2354
|
+
*/
|
|
2355
|
+
async function runDeviceFlowLoginInk(apiUrl, flags, label, start) {
|
|
2356
|
+
const [{ autoOpenBrowser }, { writeClipboard }, { renderDeviceFlow, LoginCancelledError }] = await Promise.all([
|
|
2357
|
+
import('../core/auto-open-browser.js'),
|
|
2358
|
+
import('../core/clipboard.js'),
|
|
2359
|
+
import('../tui/render.js'),
|
|
2360
|
+
]);
|
|
2361
|
+
// Best-effort: spawn the user's default browser. We do not block on
|
|
2362
|
+
// the browser process — auto-open resolves as soon as spawn returns
|
|
2363
|
+
// (the child is detached and ref-released, so its long-lived browser
|
|
2364
|
+
// handle does not keep this CLI alive). The device-flow loop is the
|
|
2365
|
+
// source of truth for success; if auto-open fails, the Ink screen
|
|
2366
|
+
// renders the copy-the-URL fallback and we keep polling.
|
|
2367
|
+
const open = await autoOpenBrowser(start.verification_uri_complete ?? start.verification_uri);
|
|
2368
|
+
const handle = renderDeviceFlow({
|
|
2369
|
+
verificationUrl: start.verification_uri_complete ?? start.verification_uri,
|
|
2370
|
+
userCode: start.user_code,
|
|
2371
|
+
browserOpened: open.opened,
|
|
2372
|
+
onCopy: () => {
|
|
2373
|
+
// Fire-and-forget; the visual flash is owned by the component.
|
|
2374
|
+
// Clipboard write failures are silent — the user already sees
|
|
2375
|
+
// the URL on screen.
|
|
2376
|
+
void writeClipboard(start.verification_uri_complete ?? start.verification_uri);
|
|
2377
|
+
},
|
|
2378
|
+
});
|
|
2379
|
+
// Race the poll loop against the user's Esc keystroke. We DO NOT
|
|
2380
|
+
// await `handle.done()` in parallel here: the loop pushes status
|
|
2381
|
+
// updates via handle.setStatus, and on terminal status we await
|
|
2382
|
+
// handle.done() (which resolves on Enter / rejects on Esc). On Esc
|
|
2383
|
+
// BEFORE a terminal status, handle.done() rejects and we surface a
|
|
2384
|
+
// cancel exit code without writing any credential.
|
|
2385
|
+
// P3 polish (triple-review 2026-05-24): the old code mirrored the
|
|
2386
|
+
// cancel state into a `cancelled` flag and then checked
|
|
2387
|
+
// `winner.kind === 'cancel' || cancelled` — the second clause was
|
|
2388
|
+
// already implied by the first, since the Promise.race winner is
|
|
2389
|
+
// the only way the cancel branch is taken. Drop the flag.
|
|
2390
|
+
const cancelWatch = handle.done().catch((error) => {
|
|
2391
|
+
if (error instanceof LoginCancelledError)
|
|
2392
|
+
return;
|
|
2393
|
+
throw error;
|
|
2394
|
+
});
|
|
2395
|
+
// P1-1 (triple-review 2026-05-24): the poll loop must be abortable
|
|
2396
|
+
// so an Esc cancel does not leave a background poll running until
|
|
2397
|
+
// the device-flow deadline (up to 1 hour). The controller is wired
|
|
2398
|
+
// into the loop's abortable `sleep()` so the loop returns within
|
|
2399
|
+
// one event-loop tick of `abort()`.
|
|
2400
|
+
const pollAbort = new AbortController();
|
|
2401
|
+
const pollPromise = pollDeviceFlowUntilTerminal(apiUrl, start, pollAbort.signal);
|
|
2402
|
+
// Whichever settles first wins. If the user pressed Esc before the
|
|
2403
|
+
// server returned a terminal status, the cancel branch aborts the
|
|
2404
|
+
// poll and short-circuits. Otherwise the poll outcome drives the
|
|
2405
|
+
// final render frame.
|
|
2406
|
+
const winner = await Promise.race([
|
|
2407
|
+
pollPromise.then((outcome) => ({ kind: 'poll', outcome })),
|
|
2408
|
+
cancelWatch.then(() => ({ kind: 'cancel' })),
|
|
2409
|
+
]);
|
|
2410
|
+
if (winner.kind === 'cancel') {
|
|
2411
|
+
// Tell the poll loop to stop. The signal short-circuits the inter-
|
|
2412
|
+
// poll sleep and the in-flight HTTP call settles on its own; we
|
|
2413
|
+
// swallow the resulting rejection so an aborted poll does not
|
|
2414
|
+
// surface as an unhandled promise rejection.
|
|
2415
|
+
pollAbort.abort();
|
|
2416
|
+
await pollPromise.catch(() => undefined);
|
|
2417
|
+
// The handle is already unmounted by the Esc path inside
|
|
2418
|
+
// renderDeviceFlow's finish() helper. Surface the cancel state to
|
|
2419
|
+
// the surrounding dispatcher exactly as the legacy stdout path
|
|
2420
|
+
// would have done on a forced Ctrl+C.
|
|
2421
|
+
writeOutput(flags, { status: 'cancelled' }, 'Login cancelled.');
|
|
2422
|
+
process.exitCode = 130;
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
const outcome = winner.outcome;
|
|
2426
|
+
// Translate the terminal outcome into both a final Ink frame AND
|
|
2427
|
+
// the same writeOutput payload the stdout path would have emitted —
|
|
2428
|
+
// tests that pin JSON shape stay green regardless of TTY.
|
|
2429
|
+
applyDeviceFlowOutcomeToInk(outcome, handle);
|
|
2430
|
+
emitDeviceFlowTerminalToStdout(outcome, { apiUrl, flags, label });
|
|
2431
|
+
// On success, the host now waits for the user's Enter keystroke
|
|
2432
|
+
// before returning control to the caller (REPL / shell). Failure
|
|
2433
|
+
// frames also wait — Esc dismisses them. Either way we await done()
|
|
2434
|
+
// so the Ink screen stays on the user's terminal until they react.
|
|
2435
|
+
await handle.done().catch((error) => {
|
|
2436
|
+
if (error instanceof LoginCancelledError)
|
|
2437
|
+
return;
|
|
2438
|
+
throw error;
|
|
2439
|
+
});
|
|
2440
|
+
}
|
|
2441
|
+
/**
|
|
2442
|
+
* Sentinel thrown (P1-1, triple-review 2026-05-24) when the caller
|
|
2443
|
+
* aborts the poll loop via the optional `AbortSignal`. The Ink host
|
|
2444
|
+
* treats this as a silent cancel — the surrounding
|
|
2445
|
+
* `runDeviceFlowLoginInk` already owns the cancel exit code via the
|
|
2446
|
+
* keystroke race.
|
|
2447
|
+
*/
|
|
2448
|
+
class DeviceFlowPollAbortedError extends Error {
|
|
2449
|
+
constructor() {
|
|
2450
|
+
super('Device-flow poll aborted by caller');
|
|
2451
|
+
this.name = 'DeviceFlowPollAbortedError';
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
async function pollDeviceFlowUntilTerminal(apiUrl, start, signal, deps = {}) {
|
|
2455
|
+
const poll = deps.poll ?? pollDeviceFlow;
|
|
2456
|
+
const sleepImpl = deps.sleepFn ?? sleep;
|
|
2457
|
+
const now = deps.now ?? Date.now;
|
|
2458
|
+
// Hard local cap on the polling deadline so a hostile or buggy
|
|
2459
|
+
// runtime returning an absurdly large `expires_in` cannot trap
|
|
2460
|
+
// the CLI in a long-running poll. The SDK schema already caps
|
|
2461
|
+
// `expires_in` at 3600s, but enforce the floor here too — the
|
|
2462
|
+
// SDK contract is what we own, and a future broadening must
|
|
2463
|
+
// still respect this local maximum.
|
|
2464
|
+
const PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC = (deps.deadlineMaxMs ?? 60 * 60 * 1000) / 1000;
|
|
2465
|
+
const expiresInSec = Math.min(start.expires_in, PUGI_DEVICE_FLOW_DEADLINE_MAX_SEC);
|
|
2466
|
+
const deadline = now() + expiresInSec * 1000;
|
|
2467
|
+
const pollInterval = start.interval;
|
|
2468
|
+
// P1-1 (triple-review 2026-05-24): cancellable interval. If the
|
|
2469
|
+
// Ink host aborts (user pressed Esc), `sleep` resolves immediately
|
|
2470
|
+
// and the loop throws DeviceFlowPollAbortedError so the caller can
|
|
2471
|
+
// drain the rejection silently without waiting up to an hour.
|
|
2472
|
+
while (now() < deadline) {
|
|
2473
|
+
if (signal?.aborted)
|
|
2474
|
+
throw new DeviceFlowPollAbortedError();
|
|
2475
|
+
await sleepImpl(pollInterval * 1000, signal);
|
|
2476
|
+
if (signal?.aborted)
|
|
2477
|
+
throw new DeviceFlowPollAbortedError();
|
|
2478
|
+
const pollResult = await poll(apiUrl, start.device_code);
|
|
2479
|
+
if (signal?.aborted)
|
|
2480
|
+
throw new DeviceFlowPollAbortedError();
|
|
2481
|
+
if (pollResult.status !== 'ok') {
|
|
2482
|
+
const failure = describeDeviceFlowFailure(pollResult, 'poll');
|
|
2483
|
+
return {
|
|
2484
|
+
kind: 'failed',
|
|
2485
|
+
failure,
|
|
2486
|
+
code: 'code' in pollResult ? pollResult.code : undefined,
|
|
2487
|
+
};
|
|
2488
|
+
}
|
|
2489
|
+
const outcome = pollResult.response;
|
|
2490
|
+
if (outcome.status === 'authorized') {
|
|
2491
|
+
return { kind: 'authorized', accessToken: outcome.access_token };
|
|
2492
|
+
}
|
|
2493
|
+
if (outcome.status === 'denied')
|
|
2494
|
+
return { kind: 'denied' };
|
|
2495
|
+
if (outcome.status === 'expired')
|
|
2496
|
+
return { kind: 'expired' };
|
|
2497
|
+
if (outcome.status === 'redeemed')
|
|
2498
|
+
return { kind: 'redeemed' };
|
|
2499
|
+
// status === 'pending' — keep polling
|
|
2500
|
+
}
|
|
2501
|
+
// P1-2 (triple-review 2026-05-24): the loop hit the LOCAL deadline
|
|
2502
|
+
// without the server returning a terminal status. The legacy stdout
|
|
2503
|
+
// path distinguished this from server-side `expired` so operators
|
|
2504
|
+
// could tell a local-cap timeout from an actual server expiry; we
|
|
2505
|
+
// preserve that signal via this dedicated outcome kind.
|
|
2506
|
+
return { kind: 'timed_out_local' };
|
|
2507
|
+
}
|
|
2508
|
+
/**
|
|
2509
|
+
* Final writeOutput emission, identical shape to the pre-parity
|
|
2510
|
+
* stdout path so JSON consumers, scripts, and CI snapshots are not
|
|
2511
|
+
* affected by the TTY-vs-stdout branch.
|
|
2512
|
+
*/
|
|
2513
|
+
function emitDeviceFlowTerminalToStdout(outcome, ctx) {
|
|
2514
|
+
if (outcome.kind === 'authorized') {
|
|
2515
|
+
const record = storeApiKey({
|
|
2516
|
+
apiUrl: ctx.apiUrl,
|
|
2517
|
+
apiKey: outcome.accessToken,
|
|
2518
|
+
label: ctx.label ?? undefined,
|
|
2519
|
+
source: 'device-flow',
|
|
2520
|
+
});
|
|
2521
|
+
// Defense-in-depth against the device-flow phishing class
|
|
2522
|
+
// (P0, 2026-05-22 review): decode the JWT we just received and
|
|
2523
|
+
// surface the principal identity to the user so they can confirm
|
|
2524
|
+
// we landed in the expected tenant.
|
|
2525
|
+
const principal = decodeJwtPrincipal(outcome.accessToken);
|
|
2526
|
+
writeOutput(ctx.flags, {
|
|
2527
|
+
status: 'logged_in',
|
|
2528
|
+
apiUrl: record.apiUrl,
|
|
2529
|
+
apiKeyMasked: maskApiKey(record.apiKey),
|
|
2530
|
+
label: record.label ?? null,
|
|
2531
|
+
createdAt: record.createdAt,
|
|
2532
|
+
via: 'device-flow',
|
|
2533
|
+
principal,
|
|
2534
|
+
}, [
|
|
2535
|
+
`Pugi logged in for ${record.apiUrl} via device flow`,
|
|
2536
|
+
`Token: ${maskApiKey(record.apiKey)}${record.label ? ` (${record.label})` : ''}`,
|
|
2537
|
+
principal
|
|
2538
|
+
? `Authenticated as user=${principal.email ?? principal.sub} tenant=${principal.customerId ?? '(unknown)'}.`
|
|
2539
|
+
: 'Authenticated (JWT payload could not be decoded for principal display).',
|
|
2540
|
+
'If this is NOT the user/tenant you expected, run `pugi logout` immediately and re-check the verification URL.',
|
|
2541
|
+
'Stored at ~/.pugi/credentials.json (mode 0600). Run `pugi whoami` to verify.',
|
|
2542
|
+
].join('\n'));
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
if (outcome.kind === 'denied') {
|
|
2546
|
+
writeOutput(ctx.flags, { status: 'denied' }, 'Pugi device flow denied. The cabinet user clicked Deny.');
|
|
2547
|
+
process.exitCode = 5;
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
if (outcome.kind === 'expired' || outcome.kind === 'redeemed') {
|
|
2551
|
+
writeOutput(ctx.flags, { status: outcome.kind }, outcome.kind === 'expired'
|
|
2552
|
+
? 'Pugi device flow expired before approval. Run `pugi login` again.'
|
|
2553
|
+
: 'Pugi device flow already redeemed by another CLI. Run `pugi login` to start a fresh flow.');
|
|
2554
|
+
process.exitCode = 5;
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
if (outcome.kind === 'timed_out_local') {
|
|
2558
|
+
// P1-2 (triple-review 2026-05-24): the legacy stdout path
|
|
2559
|
+
// surfaced local-cap timeouts under a dedicated message so
|
|
2560
|
+
// operators could tell a 1-hour client cap apart from a server
|
|
2561
|
+
// expiry. The JSON shape uses `status: 'timed_out_local'` so
|
|
2562
|
+
// downstream scripts can branch on it; the human message stays
|
|
2563
|
+
// close to the pre-parity wording.
|
|
2564
|
+
writeOutput(ctx.flags, { status: 'timed_out_local' }, 'Pugi device flow timed out locally before the server returned a terminal status. Run `pugi login` again.');
|
|
2565
|
+
process.exitCode = 5;
|
|
2566
|
+
return;
|
|
2567
|
+
}
|
|
2568
|
+
// failed
|
|
2569
|
+
writeOutput(ctx.flags, {
|
|
2570
|
+
status: outcome.failure.status,
|
|
2571
|
+
code: outcome.code,
|
|
2572
|
+
message: outcome.failure.message,
|
|
2573
|
+
}, [outcome.failure.headline, outcome.failure.next ? `Next: ${outcome.failure.next}` : '']
|
|
2574
|
+
.filter(Boolean)
|
|
2575
|
+
.join('\n'));
|
|
2576
|
+
process.exitCode = outcome.failure.exitCode;
|
|
2577
|
+
}
|
|
2578
|
+
/**
|
|
2579
|
+
* Drives the Ink screen toward the success / failure frame based on
|
|
2580
|
+
* the terminal outcome. The principal label mirrors the stdout
|
|
2581
|
+
* "Authenticated as user=… tenant=…" line so the on-screen identity
|
|
2582
|
+
* the user confirms matches the verbose message the stdout path
|
|
2583
|
+
* still emits.
|
|
2584
|
+
*/
|
|
2585
|
+
function applyDeviceFlowOutcomeToInk(outcome, handle) {
|
|
2586
|
+
if (outcome.kind === 'authorized') {
|
|
2587
|
+
const principal = decodeJwtPrincipal(outcome.accessToken);
|
|
2588
|
+
const label = principal
|
|
2589
|
+
? `${principal.email ?? principal.sub ?? 'authenticated'}${principal.customerId ? ` (tenant: ${principal.customerId})` : ''}`
|
|
2590
|
+
: 'authenticated';
|
|
2591
|
+
handle.setStatus({ kind: 'success', principalLabel: label });
|
|
2592
|
+
return;
|
|
2593
|
+
}
|
|
2594
|
+
if (outcome.kind === 'denied') {
|
|
2595
|
+
handle.setStatus({
|
|
2596
|
+
kind: 'failure',
|
|
2597
|
+
reason: 'Login was denied in the browser.',
|
|
2598
|
+
hint: 'Run `pugi login` again to retry.',
|
|
2599
|
+
});
|
|
2600
|
+
return;
|
|
2601
|
+
}
|
|
2602
|
+
if (outcome.kind === 'expired') {
|
|
2603
|
+
handle.setStatus({
|
|
2604
|
+
kind: 'failure',
|
|
2605
|
+
reason: 'Login code expired before approval.',
|
|
2606
|
+
hint: 'Run `pugi login` again to retry.',
|
|
2607
|
+
});
|
|
2608
|
+
return;
|
|
2609
|
+
}
|
|
2610
|
+
if (outcome.kind === 'timed_out_local') {
|
|
2611
|
+
handle.setStatus({
|
|
2612
|
+
kind: 'failure',
|
|
2613
|
+
reason: 'Login timed out locally before the server returned a terminal status.',
|
|
2614
|
+
hint: 'Run `pugi login` again to retry.',
|
|
2615
|
+
});
|
|
2616
|
+
return;
|
|
2617
|
+
}
|
|
2618
|
+
if (outcome.kind === 'redeemed') {
|
|
2619
|
+
handle.setStatus({
|
|
2620
|
+
kind: 'failure',
|
|
2621
|
+
reason: 'Login code already redeemed by another CLI.',
|
|
2622
|
+
hint: 'Run `pugi login` again to start a fresh flow.',
|
|
2623
|
+
});
|
|
2624
|
+
return;
|
|
2625
|
+
}
|
|
2626
|
+
handle.setStatus({
|
|
2627
|
+
kind: 'failure',
|
|
2628
|
+
reason: outcome.failure.headline,
|
|
2629
|
+
hint: outcome.failure.next ?? undefined,
|
|
2630
|
+
});
|
|
2631
|
+
}
|
|
2632
|
+
function sleep(ms, signal) {
|
|
2633
|
+
return new Promise((resolve) => {
|
|
2634
|
+
if (signal?.aborted) {
|
|
2635
|
+
resolve();
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
const timer = setTimeout(() => {
|
|
2639
|
+
if (signal)
|
|
2640
|
+
signal.removeEventListener('abort', onAbort);
|
|
2641
|
+
resolve();
|
|
2642
|
+
}, ms);
|
|
2643
|
+
const onAbort = () => {
|
|
2644
|
+
clearTimeout(timer);
|
|
2645
|
+
resolve();
|
|
2646
|
+
};
|
|
2647
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
2648
|
+
});
|
|
2649
|
+
}
|
|
2650
|
+
/**
|
|
2651
|
+
* Best-effort JWT payload decode for principal display ONLY. Does
|
|
2652
|
+
* NOT verify the signature — that's not the CLI's role (the server
|
|
2653
|
+
* is the trust anchor for the JWT it just issued). The decoded
|
|
2654
|
+
* fields are surfaced to the user so they can confirm the resulting
|
|
2655
|
+
* authentication matches the tenant they expected.
|
|
2656
|
+
*
|
|
2657
|
+
* Returns `null` on any parse error so the caller falls back to a
|
|
2658
|
+
* generic "authenticated" message.
|
|
2659
|
+
*/
|
|
2660
|
+
function decodeJwtPrincipal(token) {
|
|
2661
|
+
try {
|
|
2662
|
+
const parts = token.split('.');
|
|
2663
|
+
if (parts.length < 2)
|
|
2664
|
+
return null;
|
|
2665
|
+
const payload = parts[1];
|
|
2666
|
+
if (!payload)
|
|
2667
|
+
return null;
|
|
2668
|
+
// base64url → base64
|
|
2669
|
+
const padded = payload.replace(/-/g, '+').replace(/_/g, '/').padEnd(payload.length + ((4 - (payload.length % 4)) % 4), '=');
|
|
2670
|
+
const json = Buffer.from(padded, 'base64').toString('utf8');
|
|
2671
|
+
const obj = JSON.parse(json);
|
|
2672
|
+
if (!obj || typeof obj !== 'object')
|
|
2673
|
+
return null;
|
|
2674
|
+
return {
|
|
2675
|
+
sub: typeof obj.sub === 'string' ? obj.sub : undefined,
|
|
2676
|
+
email: typeof obj.email === 'string' ? obj.email : undefined,
|
|
2677
|
+
customerId: typeof obj.customerId === 'string' ? obj.customerId : undefined,
|
|
2678
|
+
role: typeof obj.role === 'string' ? obj.role : undefined,
|
|
2679
|
+
via: typeof obj.via === 'string' ? obj.via : undefined,
|
|
2680
|
+
plan: typeof obj.plan === 'string' ? obj.plan : undefined,
|
|
2681
|
+
};
|
|
2682
|
+
}
|
|
2683
|
+
catch {
|
|
2684
|
+
return null;
|
|
2685
|
+
}
|
|
2686
|
+
}
|
|
2687
|
+
function describeDeviceFlowFailure(result, stage) {
|
|
2688
|
+
switch (result.status) {
|
|
2689
|
+
case 'endpoint_missing':
|
|
2690
|
+
return {
|
|
2691
|
+
status: 'endpoint_missing',
|
|
2692
|
+
headline: `Pugi device-flow ${stage} endpoint not deployed on this runtime.`,
|
|
2693
|
+
message: result.message,
|
|
2694
|
+
next: 'Use `pugi login --token <PAT>` until the operator deploys POST /api/auth/device/* on this Anvil instance.',
|
|
2695
|
+
exitCode: 6,
|
|
2696
|
+
};
|
|
2697
|
+
case 'failed':
|
|
2698
|
+
default:
|
|
2699
|
+
return {
|
|
2700
|
+
status: 'failed',
|
|
2701
|
+
headline: `Pugi device-flow ${stage} failed.`,
|
|
2702
|
+
message: result.message,
|
|
2703
|
+
next: 'Use `pugi login --token <PAT>` or check network connectivity.',
|
|
2704
|
+
exitCode: 8,
|
|
2705
|
+
};
|
|
2706
|
+
}
|
|
2707
|
+
}
|
|
2708
|
+
async function logout(args, flags, _session) {
|
|
2709
|
+
const all = args.includes('--all');
|
|
2710
|
+
if (all) {
|
|
2711
|
+
const removed = purgeAllCredentials();
|
|
2712
|
+
writeOutput(flags, { status: 'logged_out_all', removed }, removed ? 'All Pugi credentials cleared.' : 'No Pugi credentials were stored.');
|
|
2713
|
+
return;
|
|
2714
|
+
}
|
|
2715
|
+
const apiUrlOverride = extractApiUrlFlag(args);
|
|
2716
|
+
// Use the same resolution precedence as `whoami` so `pugi login --api-url X`
|
|
2717
|
+
// followed by `pugi logout` clears the self-hosted credential the user
|
|
2718
|
+
// just authenticated against. Falls back to DEFAULT_API_URL only when
|
|
2719
|
+
// neither env nor active record names a host.
|
|
2720
|
+
const file = readCredentialsFile();
|
|
2721
|
+
const apiUrl = normalizeApiUrl(apiUrlOverride ?? process.env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
|
|
2722
|
+
const removed = clearApiKey(apiUrl);
|
|
2723
|
+
writeOutput(flags, { status: removed ? 'logged_out' : 'noop', apiUrl, removed }, removed
|
|
2724
|
+
? `Pugi logged out from ${apiUrl}.`
|
|
2725
|
+
: `No credential stored for ${apiUrl} — nothing to clear.`);
|
|
2726
|
+
}
|
|
2727
|
+
async function whoami(_args, flags, _session) {
|
|
2728
|
+
const credential = resolveActiveCredential();
|
|
2729
|
+
if (!credential) {
|
|
2730
|
+
// Surface the same activeApiUrl precedence the resolver uses so the
|
|
2731
|
+
// user sees which host they would be talking to before logging in.
|
|
2732
|
+
const file = readCredentialsFile();
|
|
2733
|
+
const apiUrl = normalizeApiUrl(process.env.PUGI_API_URL ?? file.activeApiUrl ?? DEFAULT_API_URL);
|
|
2734
|
+
writeOutput(flags, { status: 'anonymous', apiUrl }, [
|
|
2735
|
+
'Pugi is not logged in.',
|
|
2736
|
+
`Active API endpoint: ${apiUrl}`,
|
|
2737
|
+
'Run `pugi login` to start (interactive) or `pugi login --provider token --token <PAT>`.',
|
|
2738
|
+
].join('\n'));
|
|
2739
|
+
process.exitCode = 5;
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
// Decode the JWT principal for display only — the server is the trust
|
|
2743
|
+
// anchor for signature verification, the CLI surfaces what it sees so
|
|
2744
|
+
// the user can spot a wrong-tenant credential before running a tool.
|
|
2745
|
+
const principal = decodeJwtPrincipal(credential.apiKey);
|
|
2746
|
+
const via = describeLoginMethod(credential);
|
|
2747
|
+
const plan = principal && 'plan' in principal ? String(principal.plan ?? '') : '';
|
|
2748
|
+
// Sprint 3A: fetch the current-month usage via /api/pugi/usage. Best
|
|
2749
|
+
// effort, 4 s timeout — a slow / unreachable admin-api never blocks
|
|
2750
|
+
// `pugi whoami` from rendering the local credential info. The usage
|
|
2751
|
+
// payload, when present, lets us render "Plan: Founder ($20/mo) —
|
|
2752
|
+
// 12/100 reviews used this month (resets in 18d)".
|
|
2753
|
+
const usage = await fetchUsageSafely(credential);
|
|
2754
|
+
const payload = {
|
|
2755
|
+
status: 'authenticated',
|
|
2756
|
+
apiUrl: credential.apiUrl,
|
|
2757
|
+
source: credential.source,
|
|
2758
|
+
via,
|
|
2759
|
+
label: credential.label ?? null,
|
|
2760
|
+
apiKeyMasked: maskApiKey(credential.apiKey),
|
|
2761
|
+
createdAt: credential.createdAt ?? null,
|
|
2762
|
+
lastUsedAt: credential.lastUsedAt ?? null,
|
|
2763
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
2764
|
+
account: principal
|
|
2765
|
+
? {
|
|
2766
|
+
email: principal.email ?? null,
|
|
2767
|
+
sub: principal.sub ?? null,
|
|
2768
|
+
role: principal.role ?? null,
|
|
2769
|
+
customerId: principal.customerId ?? null,
|
|
2770
|
+
plan: plan || null,
|
|
2771
|
+
}
|
|
2772
|
+
: null,
|
|
2773
|
+
usage: usage ?? null,
|
|
2774
|
+
};
|
|
2775
|
+
const text = [
|
|
2776
|
+
`Pugi authenticated against ${credential.apiUrl}`,
|
|
2777
|
+
`via: ${via}${credential.label ? ` (${credential.label})` : ''}`,
|
|
2778
|
+
`Token: ${maskApiKey(credential.apiKey)}`,
|
|
2779
|
+
principal?.email || principal?.sub
|
|
2780
|
+
? `Account: ${principal.email ?? principal.sub}${principal.role ? ` (${principal.role})` : ''}`
|
|
2781
|
+
: null,
|
|
2782
|
+
principal?.customerId ? `Tenant: ${principal.customerId}` : null,
|
|
2783
|
+
formatPlanLine(usage, plan),
|
|
2784
|
+
formatUsageLine(usage),
|
|
2785
|
+
]
|
|
2786
|
+
.filter((line) => Boolean(line))
|
|
2787
|
+
.join('\n');
|
|
2788
|
+
writeOutput(flags, payload, text);
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* Map a tier slug to its public price tag for the `pugi whoami` plan
|
|
2792
|
+
* line. Mirrors the four-tier ladder in admin-api/billing/pricing.ts;
|
|
2793
|
+
* kept in sync via the pricing.spec.ts gate.
|
|
2794
|
+
*/
|
|
2795
|
+
const TIER_PRICE_LABEL = {
|
|
2796
|
+
free: 'Free',
|
|
2797
|
+
founder: 'Founder ($20/mo)',
|
|
2798
|
+
builder: 'Builder ($99/mo)',
|
|
2799
|
+
team: 'Team ($199/mo)',
|
|
2800
|
+
};
|
|
2801
|
+
function fetchUsageSafely(credential) {
|
|
2802
|
+
const controller = new AbortController();
|
|
2803
|
+
const timer = setTimeout(() => controller.abort(), 4000);
|
|
2804
|
+
return (async () => {
|
|
2805
|
+
try {
|
|
2806
|
+
const res = await fetch(`${credential.apiUrl}/api/pugi/usage`, {
|
|
2807
|
+
method: 'GET',
|
|
2808
|
+
headers: {
|
|
2809
|
+
authorization: `Bearer ${credential.apiKey}`,
|
|
2810
|
+
accept: 'application/json',
|
|
2811
|
+
},
|
|
2812
|
+
signal: controller.signal,
|
|
2813
|
+
});
|
|
2814
|
+
if (!res.ok)
|
|
2815
|
+
return null;
|
|
2816
|
+
const body = (await res.json());
|
|
2817
|
+
// Defensive shape check — older admin-api versions may not yet
|
|
2818
|
+
// expose this surface, in which case we silently degrade.
|
|
2819
|
+
if (body &&
|
|
2820
|
+
typeof body === 'object' &&
|
|
2821
|
+
typeof body.tier === 'string' &&
|
|
2822
|
+
body.used &&
|
|
2823
|
+
body.quotas) {
|
|
2824
|
+
return body;
|
|
2825
|
+
}
|
|
2826
|
+
return null;
|
|
2827
|
+
}
|
|
2828
|
+
catch {
|
|
2829
|
+
return null;
|
|
2830
|
+
}
|
|
2831
|
+
finally {
|
|
2832
|
+
clearTimeout(timer);
|
|
2833
|
+
}
|
|
2834
|
+
})();
|
|
2835
|
+
}
|
|
2836
|
+
function formatPlanLine(usage, jwtPlan) {
|
|
2837
|
+
if (usage) {
|
|
2838
|
+
return `Plan: ${TIER_PRICE_LABEL[usage.tier] ?? usage.tier}`;
|
|
2839
|
+
}
|
|
2840
|
+
if (jwtPlan)
|
|
2841
|
+
return `Plan: ${jwtPlan}`;
|
|
2842
|
+
return null;
|
|
2843
|
+
}
|
|
2844
|
+
function formatUsageLine(usage) {
|
|
2845
|
+
if (!usage)
|
|
2846
|
+
return null;
|
|
2847
|
+
const r = formatDimension(usage.used.review, usage.quotas.review, 'reviews');
|
|
2848
|
+
const s = formatDimension(usage.used.sync, usage.quotas.sync, 'syncs');
|
|
2849
|
+
const e = formatDimension(usage.used.engine, usage.quotas.engine, 'engine turns');
|
|
2850
|
+
const resetSuffix = formatResetSuffix(usage.resetAt);
|
|
2851
|
+
return [`Usage: ${r} · ${s} · ${e}`, resetSuffix].filter(Boolean).join(' ');
|
|
2852
|
+
}
|
|
2853
|
+
function formatDimension(used, limit, label) {
|
|
2854
|
+
if (limit === null)
|
|
2855
|
+
return `${used} ${label} (unlimited)`;
|
|
2856
|
+
return `${used}/${limit} ${label}`;
|
|
2857
|
+
}
|
|
2858
|
+
function formatResetSuffix(resetAtIso) {
|
|
2859
|
+
const parsed = Date.parse(resetAtIso);
|
|
2860
|
+
if (!Number.isFinite(parsed))
|
|
2861
|
+
return '';
|
|
2862
|
+
const diffMs = parsed - Date.now();
|
|
2863
|
+
if (diffMs <= 0)
|
|
2864
|
+
return '(resetting now)';
|
|
2865
|
+
const days = Math.max(1, Math.round(diffMs / (24 * 60 * 60 * 1000)));
|
|
2866
|
+
return `(resets in ${days}d)`;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* Render the login-method label shown in `pugi whoami` and emitted in
|
|
2870
|
+
* the JSON envelope. Aliases the resolver's `source` discriminator (env
|
|
2871
|
+
* vs file) plus the stored `fileSource` (token vs device-flow vs env
|
|
2872
|
+
* promotion) into a single short word.
|
|
2873
|
+
*/
|
|
2874
|
+
function describeLoginMethod(credential) {
|
|
2875
|
+
if (credential.source === 'env')
|
|
2876
|
+
return 'env';
|
|
2877
|
+
switch (credential.fileSource) {
|
|
2878
|
+
case 'device-flow':
|
|
2879
|
+
return 'device-flow';
|
|
2880
|
+
case 'token':
|
|
2881
|
+
return 'token';
|
|
2882
|
+
case 'env':
|
|
2883
|
+
return 'env-promoted';
|
|
2884
|
+
default:
|
|
2885
|
+
return 'token';
|
|
2886
|
+
}
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* `pugi accounts <list|switch|remove>` — manage stored credentials
|
|
2890
|
+
* across multiple Pugi hosts (api.pugi.io, self-hosted Anvil, dev
|
|
2891
|
+
* cabinet, etc.).
|
|
2892
|
+
*
|
|
2893
|
+
* `accounts list` — show every stored record with masked key, label,
|
|
2894
|
+
* source, lastUsed, and whether it is the active one.
|
|
2895
|
+
* `accounts switch <label-or-url>` — re-point `activeApiUrl` so
|
|
2896
|
+
* subsequent commands authenticate against the chosen account.
|
|
2897
|
+
* `accounts remove <label-or-url>` — delete a stored credential.
|
|
2898
|
+
*
|
|
2899
|
+
* The sub-verb is a positional, matching `gh auth <verb>` ergonomics.
|
|
2900
|
+
*/
|
|
2901
|
+
async function accounts(args, flags, _session) {
|
|
2902
|
+
const subCommand = args[0];
|
|
2903
|
+
if (!subCommand || subCommand === '--help' || subCommand === '-h') {
|
|
2904
|
+
writeOutput(flags, {
|
|
2905
|
+
command: 'accounts',
|
|
2906
|
+
usage: [
|
|
2907
|
+
'pugi accounts list',
|
|
2908
|
+
'pugi accounts switch <label-or-url>',
|
|
2909
|
+
'pugi accounts remove <label-or-url>',
|
|
2910
|
+
],
|
|
2911
|
+
}, [
|
|
2912
|
+
'Usage:',
|
|
2913
|
+
' pugi accounts list Show every stored credential.',
|
|
2914
|
+
' pugi accounts switch <label|url> Set the active account.',
|
|
2915
|
+
' pugi accounts remove <label|url> Delete a stored credential.',
|
|
2916
|
+
].join('\n'));
|
|
2917
|
+
return;
|
|
2918
|
+
}
|
|
2919
|
+
const rest = args.slice(1);
|
|
2920
|
+
switch (subCommand) {
|
|
2921
|
+
case 'list':
|
|
2922
|
+
return accountsList(flags);
|
|
2923
|
+
case 'switch':
|
|
2924
|
+
return accountsSwitch(rest, flags);
|
|
2925
|
+
case 'remove':
|
|
2926
|
+
case 'rm':
|
|
2927
|
+
return accountsRemove(rest, flags);
|
|
2928
|
+
default:
|
|
2929
|
+
throw new Error(`Unknown sub-command "pugi accounts ${subCommand}". Expected list, switch, or remove.`);
|
|
2930
|
+
}
|
|
2931
|
+
}
|
|
2932
|
+
function accountsList(flags) {
|
|
2933
|
+
const records = listStoredCredentials();
|
|
2934
|
+
if (records.length === 0) {
|
|
2935
|
+
writeOutput(flags, { status: 'empty', accounts: [] }, 'No Pugi accounts stored. Run `pugi login` to add one.');
|
|
2936
|
+
return;
|
|
2937
|
+
}
|
|
2938
|
+
const payload = {
|
|
2939
|
+
status: 'ok',
|
|
2940
|
+
accounts: records.map((record) => ({
|
|
2941
|
+
apiUrl: record.apiUrl,
|
|
2942
|
+
label: record.label ?? null,
|
|
2943
|
+
apiKeyMasked: maskApiKey(record.apiKey),
|
|
2944
|
+
source: record.source ?? null,
|
|
2945
|
+
createdAt: record.createdAt,
|
|
2946
|
+
lastUsedAt: record.lastUsedAt ?? null,
|
|
2947
|
+
isActive: record.isActive,
|
|
2948
|
+
})),
|
|
2949
|
+
};
|
|
2950
|
+
const text = [
|
|
2951
|
+
'Stored Pugi accounts:',
|
|
2952
|
+
'',
|
|
2953
|
+
...records.map((record) => {
|
|
2954
|
+
const star = record.isActive ? '*' : ' ';
|
|
2955
|
+
const label = record.label ?? '(unlabelled)';
|
|
2956
|
+
const last = record.lastUsedAt ?? record.createdAt;
|
|
2957
|
+
const source = record.source ?? 'unknown';
|
|
2958
|
+
return `${star} ${label.padEnd(20)} ${record.apiUrl.padEnd(32)} ${maskApiKey(record.apiKey)} ${source.padEnd(12)} last:${last}`;
|
|
2959
|
+
}),
|
|
2960
|
+
'',
|
|
2961
|
+
'Switch active account: pugi accounts switch <label>',
|
|
2962
|
+
].join('\n');
|
|
2963
|
+
writeOutput(flags, payload, text);
|
|
2964
|
+
}
|
|
2965
|
+
function accountsSwitch(args, flags) {
|
|
2966
|
+
const selector = args[0];
|
|
2967
|
+
if (!selector) {
|
|
2968
|
+
throw new Error('pugi accounts switch requires a label or apiUrl. Run `pugi accounts list` to see candidates.');
|
|
2969
|
+
}
|
|
2970
|
+
const next = switchActiveAccount(selector);
|
|
2971
|
+
if (!next) {
|
|
2972
|
+
writeOutput(flags, { status: 'not_found', selector }, `No stored account matches "${selector}". Run \`pugi accounts list\` to see candidates.`);
|
|
2973
|
+
process.exitCode = 5;
|
|
2974
|
+
return;
|
|
2975
|
+
}
|
|
2976
|
+
writeOutput(flags, {
|
|
2977
|
+
status: 'switched',
|
|
2978
|
+
apiUrl: next.apiUrl,
|
|
2979
|
+
label: next.label ?? null,
|
|
2980
|
+
apiKeyMasked: maskApiKey(next.apiKey),
|
|
2981
|
+
}, [
|
|
2982
|
+
`Active account is now ${next.label ?? next.apiUrl}`,
|
|
2983
|
+
`Endpoint: ${next.apiUrl}`,
|
|
2984
|
+
`Token: ${maskApiKey(next.apiKey)}`,
|
|
2985
|
+
].join('\n'));
|
|
2986
|
+
}
|
|
2987
|
+
function accountsRemove(args, flags) {
|
|
2988
|
+
const selector = args[0];
|
|
2989
|
+
if (!selector) {
|
|
2990
|
+
throw new Error('pugi accounts remove requires a label or apiUrl.');
|
|
2991
|
+
}
|
|
2992
|
+
// Map label → apiUrl when a label is supplied; clearApiKey takes a
|
|
2993
|
+
// canonicalised url.
|
|
2994
|
+
const records = listStoredCredentials();
|
|
2995
|
+
const lower = selector.toLowerCase();
|
|
2996
|
+
const target = records.find((record) => record.label && record.label.toLowerCase() === lower)?.apiUrl ??
|
|
2997
|
+
selector;
|
|
2998
|
+
const removed = clearApiKey(target);
|
|
2999
|
+
if (!removed) {
|
|
3000
|
+
writeOutput(flags, { status: 'not_found', selector }, `No stored account matches "${selector}".`);
|
|
3001
|
+
process.exitCode = 5;
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
writeOutput(flags, { status: 'removed', selector, apiUrl: normalizeApiUrl(target) }, `Pugi credential for ${normalizeApiUrl(target)} removed.`);
|
|
3005
|
+
}
|
|
3006
|
+
function extractNamedFlagValue(args, flagName) {
|
|
3007
|
+
const space = `--${flagName}`;
|
|
3008
|
+
const equals = `--${flagName}=`;
|
|
3009
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
3010
|
+
const arg = args[i] ?? '';
|
|
3011
|
+
if (arg === space) {
|
|
3012
|
+
const next = args[i + 1];
|
|
3013
|
+
// Refuse the next arg if it looks like another flag — otherwise
|
|
3014
|
+
// `--token --api-url …` silently stores the literal string
|
|
3015
|
+
// "--api-url" as the token. `pugi login --token` (last arg)
|
|
3016
|
+
// returns undefined and falls through to PUGI_LOGIN_TOKEN.
|
|
3017
|
+
if (!next || next.startsWith('--'))
|
|
3018
|
+
return undefined;
|
|
3019
|
+
return next;
|
|
3020
|
+
}
|
|
3021
|
+
if (arg.startsWith(equals))
|
|
3022
|
+
return arg.slice(equals.length);
|
|
3023
|
+
}
|
|
3024
|
+
return undefined;
|
|
3025
|
+
}
|
|
3026
|
+
function extractTokenFlag(args) {
|
|
3027
|
+
return extractNamedFlagValue(args, 'token');
|
|
3028
|
+
}
|
|
3029
|
+
function extractApiUrlFlag(args) {
|
|
3030
|
+
return extractNamedFlagValue(args, 'api-url');
|
|
3031
|
+
}
|
|
3032
|
+
function extractLabelFlag(args) {
|
|
3033
|
+
return extractNamedFlagValue(args, 'label');
|
|
3034
|
+
}
|
|
3035
|
+
/**
|
|
3036
|
+
* `pugi jobs` — surface the persistent JobRegistry on the CLI.
|
|
3037
|
+
* Sprint α5.9 (ADR-0056 PR-PUGI-CLI-M1-GAP-J). Subcommand parsing
|
|
3038
|
+
* (list/status/tail/kill) lives in `src/commands/jobs.ts`; this
|
|
3039
|
+
* handler is a thin shim so the existing command map dispatch
|
|
3040
|
+
* remains the single entry point.
|
|
3041
|
+
*/
|
|
3042
|
+
async function jobs(args, flags, session) {
|
|
3043
|
+
const exitCode = await runJobsCommand(args, { json: flags.json }, {
|
|
3044
|
+
write: (text) => process.stdout.write(text),
|
|
3045
|
+
writeError: (text) => process.stderr.write(text.endsWith('\n') ? text : `${text}\n`),
|
|
3046
|
+
}, session.id);
|
|
3047
|
+
if (exitCode !== 0) {
|
|
3048
|
+
process.exitCode = exitCode;
|
|
3049
|
+
}
|
|
3050
|
+
}
|
|
3051
|
+
function notImplemented(command) {
|
|
3052
|
+
return async (_args, flags) => {
|
|
3053
|
+
const payload = {
|
|
3054
|
+
command,
|
|
3055
|
+
status: 'not_implemented',
|
|
3056
|
+
message: `pugi ${command} is scaffolded but not implemented yet`,
|
|
3057
|
+
};
|
|
3058
|
+
writeOutput(flags, payload, payload.message);
|
|
3059
|
+
};
|
|
3060
|
+
}
|
|
3061
|
+
function ensurePugiGitIgnore(cwd, created, skipped) {
|
|
3062
|
+
const gitignorePath = resolve(cwd, '.gitignore');
|
|
3063
|
+
const marker = '.pugi/';
|
|
3064
|
+
if (!existsSync(gitignorePath)) {
|
|
3065
|
+
writeFileSync(gitignorePath, `${marker}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
3066
|
+
created.push(gitignorePath);
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
const current = readFileSync(gitignorePath, 'utf8');
|
|
3070
|
+
const lines = current.split('\n').map((line) => line.trim());
|
|
3071
|
+
if (lines.includes(marker) || lines.includes('/.pugi/') || lines.includes('.pugi')) {
|
|
3072
|
+
skipped.push(gitignorePath);
|
|
3073
|
+
return;
|
|
3074
|
+
}
|
|
3075
|
+
const next = current.endsWith('\n') ? `${current}${marker}\n` : `${current}\n${marker}\n`;
|
|
3076
|
+
writeFileSync(gitignorePath, next, { encoding: 'utf8' });
|
|
3077
|
+
created.push(`${gitignorePath} (+${marker})`);
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* Compute the workspace label surfaced in the REPL header bar
|
|
3081
|
+
* (Sprint α5.7). We prefer the basename of the workspace root because
|
|
3082
|
+
* that is what the operator sees in their shell prompt — keeping the
|
|
3083
|
+
* REPL header in sync with `pwd` lets the operator orient at a glance.
|
|
3084
|
+
* Empty / pathological cwd values (a worktree resolved to `/`) fall
|
|
3085
|
+
* back to `workspace` so the header never collapses.
|
|
3086
|
+
*/
|
|
3087
|
+
function workspaceLabel(cwd) {
|
|
3088
|
+
const segments = cwd.split('/').filter((s) => s.length > 0);
|
|
3089
|
+
const last = segments[segments.length - 1];
|
|
3090
|
+
if (!last || last.length === 0)
|
|
3091
|
+
return 'workspace';
|
|
3092
|
+
return last;
|
|
3093
|
+
}
|
|
3094
|
+
function ensureDir(path, created, skipped) {
|
|
3095
|
+
if (existsSync(path)) {
|
|
3096
|
+
skipped.push(path);
|
|
3097
|
+
return;
|
|
3098
|
+
}
|
|
3099
|
+
mkdirSync(path, { recursive: true });
|
|
3100
|
+
created.push(path);
|
|
3101
|
+
}
|
|
3102
|
+
function ensureInitialized(root) {
|
|
3103
|
+
if (!existsSync(resolve(root, '.pugi'))) {
|
|
3104
|
+
throw new Error('Run pugi init first');
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
function createArtifactDir(root, seed) {
|
|
3108
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(seed)}`;
|
|
3109
|
+
const artifactDir = resolve(root, '.pugi', 'artifacts', id);
|
|
3110
|
+
mkdirSync(artifactDir, { recursive: true });
|
|
3111
|
+
return artifactDir;
|
|
3112
|
+
}
|
|
3113
|
+
function latestArtifactDir(root) {
|
|
3114
|
+
const artifactsDir = resolve(root, '.pugi', 'artifacts');
|
|
3115
|
+
if (!existsSync(artifactsDir))
|
|
3116
|
+
return null;
|
|
3117
|
+
const dirs = readdirSync(artifactsDir, { withFileTypes: true })
|
|
3118
|
+
.filter((entry) => entry.isDirectory())
|
|
3119
|
+
.map((entry) => resolve(artifactsDir, entry.name))
|
|
3120
|
+
.sort();
|
|
3121
|
+
return dirs.at(-1) ?? null;
|
|
3122
|
+
}
|
|
3123
|
+
function listArtifactSets(root) {
|
|
3124
|
+
const artifactsDir = resolve(root, '.pugi', 'artifacts');
|
|
3125
|
+
if (!existsSync(artifactsDir))
|
|
3126
|
+
return [];
|
|
3127
|
+
return readdirSync(artifactsDir, { withFileTypes: true })
|
|
3128
|
+
.filter((entry) => entry.isDirectory())
|
|
3129
|
+
.map((entry) => {
|
|
3130
|
+
const dir = resolve(artifactsDir, entry.name);
|
|
3131
|
+
const files = readdirSync(dir, { withFileTypes: true })
|
|
3132
|
+
.filter((file) => file.isFile())
|
|
3133
|
+
.map((file) => file.name)
|
|
3134
|
+
.sort();
|
|
3135
|
+
return {
|
|
3136
|
+
id: entry.name,
|
|
3137
|
+
path: relative(root, dir),
|
|
3138
|
+
files,
|
|
3139
|
+
updatedAt: statSync(dir).mtime.toISOString(),
|
|
3140
|
+
};
|
|
3141
|
+
})
|
|
3142
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
3143
|
+
}
|
|
3144
|
+
function listHandoffBundles(root) {
|
|
3145
|
+
const handoffDir = resolve(root, '.pugi', 'handoffs');
|
|
3146
|
+
if (!existsSync(handoffDir))
|
|
3147
|
+
return [];
|
|
3148
|
+
return readdirSync(handoffDir, { withFileTypes: true })
|
|
3149
|
+
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
|
|
3150
|
+
.map((entry) => {
|
|
3151
|
+
const path = resolve(handoffDir, entry.name);
|
|
3152
|
+
const parsed = safeReadJson(path);
|
|
3153
|
+
return {
|
|
3154
|
+
id: String(parsed?.id ?? entry.name.replace(/\.json$/, '')),
|
|
3155
|
+
path: relative(root, path),
|
|
3156
|
+
reason: String(parsed?.reason ?? 'unknown'),
|
|
3157
|
+
createdAt: String(parsed?.createdAt ?? statSync(path).mtime.toISOString()),
|
|
3158
|
+
};
|
|
3159
|
+
})
|
|
3160
|
+
.sort((a, b) => b.id.localeCompare(a.id));
|
|
3161
|
+
}
|
|
3162
|
+
function artifactIdFromDir(path) {
|
|
3163
|
+
return path.split('/').at(-1) ?? randomUUID();
|
|
3164
|
+
}
|
|
3165
|
+
function createHandoffBundle(root, session, reason, prompt) {
|
|
3166
|
+
const id = `${new Date().toISOString().replace(/[:.]/g, '-')}-${slugify(reason)}`;
|
|
3167
|
+
const handoffDir = resolve(root, '.pugi', 'handoffs');
|
|
3168
|
+
mkdirSync(handoffDir, { recursive: true });
|
|
3169
|
+
const bundlePath = resolve(handoffDir, `${id}.json`);
|
|
3170
|
+
const latest = latestArtifactDir(root);
|
|
3171
|
+
const payload = pugiHandoffBundleSchema.parse({
|
|
3172
|
+
schema: 1,
|
|
3173
|
+
id,
|
|
3174
|
+
reason,
|
|
3175
|
+
prompt,
|
|
3176
|
+
createdAt: new Date().toISOString(),
|
|
3177
|
+
workspace: {
|
|
3178
|
+
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
3179
|
+
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
3180
|
+
gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
|
|
3181
|
+
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
3182
|
+
},
|
|
3183
|
+
session: {
|
|
3184
|
+
id: session.id,
|
|
3185
|
+
eventsPath: relative(root, session.eventsPath),
|
|
3186
|
+
},
|
|
3187
|
+
artifacts: {
|
|
3188
|
+
latest: latest ? relative(root, latest) : null,
|
|
3189
|
+
},
|
|
3190
|
+
privacy: {
|
|
3191
|
+
includesFileContents: false,
|
|
3192
|
+
includesSecrets: false,
|
|
3193
|
+
},
|
|
3194
|
+
});
|
|
3195
|
+
writeFileSync(bundlePath, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
3196
|
+
registerArtifact(root, {
|
|
3197
|
+
id,
|
|
3198
|
+
kind: 'handoff',
|
|
3199
|
+
path: relative(root, bundlePath),
|
|
3200
|
+
sessionId: session.id,
|
|
3201
|
+
createdAt: payload.createdAt,
|
|
3202
|
+
files: [`${id}.json`],
|
|
3203
|
+
});
|
|
3204
|
+
return { status: 'created', id, reason, path: relative(root, bundlePath) };
|
|
3205
|
+
}
|
|
3206
|
+
function parsePrivacyMode(input) {
|
|
3207
|
+
return pugiSyncPrivacyModeSchema.parse(input);
|
|
3208
|
+
}
|
|
3209
|
+
function privacyModeFromSettings(mode) {
|
|
3210
|
+
if (mode === 'airgapped')
|
|
3211
|
+
return 'local-only';
|
|
3212
|
+
if (mode === 'embeddings-only')
|
|
3213
|
+
return 'summaries';
|
|
3214
|
+
return 'metadata';
|
|
3215
|
+
}
|
|
3216
|
+
function workspaceSnapshot(root) {
|
|
3217
|
+
return {
|
|
3218
|
+
rootName: root.split('/').at(-1) ?? 'workspace',
|
|
3219
|
+
gitBranch: safeGit(root, ['branch', '--show-current']).trim() || null,
|
|
3220
|
+
gitHead: safeGit(root, ['rev-parse', '--short', 'HEAD']).trim() || null,
|
|
3221
|
+
dirty: Boolean(safeGit(root, ['status', '--short']).trim()),
|
|
3222
|
+
};
|
|
3223
|
+
}
|
|
3224
|
+
function buildSyncDryRunItems(root, mode) {
|
|
3225
|
+
if (mode === 'local-only') {
|
|
3226
|
+
return [];
|
|
3227
|
+
}
|
|
3228
|
+
const items = [];
|
|
3229
|
+
for (const artifact of listArtifactSets(root).slice(0, 20)) {
|
|
3230
|
+
const summaryMode = mode === 'summaries';
|
|
3231
|
+
items.push({
|
|
3232
|
+
kind: summaryMode ? 'artifact_summary' : 'artifact_metadata',
|
|
3233
|
+
path: artifact.path,
|
|
3234
|
+
bytes: directoryFileBytes(resolve(root, artifact.path)),
|
|
3235
|
+
action: mode === 'selected-files' || mode === 'full-sync' ? 'exclude' : 'include',
|
|
3236
|
+
reason: mode === 'metadata'
|
|
3237
|
+
? 'artifact metadata only; no raw file contents'
|
|
3238
|
+
: summaryMode
|
|
3239
|
+
? 'curated artifact summaries are eligible for explicit continuation'
|
|
3240
|
+
: 'raw file sync requires a future explicit file picker',
|
|
3241
|
+
});
|
|
3242
|
+
}
|
|
3243
|
+
for (const handoffBundle of listHandoffBundles(root).slice(0, 20)) {
|
|
3244
|
+
items.push({
|
|
3245
|
+
kind: 'handoff_bundle',
|
|
3246
|
+
path: handoffBundle.path,
|
|
3247
|
+
bytes: fileBytes(resolve(root, handoffBundle.path)),
|
|
3248
|
+
action: 'include',
|
|
3249
|
+
reason: 'handoff bundles contain metadata and artifact references only',
|
|
3250
|
+
});
|
|
3251
|
+
}
|
|
3252
|
+
const eventsPath = resolve(root, '.pugi', 'events.jsonl');
|
|
3253
|
+
if (existsSync(eventsPath)) {
|
|
3254
|
+
items.push({
|
|
3255
|
+
kind: 'session_event_log',
|
|
3256
|
+
path: relative(root, eventsPath),
|
|
3257
|
+
bytes: fileBytes(eventsPath),
|
|
3258
|
+
action: mode === 'metadata' ? 'exclude' : 'include',
|
|
3259
|
+
reason: mode === 'metadata'
|
|
3260
|
+
? 'session event logs excluded in metadata mode'
|
|
3261
|
+
: 'session timeline is eligible without raw repository contents',
|
|
3262
|
+
});
|
|
3263
|
+
}
|
|
3264
|
+
return items;
|
|
3265
|
+
}
|
|
3266
|
+
function directoryFileBytes(path) {
|
|
3267
|
+
if (!existsSync(path))
|
|
3268
|
+
return 0;
|
|
3269
|
+
return readdirSync(path, { withFileTypes: true })
|
|
3270
|
+
.filter((entry) => entry.isFile())
|
|
3271
|
+
.reduce((total, entry) => total + fileBytes(resolve(path, entry.name)), 0);
|
|
3272
|
+
}
|
|
3273
|
+
function fileBytes(path) {
|
|
3274
|
+
try {
|
|
3275
|
+
return statSync(path).size;
|
|
3276
|
+
}
|
|
3277
|
+
catch {
|
|
3278
|
+
return 0;
|
|
3279
|
+
}
|
|
3280
|
+
}
|
|
3281
|
+
function safeGit(root, args) {
|
|
3282
|
+
try {
|
|
3283
|
+
return execFileSync('git', args, {
|
|
3284
|
+
cwd: root,
|
|
3285
|
+
encoding: 'utf8',
|
|
3286
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
3287
|
+
// Default `maxBuffer` is 1 MB. Branch diffs blow past that on real
|
|
3288
|
+
// PRs and `execFileSync` throws ENOBUFS, which this helper used to
|
|
3289
|
+
// swallow as empty string — causing the triple-review request to
|
|
3290
|
+
// ship an empty `diffPatch` and get a false PASS. 64 MB matches
|
|
3291
|
+
// GitHub's typical PR diff cap.
|
|
3292
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
3293
|
+
});
|
|
3294
|
+
}
|
|
3295
|
+
catch {
|
|
3296
|
+
return '';
|
|
3297
|
+
}
|
|
3298
|
+
}
|
|
3299
|
+
/**
|
|
3300
|
+
* Glob patterns excluded from triple-review `diffPatch` before egress.
|
|
3301
|
+
*
|
|
3302
|
+
* Mirrors `protectedBasenames` + `protectedSuffixes` from
|
|
3303
|
+
* `apps/pugi-cli/src/core/permission.ts`. If a developer tracks a
|
|
3304
|
+
* protected file (uncommon but possible) we still must not POST its
|
|
3305
|
+
* contents to Anvil — the runtime endpoint does not need to see secrets
|
|
3306
|
+
* to render a verdict.
|
|
3307
|
+
*
|
|
3308
|
+
* Format follows git's pathspec exclude syntax (`:!<pattern>`).
|
|
3309
|
+
*/
|
|
3310
|
+
const PROTECTED_DIFF_EXCLUDES = [
|
|
3311
|
+
// Basename excludes apply at the repo root AND in any subdirectory
|
|
3312
|
+
// (e.g. `apps/foo/.env`) via the `**/<name>` glob form. Without the
|
|
3313
|
+
// `**/` prefix, git's literal pathspec syntax would only match the
|
|
3314
|
+
// repo root and silently let a subdir `.env` ship in the diff —
|
|
3315
|
+
// common pitfall in pnpm/turbo monorepos.
|
|
3316
|
+
':(exclude,glob)**/.env',
|
|
3317
|
+
':(exclude,glob)**/.env.*',
|
|
3318
|
+
':(exclude,glob)**/.npmrc',
|
|
3319
|
+
':(exclude,glob)**/.yarnrc',
|
|
3320
|
+
':(exclude,glob)**/.pypirc',
|
|
3321
|
+
':(exclude,glob)**/.gitconfig',
|
|
3322
|
+
':(exclude,glob)**/id_rsa',
|
|
3323
|
+
':(exclude,glob)**/id_ed25519',
|
|
3324
|
+
':(exclude,glob)**/*.pem',
|
|
3325
|
+
':(exclude,glob)**/*.key',
|
|
3326
|
+
':(exclude,glob)**/*.crt',
|
|
3327
|
+
':(exclude,glob)**/*.p12',
|
|
3328
|
+
':(exclude,glob)**/*.dump',
|
|
3329
|
+
':(exclude,glob)**/*.sql',
|
|
3330
|
+
];
|
|
3331
|
+
function collectUntrackedSummary(root) {
|
|
3332
|
+
const raw = safeGit(root, ['ls-files', '--others', '--exclude-standard']);
|
|
3333
|
+
const lines = raw.split('\n').filter((line) => line.trim().length > 0);
|
|
3334
|
+
const visible = [];
|
|
3335
|
+
let excluded = 0;
|
|
3336
|
+
for (const path of lines) {
|
|
3337
|
+
if (isProtectedPath(path)) {
|
|
3338
|
+
excluded += 1;
|
|
3339
|
+
continue;
|
|
3340
|
+
}
|
|
3341
|
+
visible.push(path);
|
|
3342
|
+
}
|
|
3343
|
+
return { paths: visible.slice(0, 50), excludedProtected: excluded };
|
|
3344
|
+
}
|
|
3345
|
+
function isProtectedPath(path) {
|
|
3346
|
+
const base = path.split('/').pop() ?? path;
|
|
3347
|
+
if (base === '.env' || base.startsWith('.env.'))
|
|
3348
|
+
return true;
|
|
3349
|
+
if (['.npmrc', '.yarnrc', '.pypirc', '.gitconfig', 'id_rsa', 'id_ed25519'].includes(base))
|
|
3350
|
+
return true;
|
|
3351
|
+
return /\.(pem|key|crt|p12|dump|sql)$/i.test(base);
|
|
3352
|
+
}
|
|
3353
|
+
function safeReadJson(path) {
|
|
3354
|
+
try {
|
|
3355
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
3356
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
3357
|
+
? parsed
|
|
3358
|
+
: null;
|
|
3359
|
+
}
|
|
3360
|
+
catch {
|
|
3361
|
+
return null;
|
|
3362
|
+
}
|
|
3363
|
+
}
|
|
3364
|
+
function writeJsonIfMissing(path, value, created, skipped) {
|
|
3365
|
+
writeTextIfMissing(path, `${JSON.stringify(value, null, 2)}\n`, created, skipped);
|
|
3366
|
+
}
|
|
3367
|
+
function writeTextIfMissing(path, value, created, skipped) {
|
|
3368
|
+
if (existsSync(path)) {
|
|
3369
|
+
skipped.push(path);
|
|
3370
|
+
return;
|
|
3371
|
+
}
|
|
3372
|
+
writeFileSync(path, value, { encoding: 'utf8', mode: 0o600 });
|
|
3373
|
+
created.push(path);
|
|
3374
|
+
}
|
|
3375
|
+
function slugify(input) {
|
|
3376
|
+
const slug = input
|
|
3377
|
+
.toLowerCase()
|
|
3378
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
3379
|
+
.replace(/^-+|-+$/g, '')
|
|
3380
|
+
.slice(0, 48);
|
|
3381
|
+
return slug || randomUUID().slice(0, 8);
|
|
3382
|
+
}
|
|
3383
|
+
function writeOutput(flags, payload, text) {
|
|
3384
|
+
if (flags.json) {
|
|
3385
|
+
console.log(JSON.stringify(payload, null, 2));
|
|
3386
|
+
}
|
|
3387
|
+
else {
|
|
3388
|
+
console.log(text);
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
export function packageRoot() {
|
|
3392
|
+
return dirname(dirname(fileURLToPath(import.meta.url)));
|
|
3393
|
+
}
|
|
3394
|
+
/**
|
|
3395
|
+
* Test-only surface for the triple-review 2026-05-24 device-flow
|
|
3396
|
+
* fixes (P1-1 abort + P1-2 local-timeout distinction). Kept under an
|
|
3397
|
+
* explicit `__test__` namespace so consumers do not accidentally
|
|
3398
|
+
* import internals; the module's runtime contract is still the
|
|
3399
|
+
* `runCli` entry point above.
|
|
3400
|
+
*/
|
|
3401
|
+
export const __test__ = {
|
|
3402
|
+
sleep,
|
|
3403
|
+
pollDeviceFlowUntilTerminal,
|
|
3404
|
+
};
|
|
3405
|
+
//# sourceMappingURL=cli.js.map
|