@pugi/cli 0.1.0-beta.1 → 0.1.0-beta.2

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.
@@ -28,6 +28,9 @@ import { runUndoCommand } from './commands/undo.js';
28
28
  import { runBudgetCommand } from './commands/budget.js';
29
29
  import { runSkillsCommand } from './commands/skills.js';
30
30
  import { runAgentsCommand } from './commands/agents.js';
31
+ import { runLspCommand } from './commands/lsp.js';
32
+ import { runPatchCommand } from './commands/patch.js';
33
+ import { runWorktreeCommand } from './commands/worktree.js';
31
34
  import { resolveWorkspaceLabel } from '../core/repl/workspace-context.js';
32
35
  import { runReviewConsensus } from './commands/review-consensus.js';
33
36
  import { FtsSyntaxError, SqliteSessionStore, resolveProjectStoreDir } from '../core/repl/store/index.js';
@@ -44,7 +47,7 @@ import { dispatchEdit, } from '../core/edits/index.js';
44
47
  * packages/pugi-sdk/package.json); the publish workflow validates the
45
48
  * three are in lockstep.
46
49
  */
47
- const PUGI_CLI_VERSION = "0.1.0-beta.1";
50
+ const PUGI_CLI_VERSION = "0.1.0-beta.2";
48
51
  const handlers = {
49
52
  accounts,
50
53
  agents: dispatchAgents,
@@ -64,6 +67,8 @@ const handlers = {
64
67
  jobs,
65
68
  login,
66
69
  logout,
70
+ lsp: dispatchLsp,
71
+ patch: dispatchPatch,
67
72
  plan: runEngineTask('plan'),
68
73
  'plan-review': dispatchPlanReview,
69
74
  privacy: dispatchPrivacy,
@@ -76,6 +81,7 @@ const handlers = {
76
81
  version,
77
82
  web: dispatchWeb,
78
83
  whoami,
84
+ worktree: dispatchWorktree,
79
85
  };
80
86
  /**
81
87
  * α6.3 `pugi ask "<question>"` — surface the office-hours forcing-question
@@ -314,6 +320,46 @@ async function dispatchWeb(args, flags, _session) {
314
320
  }
315
321
  writeOutput(flags, result, `# ${result.title}\n# ${result.url}\n# fetched ${result.fetched_at}\n\n${result.content_md}`);
316
322
  }
323
+ /**
324
+ * α7.7: `pugi lsp <op> <file> [args]` — direct LSP queries. Delegated
325
+ * to the standalone runner in `./commands/lsp.ts` so the giant cli.ts
326
+ * dispatch table stays narrow. The runner spawns + tears down the LSP
327
+ * server per invocation (no daemon yet — that ships in α7.7b).
328
+ */
329
+ async function dispatchLsp(args, flags, _session) {
330
+ const result = await runLspCommand(args, { cwd: process.cwd(), json: flags.json });
331
+ if (flags.json)
332
+ console.log(result.text);
333
+ else
334
+ console.log(result.text);
335
+ if (result.exitCode !== 0)
336
+ process.exitCode = result.exitCode;
337
+ }
338
+ /**
339
+ * α7.7: `pugi patch` — apply a unified-diff patch from stdin or a file.
340
+ * Routes through the same security gate as the Layer A/B/C applicators
341
+ * (see `src/core/edits/security-gate.ts`). Exit codes mirror the
342
+ * security taxonomy so CI loops can alert on hostile patches without
343
+ * confusing them with operator typos.
344
+ */
345
+ async function dispatchPatch(args, flags, _session) {
346
+ const result = await runPatchCommand(args, { cwd: process.cwd(), json: flags.json });
347
+ console.log(result.text);
348
+ if (result.exitCode !== 0)
349
+ process.exitCode = result.exitCode;
350
+ }
351
+ /**
352
+ * α7.7: `pugi worktree <op>` — manual scratch worktree management.
353
+ * The `pugi build` and `pugi review --consensus` paths use the same
354
+ * primitives internally (`createWorktree` / `promoteWorktree`); this
355
+ * surface is the operator escape hatch for debug + experiment flows.
356
+ */
357
+ async function dispatchWorktree(args, flags, _session) {
358
+ const result = await runWorktreeCommand(args, { cwd: process.cwd(), json: flags.json });
359
+ console.log(result.text);
360
+ if (result.exitCode !== 0)
361
+ process.exitCode = result.exitCode;
362
+ }
317
363
  export async function runCli(argv) {
318
364
  const { command, args, flags, isBareInvocation } = parseArgs(argv);
319
365
  // Bare `pugi` on a TTY enters the REPL-by-default agentic session
@@ -0,0 +1,184 @@
1
+ /**
2
+ * `pugi lsp <op> <file> [args...]` — α7.7 Phase 1.
3
+ *
4
+ * Direct LSP queries from the CLI surface. Operators use this for
5
+ * debugging and scripting; the agent loop reaches the same operations
6
+ * via the `lsp_hover` / `lsp_definition` / `lsp_references` /
7
+ * `lsp_diagnostics` tool wrappers in `src/tools/lsp-tools.ts`.
8
+ *
9
+ * Supported subcommands:
10
+ *
11
+ * pugi lsp hover <file> <line> <col> [--lang ts|js|py|go|rust]
12
+ * pugi lsp definition <file> <line> <col> [--lang ...]
13
+ * pugi lsp references <file> <line> <col> [--lang ...]
14
+ * pugi lsp diagnostics <file> [--lang ...]
15
+ *
16
+ * When `--lang` is omitted we infer from the file extension. An unknown
17
+ * extension surfaces `language_unsupported` so the operator can specify
18
+ * the language explicitly.
19
+ *
20
+ * Lifecycle: we spawn an LSP server per invocation and stop it before
21
+ * returning. This is slow on cold start (TS server takes ~2-3s the
22
+ * first time) but the single-shot scripting path doesn't need a
23
+ * persistent daemon. Future work (α7.7b) wires a per-REPL daemon.
24
+ *
25
+ * Brand voice: ASCII only, no emoji, no banned words.
26
+ */
27
+ import { extname } from 'node:path';
28
+ import { startLspClient } from '../../core/lsp/client.js';
29
+ export async function runLspCommand(args, opts) {
30
+ const [op, file, ...rest] = args;
31
+ if (!op || !file) {
32
+ return usage();
33
+ }
34
+ if (!['hover', 'definition', 'references', 'diagnostics'].includes(op)) {
35
+ return {
36
+ ok: false,
37
+ text: `unknown lsp operation: ${op}. Supported: hover, definition, references, diagnostics`,
38
+ exitCode: 2,
39
+ };
40
+ }
41
+ const { lang: explicitLang, positional } = pullLangFlag(rest);
42
+ const lang = explicitLang ?? inferLanguage(file);
43
+ if (!lang) {
44
+ return {
45
+ ok: false,
46
+ text: `cannot infer language from ${file}; pass --lang ts|js|py|go|rust. ` +
47
+ `Supported extensions: .ts/.tsx, .js/.jsx/.mjs, .py, .go, .rs`,
48
+ exitCode: 2,
49
+ };
50
+ }
51
+ const clientResult = await startLspClient(lang, { cwd: opts.cwd });
52
+ if (!clientResult.ok) {
53
+ return {
54
+ ok: false,
55
+ text: `lsp_unavailable: ${clientResult.detail}`,
56
+ exitCode: 1,
57
+ };
58
+ }
59
+ const client = clientResult.value;
60
+ try {
61
+ if (op === 'diagnostics') {
62
+ const result = await client.diagnostics(file);
63
+ if (!result.ok) {
64
+ return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
65
+ }
66
+ if (opts.json) {
67
+ return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
68
+ }
69
+ if (result.value.length === 0) {
70
+ return { ok: true, text: `${file}: no diagnostics`, exitCode: 0 };
71
+ }
72
+ const lines = result.value.map((d) => `${d.severityLabel}\t${file}:${d.range.start.line + 1}:${d.range.start.character + 1}\t${d.message}`);
73
+ return { ok: true, text: lines.join('\n'), exitCode: 0 };
74
+ }
75
+ const line = Number.parseInt(positional[0] ?? '', 10);
76
+ const col = Number.parseInt(positional[1] ?? '', 10);
77
+ if (!Number.isFinite(line) || !Number.isFinite(col)) {
78
+ return {
79
+ ok: false,
80
+ text: `lsp ${op} requires <line> <col> arguments (1-based)`,
81
+ exitCode: 2,
82
+ };
83
+ }
84
+ // LSP positions are 0-based; we accept 1-based input from the CLI
85
+ // (matches every other editor convention) and convert here.
86
+ const pos = { line: Math.max(0, line - 1), character: Math.max(0, col - 1) };
87
+ if (op === 'hover') {
88
+ const result = await client.hover(file, pos);
89
+ if (!result.ok) {
90
+ return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
91
+ }
92
+ if (opts.json) {
93
+ return { ok: true, text: JSON.stringify(result.value ?? null, null, 2), exitCode: 0 };
94
+ }
95
+ if (!result.value) {
96
+ return { ok: true, text: `${file}:${line}:${col}: no hover available`, exitCode: 0 };
97
+ }
98
+ return { ok: true, text: result.value.content, exitCode: 0 };
99
+ }
100
+ if (op === 'definition') {
101
+ const result = await client.definition(file, pos);
102
+ if (!result.ok) {
103
+ return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
104
+ }
105
+ if (opts.json) {
106
+ return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
107
+ }
108
+ const lines = result.value.map((loc) => `${loc.path || loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`);
109
+ return { ok: true, text: lines.join('\n') || 'no definition', exitCode: 0 };
110
+ }
111
+ // references
112
+ const result = await client.references(file, pos);
113
+ if (!result.ok) {
114
+ return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
115
+ }
116
+ if (opts.json) {
117
+ return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
118
+ }
119
+ const lines = result.value.map((loc) => `${loc.path || loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`);
120
+ return { ok: true, text: lines.join('\n') || 'no references', exitCode: 0 };
121
+ }
122
+ finally {
123
+ await client.stop();
124
+ }
125
+ }
126
+ function usage() {
127
+ return {
128
+ ok: false,
129
+ text: 'Usage: pugi lsp <op> <file> [line] [col] [--lang ts|js|py|go|rust]\n' +
130
+ ' pugi lsp hover <file> <line> <col>\n' +
131
+ ' pugi lsp definition <file> <line> <col>\n' +
132
+ ' pugi lsp references <file> <line> <col>\n' +
133
+ ' pugi lsp diagnostics <file>',
134
+ exitCode: 2,
135
+ };
136
+ }
137
+ function pullLangFlag(args) {
138
+ let lang;
139
+ const positional = [];
140
+ for (let i = 0; i < args.length; i += 1) {
141
+ const arg = args[i] ?? '';
142
+ if (arg === '--lang') {
143
+ const value = args[i + 1];
144
+ if (isLspLanguage(value))
145
+ lang = value;
146
+ i += 1;
147
+ continue;
148
+ }
149
+ if (arg.startsWith('--lang=')) {
150
+ const value = arg.slice('--lang='.length);
151
+ if (isLspLanguage(value))
152
+ lang = value;
153
+ continue;
154
+ }
155
+ positional.push(arg);
156
+ }
157
+ return { lang, positional };
158
+ }
159
+ function isLspLanguage(value) {
160
+ return value === 'ts' || value === 'js' || value === 'py' || value === 'go' || value === 'rust';
161
+ }
162
+ export function inferLanguage(file) {
163
+ const ext = extname(file).toLowerCase();
164
+ switch (ext) {
165
+ case '.ts':
166
+ case '.tsx':
167
+ return 'ts';
168
+ case '.js':
169
+ case '.jsx':
170
+ case '.mjs':
171
+ case '.cjs':
172
+ return 'js';
173
+ case '.py':
174
+ case '.pyi':
175
+ return 'py';
176
+ case '.go':
177
+ return 'go';
178
+ case '.rs':
179
+ return 'rust';
180
+ default:
181
+ return undefined;
182
+ }
183
+ }
184
+ //# sourceMappingURL=lsp.js.map
@@ -0,0 +1,111 @@
1
+ /**
2
+ * `pugi patch` — α7.7 Phase 1.
3
+ *
4
+ * Apply a unified-diff patch from stdin or a file. The dominant use is
5
+ * the `pugi patch < patch.diff` shell pattern that lets external tools
6
+ * (Codex, manual `git diff`) hand off changes through pugi's same
7
+ * security gate the layers use.
8
+ *
9
+ * Surface:
10
+ *
11
+ * pugi patch # read patch from stdin
12
+ * pugi patch <file.diff> # read patch from file
13
+ * pugi patch --dry-run # run --check only, report
14
+ * pugi patch --3way --base=<sha> # enable git apply --3way fuzz
15
+ *
16
+ * Exit codes:
17
+ *
18
+ * 0 patch applied (or dry-run check passed)
19
+ * 1 patch rejected (any non-security reason)
20
+ * 2 usage error
21
+ * 3 security gate refused the patch (path traversal / protected file / symlink escape)
22
+ *
23
+ * Distinct exit codes let CI loops differentiate "operator typo" from
24
+ * "model produced a hostile patch" — the latter is a security event
25
+ * worth alerting on.
26
+ *
27
+ * Brand voice: ASCII only, no emoji, no banned words.
28
+ */
29
+ import { readFileSync } from 'node:fs';
30
+ import { resolve } from 'node:path';
31
+ import { applyPatch } from '../../tools/apply-patch.js';
32
+ import { FileReadCache } from '../../core/file-cache.js';
33
+ import { openSession } from '../../core/session.js';
34
+ import { loadSettings } from '../../core/settings.js';
35
+ const SECURITY_REASONS = new Set(['path_outside_workspace', 'protected_file', 'symlink_escape']);
36
+ export async function runPatchCommand(args, opts) {
37
+ const positional = [];
38
+ const applyOpts = {};
39
+ for (let i = 0; i < args.length; i += 1) {
40
+ const arg = args[i] ?? '';
41
+ if (arg === '--dry-run')
42
+ applyOpts.dryRun = true;
43
+ else if (arg === '--3way') {
44
+ // honored only when --base is also supplied
45
+ }
46
+ else if (arg === '--base') {
47
+ const next = args[i + 1];
48
+ if (next)
49
+ applyOpts.baseSha = next;
50
+ i += 1;
51
+ }
52
+ else if (arg.startsWith('--base=')) {
53
+ applyOpts.baseSha = arg.slice('--base='.length);
54
+ }
55
+ else if (arg === '--json') {
56
+ // already parsed by the outer CLI
57
+ }
58
+ else {
59
+ positional.push(arg);
60
+ }
61
+ }
62
+ let patch;
63
+ try {
64
+ patch = await readPatchSource(positional[0], opts);
65
+ }
66
+ catch (error) {
67
+ const message = error instanceof Error ? error.message : String(error);
68
+ return failure({ ok: false, filesChanged: [], reason: 'invalid_patch', detail: message }, opts.json, 2);
69
+ }
70
+ const ctx = {
71
+ root: opts.cwd,
72
+ settings: loadSettings(opts.cwd),
73
+ session: openSession(opts.cwd),
74
+ readCache: new FileReadCache(),
75
+ };
76
+ const result = applyPatch(ctx, patch, applyOpts);
77
+ if (!result.ok) {
78
+ const exitCode = result.reason && SECURITY_REASONS.has(result.reason) ? 3 : 1;
79
+ return failure(result, opts.json, exitCode);
80
+ }
81
+ const text = opts.json
82
+ ? JSON.stringify(result, null, 2)
83
+ : `applied ${result.filesChanged.length} files:\n ${result.filesChanged.join('\n ')}`;
84
+ return { ok: true, text, exitCode: 0, result };
85
+ }
86
+ function failure(result, json, exitCode) {
87
+ const text = json
88
+ ? JSON.stringify(result, null, 2)
89
+ : `patch refused: ${result.reason ?? 'unknown'}${result.detail ? `\n ${result.detail}` : ''}`;
90
+ return { ok: false, text, exitCode, result };
91
+ }
92
+ async function readPatchSource(filePath, opts) {
93
+ if (filePath) {
94
+ const resolved = resolve(opts.cwd, filePath);
95
+ return readFileSync(resolved, 'utf8');
96
+ }
97
+ if (opts.stdinOverride !== undefined)
98
+ return opts.stdinOverride;
99
+ // Read all of stdin. The process pipe is the canonical CLI handoff
100
+ // for inbound diffs (e.g. `git diff origin/main | pugi patch`).
101
+ return new Promise((resolveFn, rejectFn) => {
102
+ let body = '';
103
+ process.stdin.setEncoding('utf8');
104
+ process.stdin.on('data', (chunk) => {
105
+ body += chunk;
106
+ });
107
+ process.stdin.on('end', () => resolveFn(body));
108
+ process.stdin.on('error', (error) => rejectFn(error));
109
+ });
110
+ }
111
+ //# sourceMappingURL=patch.js.map
@@ -0,0 +1,133 @@
1
+ /**
2
+ * `pugi worktree <op>` — α7.7 Phase 1.
3
+ *
4
+ * Manual control over the scratch worktree primitive. Three subcommands:
5
+ *
6
+ * pugi worktree create [branch] # spawns `.pugi/worktrees/<uuid>`
7
+ * pugi worktree promote <path> # applies the worktree's diff back to cwd
8
+ * pugi worktree drop <path> # removes the worktree (idempotent)
9
+ *
10
+ * Output: human-readable by default, structured JSON under --json so
11
+ * scripted callers can chain (`pugi worktree create --json | jq .path`).
12
+ *
13
+ * The same primitives are used by the `pugi build` and
14
+ * `pugi review --consensus` paths internally; this surface is the
15
+ * operator escape hatch for debugging / manual experimentation.
16
+ *
17
+ * Brand voice: ASCII only, no emoji, no banned words.
18
+ */
19
+ import { resolve } from 'node:path';
20
+ import { spawnSync } from 'node:child_process';
21
+ import { createWorktree, dropWorktree, promoteWorktree } from '../../core/edits/worktree.js';
22
+ export async function runWorktreeCommand(args, opts) {
23
+ const [op, ...rest] = args;
24
+ if (!op)
25
+ return usage();
26
+ if (op === 'create') {
27
+ const branch = rest[0];
28
+ const result = createWorktree({
29
+ cwd: opts.cwd,
30
+ ...(branch ? { branch } : {}),
31
+ });
32
+ if (!result.ok) {
33
+ return {
34
+ ok: false,
35
+ text: opts.json
36
+ ? JSON.stringify(result, null, 2)
37
+ : `worktree create failed: ${result.reason}: ${result.detail}`,
38
+ exitCode: 1,
39
+ };
40
+ }
41
+ const handle = result.value;
42
+ return {
43
+ ok: true,
44
+ text: opts.json
45
+ ? JSON.stringify({ path: handle.path, baseSha: handle.baseSha }, null, 2)
46
+ : `worktree created: ${handle.path}\nbase: ${handle.baseSha}`,
47
+ exitCode: 0,
48
+ };
49
+ }
50
+ if (op === 'promote') {
51
+ const worktreePath = rest[0];
52
+ if (!worktreePath) {
53
+ return {
54
+ ok: false,
55
+ text: 'Usage: pugi worktree promote <path>',
56
+ exitCode: 2,
57
+ };
58
+ }
59
+ // Resolve the worktree path's base SHA from its own git HEAD so
60
+ // the operator never has to remember it after `worktree create`.
61
+ const abs = resolve(opts.cwd, worktreePath);
62
+ const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: abs, encoding: 'utf8' });
63
+ if (head.status !== 0) {
64
+ return {
65
+ ok: false,
66
+ text: `cannot read HEAD of ${abs}: ${head.stderr.trim() || 'git rev-parse failed'}`,
67
+ exitCode: 1,
68
+ };
69
+ }
70
+ const baseSha = head.stdout.trim();
71
+ const result = promoteWorktree({ cwd: opts.cwd, worktreePath: abs, baseSha });
72
+ if (!result.ok) {
73
+ return {
74
+ ok: false,
75
+ text: opts.json
76
+ ? JSON.stringify(result, null, 2)
77
+ : `promote failed: ${result.reason}: ${result.detail}`,
78
+ exitCode: 1,
79
+ };
80
+ }
81
+ return {
82
+ ok: true,
83
+ text: opts.json
84
+ ? JSON.stringify({ filesChanged: result.value.filesChanged }, null, 2)
85
+ : `promoted ${result.value.filesChanged} files from ${abs}`,
86
+ exitCode: 0,
87
+ };
88
+ }
89
+ if (op === 'drop') {
90
+ const worktreePath = rest[0];
91
+ if (!worktreePath) {
92
+ return {
93
+ ok: false,
94
+ text: 'Usage: pugi worktree drop <path>',
95
+ exitCode: 2,
96
+ };
97
+ }
98
+ const abs = resolve(opts.cwd, worktreePath);
99
+ const result = dropWorktree(abs, opts.cwd);
100
+ if (!result.ok) {
101
+ return {
102
+ ok: false,
103
+ text: opts.json
104
+ ? JSON.stringify(result, null, 2)
105
+ : `drop failed: ${result.reason}: ${result.detail}`,
106
+ exitCode: 1,
107
+ };
108
+ }
109
+ return {
110
+ ok: true,
111
+ text: opts.json
112
+ ? JSON.stringify({ dropped: abs }, null, 2)
113
+ : `worktree dropped: ${abs}`,
114
+ exitCode: 0,
115
+ };
116
+ }
117
+ return {
118
+ ok: false,
119
+ text: `unknown worktree operation: ${op}. Supported: create, promote, drop`,
120
+ exitCode: 2,
121
+ };
122
+ }
123
+ function usage() {
124
+ return {
125
+ ok: false,
126
+ text: 'Usage: pugi worktree <op>\n' +
127
+ ' pugi worktree create [branch]\n' +
128
+ ' pugi worktree promote <path>\n' +
129
+ ' pugi worktree drop <path>',
130
+ exitCode: 2,
131
+ };
132
+ }
133
+ //# sourceMappingURL=worktree.js.map