@pugi/cli 0.1.0-beta.5 → 0.1.0-beta.51
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/THIRD_PARTY_NOTICES.md +40 -0
- package/assets/pugi-mascot.ansi +15 -25
- package/assets/pugi-prozr2-mascot.ansi +9 -0
- package/bin/run.js +33 -1
- package/dist/commands/jobs-watch.js +201 -0
- package/dist/commands/jobs.js +15 -0
- package/dist/commands/smoke.js +133 -0
- package/dist/core/agent-progress/cleanup.js +134 -0
- package/dist/core/agent-progress/schema.js +144 -0
- package/dist/core/agent-progress/writer.js +101 -0
- package/dist/core/artifact-chain/dispatcher.js +148 -0
- package/dist/core/artifact-chain/exporter.js +164 -0
- package/dist/core/artifact-chain/state.js +243 -0
- package/dist/core/artifact-chain/steps.js +169 -0
- package/dist/core/auth/ensure-authenticated.js +129 -0
- package/dist/core/auth/env-provider.js +238 -0
- package/dist/core/auto-update/channels.js +122 -0
- package/dist/core/auto-update/checker.js +241 -0
- package/dist/core/auto-update/state.js +235 -0
- package/dist/core/bare-mode/index.js +107 -0
- package/dist/core/bash-classifier.js +400 -4
- package/dist/core/checkpoint/resumer.js +149 -0
- package/dist/core/checkpoint/rewinder.js +291 -0
- package/dist/core/codegraph/decision-store.js +248 -0
- package/dist/core/codegraph/detect-repo.js +459 -0
- package/dist/core/codegraph/install.js +134 -0
- package/dist/core/codegraph/offer-hook.js +220 -0
- package/dist/core/compact/auto-trigger.js +96 -0
- package/dist/core/compact/buffer-rewriter.js +115 -0
- package/dist/core/compact/summarizer.js +208 -0
- package/dist/core/compact/token-counter.js +108 -0
- package/dist/core/consensus/diff-capture.js +112 -3
- package/dist/core/context/index.js +7 -0
- package/dist/core/context/markdown-traverse.js +255 -0
- package/dist/core/cost/rate-card.js +129 -0
- package/dist/core/cost/tracker.js +221 -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/bare-mode.js +42 -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/hooks.js +118 -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/pugi-md.js +89 -0
- package/dist/core/diagnostics/probes/sandbox.js +40 -0
- package/dist/core/diagnostics/probes/session.js +74 -0
- package/dist/core/diagnostics/probes/status-snapshot.js +488 -0
- package/dist/core/diagnostics/probes/workspace.js +63 -0
- package/dist/core/diagnostics/types.js +70 -0
- package/dist/core/dispatch/cache-cleanup.js +197 -0
- package/dist/core/dispatch/cache-handoff.js +295 -0
- package/dist/core/edits/dispatch.js +218 -2
- package/dist/core/edits/journal.js +199 -0
- package/dist/core/edits/layer-d-ast.js +557 -14
- package/dist/core/edits/verify-hook.js +273 -0
- package/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/anvil-client.js +115 -5
- package/dist/core/engine/auto-compact.js +179 -0
- package/dist/core/engine/budgets.js +155 -0
- package/dist/core/engine/context-prefix.js +155 -0
- package/dist/core/engine/intent.js +260 -0
- package/dist/core/engine/native-pugi.js +897 -211
- package/dist/core/engine/prompts.js +88 -2
- package/dist/core/engine/strip-internal-fields.js +124 -0
- package/dist/core/engine/tool-bridge.js +1045 -36
- package/dist/core/feedback/queue.js +177 -0
- package/dist/core/feedback/submitter.js +145 -0
- package/dist/core/file-cache.js +113 -1
- package/dist/core/hooks/events.js +44 -0
- package/dist/core/hooks/index.js +15 -0
- package/dist/core/hooks/registry.js +213 -0
- package/dist/core/hooks/runner.js +236 -0
- package/dist/core/hooks/v2/event-emitter.js +115 -0
- package/dist/core/hooks/v2/executor.js +282 -0
- package/dist/core/hooks/v2/index.js +25 -0
- package/dist/core/hooks/v2/lifecycle.js +104 -0
- package/dist/core/hooks/v2/loader.js +216 -0
- package/dist/core/hooks/v2/matcher.js +125 -0
- package/dist/core/hooks/v2/trust.js +143 -0
- package/dist/core/hooks/v2/types.js +86 -0
- package/dist/core/lsp/cache.js +105 -0
- package/dist/core/lsp/client.js +776 -0
- package/dist/core/lsp/language-detect.js +66 -0
- package/dist/core/lsp/post-edit-diagnostics.js +171 -0
- package/dist/core/mcp/client.js +75 -6
- package/dist/core/mcp/http-server.js +553 -0
- package/dist/core/mcp/orchestrator-tools.js +662 -0
- package/dist/core/mcp/permission.js +190 -0
- package/dist/core/mcp/registry.js +24 -2
- package/dist/core/mcp/server-tools.js +219 -0
- package/dist/core/mcp/server.js +397 -0
- package/dist/core/memory/dual-write.js +416 -0
- package/dist/core/memory/phase1-kinds.js +20 -0
- package/dist/core/memory-sync/queue.js +158 -0
- package/dist/core/onboarding/ensure-initialized.js +133 -0
- package/dist/core/onboarding/marker.js +111 -0
- package/dist/core/onboarding/telemetry-state.js +108 -0
- package/dist/core/output-style/presets.js +176 -0
- package/dist/core/output-style/state.js +185 -0
- package/dist/core/path-security.js +284 -2
- package/dist/core/permissions/auto-classifier.js +124 -0
- package/dist/core/permissions/circuit-breaker.js +83 -0
- package/dist/core/permissions/gate.js +278 -0
- package/dist/core/permissions/index.js +20 -0
- package/dist/core/permissions/mode.js +174 -0
- package/dist/core/permissions/state.js +241 -0
- package/dist/core/permissions/tool-class.js +93 -0
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/session-review.js +557 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/pugi-md/context-injector.js +76 -0
- package/dist/core/pugi-md/walk-up.js +207 -0
- package/dist/core/release-notes/parser.js +241 -0
- package/dist/core/release-notes/state.js +116 -0
- package/dist/core/repl/history.js +11 -1
- package/dist/core/repl/model-pricing.js +135 -0
- package/dist/core/repl/session.js +1897 -37
- package/dist/core/repl/slash-commands.js +430 -15
- package/dist/core/repl/store/session-store.js +31 -2
- package/dist/core/repl/workspace-context.js +22 -0
- package/dist/core/repo-map/build.js +125 -0
- package/dist/core/repo-map/cache.js +185 -0
- package/dist/core/repo-map/extractor.js +254 -0
- package/dist/core/repo-map/formatter.js +145 -0
- package/dist/core/repo-map/scanner.js +211 -0
- package/dist/core/retry-budget/budget.js +284 -0
- package/dist/core/retry-budget/index.js +5 -0
- package/dist/core/session.js +92 -0
- package/dist/core/settings.js +80 -0
- package/dist/core/share/formatter.js +271 -0
- package/dist/core/share/redactor.js +221 -0
- package/dist/core/share/uploader.js +267 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/core/smoke/headless-driver.js +174 -0
- package/dist/core/smoke/orchestrator.js +194 -0
- package/dist/core/smoke/runner.js +238 -0
- package/dist/core/smoke/scenario-parser.js +316 -0
- package/dist/core/subagents/dispatcher-real.js +600 -0
- package/dist/core/subagents/dispatcher.js +113 -24
- package/dist/core/subagents/index.js +18 -5
- package/dist/core/subagents/isolation-matrix.js +213 -0
- package/dist/core/subagents/spawn.js +19 -4
- package/dist/core/telemetry/emitter.js +229 -0
- package/dist/core/telemetry/queue.js +251 -0
- package/dist/core/theme/context.js +91 -0
- package/dist/core/theme/presets.js +228 -0
- package/dist/core/theme/state.js +181 -0
- package/dist/core/todos/invariant.js +10 -0
- package/dist/core/todos/state.js +177 -0
- package/dist/core/transport/version-interceptor.js +166 -0
- package/dist/core/vim/keymap.js +288 -0
- package/dist/core/vim/state.js +92 -0
- package/dist/core/worktree-manager/cleanup.js +123 -0
- package/dist/core/worktree-manager/manager.js +303 -0
- package/dist/index.js +28 -0
- package/dist/runtime/bootstrap.js +190 -0
- package/dist/runtime/cli.js +3241 -343
- package/dist/runtime/commands/cancel.js +231 -0
- package/dist/runtime/commands/chain.js +489 -0
- package/dist/runtime/commands/codegraph-status.js +227 -0
- package/dist/runtime/commands/compact.js +297 -0
- package/dist/runtime/commands/cost.js +199 -0
- package/dist/runtime/commands/delegate.js +242 -11
- package/dist/runtime/commands/dispatch.js +126 -0
- package/dist/runtime/commands/doctor.js +412 -0
- package/dist/runtime/commands/feedback.js +184 -0
- package/dist/runtime/commands/hooks.js +184 -0
- package/dist/runtime/commands/lsp.js +368 -0
- package/dist/runtime/commands/mcp.js +879 -0
- package/dist/runtime/commands/memory.js +508 -0
- package/dist/runtime/commands/model.js +237 -0
- package/dist/runtime/commands/onboarding.js +275 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/permissions.js +112 -0
- package/dist/runtime/commands/plan.js +143 -0
- package/dist/runtime/commands/prd-check.js +285 -0
- package/dist/runtime/commands/redo-blob-store.js +92 -0
- package/dist/runtime/commands/redo.js +361 -0
- package/dist/runtime/commands/release-notes.js +229 -0
- package/dist/runtime/commands/repo-map.js +95 -0
- package/dist/runtime/commands/report.js +299 -0
- package/dist/runtime/commands/resume.js +118 -0
- package/dist/runtime/commands/review-consensus.js +17 -2
- package/dist/runtime/commands/rewind.js +333 -0
- package/dist/runtime/commands/sessions.js +163 -0
- package/dist/runtime/commands/share.js +316 -0
- package/dist/runtime/commands/status.js +186 -0
- package/dist/runtime/commands/stickers.js +82 -0
- package/dist/runtime/commands/style.js +194 -0
- package/dist/runtime/commands/theme.js +196 -0
- package/dist/runtime/commands/undo.js +32 -0
- package/dist/runtime/commands/update.js +289 -0
- package/dist/runtime/commands/vim.js +140 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/commands/worktrees.js +155 -0
- package/dist/runtime/headless-repl.js +195 -0
- package/dist/runtime/headless.js +543 -0
- package/dist/runtime/load-hooks-or-exit.js +71 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/runtime/version.js +65 -0
- package/dist/tools/agent-tool.js +229 -0
- package/dist/tools/apply-patch.js +556 -0
- package/dist/tools/ask-user-question.js +213 -0
- package/dist/tools/ask-user.js +115 -0
- package/dist/tools/bash.js +203 -4
- package/dist/tools/file-tools.js +85 -14
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/mcp-tool.js +260 -0
- package/dist/tools/multi-edit.js +361 -0
- package/dist/tools/powershell.js +268 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/skill-tool.js +96 -0
- package/dist/tools/tasks.js +208 -0
- package/dist/tools/todo-write.js +184 -0
- package/dist/tools/web-fetch.js +147 -2
- package/dist/tools/web-search.js +458 -0
- package/dist/tui/agent-progress-card.js +111 -0
- package/dist/tui/agent-tree.js +10 -0
- package/dist/tui/ask-modal.js +2 -2
- package/dist/tui/ask-user-question-prompt.js +192 -0
- package/dist/tui/compact-banner.js +81 -0
- package/dist/tui/conversation-pane.js +82 -8
- package/dist/tui/cost-table.js +111 -0
- package/dist/tui/doctor-table.js +46 -0
- package/dist/tui/feedback-prompt.js +156 -0
- package/dist/tui/input-box.js +218 -3
- package/dist/tui/markdown-render.js +4 -4
- package/dist/tui/onboarding-wizard.js +240 -0
- package/dist/tui/permissions-picker.js +86 -0
- package/dist/tui/render.js +35 -0
- package/dist/tui/repl-render.js +313 -35
- package/dist/tui/repl-splash-art.js +1 -1
- package/dist/tui/repl-splash-mascot.js +32 -8
- package/dist/tui/repl-splash.js +2 -2
- package/dist/tui/repl.js +85 -5
- package/dist/tui/splash.js +1 -1
- package/dist/tui/status-bar.js +94 -16
- package/dist/tui/status-table.js +7 -0
- package/dist/tui/stickers-art.js +136 -0
- package/dist/tui/style-table.js +28 -0
- package/dist/tui/theme-table.js +29 -0
- package/dist/tui/thinking-spinner.js +123 -0
- package/dist/tui/tool-stream-pane.js +52 -3
- package/dist/tui/update-banner.js +27 -2
- package/dist/tui/vim-input.js +267 -0
- package/dist/tui/welcome-banner.js +107 -0
- package/dist/tui/welcome-data.js +293 -0
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +13 -7
- package/test/scenarios/codegen-create-file.scenario.txt +13 -0
- package/test/scenarios/compact-force.scenario.txt +11 -0
- package/test/scenarios/identity.scenario.txt +11 -0
- package/test/scenarios/persona-handoff.scenario.txt +11 -0
- package/test/scenarios/walkback.scenario.txt +12 -0
- package/dist/core/engine/compaction-hook.js +0 -154
|
@@ -0,0 +1,879 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
5
|
+
import { FileReadCache } from '../../core/file-cache.js';
|
|
6
|
+
import { openSession } from '../../core/session.js';
|
|
7
|
+
import { loadSettings } from '../../core/settings.js';
|
|
8
|
+
import { isAlive } from '../../core/mcp/client.js';
|
|
9
|
+
import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
|
|
10
|
+
import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
|
|
11
|
+
import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
|
|
12
|
+
import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
|
|
13
|
+
import { buildOrchestratorTools } from '../../core/mcp/orchestrator-tools.js';
|
|
14
|
+
import { serveHttp } from '../../core/mcp/http-server.js';
|
|
15
|
+
import { resolveActiveCredential, DEFAULT_API_URL } from '../../core/credentials.js';
|
|
16
|
+
import { listMcpPermissions, clearMcpPermission, } from '../../core/mcp/permission.js';
|
|
17
|
+
export async function runMcpCommand(args, ctx) {
|
|
18
|
+
const sub = args[0] ?? 'list';
|
|
19
|
+
switch (sub) {
|
|
20
|
+
case 'list':
|
|
21
|
+
return runMcpList(ctx);
|
|
22
|
+
case 'trust':
|
|
23
|
+
return runMcpFlip(args.slice(1), ctx, 'trusted');
|
|
24
|
+
case 'deny':
|
|
25
|
+
return runMcpFlip(args.slice(1), ctx, 'denied');
|
|
26
|
+
case 'install':
|
|
27
|
+
return runMcpInstall(args.slice(1), ctx);
|
|
28
|
+
case 'remove':
|
|
29
|
+
case 'uninstall':
|
|
30
|
+
return runMcpRemove(args.slice(1), ctx);
|
|
31
|
+
case 'doctor':
|
|
32
|
+
return runMcpDoctor(args.slice(1), ctx);
|
|
33
|
+
case 'logs':
|
|
34
|
+
return runMcpLogs(args.slice(1), ctx);
|
|
35
|
+
case 'restart':
|
|
36
|
+
return runMcpRestart(args.slice(1), ctx);
|
|
37
|
+
case 'serve':
|
|
38
|
+
return runMcpServe(args.slice(1), ctx);
|
|
39
|
+
case 'perms':
|
|
40
|
+
return runMcpPerms(args.slice(1), ctx);
|
|
41
|
+
case 'help':
|
|
42
|
+
case '--help':
|
|
43
|
+
case '-h':
|
|
44
|
+
ctx.writeOutput({ command: 'mcp', usage: USAGE_LINES }, USAGE_LINES.join('\n'));
|
|
45
|
+
return;
|
|
46
|
+
default:
|
|
47
|
+
throw new Error(`Unknown sub-command "pugi mcp ${sub}". Try one of: list, trust, deny, install, remove, doctor, logs, restart, serve, perms.`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
const USAGE_LINES = [
|
|
51
|
+
'Usage: pugi mcp <sub-command>',
|
|
52
|
+
'',
|
|
53
|
+
' list List declared MCP servers + trust state + surfaced tools',
|
|
54
|
+
' trust <name> Mark a server as trusted (operator-side ledger)',
|
|
55
|
+
' deny <name> Mark a server as denied',
|
|
56
|
+
' install <name> <command...> Add a server to .pugi/mcp.json (workspace scope).',
|
|
57
|
+
' <command> must be an absolute path OR a binary',
|
|
58
|
+
' resolvable via `which` on the operator PATH.',
|
|
59
|
+
' remove <name> Remove a server from .pugi/mcp.json (workspace scope).',
|
|
60
|
+
' Trust ledger entry is preserved — re-install reuses it.',
|
|
61
|
+
' doctor [--connect] Print per-server health (handshake, tool count, last error).',
|
|
62
|
+
' --connect actually spawns the children (slow, ~5s/server).',
|
|
63
|
+
' Without --connect, reports the declared state only.',
|
|
64
|
+
' logs <name> [--tail N] Tail the per-server log file at .pugi/logs/mcp-<name>.log.',
|
|
65
|
+
' Default tail is 40 lines.',
|
|
66
|
+
' restart <name> Bounce a server: tear down the connection, reload the',
|
|
67
|
+
' config, re-handshake. Surfaces the new tool count.',
|
|
68
|
+
' serve [options] Run Pugi as an MCP server',
|
|
69
|
+
' --http :<port> HTTP+SSE transport (default: stdio)',
|
|
70
|
+
' --host <ip> HTTP bind host (default: 127.0.0.1)',
|
|
71
|
+
' --token <bearer> HTTP bearer token (env: PUGI_MCP_TOKEN).',
|
|
72
|
+
' Required for --http unless --print-token is set.',
|
|
73
|
+
' --print-token Auto-generate a random bearer token and print it',
|
|
74
|
+
' to stderr. Opt-in for ad-hoc local testing only.',
|
|
75
|
+
' --read-only Expose read/grep/glob only (default for HTTP).',
|
|
76
|
+
' --allow-write Expose edit/write (default off — explicit opt-in).',
|
|
77
|
+
' --allow-bash Expose the bash tool (default off — explicit opt-in).',
|
|
78
|
+
' --no-bash Deprecated alias (bash is already off by default).',
|
|
79
|
+
' --orchestrator Expose pugi.run / pugi.read / pugi.write /',
|
|
80
|
+
' pugi.dispatch / pugi.publish / pugi.deploy instead of',
|
|
81
|
+
' the engine surface. Designed for external Claude Code',
|
|
82
|
+
' / Cursor sessions driving fix-publish-test loops.',
|
|
83
|
+
' Each tool family is gated by an env switch:',
|
|
84
|
+
' PUGI_MCP_EXEC_ENABLED=1 enables pugi.run',
|
|
85
|
+
' PUGI_MCP_PUBLISH_ENABLED=1 enables pugi.publish',
|
|
86
|
+
' PUGI_MCP_DEPLOY_ENABLED=1 enables pugi.deploy',
|
|
87
|
+
' PUGI_MCP_WORKSPACE_ROOT=... overrides cwd for path validation',
|
|
88
|
+
' perms list Show cached per-(server, tool) decisions',
|
|
89
|
+
' perms reset <server>:<tool> Forget one cached decision',
|
|
90
|
+
];
|
|
91
|
+
/* ---------- list ------------------------------------------------------- */
|
|
92
|
+
async function runMcpList(ctx) {
|
|
93
|
+
const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
|
|
94
|
+
const declared = Array.from(registry.servers.values()).map((state) => ({
|
|
95
|
+
name: state.name,
|
|
96
|
+
command: state.config.command,
|
|
97
|
+
args: state.config.args,
|
|
98
|
+
trust: state.trust,
|
|
99
|
+
surfacedTools: state.surfacedTools.length,
|
|
100
|
+
lastError: state.lastError ?? null,
|
|
101
|
+
}));
|
|
102
|
+
const ledger = await listMcpTrust();
|
|
103
|
+
await registry.shutdown();
|
|
104
|
+
if (declared.length === 0) {
|
|
105
|
+
ctx.writeOutput({ command: 'mcp.list', servers: [], ledger }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
ctx.writeOutput({ command: 'mcp.list', servers: declared, ledger }, [
|
|
109
|
+
'MCP servers:',
|
|
110
|
+
...declared.map((server) => ` ${server.name.padEnd(20)} ${server.trust.padEnd(8)} ${server.command} ${server.args.join(' ')}`),
|
|
111
|
+
].join('\n'));
|
|
112
|
+
}
|
|
113
|
+
/* ---------- trust / deny ---------------------------------------------- */
|
|
114
|
+
async function runMcpFlip(args, ctx, state) {
|
|
115
|
+
const name = args[0];
|
|
116
|
+
if (!name) {
|
|
117
|
+
throw new Error(`pugi mcp ${state === 'trusted' ? 'trust' : 'deny'} requires a server name.`);
|
|
118
|
+
}
|
|
119
|
+
const by = resolveDecidedBy();
|
|
120
|
+
await setMcpTrust(name, state, by);
|
|
121
|
+
ctx.writeOutput({ command: `mcp.${state === 'trusted' ? 'trust' : 'deny'}`, name, state, decidedBy: by }, state === 'trusted'
|
|
122
|
+
? `MCP server "${name}" is now trusted.`
|
|
123
|
+
: `MCP server "${name}" is now denied.`);
|
|
124
|
+
}
|
|
125
|
+
/* ---------- install --------------------------------------------------- */
|
|
126
|
+
async function runMcpInstall(args, ctx) {
|
|
127
|
+
const name = args[0];
|
|
128
|
+
const command = args[1];
|
|
129
|
+
const rest = args.slice(2);
|
|
130
|
+
if (!name || !command) {
|
|
131
|
+
throw new Error('Usage: pugi mcp install <name> <command> [args...]');
|
|
132
|
+
}
|
|
133
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
134
|
+
throw new Error(`pugi mcp install: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
135
|
+
}
|
|
136
|
+
// β4 r1 P1 #6 — validate the executable path. Trust ledger gating is
|
|
137
|
+
// not enough: a cloned-and-trusted repo could declare a relative
|
|
138
|
+
// command (`./malicious-shim.sh`) that resolves at runtime via the
|
|
139
|
+
// shell PATH or the workspace cwd. Require an ABSOLUTE path OR a
|
|
140
|
+
// `which`-resolvable binary so the operator sees the canonical
|
|
141
|
+
// executable before granting trust.
|
|
142
|
+
const resolved = resolveExecutablePath(command);
|
|
143
|
+
if (!resolved) {
|
|
144
|
+
throw new Error(`pugi mcp install: command "${command}" must be an absolute path or a binary on PATH. ` +
|
|
145
|
+
`Pass the full path (e.g. /usr/local/bin/node) or install the binary first.`);
|
|
146
|
+
}
|
|
147
|
+
// Reject shell metacharacters in args — they survive into the spawn
|
|
148
|
+
// call as positional args (no shell), but a metachar in the COMMAND
|
|
149
|
+
// slot would be a clearer foot-gun and we already validated above.
|
|
150
|
+
for (const arg of [command, ...rest]) {
|
|
151
|
+
if (containsShellMetachar(arg)) {
|
|
152
|
+
throw new Error(`pugi mcp install: argument "${arg}" contains shell metacharacters; pass tokens individually instead of a shell string.`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
|
|
156
|
+
mkdirSync(resolve(ctx.workspaceRoot, '.pugi'), { recursive: true });
|
|
157
|
+
let existing = { servers: {} };
|
|
158
|
+
if (existsSync(mcpJsonPath)) {
|
|
159
|
+
try {
|
|
160
|
+
const raw = readFileSync(mcpJsonPath, 'utf8');
|
|
161
|
+
if (raw.trim().length > 0) {
|
|
162
|
+
const parsed = JSON.parse(raw);
|
|
163
|
+
existing = { servers: parsed.servers ?? {} };
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`pugi mcp install: cannot parse existing .pugi/mcp.json: ${error.message}. ` +
|
|
168
|
+
`Fix the file by hand or delete it and re-run.`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
if (existing.servers[name]) {
|
|
172
|
+
throw new Error(`pugi mcp install: server "${name}" already declared. Remove it from .pugi/mcp.json first.`);
|
|
173
|
+
}
|
|
174
|
+
// β4 r2 P1 #1 — persist the RESOLVED absolute path as `command`, not the
|
|
175
|
+
// original input. Before this fix the install path verified `resolved` but
|
|
176
|
+
// wrote the operator's literal `command` argument back to .pugi/mcp.json;
|
|
177
|
+
// the spawn at connect-time re-walked the PATH via `spawn(command, ...)`,
|
|
178
|
+
// which defeated the P1 #6 (β4 r1) hardening — a workspace-cwd shim
|
|
179
|
+
// (`./malicious-node`) inserted between install and trust could intercept
|
|
180
|
+
// the call because PATH search includes cwd on many shells.
|
|
181
|
+
//
|
|
182
|
+
// The original input is preserved as `originalCommand` for display / debug
|
|
183
|
+
// surfaces (`pugi mcp list`, audit logs) so operators can still see how
|
|
184
|
+
// they typed the install call. The runtime spawn path consumes `command`,
|
|
185
|
+
// so the absolute path is always what we actually exec.
|
|
186
|
+
existing.servers[name] = {
|
|
187
|
+
command: resolved,
|
|
188
|
+
originalCommand: command,
|
|
189
|
+
args: rest,
|
|
190
|
+
env: {},
|
|
191
|
+
// Workspace declarations start `pending` — trust must be granted
|
|
192
|
+
// explicitly via `pugi mcp trust <name>`. This matches the
|
|
193
|
+
// server-level trust ledger override semantics in registry.ts.
|
|
194
|
+
trust: 'pending',
|
|
195
|
+
};
|
|
196
|
+
writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
|
|
197
|
+
// Surface the resolved binary loudly so the operator sees the canonical
|
|
198
|
+
// executable before granting trust. β4 r1 P1 #6.
|
|
199
|
+
process.stderr.write(`pugi mcp install: starting executable resolves to ${resolved}\n`);
|
|
200
|
+
ctx.writeOutput({
|
|
201
|
+
command: 'mcp.install',
|
|
202
|
+
name,
|
|
203
|
+
// `executable` keeps the legacy field name (= what we will actually
|
|
204
|
+
// spawn). `originalCommand` is the operator's literal input.
|
|
205
|
+
executable: resolved,
|
|
206
|
+
originalCommand: command,
|
|
207
|
+
executableResolved: resolved,
|
|
208
|
+
args: rest,
|
|
209
|
+
configPath: mcpJsonPath,
|
|
210
|
+
}, [
|
|
211
|
+
`Added MCP server "${name}" to ${mcpJsonPath}.`,
|
|
212
|
+
`Resolved executable: ${resolved}`,
|
|
213
|
+
`It starts as pending. Run \`pugi mcp trust ${name}\` to enable it.`,
|
|
214
|
+
].join('\n'));
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Resolve a command to its canonical executable path. Returns null if the
|
|
218
|
+
* input is neither an absolute path that exists nor a binary resolvable
|
|
219
|
+
* via `which` on the operator PATH.
|
|
220
|
+
*/
|
|
221
|
+
function resolveExecutablePath(command) {
|
|
222
|
+
if (isAbsolute(command)) {
|
|
223
|
+
return existsSync(command) ? command : null;
|
|
224
|
+
}
|
|
225
|
+
// Reject relative paths — `./shim.sh` could be a freshly-cloned
|
|
226
|
+
// attacker binary in the workspace cwd. Require absolute OR PATH.
|
|
227
|
+
if (command.includes('/') || command.includes('\\'))
|
|
228
|
+
return null;
|
|
229
|
+
try {
|
|
230
|
+
// `which` exits 0 with the path on stdout. We pass exactly one
|
|
231
|
+
// argument (the binary name we just validated) so no shell metachar
|
|
232
|
+
// path is possible.
|
|
233
|
+
const out = execFileSync('/usr/bin/which', [command], {
|
|
234
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
235
|
+
encoding: 'utf8',
|
|
236
|
+
timeout: 5000,
|
|
237
|
+
}).trim();
|
|
238
|
+
return out.length > 0 ? out : null;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function containsShellMetachar(value) {
|
|
245
|
+
// Reject the obvious shell control characters. We are not building a
|
|
246
|
+
// full lexer; the install path passes the args verbatim to spawn (no
|
|
247
|
+
// shell), so the goal here is purely surfacing operator surprise — a
|
|
248
|
+
// `;` in an arg almost always means the user thought they were typing
|
|
249
|
+
// a shell pipeline.
|
|
250
|
+
return /[;|&`$<>(){}\n\r]/.test(value);
|
|
251
|
+
}
|
|
252
|
+
/* ---------- remove ---------------------------------------------------- */
|
|
253
|
+
async function runMcpRemove(args, ctx) {
|
|
254
|
+
const name = args[0];
|
|
255
|
+
if (!name) {
|
|
256
|
+
throw new Error('Usage: pugi mcp remove <name>');
|
|
257
|
+
}
|
|
258
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
259
|
+
throw new Error(`pugi mcp remove: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
260
|
+
}
|
|
261
|
+
const mcpJsonPath = resolve(ctx.workspaceRoot, '.pugi/mcp.json');
|
|
262
|
+
if (!existsSync(mcpJsonPath)) {
|
|
263
|
+
throw new Error(`pugi mcp remove: no .pugi/mcp.json at ${mcpJsonPath}. Nothing to remove.`);
|
|
264
|
+
}
|
|
265
|
+
let existing;
|
|
266
|
+
try {
|
|
267
|
+
const raw = readFileSync(mcpJsonPath, 'utf8');
|
|
268
|
+
if (raw.trim().length === 0) {
|
|
269
|
+
existing = { servers: {} };
|
|
270
|
+
}
|
|
271
|
+
else {
|
|
272
|
+
const parsed = JSON.parse(raw);
|
|
273
|
+
existing = { servers: parsed.servers ?? {} };
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
throw new Error(`pugi mcp remove: cannot parse .pugi/mcp.json: ${error.message}. ` +
|
|
278
|
+
`Fix the file by hand or delete it and re-run.`);
|
|
279
|
+
}
|
|
280
|
+
if (!existing.servers[name]) {
|
|
281
|
+
throw new Error(`pugi mcp remove: server "${name}" not declared in ${mcpJsonPath}. ` +
|
|
282
|
+
`Run \`pugi mcp list\` to see declared servers.`);
|
|
283
|
+
}
|
|
284
|
+
// Preserve the trust ledger entry on purpose — a re-install of the same
|
|
285
|
+
// server name should land back at its old trust state so the operator
|
|
286
|
+
// does not have to re-approve it. To wipe trust as well, run
|
|
287
|
+
// `pugi mcp deny <name>` (or edit ~/.pugi/trust-mcp.json by hand).
|
|
288
|
+
delete existing.servers[name];
|
|
289
|
+
writeFileSync(mcpJsonPath, `${JSON.stringify(existing, null, 2)}\n`, { mode: 0o600 });
|
|
290
|
+
ctx.writeOutput({
|
|
291
|
+
command: 'mcp.remove',
|
|
292
|
+
name,
|
|
293
|
+
configPath: mcpJsonPath,
|
|
294
|
+
remaining: Object.keys(existing.servers),
|
|
295
|
+
}, [
|
|
296
|
+
`Removed MCP server "${name}" from ${mcpJsonPath}.`,
|
|
297
|
+
`Trust ledger entry preserved — re-install reuses it.`,
|
|
298
|
+
`Remaining servers: ${Object.keys(existing.servers).length === 0 ? '(none)' : Object.keys(existing.servers).join(', ')}.`,
|
|
299
|
+
].join('\n'));
|
|
300
|
+
}
|
|
301
|
+
/* ---------- doctor ---------------------------------------------------- */
|
|
302
|
+
async function runMcpDoctor(args, ctx) {
|
|
303
|
+
// `--connect` forces the doctor to actually spawn children + handshake.
|
|
304
|
+
// Default behaviour is dry-run (config + trust ledger only) so a routine
|
|
305
|
+
// `pugi doctor` does not block on a misbehaving server's 5s timeout per
|
|
306
|
+
// entry. Operators investigating an outage pass `--connect` explicitly.
|
|
307
|
+
const wantsConnect = args.includes('--connect');
|
|
308
|
+
const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: wantsConnect });
|
|
309
|
+
const ledger = await listMcpTrust();
|
|
310
|
+
const rows = [];
|
|
311
|
+
for (const state of registry.servers.values()) {
|
|
312
|
+
const conn = state.connection;
|
|
313
|
+
let handshake;
|
|
314
|
+
if (!wantsConnect) {
|
|
315
|
+
handshake = 'not-attempted';
|
|
316
|
+
}
|
|
317
|
+
else if (state.lastError) {
|
|
318
|
+
handshake = 'failed';
|
|
319
|
+
}
|
|
320
|
+
else if (conn && isAlive(conn)) {
|
|
321
|
+
handshake = 'ok';
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
handshake = 'failed';
|
|
325
|
+
}
|
|
326
|
+
rows.push({
|
|
327
|
+
name: state.name,
|
|
328
|
+
trust: state.trust,
|
|
329
|
+
handshake,
|
|
330
|
+
pid: conn?.child.pid ?? null,
|
|
331
|
+
tools: state.surfacedTools.length,
|
|
332
|
+
uptimeMs: conn ? Date.now() - conn.startedAt : null,
|
|
333
|
+
lastError: state.lastError ?? null,
|
|
334
|
+
logFile: mcpLogPath(ctx.workspaceRoot, state.name),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
rows.sort((a, b) => a.name.localeCompare(b.name));
|
|
338
|
+
await registry.shutdown();
|
|
339
|
+
if (rows.length === 0) {
|
|
340
|
+
ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, 'No MCP servers declared. Add one with `pugi mcp install <name> <command...>`.');
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const headerLines = [
|
|
344
|
+
`MCP doctor (${wantsConnect ? 'live handshake' : 'declared state only — pass --connect for live probe'}):`,
|
|
345
|
+
'',
|
|
346
|
+
` ${'NAME'.padEnd(20)} ${'TRUST'.padEnd(8)} ${'HANDSHAKE'.padEnd(14)} ${'TOOLS'.padEnd(6)} ${'PID'.padEnd(7)} NOTE`,
|
|
347
|
+
];
|
|
348
|
+
for (const row of rows) {
|
|
349
|
+
const note = row.lastError
|
|
350
|
+
? `error: ${truncate(row.lastError, 60)}`
|
|
351
|
+
: row.handshake === 'ok'
|
|
352
|
+
? `uptime ${formatUptime(row.uptimeMs ?? 0)}`
|
|
353
|
+
: row.handshake === 'failed'
|
|
354
|
+
? 'see log file'
|
|
355
|
+
: '';
|
|
356
|
+
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}`);
|
|
357
|
+
}
|
|
358
|
+
headerLines.push('', `Log dir: ${resolve(ctx.workspaceRoot, '.pugi/logs')}`);
|
|
359
|
+
if (!wantsConnect) {
|
|
360
|
+
headerLines.push('Hint: pass --connect to actually spawn the children (slow, ~5s budget/server).');
|
|
361
|
+
}
|
|
362
|
+
ctx.writeOutput({ command: 'mcp.doctor', rows, ledger, connectAttempted: wantsConnect }, headerLines.join('\n'));
|
|
363
|
+
}
|
|
364
|
+
function truncate(value, max) {
|
|
365
|
+
if (value.length <= max)
|
|
366
|
+
return value;
|
|
367
|
+
return `${value.slice(0, max - 1)}…`;
|
|
368
|
+
}
|
|
369
|
+
function formatUptime(ms) {
|
|
370
|
+
if (ms < 1000)
|
|
371
|
+
return `${ms}ms`;
|
|
372
|
+
const sec = Math.floor(ms / 1000);
|
|
373
|
+
if (sec < 60)
|
|
374
|
+
return `${sec}s`;
|
|
375
|
+
const min = Math.floor(sec / 60);
|
|
376
|
+
if (min < 60)
|
|
377
|
+
return `${min}m${sec % 60}s`;
|
|
378
|
+
const hr = Math.floor(min / 60);
|
|
379
|
+
return `${hr}h${min % 60}m`;
|
|
380
|
+
}
|
|
381
|
+
/* ---------- logs ------------------------------------------------------ */
|
|
382
|
+
async function runMcpLogs(args, ctx) {
|
|
383
|
+
const name = args[0];
|
|
384
|
+
if (!name || name.startsWith('--')) {
|
|
385
|
+
throw new Error('Usage: pugi mcp logs <name> [--tail N]');
|
|
386
|
+
}
|
|
387
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
388
|
+
throw new Error(`pugi mcp logs: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
389
|
+
}
|
|
390
|
+
// `--tail N` and `--tail=N` both supported. Default 40 lines — matches
|
|
391
|
+
// typical `tail -n 40` muscle memory.
|
|
392
|
+
let tail = 40;
|
|
393
|
+
for (let i = 1; i < args.length; i += 1) {
|
|
394
|
+
const arg = args[i] ?? '';
|
|
395
|
+
if (arg === '--tail') {
|
|
396
|
+
const next = args[i + 1];
|
|
397
|
+
if (!next)
|
|
398
|
+
throw new Error('pugi mcp logs: --tail requires a value');
|
|
399
|
+
const n = Number.parseInt(next, 10);
|
|
400
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
401
|
+
throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${next}")`);
|
|
402
|
+
}
|
|
403
|
+
tail = n;
|
|
404
|
+
i += 1;
|
|
405
|
+
}
|
|
406
|
+
else if (arg.startsWith('--tail=')) {
|
|
407
|
+
const n = Number.parseInt(arg.slice('--tail='.length), 10);
|
|
408
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
409
|
+
throw new Error(`pugi mcp logs: --tail must be a positive integer (got "${arg}")`);
|
|
410
|
+
}
|
|
411
|
+
tail = n;
|
|
412
|
+
}
|
|
413
|
+
else {
|
|
414
|
+
throw new Error(`pugi mcp logs: unknown flag "${arg}"`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const path = mcpLogPath(ctx.workspaceRoot, name);
|
|
418
|
+
if (!existsSync(path)) {
|
|
419
|
+
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).`);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
let raw;
|
|
423
|
+
try {
|
|
424
|
+
raw = readFileSync(path, 'utf8');
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
throw new Error(`pugi mcp logs: cannot read ${path}: ${error.message}.`);
|
|
428
|
+
}
|
|
429
|
+
const allLines = raw.split('\n');
|
|
430
|
+
// `split('\n')` of a trailing-newline file yields an empty last element.
|
|
431
|
+
// Drop it so the displayed tail matches `wc -l` expectations.
|
|
432
|
+
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
|
|
433
|
+
allLines.pop();
|
|
434
|
+
}
|
|
435
|
+
const tailed = allLines.slice(Math.max(0, allLines.length - tail));
|
|
436
|
+
const sizeBytes = (() => {
|
|
437
|
+
try {
|
|
438
|
+
return statSync(path).size;
|
|
439
|
+
}
|
|
440
|
+
catch {
|
|
441
|
+
return 0;
|
|
442
|
+
}
|
|
443
|
+
})();
|
|
444
|
+
ctx.writeOutput({
|
|
445
|
+
command: 'mcp.logs',
|
|
446
|
+
name,
|
|
447
|
+
path,
|
|
448
|
+
tail,
|
|
449
|
+
totalLines: allLines.length,
|
|
450
|
+
sizeBytes,
|
|
451
|
+
lines: tailed,
|
|
452
|
+
}, [
|
|
453
|
+
`pugi mcp logs ${name} (${path}, ${sizeBytes} bytes, ${allLines.length} total lines, showing last ${Math.min(tail, allLines.length)}):`,
|
|
454
|
+
...tailed,
|
|
455
|
+
].join('\n'));
|
|
456
|
+
}
|
|
457
|
+
/* ---------- restart --------------------------------------------------- */
|
|
458
|
+
async function runMcpRestart(args, ctx) {
|
|
459
|
+
const name = args[0];
|
|
460
|
+
if (!name) {
|
|
461
|
+
throw new Error('Usage: pugi mcp restart <name>');
|
|
462
|
+
}
|
|
463
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
|
|
464
|
+
throw new Error(`pugi mcp restart: server name "${name}" must be [a-zA-Z0-9_-]+`);
|
|
465
|
+
}
|
|
466
|
+
// `pugi mcp restart` is stateless from the CLI's perspective — the CLI
|
|
467
|
+
// process has no long-lived MCP registry of its own (the REPL owns it
|
|
468
|
+
// and tears it down on exit). What we DO here is: load the config,
|
|
469
|
+
// refuse if the server is not declared or not trusted, then probe-spawn
|
|
470
|
+
// the server with the live handshake. This proves it is reachable +
|
|
471
|
+
// surfaces the tool count for the operator. The REPL picks up the
|
|
472
|
+
// change on its next `loadMcpRegistry` cycle.
|
|
473
|
+
const registry = await loadMcpRegistry(ctx.workspaceRoot, { connect: false });
|
|
474
|
+
const state = registry.servers.get(name);
|
|
475
|
+
if (!state) {
|
|
476
|
+
await registry.shutdown();
|
|
477
|
+
throw new Error(`pugi mcp restart: server "${name}" not declared. Run \`pugi mcp list\` to see declared servers.`);
|
|
478
|
+
}
|
|
479
|
+
if (state.trust !== 'trusted') {
|
|
480
|
+
await registry.shutdown();
|
|
481
|
+
throw new Error(`pugi mcp restart: server "${name}" trust state is "${state.trust}". ` +
|
|
482
|
+
`Run \`pugi mcp trust ${name}\` first.`);
|
|
483
|
+
}
|
|
484
|
+
await registry.shutdown();
|
|
485
|
+
// Re-load WITH connect=true but scoped to a single-server probe via
|
|
486
|
+
// handshakeTimeoutMs (5s default keeps the CLI snappy). We use the same
|
|
487
|
+
// loadMcpRegistry path so log routing + error capture stay consistent.
|
|
488
|
+
const probe = await loadMcpRegistry(ctx.workspaceRoot, { connect: true });
|
|
489
|
+
const probed = probe.servers.get(name);
|
|
490
|
+
const lastError = probed?.lastError ?? null;
|
|
491
|
+
const toolCount = probed?.surfacedTools.length ?? 0;
|
|
492
|
+
const pid = probed?.connection?.child.pid ?? null;
|
|
493
|
+
await probe.shutdown();
|
|
494
|
+
if (lastError) {
|
|
495
|
+
ctx.writeOutput({
|
|
496
|
+
command: 'mcp.restart',
|
|
497
|
+
name,
|
|
498
|
+
ok: false,
|
|
499
|
+
error: lastError,
|
|
500
|
+
logFile: mcpLogPath(ctx.workspaceRoot, name),
|
|
501
|
+
}, [
|
|
502
|
+
`pugi mcp restart ${name}: FAILED`,
|
|
503
|
+
` error: ${lastError}`,
|
|
504
|
+
` log file: ${mcpLogPath(ctx.workspaceRoot, name)}`,
|
|
505
|
+
].join('\n'));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
ctx.writeOutput({
|
|
509
|
+
command: 'mcp.restart',
|
|
510
|
+
name,
|
|
511
|
+
ok: true,
|
|
512
|
+
pid,
|
|
513
|
+
surfacedTools: toolCount,
|
|
514
|
+
}, [
|
|
515
|
+
`pugi mcp restart ${name}: OK`,
|
|
516
|
+
` pid: ${pid ?? '-'}`,
|
|
517
|
+
` surfaced tools: ${toolCount}`,
|
|
518
|
+
].join('\n'));
|
|
519
|
+
}
|
|
520
|
+
async function runMcpServe(args, ctx) {
|
|
521
|
+
const flags = parseServeFlags(args);
|
|
522
|
+
const session = openSession(ctx.workspaceRoot);
|
|
523
|
+
const settings = loadSettings(ctx.workspaceRoot);
|
|
524
|
+
const toolCtx = {
|
|
525
|
+
root: ctx.workspaceRoot,
|
|
526
|
+
settings,
|
|
527
|
+
session,
|
|
528
|
+
readCache: new FileReadCache(),
|
|
529
|
+
};
|
|
530
|
+
// β4 r1 P1 #2 — surface defaults flip to deny-most-permissive. The
|
|
531
|
+
// previous defaults exposed every tool (read/edit/write/bash) on every
|
|
532
|
+
// transport; an HTTP listener with a leaked bearer = remote shell. We
|
|
533
|
+
// now require explicit opt-in for write + bash. `--read-only` remains
|
|
534
|
+
// the explicit "shrink to read/grep/glob" knob.
|
|
535
|
+
//
|
|
536
|
+
// β4 r2 P1 #2 — `--allow-bash` and `--allow-write` are now INDEPENDENT
|
|
537
|
+
// opt-ins. Before this fix `readOnly` collapsed to `true` whenever
|
|
538
|
+
// `--allow-write` was omitted, which forced `buildPugiMcpTools` to
|
|
539
|
+
// drop the `bash` tool even when the operator had explicitly set
|
|
540
|
+
// `--allow-bash`. The `readOnly` flag below now reflects ONLY the
|
|
541
|
+
// explicit `--read-only` request; the per-capability gates
|
|
542
|
+
// (`bashAllowed` / `writeAllowed`) handle the rest. The permission gate
|
|
543
|
+
// below (`buildServePermissionGate`) still refuses bash/edit per-tool
|
|
544
|
+
// when the corresponding capability is off, so a misconfigured
|
|
545
|
+
// `buildPugiMcpTools` call (advertising `bash` without the gate flag)
|
|
546
|
+
// would still be refused at dispatch.
|
|
547
|
+
const readOnly = flags.readOnly === true;
|
|
548
|
+
const writeAllowed = !readOnly && flags.writeAllowed;
|
|
549
|
+
const bashAllowed = !readOnly && flags.bashAllowed;
|
|
550
|
+
// Wave 7 P1 — when `--orchestrator` is set the surface swaps to the
|
|
551
|
+
// CLI-orchestrator family (pugi.run / pugi.read / pugi.write /
|
|
552
|
+
// pugi.dispatch / pugi.publish / pugi.deploy). The engine surface is
|
|
553
|
+
// intentionally dropped — the two are mutually exclusive on the wire
|
|
554
|
+
// to keep tool-name resolution unambiguous on the consumer side.
|
|
555
|
+
const tools = flags.orchestrator
|
|
556
|
+
? buildOrchestratorTools(buildOrchestratorContext(ctx.workspaceRoot))
|
|
557
|
+
: buildPugiMcpTools(toolCtx, {
|
|
558
|
+
bashAllowed,
|
|
559
|
+
// Keep the legacy contract: `readOnly` for the tool-builder means
|
|
560
|
+
// "do not advertise edit/write tools". Bash advertisement is gated
|
|
561
|
+
// by the independent `bashAllowed` knob. So the builder sees
|
|
562
|
+
// `readOnly = true` whenever the operator did not opt into write
|
|
563
|
+
// explicitly, which preserves the deny-by-default surface for
|
|
564
|
+
// edit/write but no longer accidentally suppresses bash.
|
|
565
|
+
readOnly: readOnly || !writeAllowed,
|
|
566
|
+
});
|
|
567
|
+
// β4 r1 P1 #2 — deny-by-default permissionGate. The MCP cache + FSM
|
|
568
|
+
// are consulted on every dispatch; allow_always-cached entries pass
|
|
569
|
+
// silently, allow_once entries pass and self-clear, deny entries
|
|
570
|
+
// refuse, and unset entries refuse with a hint (operator can grant
|
|
571
|
+
// out-of-band via `pugi mcp perm grant <tool>` — backlog).
|
|
572
|
+
const permissionGate = buildServePermissionGate({
|
|
573
|
+
bashAllowed,
|
|
574
|
+
writeAllowed,
|
|
575
|
+
});
|
|
576
|
+
const server = createPugiMcpServer({
|
|
577
|
+
tools,
|
|
578
|
+
permissionGate,
|
|
579
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
580
|
+
log: (level, message) => {
|
|
581
|
+
// Stdio: keep stderr empty unless explicitly debugging. HTTP:
|
|
582
|
+
// route through writeOutput so operators see lifecycle in JSON
|
|
583
|
+
// mode. For now we honour stderr only — adding a --debug flag
|
|
584
|
+
// is backlog.
|
|
585
|
+
if (level === 'error')
|
|
586
|
+
process.stderr.write(`pugi-mcp: ${message}\n`);
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
if (flags.http) {
|
|
590
|
+
// β4 r1 P0 #1 — token resolution. Prefer explicit operator config;
|
|
591
|
+
// fall back to env; only auto-generate when `--print-token` is set.
|
|
592
|
+
// The auto-generated token NEVER lands in stdout — only stderr.
|
|
593
|
+
const envToken = process.env.PUGI_MCP_TOKEN?.trim();
|
|
594
|
+
const explicitToken = flags.bearerToken ?? (envToken && envToken.length > 0 ? envToken : null);
|
|
595
|
+
if (!explicitToken && !flags.printToken) {
|
|
596
|
+
throw new Error('pugi mcp serve --http requires a bearer token. Pass --token <value>, set ' +
|
|
597
|
+
'PUGI_MCP_TOKEN, or add --print-token to auto-generate one (printed to stderr).');
|
|
598
|
+
}
|
|
599
|
+
const handle = await serveHttp({
|
|
600
|
+
server,
|
|
601
|
+
port: flags.http.port,
|
|
602
|
+
host: flags.http.host,
|
|
603
|
+
...(explicitToken ? { bearerToken: explicitToken } : {}),
|
|
604
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
605
|
+
log: (_level, message) => {
|
|
606
|
+
process.stderr.write(`pugi-mcp-http: ${message}\n`);
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
// Bearer token: print to STDERR only if auto-generated (so it never
|
|
610
|
+
// lands in CI logs / session journals / Anvil events that capture
|
|
611
|
+
// stdout). Operators consuming the JSON envelope must read it from
|
|
612
|
+
// their controlling terminal. β4 r1 P0 #1.
|
|
613
|
+
if (handle.bearerTokenAutoGenerated) {
|
|
614
|
+
process.stderr.write(`pugi-mcp-http: AUTO-GENERATED BEARER TOKEN (use once; --print-token set):\n` +
|
|
615
|
+
` ${handle.bearerToken}\n` +
|
|
616
|
+
`pugi-mcp-http: prefer --token / PUGI_MCP_TOKEN for persistent setups.\n`);
|
|
617
|
+
}
|
|
618
|
+
// Emit the URL + tool surface to stdout (JSON-safe). Bearer token
|
|
619
|
+
// is intentionally omitted from the JSON payload — operators who
|
|
620
|
+
// need it programmatically supply --token explicitly.
|
|
621
|
+
ctx.writeOutput({
|
|
622
|
+
command: 'mcp.serve',
|
|
623
|
+
transport: 'http',
|
|
624
|
+
url: handle.url,
|
|
625
|
+
surface: flags.orchestrator ? 'orchestrator' : 'engine',
|
|
626
|
+
bearerTokenSource: handle.bearerTokenAutoGenerated
|
|
627
|
+
? 'auto-generated (see stderr)'
|
|
628
|
+
: explicitToken === envToken
|
|
629
|
+
? 'env:PUGI_MCP_TOKEN'
|
|
630
|
+
: 'flag:--token',
|
|
631
|
+
tools: tools.map((t) => t.name),
|
|
632
|
+
}, [
|
|
633
|
+
`pugi mcp serve (http) — listening at ${handle.url}`,
|
|
634
|
+
handle.bearerTokenAutoGenerated
|
|
635
|
+
? `Bearer token: see stderr (auto-generated, --print-token)`
|
|
636
|
+
: `Bearer token: configured via ${explicitToken === envToken ? 'PUGI_MCP_TOKEN env' : '--token flag'}`,
|
|
637
|
+
`Tools: ${tools.map((t) => t.name).join(', ')}`,
|
|
638
|
+
'',
|
|
639
|
+
'Endpoints:',
|
|
640
|
+
' POST /mcp/v1/initialize',
|
|
641
|
+
' POST /mcp/v1/list',
|
|
642
|
+
' POST /mcp/v1/call',
|
|
643
|
+
' POST /mcp/v1/rpc',
|
|
644
|
+
' GET /mcp/v1/events (SSE)',
|
|
645
|
+
' GET /mcp/v1/health (no auth)',
|
|
646
|
+
'',
|
|
647
|
+
'Press Ctrl-C to stop.',
|
|
648
|
+
].join('\n'));
|
|
649
|
+
// Keep the process alive while the listener is up. The signal +
|
|
650
|
+
// SIGINT handlers below force a graceful close.
|
|
651
|
+
await new Promise((resolveExit) => {
|
|
652
|
+
const onSignal = async () => {
|
|
653
|
+
await handle.close();
|
|
654
|
+
resolveExit();
|
|
655
|
+
};
|
|
656
|
+
process.once('SIGINT', () => void onSignal());
|
|
657
|
+
process.once('SIGTERM', () => void onSignal());
|
|
658
|
+
if (ctx.signal) {
|
|
659
|
+
if (ctx.signal.aborted)
|
|
660
|
+
void onSignal();
|
|
661
|
+
else
|
|
662
|
+
ctx.signal.addEventListener('abort', () => void onSignal(), { once: true });
|
|
663
|
+
}
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
// Stdio transport — default. The handshake + tools/list happen on
|
|
668
|
+
// the wire; nothing is printed unless the parent agent sends a
|
|
669
|
+
// request that returns a response. Operator sees one info line on
|
|
670
|
+
// stderr so they know the server is up.
|
|
671
|
+
process.stderr.write(`pugi-mcp (stdio, ${flags.orchestrator ? 'orchestrator' : 'engine'}): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
|
|
672
|
+
await serveStdio({
|
|
673
|
+
server,
|
|
674
|
+
stdin: ctx.stdin ?? process.stdin,
|
|
675
|
+
stdout: ctx.stdout ?? process.stdout,
|
|
676
|
+
...(ctx.signal ? { signal: ctx.signal } : {}),
|
|
677
|
+
});
|
|
678
|
+
}
|
|
679
|
+
function parseServeFlags(args) {
|
|
680
|
+
const flags = {
|
|
681
|
+
http: null,
|
|
682
|
+
bearerToken: null,
|
|
683
|
+
printToken: false,
|
|
684
|
+
readOnly: false,
|
|
685
|
+
writeAllowed: false,
|
|
686
|
+
bashAllowed: false,
|
|
687
|
+
orchestrator: false,
|
|
688
|
+
};
|
|
689
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
690
|
+
const arg = args[i] ?? '';
|
|
691
|
+
if (arg === '--http' || arg === '-h') {
|
|
692
|
+
const next = args[i + 1];
|
|
693
|
+
if (!next)
|
|
694
|
+
throw new Error('--http requires a port (e.g. --http :7100 or --http 7100)');
|
|
695
|
+
flags.http = parseHttpBinding(next);
|
|
696
|
+
i += 1;
|
|
697
|
+
}
|
|
698
|
+
else if (arg.startsWith('--http=')) {
|
|
699
|
+
flags.http = parseHttpBinding(arg.slice('--http='.length));
|
|
700
|
+
}
|
|
701
|
+
else if (arg === '--host') {
|
|
702
|
+
const next = args[i + 1];
|
|
703
|
+
if (!next)
|
|
704
|
+
throw new Error('--host requires a value');
|
|
705
|
+
// Stash on a synthetic http config; binding-port may have been set
|
|
706
|
+
// earlier, OR we initialize with port 0 and require --http.
|
|
707
|
+
if (!flags.http) {
|
|
708
|
+
throw new Error('--host requires --http to be set first');
|
|
709
|
+
}
|
|
710
|
+
flags.http.host = next;
|
|
711
|
+
i += 1;
|
|
712
|
+
}
|
|
713
|
+
else if (arg === '--token') {
|
|
714
|
+
const next = args[i + 1];
|
|
715
|
+
if (!next)
|
|
716
|
+
throw new Error('--token requires a value');
|
|
717
|
+
flags.bearerToken = next;
|
|
718
|
+
i += 1;
|
|
719
|
+
}
|
|
720
|
+
else if (arg.startsWith('--token=')) {
|
|
721
|
+
flags.bearerToken = arg.slice('--token='.length);
|
|
722
|
+
}
|
|
723
|
+
else if (arg === '--print-token') {
|
|
724
|
+
flags.printToken = true;
|
|
725
|
+
}
|
|
726
|
+
else if (arg === '--read-only') {
|
|
727
|
+
flags.readOnly = true;
|
|
728
|
+
}
|
|
729
|
+
else if (arg === '--allow-write') {
|
|
730
|
+
flags.writeAllowed = true;
|
|
731
|
+
}
|
|
732
|
+
else if (arg === '--allow-bash') {
|
|
733
|
+
flags.bashAllowed = true;
|
|
734
|
+
// bash implies write capability is meaningless (bash runs anything);
|
|
735
|
+
// we still keep them orthogonal so an operator can grant bash
|
|
736
|
+
// without exposing the structured `edit` / `write` tools to the
|
|
737
|
+
// MCP surface (which would let an external agent rewrite files
|
|
738
|
+
// without going through the diff escalation pipeline).
|
|
739
|
+
}
|
|
740
|
+
else if (arg === '--no-bash') {
|
|
741
|
+
// Deprecated — bash is already off by default. Kept for back-compat
|
|
742
|
+
// so existing operator scripts do not error.
|
|
743
|
+
flags.bashAllowed = false;
|
|
744
|
+
}
|
|
745
|
+
else if (arg === '--orchestrator') {
|
|
746
|
+
flags.orchestrator = true;
|
|
747
|
+
}
|
|
748
|
+
else if (arg === '--help') {
|
|
749
|
+
// Caller renders USAGE_LINES. We surface the same via top-level
|
|
750
|
+
// dispatch — nothing to do here, just don't error.
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
throw new Error(`pugi mcp serve: unknown flag "${arg}"`);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
return flags;
|
|
757
|
+
}
|
|
758
|
+
/**
|
|
759
|
+
* Build the permission gate for `pugi mcp serve`. Default policy is
|
|
760
|
+
* deny-with-hint. The MCP cache layer at `~/.pugi/mcp-perms.json` is
|
|
761
|
+
* consulted for `allow_always` / `deny` decisions; shell-class tools
|
|
762
|
+
* (bash) can NEVER hold `allow_always` (enforced by `setMcpPermission`),
|
|
763
|
+
* so a cached approval still requires per-call FSM prompt.
|
|
764
|
+
*
|
|
765
|
+
* Wired here as a thin synchronous policy because the serve path runs
|
|
766
|
+
* in a long-lived non-TTY context (HTTP) — interactive prompts would
|
|
767
|
+
* block forever. The future TTY-mode interactive prompt lives on top
|
|
768
|
+
* of this gate in `pugi mcp serve --interactive` (backlog).
|
|
769
|
+
*/
|
|
770
|
+
function buildServePermissionGate(opts) {
|
|
771
|
+
return async (input) => {
|
|
772
|
+
const { tool } = input;
|
|
773
|
+
// Surface-level gate: even if the cache says allow_always, the
|
|
774
|
+
// operator's serve-flag opt-in is the ultimate authority. A
|
|
775
|
+
// misconfigured cache entry (legacy approval) cannot override the
|
|
776
|
+
// serve-time policy.
|
|
777
|
+
if (tool.permission === 'bash' && !opts.bashAllowed)
|
|
778
|
+
return false;
|
|
779
|
+
if (tool.permission === 'edit' && !opts.writeAllowed)
|
|
780
|
+
return false;
|
|
781
|
+
// `network` is the permission class used by orchestrator tools
|
|
782
|
+
// (pugi.dispatch / pugi.publish / pugi.deploy). The env capability
|
|
783
|
+
// gates inside each tool's `execute` body provide the per-family
|
|
784
|
+
// kill switch, so the serve-time gate is permissive here. The
|
|
785
|
+
// server's overall `permissionGate` is already deny-most-other —
|
|
786
|
+
// adding a third boolean knob (`networkAllowed`) would create more
|
|
787
|
+
// ways to misconfigure than to protect. Wave 7 P1 (2026-05-28).
|
|
788
|
+
return true;
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Build the OrchestratorToolContext for `pugi mcp serve --orchestrator`.
|
|
793
|
+
* Reads from process.env + the credentials store. Encapsulated so tests
|
|
794
|
+
* never need to mock the resolveActiveCredential path — they call
|
|
795
|
+
* `buildOrchestratorTools` directly with a hand-rolled context.
|
|
796
|
+
*
|
|
797
|
+
* Wave 7 P1 (2026-05-28).
|
|
798
|
+
*/
|
|
799
|
+
function buildOrchestratorContext(workspaceRoot) {
|
|
800
|
+
const envRoot = process.env.PUGI_MCP_WORKSPACE_ROOT;
|
|
801
|
+
const root = envRoot && envRoot.length > 0 ? resolve(envRoot) : workspaceRoot;
|
|
802
|
+
const credential = resolveActiveCredential();
|
|
803
|
+
return {
|
|
804
|
+
workspaceRoot: root,
|
|
805
|
+
pugiBin: process.env.PUGI_MCP_PUGI_BIN ?? 'pugi',
|
|
806
|
+
apiUrl: credential?.apiUrl ?? DEFAULT_API_URL,
|
|
807
|
+
apiKey: credential?.apiKey ?? null,
|
|
808
|
+
capabilities: {
|
|
809
|
+
exec: process.env.PUGI_MCP_EXEC_ENABLED === '1',
|
|
810
|
+
publish: process.env.PUGI_MCP_PUBLISH_ENABLED === '1',
|
|
811
|
+
deploy: process.env.PUGI_MCP_DEPLOY_ENABLED === '1',
|
|
812
|
+
},
|
|
813
|
+
sshAlias: process.env.PUGI_MCP_SSH_ALIAS ?? 'codeforge',
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
function parseHttpBinding(input) {
|
|
817
|
+
// Accept `:7100`, `7100`, or `host:7100`.
|
|
818
|
+
let host = '127.0.0.1';
|
|
819
|
+
let portStr = input;
|
|
820
|
+
if (input.startsWith(':')) {
|
|
821
|
+
portStr = input.slice(1);
|
|
822
|
+
}
|
|
823
|
+
else if (input.includes(':')) {
|
|
824
|
+
const idx = input.lastIndexOf(':');
|
|
825
|
+
host = input.slice(0, idx);
|
|
826
|
+
portStr = input.slice(idx + 1);
|
|
827
|
+
}
|
|
828
|
+
const port = Number.parseInt(portStr, 10);
|
|
829
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
830
|
+
throw new Error(`invalid --http port: "${input}" (expected :PORT or HOST:PORT)`);
|
|
831
|
+
}
|
|
832
|
+
return { host, port };
|
|
833
|
+
}
|
|
834
|
+
/* ---------- perms ------------------------------------------------------ */
|
|
835
|
+
async function runMcpPerms(args, ctx) {
|
|
836
|
+
const sub = args[0] ?? 'list';
|
|
837
|
+
switch (sub) {
|
|
838
|
+
case 'list': {
|
|
839
|
+
const entries = listMcpPermissions();
|
|
840
|
+
ctx.writeOutput({ command: 'mcp.perms.list', entries }, entries.length === 0
|
|
841
|
+
? 'No cached MCP permission decisions.'
|
|
842
|
+
: [
|
|
843
|
+
'MCP permission cache:',
|
|
844
|
+
...entries.map((entry) => ` ${entry.server.padEnd(16)} ${entry.tool.padEnd(20)} ${entry.decision.padEnd(14)} ${entry.decidedAt}`),
|
|
845
|
+
].join('\n'));
|
|
846
|
+
return;
|
|
847
|
+
}
|
|
848
|
+
case 'reset': {
|
|
849
|
+
const target = args[1];
|
|
850
|
+
if (!target || !target.includes(':')) {
|
|
851
|
+
throw new Error('Usage: pugi mcp perms reset <server>:<tool>');
|
|
852
|
+
}
|
|
853
|
+
const idx = target.indexOf(':');
|
|
854
|
+
const server = target.slice(0, idx);
|
|
855
|
+
const tool = target.slice(idx + 1);
|
|
856
|
+
const removed = clearMcpPermission(server, tool);
|
|
857
|
+
ctx.writeOutput({ command: 'mcp.perms.reset', server, tool, removed }, removed
|
|
858
|
+
? `Forgot permission decision for ${server}:${tool}.`
|
|
859
|
+
: `No cached decision for ${server}:${tool}.`);
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
default:
|
|
863
|
+
throw new Error(`Unknown "pugi mcp perms ${sub}". Try: list, reset.`);
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
function resolveDecidedBy() {
|
|
867
|
+
return (process.env.PUGI_TRUSTED_BY?.trim() ||
|
|
868
|
+
process.env.USER?.trim() ||
|
|
869
|
+
process.env.USERNAME?.trim() ||
|
|
870
|
+
'cli');
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* Resolve the effective user home for diagnostics (e.g. surfacing the
|
|
874
|
+
* trust ledger path in `pugi mcp list --verbose`). Mirrors registry.ts.
|
|
875
|
+
*/
|
|
876
|
+
export function pugiHome() {
|
|
877
|
+
return process.env.PUGI_HOME ?? resolve(homedir(), '.pugi');
|
|
878
|
+
}
|
|
879
|
+
//# sourceMappingURL=mcp.js.map
|