@pugi/cli 0.1.0-beta.35 → 0.1.0-beta.36

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.
@@ -0,0 +1,595 @@
1
+ /**
2
+ * Pugi MCP server — orchestrator-tools surface (Wave 7 P1 / 2026-05-28).
3
+ *
4
+ * SCOPE — this module is intentionally orthogonal to `server-tools.ts`.
5
+ *
6
+ * - `server-tools.ts` exposes the *engine* surface (read / grep / glob /
7
+ * edit / write / bash) — workspace-scoped, file-tools-backed, used by
8
+ * "external agent borrows Pugi's in-process executor".
9
+ *
10
+ * - `orchestrator-tools.ts` (THIS FILE) exposes the *orchestrator*
11
+ * surface — `pugi.run` / `pugi.read` / `pugi.write` / `pugi.dispatch`
12
+ * / `pugi.publish` / `pugi.deploy`. These are CLI-level operations
13
+ * used by an EXTERNAL Claude Code (or Cursor) session that wants to
14
+ * loop fix-publish-test against the LIVE Pugi runtime. The motivating
15
+ * use case is the 2026-05-28 CEO dogfood blocker: Pugi REPL emits
16
+ * pseudo-tool-tags inline (no real file writes / no real shell exec);
17
+ * the operator wants to drive a remote Claude Code session that
18
+ * programmatically invokes Pugi against the engine VM, captures
19
+ * output, edits source, republishes the CLI, and re-tests — all
20
+ * without an interactive human at every step.
21
+ *
22
+ * SECURITY POSTURE — every orchestrator tool is gated by an env-var
23
+ * permission switch that defaults to OFF. The MCP server's
24
+ * `permissionGate` still applies on top (deny-by-default), but env
25
+ * gates are a coarser kill-switch the operator can flip per-machine
26
+ * without rebuilding the CLI.
27
+ *
28
+ * - PUGI_MCP_EXEC_ENABLED=1 — enables `pugi.run`
29
+ * - PUGI_MCP_PUBLISH_ENABLED=1 — enables `pugi.publish`
30
+ * - PUGI_MCP_DEPLOY_ENABLED=1 — enables `pugi.deploy`
31
+ *
32
+ * `pugi.read` / `pugi.write` / `pugi.dispatch` do not require an env
33
+ * gate (read+write enforce workspace + protected-path containment;
34
+ * dispatch only sends prompts to the operator's already-authenticated
35
+ * Anvil session). All three still pass through the MCP-server
36
+ * permissionGate, so an operator running `pugi mcp serve` without
37
+ * `--allow-write` still sees `pugi.write` refused at dispatch.
38
+ */
39
+ import { execFile } from 'node:child_process';
40
+ import { promisify } from 'node:util';
41
+ import { closeSync, fstatSync, mkdirSync, openSync, readFileSync, renameSync, statSync, writeFileSync, } from 'node:fs';
42
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
43
+ import { fileURLToPath } from 'node:url';
44
+ const execFileAsync = promisify(execFile);
45
+ /**
46
+ * Protected basename patterns — mirror of
47
+ * `core/bash-classifier.ts::PROTECTED_BASENAME_PATTERNS`. We DO NOT
48
+ * import from there because that module is bash-classifier specific
49
+ * (the regex shapes there carry shell-quote boundaries). For path-only
50
+ * matching we use simpler RegExps anchored on the basename. Keeps the
51
+ * two modules independently auditable.
52
+ */
53
+ const PROTECTED_BASENAMES = [
54
+ /^\.env$/,
55
+ /^\.env\.[A-Za-z0-9_-]+$/,
56
+ /^id_(rsa|ed25519|ecdsa|dsa)(\.pub)?$/,
57
+ /^\.npmrc$/,
58
+ /^\.pypirc$/,
59
+ /^\.gitconfig$/,
60
+ /^credentials(\.json)?$/,
61
+ ];
62
+ const PROTECTED_DIR_SEGMENTS = new Set([
63
+ '.git',
64
+ '.ssh',
65
+ '.gnupg',
66
+ 'node_modules',
67
+ ]);
68
+ /**
69
+ * Resolve + validate a caller-supplied path against the workspace
70
+ * root. Refuses absolute paths outside the root, parent-traversal
71
+ * escapes, and protected basenames / dir segments.
72
+ *
73
+ * Exported so the spec can drive it directly — pinning the security
74
+ * boundary at a single audited entry point.
75
+ */
76
+ export function resolveWorkspacePathOrThrow(ctx, requested) {
77
+ if (typeof requested !== 'string' || requested.length === 0) {
78
+ throw new Error('path must be a non-empty string');
79
+ }
80
+ if (requested.includes('\0')) {
81
+ throw new Error('path contains a null byte');
82
+ }
83
+ const root = resolve(ctx.workspaceRoot);
84
+ const candidate = isAbsolute(requested) ? requested : resolve(root, requested);
85
+ const absolute = resolve(candidate);
86
+ // Containment check — absolute must live under root. We use
87
+ // `relative` + `..` detection rather than `startsWith(root)` so a
88
+ // sibling dir whose name happens to share a prefix (e.g. /tmp/wsX
89
+ // vs /tmp/ws) does not accidentally pass.
90
+ const rel = relative(root, absolute);
91
+ if (rel === '' || rel === '.') {
92
+ throw new Error(`path "${requested}" resolves to the workspace root itself`);
93
+ }
94
+ if (rel.startsWith('..') || isAbsolute(rel)) {
95
+ throw new Error(`path "${requested}" escapes the workspace root`);
96
+ }
97
+ // Protected segment / basename check — applied to EVERY component of
98
+ // the resolved path under the root. We split on the OS separator so
99
+ // Windows + POSIX share the same gate.
100
+ const segments = rel.split(sep);
101
+ for (const segment of segments) {
102
+ if (PROTECTED_DIR_SEGMENTS.has(segment)) {
103
+ throw new Error(`path "${requested}" touches protected segment "${segment}"`);
104
+ }
105
+ for (const pattern of PROTECTED_BASENAMES) {
106
+ if (pattern.test(segment)) {
107
+ throw new Error(`path "${requested}" touches protected basename "${segment}"`);
108
+ }
109
+ }
110
+ }
111
+ return { absolute, relativeToRoot: rel };
112
+ }
113
+ /**
114
+ * Build the orchestrator tool surface. The MCP server consumes the
115
+ * returned array via `createPugiMcpServer({ tools })`. Permission
116
+ * gating happens at TWO layers:
117
+ *
118
+ * 1. `capabilities.{exec,publish,deploy}` — env-var kill-switch
119
+ * checked at tool-execute time. A tool whose capability is OFF
120
+ * throws a deterministic refusal message; the MCP wire surfaces
121
+ * it as `isError: true` content.
122
+ *
123
+ * 2. The MCP server's `permissionGate` — checked BEFORE execute
124
+ * runs. The `pugi mcp serve` wiring in `runtime/commands/mcp.ts`
125
+ * synthesises a default gate; callers (tests) can pass
126
+ * `() => true` to bypass.
127
+ *
128
+ * The double-layer design is intentional — it lets an operator
129
+ * configure `PUGI_MCP_EXEC_ENABLED=1` system-wide AND still refuse a
130
+ * specific `pugi.run` call via the per-tool prompt without restarting
131
+ * the server.
132
+ */
133
+ export function buildOrchestratorTools(ctx) {
134
+ const fetchImpl = ctx.fetchImpl ??
135
+ ((...args) => fetch(...args));
136
+ const execImpl = ctx.execFileImpl ?? execFileAsync;
137
+ const tools = [
138
+ {
139
+ name: 'pugi.run',
140
+ description: 'Execute a pugi CLI subcommand and capture stdout/stderr/exitCode. ' +
141
+ 'Use for `--version`, `explain`, `smoke`, etc. ' +
142
+ 'Requires PUGI_MCP_EXEC_ENABLED=1 at server boot.',
143
+ permission: 'bash',
144
+ inputSchema: {
145
+ type: 'object',
146
+ additionalProperties: false,
147
+ required: ['command'],
148
+ properties: {
149
+ command: {
150
+ type: 'string',
151
+ description: 'Whitespace-tokenised argv tail (e.g. "explain README.md").',
152
+ },
153
+ cwd: {
154
+ type: 'string',
155
+ description: 'Optional workspace-relative cwd; defaults to workspace root.',
156
+ },
157
+ timeoutMs: {
158
+ type: 'number',
159
+ minimum: 100,
160
+ maximum: 300000,
161
+ description: 'Hard timeout in ms (default 30000).',
162
+ },
163
+ },
164
+ },
165
+ async execute(args) {
166
+ if (!ctx.capabilities.exec) {
167
+ throw new Error('pugi.run: PUGI_MCP_EXEC_ENABLED is not set. ' +
168
+ 'Restart `pugi mcp serve` with PUGI_MCP_EXEC_ENABLED=1 to enable shell execution.');
169
+ }
170
+ const command = requireString(args, 'command');
171
+ const tokens = tokeniseArgv(command);
172
+ if (tokens.length === 0) {
173
+ throw new Error('pugi.run: command tokenises to zero args');
174
+ }
175
+ const timeoutMs = optionalNumber(args, 'timeoutMs', 30000);
176
+ const cwdInput = optionalString(args, 'cwd');
177
+ const cwd = cwdInput
178
+ ? resolveWorkspacePathOrThrow(ctx, cwdInput).absolute
179
+ : ctx.workspaceRoot;
180
+ const started = Date.now();
181
+ try {
182
+ const { stdout, stderr } = await execImpl(ctx.pugiBin, tokens, {
183
+ cwd,
184
+ timeout: timeoutMs,
185
+ maxBuffer: 4 * 1024 * 1024,
186
+ // Strip secret envs — orchestrator-driven CLI runs do NOT
187
+ // need the operator's NPM_TOKEN / GH_TOKEN / OPENAI_API_KEY
188
+ // visible. We pass through only PATH + HOME + a minimal
189
+ // shell. Same posture as bashToolSync(source='mcp').
190
+ env: sanitisedEnv(),
191
+ });
192
+ const durationMs = Date.now() - started;
193
+ return JSON.stringify({
194
+ stdout: clamp(stdout, 32 * 1024),
195
+ stderr: clamp(stderr, 32 * 1024),
196
+ exitCode: 0,
197
+ durationMs,
198
+ });
199
+ }
200
+ catch (err) {
201
+ const e = err;
202
+ const durationMs = Date.now() - started;
203
+ return JSON.stringify({
204
+ stdout: clamp(e.stdout ?? '', 32 * 1024),
205
+ stderr: clamp(e.stderr ?? (e.message ?? ''), 32 * 1024),
206
+ exitCode: typeof e.code === 'number' ? e.code : 1,
207
+ durationMs,
208
+ ...(e.signal ? { signal: e.signal } : {}),
209
+ ...(e.killed ? { killed: true } : {}),
210
+ });
211
+ }
212
+ },
213
+ },
214
+ {
215
+ name: 'pugi.read',
216
+ description: 'Read a file inside the configured workspace root. Refuses paths outside ' +
217
+ 'the root, parent-traversal escapes, and protected basenames (.env / .git / ' +
218
+ '.ssh / id_rsa / .npmrc / credentials.json). Default cap 256KB.',
219
+ permission: 'read',
220
+ inputSchema: {
221
+ type: 'object',
222
+ additionalProperties: false,
223
+ required: ['path'],
224
+ properties: {
225
+ path: { type: 'string' },
226
+ },
227
+ },
228
+ async execute(args) {
229
+ const path = requireString(args, 'path');
230
+ const { absolute, relativeToRoot } = resolveWorkspacePathOrThrow(ctx, path);
231
+ const stat = statSync(absolute);
232
+ if (!stat.isFile()) {
233
+ throw new Error(`pugi.read: "${relativeToRoot}" is not a regular file`);
234
+ }
235
+ const CAP = 256 * 1024;
236
+ const content = readFileSync(absolute, 'utf8');
237
+ const sizeBytes = Buffer.byteLength(content, 'utf8');
238
+ const truncated = sizeBytes > CAP;
239
+ return JSON.stringify({
240
+ path: relativeToRoot,
241
+ content: truncated ? content.slice(0, CAP) : content,
242
+ sizeBytes,
243
+ mtime: stat.mtime.toISOString(),
244
+ ...(truncated ? { truncated: true, capBytes: CAP } : {}),
245
+ });
246
+ },
247
+ },
248
+ {
249
+ name: 'pugi.write',
250
+ description: 'Create or overwrite a workspace file using atomic tmp+rename. Refuses paths ' +
251
+ 'outside the workspace root and protected basenames.',
252
+ permission: 'edit',
253
+ inputSchema: {
254
+ type: 'object',
255
+ additionalProperties: false,
256
+ required: ['path', 'content'],
257
+ properties: {
258
+ path: { type: 'string' },
259
+ content: { type: 'string' },
260
+ },
261
+ },
262
+ async execute(args) {
263
+ const path = requireString(args, 'path');
264
+ const content = requireString(args, 'content');
265
+ const { absolute, relativeToRoot } = resolveWorkspacePathOrThrow(ctx, path);
266
+ mkdirSync(dirname(absolute), { recursive: true });
267
+ const tmpPath = `${absolute}.pugi-mcp-tmp-${process.pid}-${Date.now()}`;
268
+ // Open with O_CREAT|O_EXCL so a concurrent writer cannot race
269
+ // a same-named tmp file out from under us. Mode 0o600 (operator
270
+ // only) — orchestrator writes are NOT shared artefacts.
271
+ const fd = openSync(tmpPath, 'wx', 0o600);
272
+ try {
273
+ writeFileSync(fd, content, 'utf8');
274
+ // fsync via fstatSync is a no-op on most kernels — the real
275
+ // durability win comes from rename being atomic at the inode
276
+ // layer. We still touch the fd to surface any late-EIO before
277
+ // rename commits.
278
+ fstatSync(fd);
279
+ }
280
+ finally {
281
+ closeSync(fd);
282
+ }
283
+ renameSync(tmpPath, absolute);
284
+ const bytesWritten = Buffer.byteLength(content, 'utf8');
285
+ return JSON.stringify({
286
+ path: relativeToRoot,
287
+ bytesWritten,
288
+ });
289
+ },
290
+ },
291
+ {
292
+ name: 'pugi.dispatch',
293
+ description: 'Send a prompt to the live Pugi engine (Anvil-routed). Returns the synthesised ' +
294
+ 'response, tool calls, cost, duration, and file changes. Uses the operator’s ' +
295
+ 'currently active credential — set PUGI_API_KEY or run `pugi login` first.',
296
+ permission: 'network',
297
+ inputSchema: {
298
+ type: 'object',
299
+ additionalProperties: false,
300
+ required: ['prompt'],
301
+ properties: {
302
+ prompt: { type: 'string' },
303
+ persona: {
304
+ type: 'string',
305
+ description: 'Persona id, default "mira".',
306
+ },
307
+ workspace: {
308
+ type: 'string',
309
+ description: 'Workspace label (logical, not a filesystem path).',
310
+ },
311
+ },
312
+ },
313
+ async execute(args) {
314
+ const prompt = requireString(args, 'prompt');
315
+ const persona = optionalString(args, 'persona') ?? 'mira';
316
+ const workspace = optionalString(args, 'workspace');
317
+ if (!ctx.apiKey) {
318
+ throw new Error('pugi.dispatch: no active credential. Set PUGI_API_KEY or run `pugi login`.');
319
+ }
320
+ const endpoint = `${ctx.apiUrl.replace(/\/+$/, '')}/api/pugi/engine`;
321
+ const started = Date.now();
322
+ const response = await fetchImpl(endpoint, {
323
+ method: 'POST',
324
+ headers: {
325
+ Authorization: `Bearer ${ctx.apiKey}`,
326
+ 'Content-Type': 'application/json',
327
+ Accept: 'application/json',
328
+ 'User-Agent': 'pugi-mcp-orchestrator/1',
329
+ },
330
+ body: JSON.stringify({
331
+ prompt,
332
+ persona,
333
+ ...(workspace ? { workspace } : {}),
334
+ }),
335
+ });
336
+ const durationMs = Date.now() - started;
337
+ if (!response.ok) {
338
+ const bodyText = await safeText(response);
339
+ throw new Error(`pugi.dispatch: ${response.status} ${response.statusText} — ${clamp(bodyText, 2000)}`);
340
+ }
341
+ const parsed = (await response.json().catch(() => ({})));
342
+ return JSON.stringify({
343
+ response: typeof parsed.response === 'string' ? parsed.response : '',
344
+ toolCalls: Array.isArray(parsed.toolCalls) ? parsed.toolCalls : [],
345
+ cost: typeof parsed.cost === 'number' ? parsed.cost : 0,
346
+ durationMs,
347
+ fileChanges: Array.isArray(parsed.fileChanges) ? parsed.fileChanges : [],
348
+ });
349
+ },
350
+ },
351
+ {
352
+ name: 'pugi.publish',
353
+ description: 'Bump @pugi/cli version + build + publish to npm. Use bumpType "beta" for ' +
354
+ 'prerelease bumps (default) or "patch" for stable. Requires ' +
355
+ 'PUGI_MCP_PUBLISH_ENABLED=1 AND a configured ~/.npmrc auth token.',
356
+ permission: 'network',
357
+ inputSchema: {
358
+ type: 'object',
359
+ additionalProperties: false,
360
+ properties: {
361
+ bumpType: {
362
+ type: 'string',
363
+ enum: ['patch', 'beta'],
364
+ description: 'Default "beta" — pre-release bump.',
365
+ },
366
+ },
367
+ },
368
+ async execute(args) {
369
+ if (!ctx.capabilities.publish) {
370
+ throw new Error('pugi.publish: PUGI_MCP_PUBLISH_ENABLED is not set. ' +
371
+ 'Restart `pugi mcp serve` with PUGI_MCP_PUBLISH_ENABLED=1 to enable.');
372
+ }
373
+ const bumpType = optionalString(args, 'bumpType') ?? 'beta';
374
+ if (bumpType !== 'patch' && bumpType !== 'beta') {
375
+ throw new Error(`pugi.publish: invalid bumpType "${bumpType}"`);
376
+ }
377
+ // npm version semantics: "patch" bumps z; "prerelease --preid beta"
378
+ // bumps the beta tag. We thread through `pnpm` because the
379
+ // monorepo build expects the workspace-aware variant.
380
+ const versionArgs = bumpType === 'beta'
381
+ ? ['version', 'prerelease', '--preid', 'beta', '--no-git-tag-version']
382
+ : ['version', 'patch', '--no-git-tag-version'];
383
+ const versionOut = await execImpl('npm', versionArgs, {
384
+ cwd: ctx.workspaceRoot,
385
+ timeout: 60000,
386
+ env: sanitisedEnv(),
387
+ });
388
+ const newVersion = (versionOut.stdout || '').trim().replace(/^v/, '');
389
+ const buildOut = await execImpl('pnpm', ['build'], {
390
+ cwd: ctx.workspaceRoot,
391
+ timeout: 180000,
392
+ env: sanitisedEnv(),
393
+ });
394
+ const publishOut = await execImpl('pnpm', ['publish', '--no-git-checks', '--access', 'public'], {
395
+ cwd: ctx.workspaceRoot,
396
+ timeout: 180000,
397
+ env: sanitisedEnv(),
398
+ });
399
+ return JSON.stringify({
400
+ newVersion,
401
+ registry: 'https://registry.npmjs.org',
402
+ npmExitCode: 0,
403
+ buildStdoutTail: clamp(buildOut.stdout, 2000),
404
+ publishStdoutTail: clamp(publishOut.stdout, 2000),
405
+ });
406
+ },
407
+ },
408
+ {
409
+ name: 'pugi.deploy',
410
+ description: 'SSH-redeploy a Pugi service on the engine VM (admin-api / admin-web / ' +
411
+ 'pugi-web / all). Runs git pull + pnpm install + build + pm2 restart. ' +
412
+ 'Requires PUGI_MCP_DEPLOY_ENABLED=1.',
413
+ permission: 'network',
414
+ inputSchema: {
415
+ type: 'object',
416
+ additionalProperties: false,
417
+ required: ['target'],
418
+ properties: {
419
+ target: {
420
+ type: 'string',
421
+ enum: ['admin-api', 'admin-web', 'pugi-web', 'all'],
422
+ },
423
+ },
424
+ },
425
+ async execute(args) {
426
+ if (!ctx.capabilities.deploy) {
427
+ throw new Error('pugi.deploy: PUGI_MCP_DEPLOY_ENABLED is not set. ' +
428
+ 'Restart `pugi mcp serve` with PUGI_MCP_DEPLOY_ENABLED=1 to enable.');
429
+ }
430
+ const target = requireString(args, 'target');
431
+ const allowed = ['admin-api', 'admin-web', 'pugi-web', 'all'];
432
+ if (!allowed.includes(target)) {
433
+ throw new Error(`pugi.deploy: invalid target "${target}" (allowed: ${allowed.join(', ')})`);
434
+ }
435
+ // The redeploy script lives on the engine VM at ~/deploy/<target>.sh.
436
+ // We do NOT inline the shell — the operator owns the remote
437
+ // script and can tune it without rebuilding the CLI.
438
+ const remoteCmd = `set -euo pipefail; ~/deploy/${target}.sh`;
439
+ const started = Date.now();
440
+ const { stdout, stderr } = await execImpl('ssh', [
441
+ // BatchMode rejects password prompts so a misconfigured
442
+ // ssh-agent fails fast instead of blocking the dispatch.
443
+ '-o',
444
+ 'BatchMode=yes',
445
+ '-o',
446
+ 'StrictHostKeyChecking=accept-new',
447
+ ctx.sshAlias,
448
+ remoteCmd,
449
+ ], {
450
+ cwd: ctx.workspaceRoot,
451
+ timeout: 300000,
452
+ maxBuffer: 4 * 1024 * 1024,
453
+ env: sanitisedEnv(),
454
+ });
455
+ const durationMs = Date.now() - started;
456
+ return JSON.stringify({
457
+ host: ctx.sshAlias,
458
+ target,
459
+ gitPullHead: extractGitHead(stdout) ?? null,
460
+ pm2Status: extractPm2Status(stdout, stderr) ?? null,
461
+ durationMs,
462
+ stdoutTail: clamp(stdout, 4000),
463
+ stderrTail: clamp(stderr, 2000),
464
+ });
465
+ },
466
+ },
467
+ ];
468
+ return tools.sort((a, b) => a.name.localeCompare(b.name));
469
+ }
470
+ /* ---------- helpers ---------------------------------------------------- */
471
+ function requireString(args, key) {
472
+ const v = args[key];
473
+ if (typeof v !== 'string' || v.length === 0) {
474
+ throw new Error(`argument "${key}" must be a non-empty string`);
475
+ }
476
+ return v;
477
+ }
478
+ function optionalString(args, key) {
479
+ const v = args[key];
480
+ if (v === undefined || v === null)
481
+ return undefined;
482
+ if (typeof v !== 'string') {
483
+ throw new Error(`argument "${key}" must be a string when set`);
484
+ }
485
+ return v;
486
+ }
487
+ function optionalNumber(args, key, fallback) {
488
+ const v = args[key];
489
+ if (v === undefined || v === null)
490
+ return fallback;
491
+ if (typeof v !== 'number' || !Number.isFinite(v)) {
492
+ throw new Error(`argument "${key}" must be a finite number when set`);
493
+ }
494
+ return v;
495
+ }
496
+ function clamp(s, max) {
497
+ if (typeof s !== 'string')
498
+ return '';
499
+ if (s.length <= max)
500
+ return s;
501
+ return `${s.slice(0, max)}\n…(truncated at ${max} bytes)`;
502
+ }
503
+ /**
504
+ * Tokenise an argv tail the same way Claude Code's `pugi run` quoting
505
+ * convention does — whitespace-split with double-quote groups
506
+ * preserved. We do NOT eval a shell because that would let the model
507
+ * inject arbitrary commands (e.g. `; rm -rf ~`) into the orchestrator
508
+ * surface. Anything fancier (env-var expansion, globbing) must be
509
+ * delegated to the model via a `bash` capability flag — which is
510
+ * intentionally not part of this surface.
511
+ *
512
+ * Exported for the spec.
513
+ */
514
+ export function tokeniseArgv(command) {
515
+ const out = [];
516
+ let buf = '';
517
+ let inQuotes = false;
518
+ for (let i = 0; i < command.length; i += 1) {
519
+ const ch = command[i];
520
+ if (ch === '"') {
521
+ inQuotes = !inQuotes;
522
+ continue;
523
+ }
524
+ if (ch === '\\' && command[i + 1] === '"') {
525
+ buf += '"';
526
+ i += 1;
527
+ continue;
528
+ }
529
+ if (!inQuotes && (ch === ' ' || ch === '\t')) {
530
+ if (buf.length > 0) {
531
+ out.push(buf);
532
+ buf = '';
533
+ }
534
+ continue;
535
+ }
536
+ buf += ch;
537
+ }
538
+ if (inQuotes) {
539
+ throw new Error('pugi.run: unterminated double-quote in command');
540
+ }
541
+ if (buf.length > 0)
542
+ out.push(buf);
543
+ return out;
544
+ }
545
+ function sanitisedEnv() {
546
+ // Allowlist — pass through only what `pugi` needs to find itself
547
+ // and the local toolchain. NPM_TOKEN is added back for
548
+ // `pugi.publish` via the npm CLI's own ~/.npmrc lookup — we do not
549
+ // pass it via env because that surface ends up in `ps` output on
550
+ // some kernels.
551
+ const allow = ['PATH', 'HOME', 'USER', 'SHELL', 'LANG', 'LC_ALL', 'TERM', 'NODE_OPTIONS'];
552
+ const out = {};
553
+ for (const key of allow) {
554
+ const value = process.env[key];
555
+ if (value !== undefined)
556
+ out[key] = value;
557
+ }
558
+ return out;
559
+ }
560
+ async function safeText(response) {
561
+ try {
562
+ return await response.text();
563
+ }
564
+ catch {
565
+ return '';
566
+ }
567
+ }
568
+ function extractGitHead(stdout) {
569
+ // Match "HEAD is now at <sha> …" or "<sha> commit message" — the
570
+ // remote redeploy script logs `git rev-parse HEAD` after pull.
571
+ const m = stdout.match(/(?:HEAD is now at|^|\n)([0-9a-f]{7,40})\b/);
572
+ return m ? m[1] : null;
573
+ }
574
+ function extractPm2Status(stdout, stderr) {
575
+ const haystack = `${stdout}\n${stderr}`;
576
+ // Match "[PM2] Process pugi-admin-api restarted" or "online" / "stopped"
577
+ const restart = haystack.match(/\[PM2\][^\n]+(restarted|online|stopped|errored)/i);
578
+ if (restart)
579
+ return restart[0].trim();
580
+ return null;
581
+ }
582
+ /* ---------- helper: load this module from compiled JS at runtime ------- */
583
+ // `fileURLToPath(import.meta.url)` is used by sibling modules to find
584
+ // fixtures at runtime; we re-export it here so the spec can build an
585
+ // isolated workspace next to the compiled module without hard-coding
586
+ // paths. Defensive — not currently used by the production wiring.
587
+ export const ORCHESTRATOR_TOOLS_MODULE_FILE = (() => {
588
+ try {
589
+ return fileURLToPath(import.meta.url);
590
+ }
591
+ catch {
592
+ return '';
593
+ }
594
+ })();
595
+ //# sourceMappingURL=orchestrator-tools.js.map
@@ -10,7 +10,9 @@ import { loadMcpRegistry, mcpLogPath } from '../../core/mcp/registry.js';
10
10
  import { listMcpTrust, setMcpTrust } from '../../core/mcp/trust.js';
11
11
  import { createPugiMcpServer, serveStdio } from '../../core/mcp/server.js';
12
12
  import { buildPugiMcpTools } from '../../core/mcp/server-tools.js';
13
+ import { buildOrchestratorTools } from '../../core/mcp/orchestrator-tools.js';
13
14
  import { serveHttp } from '../../core/mcp/http-server.js';
15
+ import { resolveActiveCredential, DEFAULT_API_URL } from '../../core/credentials.js';
14
16
  import { listMcpPermissions, clearMcpPermission, } from '../../core/mcp/permission.js';
15
17
  export async function runMcpCommand(args, ctx) {
16
18
  const sub = args[0] ?? 'list';
@@ -74,6 +76,15 @@ const USAGE_LINES = [
74
76
  ' --allow-write Expose edit/write (default off — explicit opt-in).',
75
77
  ' --allow-bash Expose the bash tool (default off — explicit opt-in).',
76
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',
77
88
  ' perms list Show cached per-(server, tool) decisions',
78
89
  ' perms reset <server>:<tool> Forget one cached decision',
79
90
  ];
@@ -536,16 +547,23 @@ async function runMcpServe(args, ctx) {
536
547
  const readOnly = flags.readOnly === true;
537
548
  const writeAllowed = !readOnly && flags.writeAllowed;
538
549
  const bashAllowed = !readOnly && flags.bashAllowed;
539
- const tools = buildPugiMcpTools(toolCtx, {
540
- bashAllowed,
541
- // Keep the legacy contract: `readOnly` for the tool-builder means
542
- // "do not advertise edit/write tools". Bash advertisement is gated
543
- // by the independent `bashAllowed` knob. So the builder sees
544
- // `readOnly = true` whenever the operator did not opt into write
545
- // explicitly, which preserves the deny-by-default surface for
546
- // edit/write but no longer accidentally suppresses bash.
547
- readOnly: readOnly || !writeAllowed,
548
- });
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
+ });
549
567
  // β4 r1 P1 #2 — deny-by-default permissionGate. The MCP cache + FSM
550
568
  // are consulted on every dispatch; allow_always-cached entries pass
551
569
  // silently, allow_once entries pass and self-clear, deny entries
@@ -604,6 +622,7 @@ async function runMcpServe(args, ctx) {
604
622
  command: 'mcp.serve',
605
623
  transport: 'http',
606
624
  url: handle.url,
625
+ surface: flags.orchestrator ? 'orchestrator' : 'engine',
607
626
  bearerTokenSource: handle.bearerTokenAutoGenerated
608
627
  ? 'auto-generated (see stderr)'
609
628
  : explicitToken === envToken
@@ -649,7 +668,7 @@ async function runMcpServe(args, ctx) {
649
668
  // the wire; nothing is printed unless the parent agent sends a
650
669
  // request that returns a response. Operator sees one info line on
651
670
  // stderr so they know the server is up.
652
- process.stderr.write(`pugi-mcp (stdio): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
671
+ process.stderr.write(`pugi-mcp (stdio, ${flags.orchestrator ? 'orchestrator' : 'engine'}): ${tools.length} tool(s) — ${tools.map((t) => t.name).join(', ')}\n`);
653
672
  await serveStdio({
654
673
  server,
655
674
  stdin: ctx.stdin ?? process.stdin,
@@ -665,6 +684,7 @@ function parseServeFlags(args) {
665
684
  readOnly: false,
666
685
  writeAllowed: false,
667
686
  bashAllowed: false,
687
+ orchestrator: false,
668
688
  };
669
689
  for (let i = 0; i < args.length; i += 1) {
670
690
  const arg = args[i] ?? '';
@@ -722,6 +742,9 @@ function parseServeFlags(args) {
722
742
  // so existing operator scripts do not error.
723
743
  flags.bashAllowed = false;
724
744
  }
745
+ else if (arg === '--orchestrator') {
746
+ flags.orchestrator = true;
747
+ }
725
748
  else if (arg === '--help') {
726
749
  // Caller renders USAGE_LINES. We surface the same via top-level
727
750
  // dispatch — nothing to do here, just don't error.
@@ -755,9 +778,41 @@ function buildServePermissionGate(opts) {
755
778
  return false;
756
779
  if (tool.permission === 'edit' && !opts.writeAllowed)
757
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).
758
788
  return true;
759
789
  };
760
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
+ }
761
816
  function parseHttpBinding(input) {
762
817
  // Accept `:7100`, `7100`, or `host:7100`.
763
818
  let host = '127.0.0.1';
@@ -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.35');
47
+ export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.36');
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pugi/cli",
3
- "version": "0.1.0-beta.35",
3
+ "version": "0.1.0-beta.36",
4
4
  "description": "Pugi CLI - terminal-native software execution system",
5
5
  "homepage": "https://pugi.io",
6
6
  "repository": {
@@ -55,7 +55,7 @@
55
55
  "undici": "^8.3.0",
56
56
  "zod": "^3.23.0",
57
57
  "@pugi/personas": "0.1.2",
58
- "@pugi/sdk": "0.1.0-beta.35"
58
+ "@pugi/sdk": "0.1.0-beta.36"
59
59
  },
60
60
  "devDependencies": {
61
61
  "@types/node": "^22.0.0",