@pugi/cli 0.1.0-alpha.9 → 0.1.0-beta.1
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/README.md +33 -0
- package/assets/pugi-mascot.ansi +41 -0
- package/dist/commands/deploy.js +439 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +1 -1
- package/dist/core/consensus/anvil-fanout.js +276 -0
- package/dist/core/consensus/diff-capture.js +382 -0
- package/dist/core/consensus/rubric.js +233 -0
- package/dist/core/context/index.js +21 -0
- package/dist/core/context/pugiignore.js +316 -0
- package/dist/core/context/repo-skeleton.js +533 -0
- package/dist/core/context/watcher.js +342 -0
- package/dist/core/context/working-set.js +165 -0
- package/dist/core/edits/dispatch.js +185 -0
- package/dist/core/edits/index.js +15 -0
- package/dist/core/edits/layer-a-apply.js +217 -0
- package/dist/core/edits/layer-b-apply.js +211 -0
- package/dist/core/edits/layer-c-apply.js +160 -0
- package/dist/core/edits/layer-d-ast.js +29 -0
- package/dist/core/edits/marker-parser.js +401 -0
- package/dist/core/edits/security-gate.js +223 -0
- package/dist/core/engine/native-pugi.js +6 -1
- package/dist/core/engine/tool-bridge.js +33 -1
- package/dist/core/repl/ask.js +512 -0
- package/dist/core/repl/cancellation.js +98 -0
- package/dist/core/repl/dispatch-fsm.js +220 -0
- package/dist/core/repl/privacy-banner.js +71 -0
- package/dist/core/repl/session.js +1882 -12
- package/dist/core/repl/slash-commands.js +59 -32
- package/dist/core/repl/store/index.js +12 -0
- package/dist/core/repl/store/jsonl-log.js +321 -0
- package/dist/core/repl/store/lockfile.js +155 -0
- package/dist/core/repl/store/session-store.js +792 -0
- package/dist/core/repl/store/types.js +44 -0
- package/dist/core/repl/store/uuid-v7.js +68 -0
- package/dist/core/repl/workspace-context.js +72 -1
- package/dist/core/skills/loader.js +454 -0
- package/dist/core/skills/sources.js +480 -0
- package/dist/core/skills/trust.js +172 -0
- package/dist/runtime/cli.js +721 -10
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/config.js +338 -8
- package/dist/runtime/commands/review-consensus.js +399 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/tools/file-tools.js +90 -0
- package/dist/tools/web-fetch.js +1 -1
- package/dist/tui/agent-tree-pane.js +9 -0
- package/dist/tui/ask-cli.js +52 -0
- package/dist/tui/ask-modal.js +211 -0
- package/dist/tui/conversation-pane.js +48 -3
- package/dist/tui/input-box.js +48 -5
- package/dist/tui/markdown-render.js +266 -0
- package/dist/tui/repl-render.js +157 -0
- package/dist/tui/repl-splash-mascot.js +130 -0
- package/dist/tui/repl-splash.js +7 -1
- package/dist/tui/repl.js +82 -11
- package/dist/tui/status-bar.js +63 -3
- package/dist/tui/tool-stream-pane.js +91 -0
- package/package.json +11 -5
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
3
|
+
import { assertValidSlug, globalSkillDir, installSkill, listSkills, parseSkillMarkdown, removeSkill, workspaceSkillDir, } from '../../core/skills/loader.js';
|
|
4
|
+
import { cleanupTmp, fetchSource } from '../../core/skills/sources.js';
|
|
5
|
+
import { hashSkillDir, recordTrust, revokeTrust, verifyTrust, } from '../../core/skills/trust.js';
|
|
6
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
const USAGE = [
|
|
9
|
+
'Usage:',
|
|
10
|
+
' pugi skills list [--global|--workspace] Show installed skills.',
|
|
11
|
+
' pugi skills install <source> [--global|--workspace] [--yes] [--as <name>]',
|
|
12
|
+
' Fetch + trust-prompt + activate a skill.',
|
|
13
|
+
' pugi skills info <name> Show metadata + body preview.',
|
|
14
|
+
' pugi skills remove <name> [--global|--workspace] Delete an installed skill.',
|
|
15
|
+
' pugi skills trust <name> [--global|--workspace] Re-trust the on-disk payload.',
|
|
16
|
+
'',
|
|
17
|
+
'Source forms accepted by `install`:',
|
|
18
|
+
' gh:owner/repo[/subdir][@ref] GitHub tree fetch (default ref: main).',
|
|
19
|
+
' https://github.com/... GitHub tree/blob URL.',
|
|
20
|
+
' anthropic:<slug> Shortcut for gh:anthropics/skills/<slug>@main.',
|
|
21
|
+
' npm:<package>[@version] npm registry tarball.',
|
|
22
|
+
' ./path | /abs/path Local filesystem.',
|
|
23
|
+
' <slug> Catalog lookup (catalog.pugi.dev).',
|
|
24
|
+
].join('\n');
|
|
25
|
+
export async function runSkillsCommand(args, ctx) {
|
|
26
|
+
const sub = args[0];
|
|
27
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
28
|
+
ctx.writeOutput({ command: 'skills', usage: USAGE.split('\n') }, USAGE);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
switch (sub) {
|
|
32
|
+
case 'list':
|
|
33
|
+
return runSkillsList(args.slice(1), ctx);
|
|
34
|
+
case 'install':
|
|
35
|
+
return runSkillsInstall(args.slice(1), ctx);
|
|
36
|
+
case 'info':
|
|
37
|
+
return runSkillsInfo(args.slice(1), ctx);
|
|
38
|
+
case 'remove':
|
|
39
|
+
return runSkillsRemove(args.slice(1), ctx);
|
|
40
|
+
case 'trust':
|
|
41
|
+
return runSkillsTrust(args.slice(1), ctx);
|
|
42
|
+
default:
|
|
43
|
+
throw new Error(`Unknown sub-command "pugi skills ${sub}". Expected list, install, info, remove, or trust.`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function parseFlags(args) {
|
|
47
|
+
let scope = 'both';
|
|
48
|
+
let yes = false;
|
|
49
|
+
let asName;
|
|
50
|
+
const positional = [];
|
|
51
|
+
for (let i = 0; i < args.length; i++) {
|
|
52
|
+
const arg = args[i];
|
|
53
|
+
if (arg === '--global')
|
|
54
|
+
scope = 'global';
|
|
55
|
+
else if (arg === '--workspace')
|
|
56
|
+
scope = 'workspace';
|
|
57
|
+
else if (arg === '--yes' || arg === '-y')
|
|
58
|
+
yes = true;
|
|
59
|
+
else if (arg === '--as') {
|
|
60
|
+
asName = args[++i];
|
|
61
|
+
}
|
|
62
|
+
else if (arg && !arg.startsWith('--')) {
|
|
63
|
+
positional.push(arg);
|
|
64
|
+
}
|
|
65
|
+
else if (arg) {
|
|
66
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { scope, yes, asName, positional };
|
|
70
|
+
}
|
|
71
|
+
async function runSkillsList(args, ctx) {
|
|
72
|
+
const flags = parseFlags(args);
|
|
73
|
+
const includeGlobal = flags.scope === 'global' || flags.scope === 'both';
|
|
74
|
+
const includeWorkspace = flags.scope === 'workspace' || flags.scope === 'both';
|
|
75
|
+
const global = includeGlobal ? listSkills('global', ctx.workspaceRoot) : [];
|
|
76
|
+
const workspace = includeWorkspace ? listSkills('workspace', ctx.workspaceRoot) : [];
|
|
77
|
+
const all = [...global, ...workspace];
|
|
78
|
+
const trustStatuses = await Promise.all(all.map(async (skill) => {
|
|
79
|
+
const actual = hashSkillDir(skill.dir);
|
|
80
|
+
const verdict = await verifyTrust('skill', skill.scope, skill.name, actual);
|
|
81
|
+
return { skill, trust: verdict };
|
|
82
|
+
}));
|
|
83
|
+
if (ctx.json) {
|
|
84
|
+
ctx.writeOutput({
|
|
85
|
+
command: 'skills.list',
|
|
86
|
+
skills: trustStatuses.map(({ skill, trust }) => ({
|
|
87
|
+
name: skill.name,
|
|
88
|
+
scope: skill.scope,
|
|
89
|
+
dir: skill.dir,
|
|
90
|
+
description: skill.frontmatter.description,
|
|
91
|
+
version: skill.frontmatter.metadata.version ?? null,
|
|
92
|
+
tools: skill.frontmatter.metadata.tools ?? [],
|
|
93
|
+
trust: trust.status,
|
|
94
|
+
})),
|
|
95
|
+
}, '');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (trustStatuses.length === 0) {
|
|
99
|
+
ctx.writeOutput({ command: 'skills.list', skills: [] }, 'No skills installed. Try `pugi skills install anthropic:python-coding-standards`.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const lines = ['Installed skills:'];
|
|
103
|
+
for (const { skill, trust } of trustStatuses) {
|
|
104
|
+
const version = skill.frontmatter.metadata.version ? ` v${String(skill.frontmatter.metadata.version)}` : '';
|
|
105
|
+
const trustMark = trust.status === 'trusted' ? '[trusted]' : trust.status === 'unsigned' ? '[unsigned]' : '[mismatch]';
|
|
106
|
+
lines.push(` ${skill.name.padEnd(28)} ${skill.scope.padEnd(10)} ${trustMark.padEnd(11)}${version}`);
|
|
107
|
+
lines.push(` ${truncate(skill.frontmatter.description, 80)}`);
|
|
108
|
+
}
|
|
109
|
+
ctx.writeOutput({ command: 'skills.list', skills: trustStatuses }, lines.join('\n'));
|
|
110
|
+
}
|
|
111
|
+
async function runSkillsInstall(args, ctx) {
|
|
112
|
+
const flags = parseFlags(args);
|
|
113
|
+
const source = flags.positional[0];
|
|
114
|
+
if (!source) {
|
|
115
|
+
throw new Error('pugi skills install requires a <source> argument. See `pugi skills --help`.');
|
|
116
|
+
}
|
|
117
|
+
const scope = flags.scope === 'workspace' ? 'workspace' : 'global';
|
|
118
|
+
// 1. Fetch payload to tmp dir.
|
|
119
|
+
let fetched;
|
|
120
|
+
try {
|
|
121
|
+
fetched = await fetchSource(source);
|
|
122
|
+
}
|
|
123
|
+
catch (error) {
|
|
124
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
125
|
+
ctx.writeOutput({ command: 'skills.install', status: 'fetch_failed', source, error: message }, `Skill fetch failed: ${message}`);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
if (fetched.inferredKind !== 'skill') {
|
|
130
|
+
cleanupTmp(fetched.tmpDir);
|
|
131
|
+
const message = `Source ${source} looks like an agent (single .md), not a skill. Use \`pugi agents install\` instead.`;
|
|
132
|
+
ctx.writeOutput({ command: 'skills.install', status: 'wrong_kind', source }, message);
|
|
133
|
+
process.exitCode = 1;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
// 2. Parse SKILL.md to surface metadata in the trust prompt.
|
|
137
|
+
const skillMdPath = join(fetched.tmpDir, 'SKILL.md');
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = parseSkillMarkdown(readFileSync(skillMdPath, 'utf8'));
|
|
141
|
+
}
|
|
142
|
+
catch (error) {
|
|
143
|
+
cleanupTmp(fetched.tmpDir);
|
|
144
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
145
|
+
ctx.writeOutput({ command: 'skills.install', status: 'parse_failed', source, error: message }, `Skill parse failed: ${message}`);
|
|
146
|
+
process.exitCode = 1;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const skillName = flags.asName ?? parsed.frontmatter.name;
|
|
150
|
+
// Fail-fast before any FS or trust-prompt work: a hostile `--as` flag
|
|
151
|
+
// or a hostile frontmatter `name` must never reach path.join.
|
|
152
|
+
try {
|
|
153
|
+
assertValidSlug(skillName, 'skill');
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
cleanupTmp(fetched.tmpDir);
|
|
157
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
158
|
+
ctx.writeOutput({ command: 'skills.install', status: 'invalid_name', source, name: skillName, error: message }, message);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const sha256 = hashSkillDir(fetched.tmpDir);
|
|
163
|
+
const targetDir = scope === 'global' ? globalSkillDir(skillName) : workspaceSkillDir(ctx.workspaceRoot, skillName);
|
|
164
|
+
const summary = {
|
|
165
|
+
name: skillName,
|
|
166
|
+
description: parsed.frontmatter.description,
|
|
167
|
+
version: parsed.frontmatter.metadata.version ?? null,
|
|
168
|
+
tools: parsed.frontmatter.metadata.tools ?? [],
|
|
169
|
+
files: countFiles(fetched.tmpDir),
|
|
170
|
+
source: fetched.sourceUrl,
|
|
171
|
+
targetDir,
|
|
172
|
+
sha256,
|
|
173
|
+
};
|
|
174
|
+
if (!flags.yes) {
|
|
175
|
+
const decision = await promptTrust('skill', summary, parsed.body);
|
|
176
|
+
if (decision === 'no') {
|
|
177
|
+
cleanupTmp(fetched.tmpDir);
|
|
178
|
+
ctx.writeOutput({ command: 'skills.install', status: 'declined', source, name: skillName }, 'Install declined. Nothing changed.');
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// 3. Install to disk.
|
|
183
|
+
const installedDir = installSkill({
|
|
184
|
+
payloadDir: fetched.tmpDir,
|
|
185
|
+
name: skillName,
|
|
186
|
+
scope,
|
|
187
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
188
|
+
});
|
|
189
|
+
// 4. Record trust.
|
|
190
|
+
await recordTrust({
|
|
191
|
+
kind: 'skill',
|
|
192
|
+
scope,
|
|
193
|
+
name: skillName,
|
|
194
|
+
sha256,
|
|
195
|
+
source: fetched.sourceUrl,
|
|
196
|
+
signedBy: signerIdentity(),
|
|
197
|
+
});
|
|
198
|
+
cleanupTmp(fetched.tmpDir);
|
|
199
|
+
ctx.writeOutput({
|
|
200
|
+
command: 'skills.install',
|
|
201
|
+
status: 'installed',
|
|
202
|
+
name: skillName,
|
|
203
|
+
scope,
|
|
204
|
+
dir: installedDir,
|
|
205
|
+
sha256,
|
|
206
|
+
source: fetched.sourceUrl,
|
|
207
|
+
}, [
|
|
208
|
+
`Installed skill "${skillName}" (${scope}).`,
|
|
209
|
+
` Path: ${installedDir}`,
|
|
210
|
+
` Source: ${fetched.sourceUrl}`,
|
|
211
|
+
` sha256: ${sha256}`,
|
|
212
|
+
].join('\n'));
|
|
213
|
+
}
|
|
214
|
+
async function runSkillsInfo(args, ctx) {
|
|
215
|
+
const flags = parseFlags(args);
|
|
216
|
+
const name = flags.positional[0];
|
|
217
|
+
if (!name) {
|
|
218
|
+
throw new Error('pugi skills info requires a <name> argument.');
|
|
219
|
+
}
|
|
220
|
+
const matches = findSkill(name, flags.scope, ctx.workspaceRoot);
|
|
221
|
+
if (matches.length === 0) {
|
|
222
|
+
ctx.writeOutput({ command: 'skills.info', status: 'not_found', name }, `No skill "${name}" installed. Try \`pugi skills list\`.`);
|
|
223
|
+
process.exitCode = 1;
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const skill = matches[0];
|
|
227
|
+
if (!skill) {
|
|
228
|
+
ctx.writeOutput({ command: 'skills.info', status: 'not_found', name }, `No skill "${name}" installed.`);
|
|
229
|
+
process.exitCode = 1;
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
const actualSha = hashSkillDir(skill.dir);
|
|
233
|
+
const trust = await verifyTrust('skill', skill.scope, skill.name, actualSha);
|
|
234
|
+
const preview = skill.body.slice(0, 800);
|
|
235
|
+
const lines = [
|
|
236
|
+
`Skill: ${skill.name}`,
|
|
237
|
+
` Scope: ${skill.scope}`,
|
|
238
|
+
` Path: ${skill.dir}`,
|
|
239
|
+
` Description: ${skill.frontmatter.description}`,
|
|
240
|
+
` Version: ${String(skill.frontmatter.metadata.version ?? '(unset)')}`,
|
|
241
|
+
` Tools: ${(skill.frontmatter.metadata.tools ?? []).join(', ') || '(none)'}`,
|
|
242
|
+
` Trust: ${trust.status}`,
|
|
243
|
+
` sha256: ${actualSha}`,
|
|
244
|
+
'',
|
|
245
|
+
'Body preview:',
|
|
246
|
+
preview,
|
|
247
|
+
skill.body.length > 800 ? `... (${skill.body.length - 800} more chars)` : '',
|
|
248
|
+
]
|
|
249
|
+
.filter((line) => line !== '')
|
|
250
|
+
.join('\n');
|
|
251
|
+
ctx.writeOutput({
|
|
252
|
+
command: 'skills.info',
|
|
253
|
+
name: skill.name,
|
|
254
|
+
scope: skill.scope,
|
|
255
|
+
dir: skill.dir,
|
|
256
|
+
frontmatter: skill.frontmatter,
|
|
257
|
+
sha256: actualSha,
|
|
258
|
+
trust,
|
|
259
|
+
bodyPreview: preview,
|
|
260
|
+
bodyLength: skill.body.length,
|
|
261
|
+
}, lines);
|
|
262
|
+
}
|
|
263
|
+
async function runSkillsRemove(args, ctx) {
|
|
264
|
+
const flags = parseFlags(args);
|
|
265
|
+
const name = flags.positional[0];
|
|
266
|
+
if (!name) {
|
|
267
|
+
throw new Error('pugi skills remove requires a <name> argument.');
|
|
268
|
+
}
|
|
269
|
+
const matches = findSkill(name, flags.scope, ctx.workspaceRoot);
|
|
270
|
+
if (matches.length === 0) {
|
|
271
|
+
ctx.writeOutput({ command: 'skills.remove', status: 'not_found', name }, `No skill "${name}" installed.`);
|
|
272
|
+
process.exitCode = 1;
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
for (const skill of matches) {
|
|
276
|
+
removeSkill(skill.name, skill.scope, ctx.workspaceRoot);
|
|
277
|
+
await revokeTrust('skill', skill.scope, skill.name);
|
|
278
|
+
}
|
|
279
|
+
ctx.writeOutput({
|
|
280
|
+
command: 'skills.remove',
|
|
281
|
+
status: 'removed',
|
|
282
|
+
name,
|
|
283
|
+
removed: matches.map((m) => ({ name: m.name, scope: m.scope, dir: m.dir })),
|
|
284
|
+
}, `Removed ${matches.length} skill install(s) named "${name}".`);
|
|
285
|
+
}
|
|
286
|
+
async function runSkillsTrust(args, ctx) {
|
|
287
|
+
const flags = parseFlags(args);
|
|
288
|
+
const name = flags.positional[0];
|
|
289
|
+
if (!name) {
|
|
290
|
+
throw new Error('pugi skills trust requires a <name> argument.');
|
|
291
|
+
}
|
|
292
|
+
const matches = findSkill(name, flags.scope, ctx.workspaceRoot);
|
|
293
|
+
if (matches.length === 0) {
|
|
294
|
+
ctx.writeOutput({ command: 'skills.trust', status: 'not_found', name }, `No skill "${name}" installed.`);
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const skill = matches[0];
|
|
299
|
+
if (!skill) {
|
|
300
|
+
process.exitCode = 1;
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const sha256 = hashSkillDir(skill.dir);
|
|
304
|
+
await recordTrust({
|
|
305
|
+
kind: 'skill',
|
|
306
|
+
scope: skill.scope,
|
|
307
|
+
name: skill.name,
|
|
308
|
+
sha256,
|
|
309
|
+
source: skill.dir,
|
|
310
|
+
signedBy: signerIdentity(),
|
|
311
|
+
});
|
|
312
|
+
ctx.writeOutput({ command: 'skills.trust', status: 'trusted', name: skill.name, scope: skill.scope, sha256 }, `Re-trusted skill "${skill.name}" (${skill.scope}). sha256 = ${sha256}.`);
|
|
313
|
+
}
|
|
314
|
+
/* ----------------------------- shared helpers ----------------------------- */
|
|
315
|
+
function findSkill(name, scope, workspaceRoot) {
|
|
316
|
+
const scopes = scope === 'both' ? ['global', 'workspace'] : [scope];
|
|
317
|
+
const out = [];
|
|
318
|
+
for (const s of scopes) {
|
|
319
|
+
const all = listSkills(s, workspaceRoot);
|
|
320
|
+
for (const skill of all) {
|
|
321
|
+
if (skill.name === name)
|
|
322
|
+
out.push(skill);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
function countFiles(dir) {
|
|
328
|
+
// Conservative — recurse and count regular files only. Used for the
|
|
329
|
+
// trust prompt summary so the operator sees payload bulk.
|
|
330
|
+
let total = 0;
|
|
331
|
+
const walk = (d) => {
|
|
332
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
333
|
+
const full = join(d, entry.name);
|
|
334
|
+
if (entry.isDirectory())
|
|
335
|
+
walk(full);
|
|
336
|
+
else if (entry.isFile())
|
|
337
|
+
total++;
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
walk(dir);
|
|
341
|
+
return total;
|
|
342
|
+
}
|
|
343
|
+
function truncate(text, max) {
|
|
344
|
+
if (text.length <= max)
|
|
345
|
+
return text;
|
|
346
|
+
return `${text.slice(0, max - 1)}…`;
|
|
347
|
+
}
|
|
348
|
+
function signerIdentity() {
|
|
349
|
+
return (process.env.PUGI_TRUSTED_BY?.trim() ||
|
|
350
|
+
process.env.USER?.trim() ||
|
|
351
|
+
process.env.USERNAME?.trim() ||
|
|
352
|
+
'cli');
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Interactive trust prompt. Reads from stdin; `info` shows the full
|
|
356
|
+
* body; `y` accepts; everything else declines.
|
|
357
|
+
*
|
|
358
|
+
* Bypassable only via the operator-passed `--yes` flag in the caller
|
|
359
|
+
* (never auto-injected from another code path).
|
|
360
|
+
*/
|
|
361
|
+
async function promptTrust(kind, summary, body) {
|
|
362
|
+
const banner = [
|
|
363
|
+
'',
|
|
364
|
+
`About to install ${kind} "${summary.name}"`,
|
|
365
|
+
` Description: ${summary.description}`,
|
|
366
|
+
` Version: ${summary.version ?? '(unset)'}`,
|
|
367
|
+
` Tools: ${summary.tools.join(', ') || '(none)'}`,
|
|
368
|
+
` Files: ${summary.files}`,
|
|
369
|
+
` Source: ${summary.source}`,
|
|
370
|
+
` Target: ${summary.targetDir}`,
|
|
371
|
+
` sha256: ${summary.sha256}`,
|
|
372
|
+
'',
|
|
373
|
+
'This payload becomes part of the system prompt and may influence agent decisions.',
|
|
374
|
+
'Trust this install? [y/N/info] ',
|
|
375
|
+
].join('\n');
|
|
376
|
+
process.stdout.write(banner);
|
|
377
|
+
if (!input.isTTY) {
|
|
378
|
+
// Non-interactive caller without --yes: refuse rather than block.
|
|
379
|
+
process.stdout.write('\n(non-interactive stdin; declining install)\n');
|
|
380
|
+
return 'no';
|
|
381
|
+
}
|
|
382
|
+
const rl = createInterface({ input, output });
|
|
383
|
+
try {
|
|
384
|
+
while (true) {
|
|
385
|
+
const answer = (await rl.question('')).trim().toLowerCase();
|
|
386
|
+
if (answer === 'y' || answer === 'yes')
|
|
387
|
+
return 'yes';
|
|
388
|
+
if (answer === 'info') {
|
|
389
|
+
process.stdout.write('\n');
|
|
390
|
+
process.stdout.write(body);
|
|
391
|
+
process.stdout.write('\n\nTrust this install? [y/N] ');
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
return 'no';
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
finally {
|
|
398
|
+
rl.close();
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
//# sourceMappingURL=skills.js.map
|
package/dist/tools/file-tools.js
CHANGED
|
@@ -6,6 +6,35 @@ import { decidePermission } from '../core/permission.js';
|
|
|
6
6
|
import { createReadRecord, hashContent } from '../core/file-cache.js';
|
|
7
7
|
import { resolveWorkspacePath } from '../core/path-security.js';
|
|
8
8
|
import { recordFileMutation, recordToolCall, recordToolResult } from '../core/session.js';
|
|
9
|
+
/**
|
|
10
|
+
* α6.9 WriteGate marker — thrown by `gateOnCancellation` when the
|
|
11
|
+
* caller supplied a cancellation token that has already aborted. The
|
|
12
|
+
* tool dispatch loop in `tool-bridge.ts` recognises the name and folds
|
|
13
|
+
* the throw into a `status: 'aborted'` tool result rather than a hard
|
|
14
|
+
* error so the loop terminates cleanly.
|
|
15
|
+
*/
|
|
16
|
+
export class OperatorAbortedError extends Error {
|
|
17
|
+
constructor(toolName) {
|
|
18
|
+
super(`operator_aborted: ${toolName} refused — operator cancelled the dispatch.`);
|
|
19
|
+
this.name = 'OperatorAbortedError';
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* α6.9 WriteGate: refuse the tool dispatch when the active
|
|
24
|
+
* cancellation token has aborted. Idempotent (the token's `isAborted`
|
|
25
|
+
* is a getter, no side effects). Returns void on the happy path so the
|
|
26
|
+
* tool can proceed; throws `OperatorAbortedError` when cancelled.
|
|
27
|
+
*
|
|
28
|
+
* The audit trail still gets the call: `recordToolCall` already fired
|
|
29
|
+
* upstream of this guard so the abort + reason are persisted. The
|
|
30
|
+
* matching `recordToolResult` is fired by the caller in its catch
|
|
31
|
+
* block with `status: 'cancelled'` (see existing path for `error`).
|
|
32
|
+
*/
|
|
33
|
+
export function gateOnCancellation(ctx, toolName) {
|
|
34
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
35
|
+
throw new OperatorAbortedError(toolName);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
9
38
|
/**
|
|
10
39
|
* Re-check the permission decision against the *resolved* real path so
|
|
11
40
|
* a workspace-local symlink (`alias -> .env`) cannot bypass the protected
|
|
@@ -42,6 +71,13 @@ function permissionGatedResolve(ctx, inputPath, action, toolName) {
|
|
|
42
71
|
}
|
|
43
72
|
export function readTool(ctx, path) {
|
|
44
73
|
const toolCallId = recordToolCall(ctx.session, 'read', path);
|
|
74
|
+
// α6.9 WriteGate: fail fast on operator cancel BEFORE permission
|
|
75
|
+
// decision so a half-second post-cancel race never lands the read.
|
|
76
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
77
|
+
const reason = 'operator_aborted: read refused';
|
|
78
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
79
|
+
throw new OperatorAbortedError('read');
|
|
80
|
+
}
|
|
45
81
|
const decision = decidePermission({ tool: 'read', kind: 'read', target: path }, ctx.settings, ctx.root);
|
|
46
82
|
if (decision.decision !== 'allow') {
|
|
47
83
|
const reason = `Permission ${decision.decision} for read ${path}: ${decision.reason}`;
|
|
@@ -64,6 +100,14 @@ export function readTool(ctx, path) {
|
|
|
64
100
|
}
|
|
65
101
|
export function writeTool(ctx, path, content) {
|
|
66
102
|
const toolCallId = recordToolCall(ctx.session, 'write', path);
|
|
103
|
+
// α6.9 WriteGate: refuse the write when the operator has cancelled
|
|
104
|
+
// the dispatch. The audit log captures the cancellation reason so a
|
|
105
|
+
// post-mortem can distinguish operator_aborted from settings-deny.
|
|
106
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
107
|
+
const reason = 'operator_aborted: write refused';
|
|
108
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
109
|
+
throw new OperatorAbortedError('write');
|
|
110
|
+
}
|
|
67
111
|
const decision = decidePermission({ tool: 'write', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
68
112
|
if (decision.decision !== 'allow') {
|
|
69
113
|
const reason = `Permission ${decision.decision} for write ${path}: ${decision.reason}`;
|
|
@@ -95,6 +139,15 @@ export function writeTool(ctx, path, content) {
|
|
|
95
139
|
}
|
|
96
140
|
export function editTool(ctx, path, oldString, newString) {
|
|
97
141
|
const toolCallId = recordToolCall(ctx.session, 'edit', path);
|
|
142
|
+
// α6.9 WriteGate: refuse the edit when the operator has cancelled
|
|
143
|
+
// the dispatch. Edits are higher-risk than reads — surface the abort
|
|
144
|
+
// BEFORE we even consult permissions so a cancel-during-tool-loop
|
|
145
|
+
// never partially mutates the workspace.
|
|
146
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
147
|
+
const reason = 'operator_aborted: edit refused';
|
|
148
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
149
|
+
throw new OperatorAbortedError('edit');
|
|
150
|
+
}
|
|
98
151
|
const decision = decidePermission({ tool: 'edit', kind: 'edit', target: path }, ctx.settings, ctx.root);
|
|
99
152
|
if (decision.decision !== 'allow') {
|
|
100
153
|
const reason = `Permission ${decision.decision} for edit ${path}: ${decision.reason}`;
|
|
@@ -140,6 +193,14 @@ export function editTool(ctx, path, oldString, newString) {
|
|
|
140
193
|
}
|
|
141
194
|
export function globTool(ctx, pattern) {
|
|
142
195
|
const toolCallId = recordToolCall(ctx.session, 'glob', pattern);
|
|
196
|
+
// α6.9 WriteGate: cancel-aware short-circuit. Glob is read-only but
|
|
197
|
+
// can be expensive on large trees; respecting the abort here keeps
|
|
198
|
+
// the tool loop responsive when the operator hits Ctrl+C mid-scan.
|
|
199
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
200
|
+
const reason = 'operator_aborted: glob refused';
|
|
201
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
202
|
+
throw new OperatorAbortedError('glob');
|
|
203
|
+
}
|
|
143
204
|
// Pugi globs are workspace-scoped. Reject any pattern that could enumerate
|
|
144
205
|
// outside the workspace:
|
|
145
206
|
// 1. absolute paths (`/etc/**/*`) — globSync resolves these against `/`
|
|
@@ -169,11 +230,28 @@ export function globTool(ctx, pattern) {
|
|
|
169
230
|
}
|
|
170
231
|
export function grepTool(ctx, query) {
|
|
171
232
|
const toolCallId = recordToolCall(ctx.session, 'grep', query);
|
|
233
|
+
// α6.9 WriteGate: refuse before scanning. Grep walks the whole
|
|
234
|
+
// workspace and can take seconds on a large repo; check abort first
|
|
235
|
+
// so a cancel mid-scan returns immediately rather than after the
|
|
236
|
+
// full walk completes.
|
|
237
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
238
|
+
const reason = 'operator_aborted: grep refused';
|
|
239
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
240
|
+
throw new OperatorAbortedError('grep');
|
|
241
|
+
}
|
|
172
242
|
const files = globTool(ctx, '**/*').filter((path) => !path.endsWith('/'));
|
|
173
243
|
const matches = [];
|
|
174
244
|
for (const path of files) {
|
|
175
245
|
if (matches.length >= 200)
|
|
176
246
|
break;
|
|
247
|
+
// α6.9 WriteGate: poll abort inside the file loop so a cancel
|
|
248
|
+
// arriving mid-scan terminates early. The per-file branch keeps
|
|
249
|
+
// the responsiveness bounded by the slowest single-file read.
|
|
250
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
251
|
+
const reason = `operator_aborted: grep stopped mid-scan after ${matches.length} matches`;
|
|
252
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
253
|
+
throw new OperatorAbortedError('grep');
|
|
254
|
+
}
|
|
177
255
|
// Permission gate every file read individually — grep used to bypass
|
|
178
256
|
// `decidePermission` and could surface lines from protected files
|
|
179
257
|
// (.env, *.sql, *.pem, ~/.ssh/**) when invoked from a directory walk.
|
|
@@ -241,6 +319,18 @@ export const BASH_DEFAULT_TIMEOUT_MS = 30_000;
|
|
|
241
319
|
export const BASH_CHILD_MAXBUFFER = 10 * 1024 * 1024;
|
|
242
320
|
export function bashTool(ctx, command, options = {}) {
|
|
243
321
|
const toolCallId = recordToolCall(ctx.session, 'bash', command);
|
|
322
|
+
// α6.9 WriteGate: bash is the highest-risk tool surface. Refuse
|
|
323
|
+
// before the destructive-pattern classifier even runs so a
|
|
324
|
+
// cancelled dispatch never spawns a child process. Note: this is
|
|
325
|
+
// pre-spawn cancellation only; once the /bin/sh -c process is
|
|
326
|
+
// running, the synchronous spawnSync wait blocks until it exits or
|
|
327
|
+
// the 30s timeout fires. Phase 2 will wire SIGTERM forwarding via
|
|
328
|
+
// an async wrapper.
|
|
329
|
+
if (ctx.cancellation && ctx.cancellation.isAborted) {
|
|
330
|
+
const reason = 'operator_aborted: bash refused';
|
|
331
|
+
recordToolResult(ctx.session, toolCallId, 'cancelled', reason);
|
|
332
|
+
throw new OperatorAbortedError('bash');
|
|
333
|
+
}
|
|
244
334
|
const decision = decidePermission({ tool: 'bash', kind: 'bash', target: command }, ctx.settings, ctx.root);
|
|
245
335
|
if (decision.decision !== 'allow') {
|
|
246
336
|
const reason = `Permission ${decision.decision} for bash: ${decision.reason}`;
|
package/dist/tools/web-fetch.js
CHANGED
|
@@ -240,7 +240,7 @@ function ipv4IsBlocked(ip) {
|
|
|
240
240
|
* a literal IP (with brackets stripped). We honor that fast-path and
|
|
241
241
|
* skip DNS.
|
|
242
242
|
*/
|
|
243
|
-
async function validateHostnameForFetch(hostname) {
|
|
243
|
+
export async function validateHostnameForFetch(hostname) {
|
|
244
244
|
// URL.hostname keeps the brackets off IPv6 literals already.
|
|
245
245
|
if (!hostname)
|
|
246
246
|
return 'empty hostname';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Box, Text } from 'ink';
|
|
3
|
+
import { AgentTree } from './agent-tree.js';
|
|
4
|
+
export function AgentTreePane(props) {
|
|
5
|
+
const onWatch = props.agents.filter((a) => a.status === 'queued' || a.status === 'thinking').length;
|
|
6
|
+
const total = props.agents.length;
|
|
7
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, dimColor: true, children: '─ agents ' }), _jsx(Text, { dimColor: true, children: `(${total} total, ${onWatch} on watch)` })] }), _jsx(AgentTree, { agents: props.agents, nowEpochMs: props.nowEpochMs })] }));
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=agent-tree-pane.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render, useApp } from 'ink';
|
|
3
|
+
import { AskModal, PlanReviewModal } from './ask-modal.js';
|
|
4
|
+
export async function renderAskCli(options) {
|
|
5
|
+
let resolveOuter;
|
|
6
|
+
const outerPromise = new Promise((resolve) => {
|
|
7
|
+
resolveOuter = resolve;
|
|
8
|
+
});
|
|
9
|
+
function App() {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
return (_jsx(AskModal, { tag: options.tag, onResolve: (verdict) => {
|
|
12
|
+
resolveOuter(verdict);
|
|
13
|
+
// Slight delay so Ink flushes the unmount before the parent
|
|
14
|
+
// CLI prints the verdict line. Otherwise the modal frame and
|
|
15
|
+
// the verdict line can interleave on slow terminals.
|
|
16
|
+
setTimeout(() => exit(), 16);
|
|
17
|
+
} }));
|
|
18
|
+
}
|
|
19
|
+
const instance = render(_jsx(App, {}));
|
|
20
|
+
const verdict = await outerPromise;
|
|
21
|
+
try {
|
|
22
|
+
await instance.waitUntilExit();
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Ink may throw if exit() races with a re-render; the verdict is
|
|
26
|
+
// already captured so we ignore.
|
|
27
|
+
}
|
|
28
|
+
return verdict;
|
|
29
|
+
}
|
|
30
|
+
export async function renderPlanReviewCli(options) {
|
|
31
|
+
let resolveOuter;
|
|
32
|
+
const outerPromise = new Promise((resolve) => {
|
|
33
|
+
resolveOuter = resolve;
|
|
34
|
+
});
|
|
35
|
+
function App() {
|
|
36
|
+
const { exit } = useApp();
|
|
37
|
+
return (_jsx(PlanReviewModal, { tag: options.tag, onResolve: (result) => {
|
|
38
|
+
resolveOuter(result);
|
|
39
|
+
setTimeout(() => exit(), 16);
|
|
40
|
+
} }));
|
|
41
|
+
}
|
|
42
|
+
const instance = render(_jsx(App, {}));
|
|
43
|
+
const result = await outerPromise;
|
|
44
|
+
try {
|
|
45
|
+
await instance.waitUntilExit();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// See renderAskCli — captured verdict supersedes a late Ink throw.
|
|
49
|
+
}
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=ask-cli.js.map
|