@pugi/cli 0.1.0-beta.17 → 0.1.0-beta.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +196 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/denial-tracking/index.js +8 -0
- package/dist/core/denial-tracking/state.js +264 -0
- package/dist/core/diagnostics/probe-runner.js +93 -0
- package/dist/core/diagnostics/probes/api.js +46 -0
- package/dist/core/diagnostics/probes/auth.js +86 -0
- package/dist/core/diagnostics/probes/cli-version.js +127 -0
- package/dist/core/diagnostics/probes/config.js +72 -0
- package/dist/core/diagnostics/probes/denial-tracking.js +57 -0
- package/dist/core/diagnostics/probes/disk.js +81 -0
- package/dist/core/diagnostics/probes/git.js +65 -0
- package/dist/core/diagnostics/probes/mcp.js +75 -0
- package/dist/core/diagnostics/probes/node.js +59 -0
- package/dist/core/diagnostics/probes/pnpm.js +36 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +442 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/engine/native-pugi.js +20 -0
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +251 -49
- package/dist/core/file-cache.js +113 -1
- package/dist/core/mcp/client.js +66 -6
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/permissions/gate.js +187 -0
- package/dist/core/permissions/index.js +18 -0
- package/dist/core/permissions/mode.js +102 -0
- package/dist/core/permissions/state.js +160 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/repl/session.js +261 -9
- package/dist/core/repl/slash-commands.js +67 -4
- package/dist/runtime/cli.js +153 -58
- package/dist/runtime/commands/compact.js +296 -0
- package/dist/runtime/commands/doctor.js +369 -0
- package/dist/runtime/commands/mcp.js +290 -3
- package/dist/runtime/commands/permissions.js +87 -0
- package/dist/runtime/commands/status.js +178 -0
- package/dist/runtime/version.js +1 -1
- package/dist/tools/agent-tool.js +18 -4
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/file-tools.js +57 -14
- package/dist/tools/registry.js +7 -0
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +54 -0
- package/dist/tui/conversation-pane.js +68 -7
- package/dist/tui/doctor-table.js +31 -0
- package/dist/tui/status-table.js +7 -0
- package/package.json +2 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { isAbsolute, resolve } from 'node:path';
|
|
5
5
|
import { FileReadCache } from '../../core/file-cache.js';
|
|
6
6
|
import { openSession } from '../../core/session.js';
|
|
7
7
|
import { loadSettings } from '../../core/settings.js';
|
|
8
|
-
import {
|
|
8
|
+
import { isAlive } from '../../core/mcp/client.js';
|
|
9
|
+
import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
|
|
9
10
|
import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
|
|
10
11
|
import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
|
|
11
12
|
import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
|
|
@@ -22,6 +23,15 @@ export async function runMcpCommand(args, ctx) {
|
|
|
22
23
|
return runMcpFlip(args.slice(1), ctx, 'denied');
|
|
23
24
|
case 'install':
|
|
24
25
|
return runMcpInstall(args.slice(1), ctx);
|
|
26
|
+
case 'remove':
|
|
27
|
+
case 'uninstall':
|
|
28
|
+
return runMcpRemove(args.slice(1), ctx);
|
|
29
|
+
case 'doctor':
|
|
30
|
+
return runMcpDoctor(args.slice(1), ctx);
|
|
31
|
+
case 'logs':
|
|
32
|
+
return runMcpLogs(args.slice(1), ctx);
|
|
33
|
+
case 'restart':
|
|
34
|
+
return runMcpRestart(args.slice(1), ctx);
|
|
25
35
|
case 'serve':
|
|
26
36
|
return runMcpServe(args.slice(1), ctx);
|
|
27
37
|
case 'perms':
|
|
@@ -32,7 +42,7 @@ export async function runMcpCommand(args, ctx) {
|
|
|
32
42
|
ctx.writeOutput({ command: 'mcp', usage: USAGE_LINES }, USAGE_LINES.join('\n'));
|
|
33
43
|
return;
|
|
34
44
|
default:
|
|
35
|
-
throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, serve, perms.`);
|
|
45
|
+
throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, remove, doctor, logs, restart, serve, perms.`);
|
|
36
46
|
}
|
|
37
47
|
}
|
|
38
48
|
const USAGE_LINES = [
|
|
@@ -44,6 +54,15 @@ const USAGE_LINES = [
|
|
|
44
54
|
' install <name> <command...> Add a server to .pugi/mcp.json (workspace scope).',
|
|
45
55
|
' <command> must be an absolute path OR a binary',
|
|
46
56
|
' resolvable via `which` on the operator PATH.',
|
|
57
|
+
' remove <name> Remove a server from .pugi/mcp.json (workspace scope).',
|
|
58
|
+
' Trust ledger entry is preserved — re-install reuses it.',
|
|
59
|
+
' doctor [--connect] Print per-server health (handshake, tool count, last error).',
|
|
60
|
+
' --connect actually spawns the children (slow, ~5s/server).',
|
|
61
|
+
' Without --connect, reports the declared state only.',
|
|
62
|
+
' logs <name> [--tail N] Tail the per-server log file at .pugi/logs/mcp-<name>.log.',
|
|
63
|
+
' Default tail is 40 lines.',
|
|
64
|
+
' restart <name> Bounce a server: tear down the connection, reload the',
|
|
65
|
+
' config, re-handshake. Surfaces the new tool count.',
|
|
47
66
|
' serve [options] Run Pugi as an MCP server',
|
|
48
67
|
' --http :<port> HTTP+SSE transport (default: stdio)',
|
|
49
68
|
' --host <ip> HTTP bind host (default: 127.0.0.1)',
|
|
@@ -219,6 +238,274 @@ function containsShellMetachar(value) {
|
|
|
219
238
|
// a shell pipeline.
|
|
220
239
|
return /[;|&`$<>(){}\n\r]/.test(value);
|
|
221
240
|
}
|
|
241
|
+
/* ---------- remove ---------------------------------------------------- */
|
|
242
|
+
async function runMcpRemove(args, ctx) {
|
|
243
|
+
const name = args[0];
|
|
244
|
+
if (!name) {
|
|
245
|
+
throw new Error('Usage: pugi mcp remove <name>');
|
|
246
|
+
}
|
|
247
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
248
|
+
throw new Error(`pugi mcp remove: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
249
|
+
}
|
|
250
|
+
const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
|
|
251
|
+
if (!existsSync(mcpJsonPath)) {
|
|
252
|
+
throw new Error(`pugi mcp remove: no .pugi/mcp.json at ${mcpJsonPath}. Nothing to remove.`);
|
|
253
|
+
}
|
|
254
|
+
let existing;
|
|
255
|
+
try {
|
|
256
|
+
const raw = readFileSync(mcpJsonPath, 'utf8');
|
|
257
|
+
if (raw.trim().length === 0) {
|
|
258
|
+
existing = { servers: {} };
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
const parsed = JSON.parse(raw);
|
|
262
|
+
existing = { servers: parsed.servers ?? {} };
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
catch (error) {
|
|
266
|
+
throw new Error(`pugi mcp remove: cannot parse .pugi/mcp.json: ${error.message}. ` +
|
|
267
|
+
`Fix the file by hand or delete it and re-run.`);
|
|
268
|
+
}
|
|
269
|
+
if (!existing.servers[name]) {
|
|
270
|
+
throw new Error(`pugi mcp remove: server "${name}" not declared in ${mcpJsonPath}. ` +
|
|
271
|
+
`Run \`pugi mcp list\` to see declared servers.`);
|
|
272
|
+
}
|
|
273
|
+
// Preserve the trust ledger entry on purpose — a re-install of the same
|
|
274
|
+
// server name should land back at its old trust state so the operator
|
|
275
|
+
// does not have to re-approve it. To wipe trust as well, run
|
|
276
|
+
// `pugi mcp deny <name>` (or edit ~/.pugi/trust-mcp.json by hand).
|
|
277
|
+
delete existing.servers[name];
|
|
278
|
+
writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
|
|
279
|
+
ctx.writeOutput({
|
|
280
|
+
command: 'mcp.remove',
|
|
281
|
+
name,
|
|
282
|
+
configPath: mcpJsonPath,
|
|
283
|
+
remaining: Object.keys(existing.servers),
|
|
284
|
+
}, [
|
|
285
|
+
`Removed MCP server "${name}" from ${mcpJsonPath}.`,
|
|
286
|
+
`Trust ledger entry preserved — re-install reuses it.`,
|
|
287
|
+
`Remaining servers: ${Object.keys(existing.servers).length === 0 ? '(none)' : Object.keys(existing.servers).join(', ')}.`,
|
|
288
|
+
].join('\n'));
|
|
289
|
+
}
|
|
290
|
+
/* ---------- doctor ---------------------------------------------------- */
|
|
291
|
+
async function runMcpDoctor(args, ctx) {
|
|
292
|
+
// `--connect` forces the doctor to actually spawn children + handshake.
|
|
293
|
+
// Default behaviour is dry-run (config + trust ledger only) so a routine
|
|
294
|
+
// `pugi doctor` does not block on a misbehaving server's 5s timeout per
|
|
295
|
+
// entry. Operators investigating an outage pass `--connect` explicitly.
|
|
296
|
+
const wantsConnect = args.includes('--connect');
|
|
297
|
+
const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: wantsConnect });
|
|
298
|
+
const ledger = await listMcpTrust();
|
|
299
|
+
const rows = [];
|
|
300
|
+
for (const state of registry.servers.values()) {
|
|
301
|
+
const conn = state.connection;
|
|
302
|
+
let handshake;
|
|
303
|
+
if (!wantsConnect) {
|
|
304
|
+
handshake = 'not-attempted';
|
|
305
|
+
}
|
|
306
|
+
else if (state.lastError) {
|
|
307
|
+
handshake = 'failed';
|
|
308
|
+
}
|
|
309
|
+
else if (conn && isAlive(conn)) {
|
|
310
|
+
handshake = 'ok';
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
handshake = 'failed';
|
|
314
|
+
}
|
|
315
|
+
rows.push({
|
|
316
|
+
name: state.name,
|
|
317
|
+
trust: state.trust,
|
|
318
|
+
handshake,
|
|
319
|
+
pid: conn?.child.pid ?? null,
|
|
320
|
+
tools: state.surfacedTools.length,
|
|
321
|
+
uptimeMs: conn ? Date.now() - conn.startedAt : null,
|
|
322
|
+
lastError: state.lastError ?? null,
|
|
323
|
+
logFile: mcpLogPath(ctx.workspaceRoot, state.name),
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
327
|
+
await registry.shutdown();
|
|
328
|
+
if (rows.length === 0) {
|
|
329
|
+
ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const headerLines = [
|
|
333
|
+
`MCP doctor (${wantsConnect ? 'live handshake' : 'declared state only — pass --connect for live probe'}):`,
|
|
334
|
+
'',
|
|
335
|
+
` ${'NAME'.padEnd(20)} ${'TRUST'.padEnd(8)} ${'HANDSHAKE'.padEnd(14)} ${'TOOLS'.padEnd(6)} ${'PID'.padEnd(7)} NOTE`,
|
|
336
|
+
];
|
|
337
|
+
for (const row of rows) {
|
|
338
|
+
const note = row.lastError
|
|
339
|
+
? `error: ${truncate(row.lastError, 60)}`
|
|
340
|
+
: row.handshake === 'ok'
|
|
341
|
+
? `uptime ${formatUptime(row.uptimeMs ?? 0)}`
|
|
342
|
+
: row.handshake === 'failed'
|
|
343
|
+
? 'see log file'
|
|
344
|
+
: '';
|
|
345
|
+
headerLines.push(` ${row.name.padEnd(20)} ${row.trust.padEnd(8)} ${row.handshake.padEnd(14)} ${String(row.tools).padEnd(6)} ${String(row.pid ?? '-').padEnd(7)} ${note}`);
|
|
346
|
+
}
|
|
347
|
+
headerLines.push('', `Log dir: ${resolve(ctx.workspaceRoot, '.pugi/logs')}`);
|
|
348
|
+
if (!wantsConnect) {
|
|
349
|
+
headerLines.push('Hint: pass --connect to actually spawn the children (slow, ~5s budget/server).');
|
|
350
|
+
}
|
|
351
|
+
ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, headerLines.join('\n'));
|
|
352
|
+
}
|
|
353
|
+
function truncate(value, max) {
|
|
354
|
+
if (value.length <= max)
|
|
355
|
+
return value;
|
|
356
|
+
return `${value.slice(0, max - 1)}…`;
|
|
357
|
+
}
|
|
358
|
+
function formatUptime(ms) {
|
|
359
|
+
if (ms < 1000)
|
|
360
|
+
return `${ms}ms`;
|
|
361
|
+
const sec = Math.floor(ms / 1000);
|
|
362
|
+
if (sec < 60)
|
|
363
|
+
return `${sec}s`;
|
|
364
|
+
const min = Math.floor(sec / 60);
|
|
365
|
+
if (min < 60)
|
|
366
|
+
return `${min}m${sec % 60}s`;
|
|
367
|
+
const hr = Math.floor(min / 60);
|
|
368
|
+
return `${hr}h${min % 60}m`;
|
|
369
|
+
}
|
|
370
|
+
/* ---------- logs ------------------------------------------------------ */
|
|
371
|
+
async function runMcpLogs(args, ctx) {
|
|
372
|
+
const name = args[0];
|
|
373
|
+
if (!name || name.startsWith('--')) {
|
|
374
|
+
throw new Error('Usage: pugi mcp logs <name> [--tail N]');
|
|
375
|
+
}
|
|
376
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
377
|
+
throw new Error(`pugi mcp logs: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
378
|
+
}
|
|
379
|
+
// `--tail N` and `--tail=N` both supported. Default 40 lines — matches
|
|
380
|
+
// typical `tail -n 40` muscle memory.
|
|
381
|
+
let tail = 40;
|
|
382
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
383
|
+
const arg = args[i] ?? '';
|
|
384
|
+
if (arg === '--tail') {
|
|
385
|
+
const next = args[i + 1];
|
|
386
|
+
if (!next)
|
|
387
|
+
throw new Error('pugi mcp logs: --tail requires a value');
|
|
388
|
+
const n = Number.parseInt(next, 10);
|
|
389
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
390
|
+
throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${next}")`);
|
|
391
|
+
}
|
|
392
|
+
tail = n;
|
|
393
|
+
i += 1;
|
|
394
|
+
}
|
|
395
|
+
else if (arg.startsWith('--tail=')) {
|
|
396
|
+
const n = Number.parseInt(arg.slice('--tail='.length), 10);
|
|
397
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
398
|
+
throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${arg}")`);
|
|
399
|
+
}
|
|
400
|
+
tail = n;
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
throw new Error(`pugi mcp logs: unknown flag "${arg}"`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
const path = mcpLogPath(ctx.workspaceRoot, name);
|
|
407
|
+
if (!existsSync(path)) {
|
|
408
|
+
ctx.writeOutput({ command: 'mcp.logs', name, path, tail, lines: [] }, `No log file at ${path}. The server has not produced stderr output yet (or has never been started).`);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
let raw;
|
|
412
|
+
try {
|
|
413
|
+
raw = readFileSync(path, 'utf8');
|
|
414
|
+
}
|
|
415
|
+
catch (error) {
|
|
416
|
+
throw new Error(`pugi mcp logs: cannot read ${path}: ${error.message}.`);
|
|
417
|
+
}
|
|
418
|
+
const allLines = raw.split('\n');
|
|
419
|
+
// `split('\n')` of a trailing-newline file yields an empty last element.
|
|
420
|
+
// Drop it so the displayed tail matches `wc -l` expectations.
|
|
421
|
+
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
|
|
422
|
+
allLines.pop();
|
|
423
|
+
}
|
|
424
|
+
const tailed = allLines.slice(Math.max(0, allLines.length - tail));
|
|
425
|
+
const sizeBytes = (() => {
|
|
426
|
+
try {
|
|
427
|
+
return statSync(path).size;
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
})();
|
|
433
|
+
ctx.writeOutput({
|
|
434
|
+
command: 'mcp.logs',
|
|
435
|
+
name,
|
|
436
|
+
path,
|
|
437
|
+
tail,
|
|
438
|
+
totalLines: allLines.length,
|
|
439
|
+
sizeBytes,
|
|
440
|
+
lines: tailed,
|
|
441
|
+
}, [
|
|
442
|
+
`pugi mcp logs ${name} (${path}, ${sizeBytes} bytes, ${allLines.length} total lines, showing last ${Math.min(tail, allLines.length)}):`,
|
|
443
|
+
...tailed,
|
|
444
|
+
].join('\n'));
|
|
445
|
+
}
|
|
446
|
+
/* ---------- restart --------------------------------------------------- */
|
|
447
|
+
async function runMcpRestart(args, ctx) {
|
|
448
|
+
const name = args[0];
|
|
449
|
+
if (!name) {
|
|
450
|
+
throw new Error('Usage: pugi mcp restart <name>');
|
|
451
|
+
}
|
|
452
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
453
|
+
throw new Error(`pugi mcp restart: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
454
|
+
}
|
|
455
|
+
// `pugi mcp restart` is stateless from the CLI's perspective — the CLI
|
|
456
|
+
// process has no long-lived MCP registry of its own (the REPL owns it
|
|
457
|
+
// and tears it down on exit). What we DO here is: load the config,
|
|
458
|
+
// refuse if the server is not declared or not trusted, then probe-spawn
|
|
459
|
+
// the server with the live handshake. This proves it is reachable +
|
|
460
|
+
// surfaces the tool count for the operator. The REPL picks up the
|
|
461
|
+
// change on its next `loadMcpRegistry` cycle.
|
|
462
|
+
const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
|
|
463
|
+
const state = registry.servers.get(name);
|
|
464
|
+
if (!state) {
|
|
465
|
+
await registry.shutdown();
|
|
466
|
+
throw new Error(`pugi mcp restart: server "${name}" not declared. Run \`pugi mcp list\` to see declared servers.`);
|
|
467
|
+
}
|
|
468
|
+
if (state.trust !== 'trusted') {
|
|
469
|
+
await registry.shutdown();
|
|
470
|
+
throw new Error(`pugi mcp restart: server "${name}" trust state is "${state.trust}". ` +
|
|
471
|
+
`Run \`pugi mcp trust ${name}\` first.`);
|
|
472
|
+
}
|
|
473
|
+
await registry.shutdown();
|
|
474
|
+
// Re-load WITH connect=true but scoped to a single-server probe via
|
|
475
|
+
// handshakeTimeoutMs (5s default keeps the CLI snappy). We use the same
|
|
476
|
+
// loadMcpRegistry path so log routing + error capture stay consistent.
|
|
477
|
+
const probe = await loadMcpRegistry(ctx.workspaceRoot, { connect: true });
|
|
478
|
+
const probed = probe.servers.get(name);
|
|
479
|
+
const lastError = probed?.lastError ?? null;
|
|
480
|
+
const toolCount = probed?.surfacedTools.length ?? 0;
|
|
481
|
+
const pid = probed?.connection?.child.pid ?? null;
|
|
482
|
+
await probe.shutdown();
|
|
483
|
+
if (lastError) {
|
|
484
|
+
ctx.writeOutput({
|
|
485
|
+
command: 'mcp.restart',
|
|
486
|
+
name,
|
|
487
|
+
ok: false,
|
|
488
|
+
error: lastError,
|
|
489
|
+
logFile: mcpLogPath(ctx.workspaceRoot, name),
|
|
490
|
+
}, [
|
|
491
|
+
`pugi mcp restart ${name}: FAILED`,
|
|
492
|
+
` error: ${lastError}`,
|
|
493
|
+
` log file: ${mcpLogPath(ctx.workspaceRoot, name)}`,
|
|
494
|
+
].join('\n'));
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
ctx.writeOutput({
|
|
498
|
+
command: 'mcp.restart',
|
|
499
|
+
name,
|
|
500
|
+
ok: true,
|
|
501
|
+
pid,
|
|
502
|
+
surfacedTools: toolCount,
|
|
503
|
+
}, [
|
|
504
|
+
`pugi mcp restart ${name}: OK`,
|
|
505
|
+
` pid: ${pid ?? '-'}`,
|
|
506
|
+
` surfaced tools: ${toolCount}`,
|
|
507
|
+
].join('\n'));
|
|
508
|
+
}
|
|
222
509
|
async function runMcpServe(args, ctx) {
|
|
223
510
|
const flags = parseServeFlags(args);
|
|
224
511
|
const session = openSession(ctx.workspaceRoot);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi permissions` / `/permissions` — Leak L6 4-mode gate control.
|
|
3
|
+
*
|
|
4
|
+
* Two entry points share one runtime helper:
|
|
5
|
+
* 1. `/permissions` in the REPL — forwarded by `core/repl/session.ts`.
|
|
6
|
+
* 2. `pugi permissions ...` top-level CLI command (handler in
|
|
7
|
+
* `runtime/cli.ts`).
|
|
8
|
+
*
|
|
9
|
+
* Both pass a `PermissionsCommand` payload describing the operator
|
|
10
|
+
* intent (show / flip / persist) and a `writeOutput` callback that
|
|
11
|
+
* lets the caller route the rendered lines into the right surface
|
|
12
|
+
* (REPL transcript vs. stdout). The helper is intentionally I/O-free
|
|
13
|
+
* itself — it produces lines and lets the caller stream them.
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_PERMISSION_MODE, PERMISSION_MODES, PERMISSION_MODE_GLOSS, getCurrentMode, getGlobalDefaultMode, setCurrentMode, setGlobalDefaultMode, } from '../../core/permissions/index.js';
|
|
16
|
+
/**
|
|
17
|
+
* Run the `/permissions` or `pugi permissions` flow. Side effects:
|
|
18
|
+
* - When `command.mode` is undefined: prints the current mode + the
|
|
19
|
+
* 4-mode table (no writes).
|
|
20
|
+
* - When `command.mode === 'bypass'` without `confirmBypass`: prints
|
|
21
|
+
* a refusal + the safety copy, no writes.
|
|
22
|
+
* - When `command.mode` is set + valid: writes workspace session
|
|
23
|
+
* state; optionally writes global default when `persist` is true.
|
|
24
|
+
* - Always prints the new effective mode + a one-line confirmation.
|
|
25
|
+
*/
|
|
26
|
+
export async function runPermissionsCommand(command, ctx) {
|
|
27
|
+
if (!command.mode) {
|
|
28
|
+
renderCurrentMode(ctx);
|
|
29
|
+
renderModeTable(ctx);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
if (command.mode === 'bypass' && !command.confirmBypass) {
|
|
33
|
+
ctx.writeOutput('Bypass mode disables policy hooks (skill steering, denial tracking).');
|
|
34
|
+
ctx.writeOutput('Run `/permissions bypass --confirm` to acknowledge before flipping.');
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
setCurrentMode(ctx.workspaceRoot, command.mode);
|
|
38
|
+
if (command.persist) {
|
|
39
|
+
setGlobalDefaultMode(command.mode, ctx.homeDir);
|
|
40
|
+
}
|
|
41
|
+
const persistedHint = command.persist
|
|
42
|
+
? ' Persisted to ~/.pugi/config.json for future sessions.'
|
|
43
|
+
: '';
|
|
44
|
+
ctx.writeOutput(`Permission mode set to '${command.mode}'.${persistedHint} ${PERMISSION_MODE_GLOSS[command.mode]}`);
|
|
45
|
+
if (command.mode === 'bypass') {
|
|
46
|
+
ctx.writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Print the resolved current mode + the layered source. The merge
|
|
51
|
+
* order mirrors `resolveMode()`: workspace > global > default.
|
|
52
|
+
*/
|
|
53
|
+
function renderCurrentMode(ctx) {
|
|
54
|
+
const workspace = getCurrentMode(ctx.workspaceRoot);
|
|
55
|
+
const global = getGlobalDefaultMode(ctx.homeDir);
|
|
56
|
+
const effective = workspace ?? global ?? DEFAULT_PERMISSION_MODE;
|
|
57
|
+
const source = workspace
|
|
58
|
+
? 'workspace session.json'
|
|
59
|
+
: global
|
|
60
|
+
? 'global ~/.pugi/config.json'
|
|
61
|
+
: 'default (no override)';
|
|
62
|
+
ctx.writeOutput(`Current permission mode: ${effective} (source: ${source})`);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Print the 4-mode reference table. Keeps the gloss + the side-effect
|
|
66
|
+
* matrix in one place so the operator can see the contract while they
|
|
67
|
+
* decide which mode to switch to.
|
|
68
|
+
*/
|
|
69
|
+
function renderModeTable(ctx) {
|
|
70
|
+
ctx.writeOutput('');
|
|
71
|
+
ctx.writeOutput('Permission modes:');
|
|
72
|
+
for (const mode of PERMISSION_MODES) {
|
|
73
|
+
ctx.writeOutput(` ${mode.padEnd(7)} ${PERMISSION_MODE_GLOSS[mode]}`);
|
|
74
|
+
}
|
|
75
|
+
ctx.writeOutput('');
|
|
76
|
+
ctx.writeOutput('Switch with `/permissions <mode> [--persist]`. Bypass requires `--confirm`.');
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render the one-shot banner shown on session boot when the effective
|
|
80
|
+
* mode is `bypass`. The caller (engine adapter / REPL bootstrap) calls
|
|
81
|
+
* this once per session — repeated invocations are idempotent in copy
|
|
82
|
+
* but the caller is responsible for the once-only semantics.
|
|
83
|
+
*/
|
|
84
|
+
export function renderBypassBanner(writeOutput) {
|
|
85
|
+
writeOutput('BYPASS MODE — all tools execute without prompts AND policy hooks disabled. Switch back with /permissions allow.');
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi status` — concise session-state probe (Leak L34, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Different from `pugi doctor` (environment health). The status
|
|
5
|
+
* command answers "what is this Pugi session doing right now?" —
|
|
6
|
+
* session id + age, cwd, permission mode, CLI version, token
|
|
7
|
+
* usage, dispatch counts, last command, compact boundaries, auth
|
|
8
|
+
* identity.
|
|
9
|
+
*
|
|
10
|
+
* # Module contract
|
|
11
|
+
*
|
|
12
|
+
* - This file owns the WIRING from CLI flags + ambient process
|
|
13
|
+
* state к the snapshot collector. The data collector itself
|
|
14
|
+
* lives in `core/diagnostics/probes/status-snapshot.ts` and
|
|
15
|
+
* has zero coupling к the CLI dispatch surface.
|
|
16
|
+
*
|
|
17
|
+
* - `runStatusCommand` is the single entry point. Both the
|
|
18
|
+
* top-level `pugi status` handler in `runtime/cli.ts` AND the
|
|
19
|
+
* in-REPL `/status` slash command call it. The function
|
|
20
|
+
* returns the `StatusSnapshot` so the REPL slash handler can
|
|
21
|
+
* mount the Ink renderer without re-collecting fields.
|
|
22
|
+
*
|
|
23
|
+
* - The credential resolver is captured behind a function so the
|
|
24
|
+
* spec can stub it without monkey-patching `core/credentials.ts`.
|
|
25
|
+
*
|
|
26
|
+
* - The permission state module is dynamic-imported with a
|
|
27
|
+
* try/catch so this command lands cleanly before the L6
|
|
28
|
+
* permission-mode work — when the module is absent the field
|
|
29
|
+
* degrades к "unknown" instead of crashing the snapshot.
|
|
30
|
+
*
|
|
31
|
+
* - Exit code is 0 unless the snapshot itself throws. The
|
|
32
|
+
* command is intentionally informational — even a fully
|
|
33
|
+
* "unavailable" snapshot (everything degraded к sentinels)
|
|
34
|
+
* exits 0 so monitoring scripts can rely on a stable contract.
|
|
35
|
+
*/
|
|
36
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { resolveActiveCredential } from '../../core/credentials.js';
|
|
39
|
+
import { PUGI_CLI_VERSION } from '../version.js';
|
|
40
|
+
import { collectStatusSnapshot, } from '../../core/diagnostics/probes/status-snapshot.js';
|
|
41
|
+
/**
|
|
42
|
+
* Default production filesystem stubs. Wraps node:fs so the
|
|
43
|
+
* snapshot collector can stay sync + structurally testable.
|
|
44
|
+
*/
|
|
45
|
+
const DEFAULT_FS = {
|
|
46
|
+
existsSync,
|
|
47
|
+
statSync,
|
|
48
|
+
readdirSync: (p) => readdirSync(p),
|
|
49
|
+
readFileSync: (p, encoding) => readFileSync(p, encoding),
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Default credential resolver. Wraps `resolveActiveCredential` so
|
|
53
|
+
* the snapshot only sees the trimmed `ResolvedCredentialSummary`
|
|
54
|
+
* shape and не the full store record.
|
|
55
|
+
*/
|
|
56
|
+
function defaultResolveCredential(env, home) {
|
|
57
|
+
const cred = resolveActiveCredential(env, home);
|
|
58
|
+
if (!cred)
|
|
59
|
+
return null;
|
|
60
|
+
// Tier is не yet recorded в the credentials file — set к null
|
|
61
|
+
// until a future sprint surfaces `tier` on the stored record.
|
|
62
|
+
// The renderer hides the parenthetical when tier is null.
|
|
63
|
+
return {
|
|
64
|
+
apiUrl: cred.apiUrl,
|
|
65
|
+
label: cred.label ?? null,
|
|
66
|
+
tier: null,
|
|
67
|
+
identity: cred.label ?? null,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Default permission-mode loader. Dynamic-imports
|
|
72
|
+
* `core/permissions/state.ts` (L6 in the leak-parity roadmap);
|
|
73
|
+
* returns null when the module is absent OR malformed, which the
|
|
74
|
+
* snapshot field degrades к the "unknown" sentinel.
|
|
75
|
+
*/
|
|
76
|
+
async function defaultResolvePermissionMode() {
|
|
77
|
+
try {
|
|
78
|
+
const mod = (await import('../../core/permissions/state.js').catch(() => null));
|
|
79
|
+
if (!mod || typeof mod.getCurrentPermissionMode !== 'function') {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const mode = mod.getCurrentPermissionMode();
|
|
83
|
+
return typeof mode === 'string' && mode.length > 0 ? mode : null;
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Collect the snapshot + emit the output via the supplied
|
|
91
|
+
* writeOutput sink. Returns the snapshot so REPL callers can
|
|
92
|
+
* route it к the Ink renderer instead of the plain-text fallback.
|
|
93
|
+
*/
|
|
94
|
+
export async function runStatusCommand(ctx) {
|
|
95
|
+
// Resolve the permission mode upfront — the snapshot is sync but
|
|
96
|
+
// the L6 loader is async (dynamic import). We materialise the
|
|
97
|
+
// value here and hand the snapshot a sync getter.
|
|
98
|
+
const permissionMode = ctx.resolvePermissionMode === undefined
|
|
99
|
+
? await defaultResolvePermissionMode()
|
|
100
|
+
: null;
|
|
101
|
+
const deps = {
|
|
102
|
+
cwd: ctx.cwd,
|
|
103
|
+
home: ctx.home,
|
|
104
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
105
|
+
now: ctx.now ?? Date.now,
|
|
106
|
+
liveSessionId: ctx.liveSessionId ?? null,
|
|
107
|
+
sessionStartedAtEpochMs: ctx.sessionStartedAtEpochMs ?? null,
|
|
108
|
+
liveTokensUsed: ctx.liveTokensUsed ?? null,
|
|
109
|
+
lastCommand: ctx.lastCommand ?? null,
|
|
110
|
+
lastCommandAtEpochMs: ctx.lastCommandAtEpochMs ?? null,
|
|
111
|
+
fs: ctx.fs ?? DEFAULT_FS,
|
|
112
|
+
resolveCredential: ctx.resolveCredential ?? (() => defaultResolveCredential(ctx.env, ctx.home)),
|
|
113
|
+
resolvePermissionMode: ctx.resolvePermissionMode ?? (() => permissionMode),
|
|
114
|
+
};
|
|
115
|
+
let snapshot;
|
|
116
|
+
try {
|
|
117
|
+
snapshot = collectStatusSnapshot(deps);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
// Defensive: the snapshot collector is fail-soft per field, but
|
|
121
|
+
// a structural crash (e.g. a hostile env that throws on
|
|
122
|
+
// `process.version` access) must не bring down `pugi status`.
|
|
123
|
+
// We synthesise a minimal envelope and surface the error в the
|
|
124
|
+
// text output so the operator can file a bug.
|
|
125
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
126
|
+
snapshot = {
|
|
127
|
+
command: 'status',
|
|
128
|
+
fields: [
|
|
129
|
+
{
|
|
130
|
+
key: 'session',
|
|
131
|
+
label: 'Session',
|
|
132
|
+
value: 'unknown',
|
|
133
|
+
available: false,
|
|
134
|
+
note: `snapshot collector crashed: ${message}`,
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
meta: {
|
|
138
|
+
cliVersion: PUGI_CLI_VERSION,
|
|
139
|
+
nodeVersion: process.version,
|
|
140
|
+
cwd: ctx.cwd,
|
|
141
|
+
capturedAt: new Date((ctx.now ?? Date.now)()).toISOString(),
|
|
142
|
+
},
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const text = renderStatusTable(snapshot);
|
|
146
|
+
ctx.writeOutput(snapshot, text);
|
|
147
|
+
// Always exit 0 — `pugi status` is informational, never a gate.
|
|
148
|
+
process.exitCode = 0;
|
|
149
|
+
return snapshot;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Plain-text table renderer. Lays out the snapshot as a two-column
|
|
153
|
+
* (LABEL / VALUE) table with the brand-voice header `Pugi status`.
|
|
154
|
+
* Matches the column-light pattern used by `renderDoctorTable` so
|
|
155
|
+
* narrow terminals stay legible without a layout library.
|
|
156
|
+
*/
|
|
157
|
+
export function renderStatusTable(snapshot) {
|
|
158
|
+
const labelWidth = Math.max('Label'.length, ...snapshot.fields.map((f) => f.label.length));
|
|
159
|
+
const lines = [];
|
|
160
|
+
lines.push('Pugi status');
|
|
161
|
+
lines.push('═'.repeat(50));
|
|
162
|
+
for (const field of snapshot.fields) {
|
|
163
|
+
const labelPart = field.label.padEnd(labelWidth, ' ');
|
|
164
|
+
lines.push(`${labelPart} ${field.value}`);
|
|
165
|
+
}
|
|
166
|
+
lines.push('');
|
|
167
|
+
lines.push(`CLI ${snapshot.meta.cliVersion} Node ${snapshot.meta.nodeVersion} cwd ${snapshot.meta.cwd}`);
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Default home dir resolver. Centralised so the CLI handler can
|
|
172
|
+
* call `runStatusCommand` without re-importing `os.homedir`
|
|
173
|
+
* everywhere.
|
|
174
|
+
*/
|
|
175
|
+
export function defaultStatusHome() {
|
|
176
|
+
return homedir();
|
|
177
|
+
}
|
|
178
|
+
//# sourceMappingURL=status.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.19');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/dist/tools/agent-tool.js
CHANGED
|
@@ -58,8 +58,16 @@ import { spawnSubagentWithOutcome } from '../core/subagents/spawn.js';
|
|
|
58
58
|
* inverse (forces shared-fs even for roles whose default is `worktree`).
|
|
59
59
|
*
|
|
60
60
|
* The role enum mirrors the SDK's SubagentRole — keep both in lockstep.
|
|
61
|
+
*
|
|
62
|
+
* Leak P0 L2 (2026-05-27): `z.strictObject` rejects ANY additional or
|
|
63
|
+
* aliased fields at parse time. Matches the openclaude FileEditTool /
|
|
64
|
+
* FileWriteTool posture (research memo §1.1). The model-facing JSON
|
|
65
|
+
* schema already declares `additionalProperties: false`; the strict
|
|
66
|
+
* Zod variant is defense-in-depth — if the bridge ever bypasses the
|
|
67
|
+
* model-side gate (raw test fixture, internal dispatch), the runtime
|
|
68
|
+
* still refuses unknown keys instead of silently dropping them.
|
|
61
69
|
*/
|
|
62
|
-
export const agentToolArgsSchema = z.
|
|
70
|
+
export const agentToolArgsSchema = z.strictObject({
|
|
63
71
|
role: z.enum([
|
|
64
72
|
'orchestrator',
|
|
65
73
|
'architect',
|
|
@@ -70,12 +78,18 @@ export const agentToolArgsSchema = z.object({
|
|
|
70
78
|
'release',
|
|
71
79
|
'devops',
|
|
72
80
|
'design_qa',
|
|
73
|
-
]),
|
|
81
|
+
]).describe('SubagentRole — selects persona + isolation tier.'),
|
|
74
82
|
brief: z
|
|
75
83
|
.string()
|
|
76
84
|
.min(1, 'brief must not be empty')
|
|
77
|
-
.max(8000, 'brief must be ≤ 8000 chars')
|
|
78
|
-
|
|
85
|
+
.max(8000, 'brief must be ≤ 8000 chars')
|
|
86
|
+
.describe('One-paragraph task description forwarded to the child as the user prompt. '
|
|
87
|
+
+ 'Be concrete: include filenames, expected behavior, and acceptance criteria.'),
|
|
88
|
+
isolation: z
|
|
89
|
+
.enum(['worktree', 'shared_fs', 'auto'])
|
|
90
|
+
.optional()
|
|
91
|
+
.describe('Optional override. `worktree` forces a scratch git worktree for write isolation; '
|
|
92
|
+
+ '`shared_fs` forces same-tree execution; `auto` (default) defers to the role tier.'),
|
|
79
93
|
});
|
|
80
94
|
/**
|
|
81
95
|
* Dispatch a subagent via the `agent` tool. Returns the JSON envelope
|