@pugi/cli 0.1.0-beta.8 → 0.1.0-beta.9
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/dist/core/edits/worktree.js +322 -0
- package/dist/core/engine/prompts.js +8 -0
- package/dist/core/lsp/client.js +719 -0
- package/dist/core/repl/session.js +12 -0
- package/dist/core/repl/slash-commands.js +33 -0
- package/dist/core/skills/defaults.js +457 -0
- package/dist/runtime/cli.js +244 -32
- package/dist/runtime/commands/delegate.js +219 -11
- package/dist/runtime/commands/lsp.js +206 -0
- package/dist/runtime/commands/patch.js +128 -0
- package/dist/runtime/commands/worktree.js +177 -0
- package/dist/runtime/plan-decompose.js +531 -0
- package/dist/tools/apply-patch.js +495 -0
- package/dist/tools/lsp-tools.js +189 -0
- package/dist/tools/registry.js +26 -0
- package/dist/tui/repl-render.js +159 -32
- package/docs/examples/codegraph.mcp.json +10 -0
- package/package.json +18 -15
|
@@ -6,13 +6,20 @@
|
|
|
6
6
|
* handlers table) does not need a try/catch wrapper.
|
|
7
7
|
*/
|
|
8
8
|
export async function runDelegateCommand(args, ctx) {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
// Extract the optional `--wait` flag from positional args. Accept it
|
|
10
|
+
// in any position so `pugi delegate --wait dev "..."` and
|
|
11
|
+
// `pugi delegate dev "..." --wait` both work; positional ordering of
|
|
12
|
+
// slug + brief is preserved by filtering the flag out before the
|
|
13
|
+
// slug/brief split (Claude P2 fix 2026-05-25).
|
|
14
|
+
const wait = args.some((a) => a === '--wait');
|
|
15
|
+
const positional = args.filter((a) => a !== '--wait');
|
|
16
|
+
const slug = positional[0];
|
|
17
|
+
const brief = positional.slice(1).join(' ').trim();
|
|
11
18
|
if (!slug || !brief) {
|
|
12
19
|
ctx.writeOutput({
|
|
13
20
|
ok: false,
|
|
14
|
-
error: 'Usage: pugi delegate <persona-slug> "<one-sentence brief>"',
|
|
15
|
-
}, 'Usage: pugi delegate <persona-slug> "<one-sentence brief>"');
|
|
21
|
+
error: 'Usage: pugi delegate [--wait] <persona-slug> "<one-sentence brief>"',
|
|
22
|
+
}, 'Usage: pugi delegate [--wait] <persona-slug> "<one-sentence brief>"');
|
|
16
23
|
process.exitCode = 2;
|
|
17
24
|
return;
|
|
18
25
|
}
|
|
@@ -47,14 +54,66 @@ export async function runDelegateCommand(args, ctx) {
|
|
|
47
54
|
brief,
|
|
48
55
|
});
|
|
49
56
|
switch (result.status) {
|
|
50
|
-
case 'ok':
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
+
case 'ok': {
|
|
58
|
+
const ok = result.response;
|
|
59
|
+
if (!wait) {
|
|
60
|
+
ctx.writeOutput({
|
|
61
|
+
ok: true,
|
|
62
|
+
sessionId: opened.sessionId,
|
|
63
|
+
dispatchId: ok.dispatchId,
|
|
64
|
+
personaSlug: ok.personaSlug,
|
|
65
|
+
}, `dispatched ${ok.personaSlug} (dispatchId=${ok.dispatchId}); stream via GET /sessions/${opened.sessionId}/stream.`);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// --wait: subscribe to SSE until the persona reaches a terminal
|
|
69
|
+
// event. The waiter contract is provider-shaped so tests can pass
|
|
70
|
+
// a fake without standing up fetch.
|
|
71
|
+
const waiter = ctx.waitForTerminal ?? waitForDelegateTerminal;
|
|
72
|
+
const outcome = await waiter(config, opened.sessionId, ok.personaSlug);
|
|
73
|
+
switch (outcome.kind) {
|
|
74
|
+
case 'completed':
|
|
75
|
+
ctx.writeOutput({
|
|
76
|
+
ok: true,
|
|
77
|
+
sessionId: opened.sessionId,
|
|
78
|
+
dispatchId: ok.dispatchId,
|
|
79
|
+
personaSlug: outcome.personaSlug,
|
|
80
|
+
status: 'completed',
|
|
81
|
+
}, `completed ${outcome.personaSlug} (dispatchId=${ok.dispatchId}).`);
|
|
82
|
+
return;
|
|
83
|
+
case 'blocked':
|
|
84
|
+
ctx.writeOutput({
|
|
85
|
+
ok: false,
|
|
86
|
+
sessionId: opened.sessionId,
|
|
87
|
+
dispatchId: ok.dispatchId,
|
|
88
|
+
personaSlug: outcome.personaSlug,
|
|
89
|
+
status: 'blocked',
|
|
90
|
+
detail: outcome.detail,
|
|
91
|
+
}, `blocked ${outcome.personaSlug}: ${outcome.detail}`);
|
|
92
|
+
process.exitCode = 5;
|
|
93
|
+
return;
|
|
94
|
+
case 'failed':
|
|
95
|
+
ctx.writeOutput({
|
|
96
|
+
ok: false,
|
|
97
|
+
sessionId: opened.sessionId,
|
|
98
|
+
dispatchId: ok.dispatchId,
|
|
99
|
+
personaSlug: outcome.personaSlug,
|
|
100
|
+
status: 'failed',
|
|
101
|
+
error: outcome.error,
|
|
102
|
+
}, `failed ${outcome.personaSlug}: ${outcome.error}`);
|
|
103
|
+
process.exitCode = 5;
|
|
104
|
+
return;
|
|
105
|
+
case 'stream_error':
|
|
106
|
+
ctx.writeOutput({
|
|
107
|
+
ok: false,
|
|
108
|
+
sessionId: opened.sessionId,
|
|
109
|
+
dispatchId: ok.dispatchId,
|
|
110
|
+
error: outcome.error,
|
|
111
|
+
}, `pugi delegate --wait: ${outcome.error}`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
57
115
|
return;
|
|
116
|
+
}
|
|
58
117
|
case 'unknown_persona':
|
|
59
118
|
ctx.writeOutput({ ok: false, error: result.message, code: result.code }, `pugi delegate: ${result.message}`);
|
|
60
119
|
process.exitCode = 3;
|
|
@@ -78,4 +137,153 @@ export async function runDelegateCommand(args, ctx) {
|
|
|
78
137
|
return;
|
|
79
138
|
}
|
|
80
139
|
}
|
|
140
|
+
/* ------------------------------------------------------------------ */
|
|
141
|
+
/* --wait waiter */
|
|
142
|
+
/* ------------------------------------------------------------------ */
|
|
143
|
+
/**
|
|
144
|
+
* Subscribe to the session SSE stream and resolve when the named
|
|
145
|
+
* persona reaches a terminal event (agent.completed / agent.blocked /
|
|
146
|
+
* agent.failed). Falls back to a `stream_error` outcome when the
|
|
147
|
+
* stream closes before any terminal event arrives or when the
|
|
148
|
+
* underlying fetch fails.
|
|
149
|
+
*
|
|
150
|
+
* The waiter is intentionally a single-call helper (not a long-lived
|
|
151
|
+
* subscriber) - the scripted `pugi delegate --wait` caller wants to
|
|
152
|
+
* exit on the first terminal event for THIS persona, not maintain a
|
|
153
|
+
* persistent connection.
|
|
154
|
+
*
|
|
155
|
+
* Why we parse the lifecycle in this file (not via the repl-render
|
|
156
|
+
* subscribe helper): repl-render carries Ink/React deps the
|
|
157
|
+
* non-interactive delegate path does not want to load. Duplicating the
|
|
158
|
+
* minimal SSE-line parser here keeps the command lean (this is the same
|
|
159
|
+
* shape `apps/admin-api/src/pugi/sessions.controller.ts` writes to the
|
|
160
|
+
* wire).
|
|
161
|
+
*/
|
|
162
|
+
export async function waitForDelegateTerminal(config, sessionId, targetPersonaSlug) {
|
|
163
|
+
const url = `${config.apiUrl.replace(/\/+$/, '')}/api/pugi/sessions/${encodeURIComponent(sessionId)}/stream`;
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
// Hard-cap the waiter at 5 minutes so a stalled server cannot keep
|
|
166
|
+
// a scripted caller hanging forever; the dispatcher's per-turn budget
|
|
167
|
+
// is well under this.
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), 5 * 60 * 1000);
|
|
169
|
+
try {
|
|
170
|
+
const response = await fetch(url, {
|
|
171
|
+
method: 'GET',
|
|
172
|
+
headers: {
|
|
173
|
+
Accept: 'text/event-stream',
|
|
174
|
+
Authorization: `Bearer ${config.apiKey}`,
|
|
175
|
+
},
|
|
176
|
+
signal: controller.signal,
|
|
177
|
+
});
|
|
178
|
+
if (!response.ok) {
|
|
179
|
+
return { kind: 'stream_error', error: `HTTP ${response.status}` };
|
|
180
|
+
}
|
|
181
|
+
if (!response.body) {
|
|
182
|
+
return { kind: 'stream_error', error: 'no response body' };
|
|
183
|
+
}
|
|
184
|
+
// Track the most-recent persona slug seen on agent.spawned so the
|
|
185
|
+
// terminal event (which carries only taskId) can be matched to the
|
|
186
|
+
// delegate target without parsing the random nonce suffix.
|
|
187
|
+
const taskToPersona = new Map();
|
|
188
|
+
const reader = response.body.getReader();
|
|
189
|
+
const decoder = new TextDecoder('utf-8');
|
|
190
|
+
let buffer = '';
|
|
191
|
+
let currentData = '';
|
|
192
|
+
while (true) {
|
|
193
|
+
const { value, done } = await reader.read();
|
|
194
|
+
if (done) {
|
|
195
|
+
return { kind: 'stream_error', error: 'stream ended without terminal event' };
|
|
196
|
+
}
|
|
197
|
+
buffer += decoder.decode(value, { stream: true });
|
|
198
|
+
let newlineIndex;
|
|
199
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
200
|
+
const rawLine = buffer.slice(0, newlineIndex).replace(/\r$/, '');
|
|
201
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
202
|
+
if (rawLine.length === 0) {
|
|
203
|
+
if (currentData.length > 0) {
|
|
204
|
+
const parsed = parseDelegateFrame(currentData);
|
|
205
|
+
if (parsed) {
|
|
206
|
+
if (parsed.type === 'agent.spawned') {
|
|
207
|
+
taskToPersona.set(parsed.taskId, parsed.personaSlug);
|
|
208
|
+
}
|
|
209
|
+
else if (parsed.type === 'agent.completed' ||
|
|
210
|
+
parsed.type === 'agent.blocked' ||
|
|
211
|
+
parsed.type === 'agent.failed') {
|
|
212
|
+
const slug = taskToPersona.get(parsed.taskId) ?? targetPersonaSlug;
|
|
213
|
+
if (slug === targetPersonaSlug) {
|
|
214
|
+
controller.abort();
|
|
215
|
+
if (parsed.type === 'agent.completed') {
|
|
216
|
+
return {
|
|
217
|
+
kind: 'completed',
|
|
218
|
+
personaSlug: slug,
|
|
219
|
+
taskId: parsed.taskId,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
if (parsed.type === 'agent.blocked') {
|
|
223
|
+
return {
|
|
224
|
+
kind: 'blocked',
|
|
225
|
+
personaSlug: slug,
|
|
226
|
+
taskId: parsed.taskId,
|
|
227
|
+
detail: parsed.detail,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
kind: 'failed',
|
|
232
|
+
personaSlug: slug,
|
|
233
|
+
taskId: parsed.taskId,
|
|
234
|
+
error: parsed.error,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
currentData = '';
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
if (rawLine.startsWith(':'))
|
|
244
|
+
continue;
|
|
245
|
+
const colonIndex = rawLine.indexOf(':');
|
|
246
|
+
const field = colonIndex === -1 ? rawLine : rawLine.slice(0, colonIndex);
|
|
247
|
+
const value = colonIndex === -1 ? '' : rawLine.slice(colonIndex + 1).replace(/^ /, '');
|
|
248
|
+
if (field === 'data') {
|
|
249
|
+
currentData = currentData.length === 0 ? value : `${currentData}\n${value}`;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (controller.signal.aborted) {
|
|
256
|
+
return { kind: 'stream_error', error: 'wait timed out' };
|
|
257
|
+
}
|
|
258
|
+
return { kind: 'stream_error', error: err.message };
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function parseDelegateFrame(data) {
|
|
265
|
+
try {
|
|
266
|
+
const raw = JSON.parse(data);
|
|
267
|
+
const type = raw.type;
|
|
268
|
+
const taskId = typeof raw.taskId === 'string' ? raw.taskId : null;
|
|
269
|
+
if (!taskId)
|
|
270
|
+
return null;
|
|
271
|
+
if (type === 'agent.spawned' && typeof raw.personaSlug === 'string') {
|
|
272
|
+
return { type, taskId, personaSlug: raw.personaSlug };
|
|
273
|
+
}
|
|
274
|
+
if (type === 'agent.completed') {
|
|
275
|
+
return { type, taskId };
|
|
276
|
+
}
|
|
277
|
+
if (type === 'agent.blocked') {
|
|
278
|
+
return { type, taskId, detail: typeof raw.detail === 'string' ? raw.detail : '' };
|
|
279
|
+
}
|
|
280
|
+
if (type === 'agent.failed') {
|
|
281
|
+
return { type, taskId, error: typeof raw.error === 'string' ? raw.error : '' };
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
81
289
|
//# sourceMappingURL=delegate.js.map
|
|
@@ -0,0 +1,206 @@
|
|
|
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
|
+
// R1 fix (2026-05-26, PR #413 r1, P2 #12): propagate SIGINT/SIGTERM
|
|
61
|
+
// to the LSP child process. Without this, ^C in the middle of a
|
|
62
|
+
// hung definition request would kill the CLI but leave the spawned
|
|
63
|
+
// language server orphaned (especially expensive for rust-analyzer
|
|
64
|
+
// / pyright which hold workspace indices in memory). We register
|
|
65
|
+
// listeners narrowly scoped to this single command invocation and
|
|
66
|
+
// tear them down in the `finally` block.
|
|
67
|
+
let interrupted = false;
|
|
68
|
+
const signalHandler = () => {
|
|
69
|
+
interrupted = true;
|
|
70
|
+
void client.stop();
|
|
71
|
+
};
|
|
72
|
+
process.once('SIGINT', signalHandler);
|
|
73
|
+
process.once('SIGTERM', signalHandler);
|
|
74
|
+
try {
|
|
75
|
+
if (op === 'diagnostics') {
|
|
76
|
+
const result = await client.diagnostics(file);
|
|
77
|
+
if (!result.ok) {
|
|
78
|
+
return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
|
|
79
|
+
}
|
|
80
|
+
if (opts.json) {
|
|
81
|
+
return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
|
|
82
|
+
}
|
|
83
|
+
if (result.value.length === 0) {
|
|
84
|
+
return { ok: true, text: `${file}: no diagnostics`, exitCode: 0 };
|
|
85
|
+
}
|
|
86
|
+
const lines = result.value.map((d) => `${d.severityLabel}\t${file}:${d.range.start.line + 1}:${d.range.start.character + 1}\t${d.message}`);
|
|
87
|
+
return { ok: true, text: lines.join('\n'), exitCode: 0 };
|
|
88
|
+
}
|
|
89
|
+
const line = Number.parseInt(positional[0] ?? '', 10);
|
|
90
|
+
const col = Number.parseInt(positional[1] ?? '', 10);
|
|
91
|
+
if (!Number.isFinite(line) || !Number.isFinite(col)) {
|
|
92
|
+
return {
|
|
93
|
+
ok: false,
|
|
94
|
+
text: `lsp ${op} requires <line> <col> arguments (1-based)`,
|
|
95
|
+
exitCode: 2,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
// LSP positions are 0-based; we accept 1-based input from the CLI
|
|
99
|
+
// (matches every other editor convention) and convert here.
|
|
100
|
+
const pos = { line: Math.max(0, line - 1), character: Math.max(0, col - 1) };
|
|
101
|
+
if (op === 'hover') {
|
|
102
|
+
const result = await client.hover(file, pos);
|
|
103
|
+
if (!result.ok) {
|
|
104
|
+
return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
|
|
105
|
+
}
|
|
106
|
+
if (opts.json) {
|
|
107
|
+
return { ok: true, text: JSON.stringify(result.value ?? null, null, 2), exitCode: 0 };
|
|
108
|
+
}
|
|
109
|
+
if (!result.value) {
|
|
110
|
+
return { ok: true, text: `${file}:${line}:${col}: no hover available`, exitCode: 0 };
|
|
111
|
+
}
|
|
112
|
+
return { ok: true, text: result.value.content, exitCode: 0 };
|
|
113
|
+
}
|
|
114
|
+
if (op === 'definition') {
|
|
115
|
+
const result = await client.definition(file, pos);
|
|
116
|
+
if (!result.ok) {
|
|
117
|
+
return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
|
|
118
|
+
}
|
|
119
|
+
if (opts.json) {
|
|
120
|
+
return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
|
|
121
|
+
}
|
|
122
|
+
const lines = result.value.map((loc) => `${loc.path || loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`);
|
|
123
|
+
return { ok: true, text: lines.join('\n') || 'no definition', exitCode: 0 };
|
|
124
|
+
}
|
|
125
|
+
// references
|
|
126
|
+
const result = await client.references(file, pos);
|
|
127
|
+
if (!result.ok) {
|
|
128
|
+
return { ok: false, text: `${result.reason}: ${result.detail}`, exitCode: 1 };
|
|
129
|
+
}
|
|
130
|
+
if (opts.json) {
|
|
131
|
+
return { ok: true, text: JSON.stringify(result.value, null, 2), exitCode: 0 };
|
|
132
|
+
}
|
|
133
|
+
const lines = result.value.map((loc) => `${loc.path || loc.uri}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`);
|
|
134
|
+
return { ok: true, text: lines.join('\n') || 'no references', exitCode: 0 };
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
process.removeListener('SIGINT', signalHandler);
|
|
138
|
+
process.removeListener('SIGTERM', signalHandler);
|
|
139
|
+
await client.stop();
|
|
140
|
+
if (interrupted) {
|
|
141
|
+
// Propagate the interruption so the shell sees a sensible exit
|
|
142
|
+
// code on ^C rather than the last successful result code.
|
|
143
|
+
// 130 is the canonical "terminated by SIGINT" exit value.
|
|
144
|
+
return { ok: false, text: 'lsp aborted by signal', exitCode: 130 };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function usage() {
|
|
149
|
+
return {
|
|
150
|
+
ok: false,
|
|
151
|
+
text: 'Usage: pugi lsp <op> <file> [line] [col] [--lang ts|js|py|go|rust]\n' +
|
|
152
|
+
' pugi lsp hover <file> <line> <col>\n' +
|
|
153
|
+
' pugi lsp definition <file> <line> <col>\n' +
|
|
154
|
+
' pugi lsp references <file> <line> <col>\n' +
|
|
155
|
+
' pugi lsp diagnostics <file>',
|
|
156
|
+
exitCode: 2,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function pullLangFlag(args) {
|
|
160
|
+
let lang;
|
|
161
|
+
const positional = [];
|
|
162
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
163
|
+
const arg = args[i] ?? '';
|
|
164
|
+
if (arg === '--lang') {
|
|
165
|
+
const value = args[i + 1];
|
|
166
|
+
if (isLspLanguage(value))
|
|
167
|
+
lang = value;
|
|
168
|
+
i += 1;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
if (arg.startsWith('--lang=')) {
|
|
172
|
+
const value = arg.slice('--lang='.length);
|
|
173
|
+
if (isLspLanguage(value))
|
|
174
|
+
lang = value;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
positional.push(arg);
|
|
178
|
+
}
|
|
179
|
+
return { lang, positional };
|
|
180
|
+
}
|
|
181
|
+
function isLspLanguage(value) {
|
|
182
|
+
return value === 'ts' || value === 'js' || value === 'py' || value === 'go' || value === 'rust';
|
|
183
|
+
}
|
|
184
|
+
export function inferLanguage(file) {
|
|
185
|
+
const ext = extname(file).toLowerCase();
|
|
186
|
+
switch (ext) {
|
|
187
|
+
case '.ts':
|
|
188
|
+
case '.tsx':
|
|
189
|
+
return 'ts';
|
|
190
|
+
case '.js':
|
|
191
|
+
case '.jsx':
|
|
192
|
+
case '.mjs':
|
|
193
|
+
case '.cjs':
|
|
194
|
+
return 'js';
|
|
195
|
+
case '.py':
|
|
196
|
+
case '.pyi':
|
|
197
|
+
return 'py';
|
|
198
|
+
case '.go':
|
|
199
|
+
return 'go';
|
|
200
|
+
case '.rs':
|
|
201
|
+
return 'rust';
|
|
202
|
+
default:
|
|
203
|
+
return undefined;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
//# sourceMappingURL=lsp.js.map
|
|
@@ -0,0 +1,128 @@
|
|
|
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
|
+
// Seed from caller-supplied options first; arg-flag parsing below
|
|
40
|
+
// overrides when present.
|
|
41
|
+
if (opts.dryRun)
|
|
42
|
+
applyOpts.dryRun = true;
|
|
43
|
+
if (opts.baseSha)
|
|
44
|
+
applyOpts.baseSha = opts.baseSha;
|
|
45
|
+
let threeWaySeen = false;
|
|
46
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
47
|
+
const arg = args[i] ?? '';
|
|
48
|
+
if (arg === '--dry-run')
|
|
49
|
+
applyOpts.dryRun = true;
|
|
50
|
+
else if (arg === '--3way') {
|
|
51
|
+
// honored only when --base is also supplied
|
|
52
|
+
threeWaySeen = true;
|
|
53
|
+
}
|
|
54
|
+
else if (arg === '--base') {
|
|
55
|
+
const next = args[i + 1];
|
|
56
|
+
if (next)
|
|
57
|
+
applyOpts.baseSha = next;
|
|
58
|
+
i += 1;
|
|
59
|
+
}
|
|
60
|
+
else if (arg.startsWith('--base=')) {
|
|
61
|
+
applyOpts.baseSha = arg.slice('--base='.length);
|
|
62
|
+
}
|
|
63
|
+
else if (arg === '--json') {
|
|
64
|
+
// already parsed by the outer CLI
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
positional.push(arg);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// R1 fix (2026-05-26, PR #413 r1, P2 #14): `--3way` without `--base`
|
|
71
|
+
// is meaningless because `git apply --3way` falls back to the index,
|
|
72
|
+
// which a CLI-side `pugi patch` invocation does not have populated
|
|
73
|
+
// with the patch's pre-image. Warn the operator instead of dropping
|
|
74
|
+
// the flag silently.
|
|
75
|
+
if (threeWaySeen && !applyOpts.baseSha) {
|
|
76
|
+
const warn = opts.warn ?? ((m) => console.warn(m));
|
|
77
|
+
warn('warning: --3way ignored without --base=<sha>; pass --base or drop --3way');
|
|
78
|
+
}
|
|
79
|
+
let patch;
|
|
80
|
+
try {
|
|
81
|
+
patch = await readPatchSource(positional[0], opts);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
85
|
+
return failure({ ok: false, filesChanged: [], reason: 'invalid_patch', detail: message }, opts.json, 2);
|
|
86
|
+
}
|
|
87
|
+
const ctx = {
|
|
88
|
+
root: opts.cwd,
|
|
89
|
+
settings: loadSettings(opts.cwd),
|
|
90
|
+
session: openSession(opts.cwd),
|
|
91
|
+
readCache: new FileReadCache(),
|
|
92
|
+
};
|
|
93
|
+
const result = applyPatch(ctx, patch, applyOpts);
|
|
94
|
+
if (!result.ok) {
|
|
95
|
+
const exitCode = result.reason && SECURITY_REASONS.has(result.reason) ? 3 : 1;
|
|
96
|
+
return failure(result, opts.json, exitCode);
|
|
97
|
+
}
|
|
98
|
+
const text = opts.json
|
|
99
|
+
? JSON.stringify(result, null, 2)
|
|
100
|
+
: `applied ${result.filesChanged.length} files:\n ${result.filesChanged.join('\n ')}`;
|
|
101
|
+
return { ok: true, text, exitCode: 0, result };
|
|
102
|
+
}
|
|
103
|
+
function failure(result, json, exitCode) {
|
|
104
|
+
const text = json
|
|
105
|
+
? JSON.stringify(result, null, 2)
|
|
106
|
+
: `patch refused: ${result.reason ?? 'unknown'}${result.detail ? `\n ${result.detail}` : ''}`;
|
|
107
|
+
return { ok: false, text, exitCode, result };
|
|
108
|
+
}
|
|
109
|
+
async function readPatchSource(filePath, opts) {
|
|
110
|
+
if (filePath) {
|
|
111
|
+
const resolved = resolve(opts.cwd, filePath);
|
|
112
|
+
return readFileSync(resolved, 'utf8');
|
|
113
|
+
}
|
|
114
|
+
if (opts.stdinOverride !== undefined)
|
|
115
|
+
return opts.stdinOverride;
|
|
116
|
+
// Read all of stdin. The process pipe is the canonical CLI handoff
|
|
117
|
+
// for inbound diffs (e.g. `git diff origin/main | pugi patch`).
|
|
118
|
+
return new Promise((resolveFn, rejectFn) => {
|
|
119
|
+
let body = '';
|
|
120
|
+
process.stdin.setEncoding('utf8');
|
|
121
|
+
process.stdin.on('data', (chunk) => {
|
|
122
|
+
body += chunk;
|
|
123
|
+
});
|
|
124
|
+
process.stdin.on('end', () => resolveFn(body));
|
|
125
|
+
process.stdin.on('error', (error) => rejectFn(error));
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=patch.js.map
|