@pugi/cli 0.1.0-alpha.10
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/LICENSE +21 -0
- package/README.md +172 -0
- package/bin/run.js +2 -0
- package/dist/commands/jobs.js +245 -0
- package/dist/core/agents/loader.js +104 -0
- package/dist/core/agents/registry.js +69 -0
- package/dist/core/auto-open-browser.js +128 -0
- package/dist/core/bash-classifier.js +1001 -0
- package/dist/core/clipboard.js +70 -0
- package/dist/core/context/builder.js +114 -0
- package/dist/core/context/compaction-events.js +99 -0
- package/dist/core/context/compaction.js +602 -0
- package/dist/core/context/invariants.js +250 -0
- package/dist/core/context/markdown-loader.js +270 -0
- package/dist/core/credentials.js +355 -0
- package/dist/core/engine/adapter-runner.js +8 -0
- package/dist/core/engine/anvil-client.js +156 -0
- package/dist/core/engine/compaction-hook.js +154 -0
- package/dist/core/engine/index.js +12 -0
- package/dist/core/engine/native-pugi.js +369 -0
- package/dist/core/engine/noop.js +27 -0
- package/dist/core/engine/prompts.js +118 -0
- package/dist/core/engine/tool-bridge.js +313 -0
- package/dist/core/file-cache.js +29 -0
- package/dist/core/hooks.js +415 -0
- package/dist/core/index-store.js +260 -0
- package/dist/core/jobs/registry.js +462 -0
- package/dist/core/mcp/client.js +316 -0
- package/dist/core/mcp/registry.js +171 -0
- package/dist/core/mcp/trust.js +91 -0
- package/dist/core/path-security.js +63 -0
- package/dist/core/permission.js +309 -0
- package/dist/core/repl/cap-warning.js +91 -0
- package/dist/core/repl/clipboard-read.js +174 -0
- package/dist/core/repl/history-search.js +175 -0
- package/dist/core/repl/history.js +172 -0
- package/dist/core/repl/kill-ring.js +138 -0
- package/dist/core/repl/session.js +618 -0
- package/dist/core/repl/slash-commands.js +227 -0
- package/dist/core/repl/workspace-context.js +113 -0
- package/dist/core/session.js +258 -0
- package/dist/core/settings.js +59 -0
- 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/core/subagents/dispatcher.js +258 -0
- package/dist/core/subagents/index.js +26 -0
- package/dist/core/subagents/spawn.js +86 -0
- package/dist/core/trust.js +109 -0
- package/dist/index.js +8 -0
- package/dist/runtime/cli.js +3405 -0
- package/dist/runtime/commands/agents.js +385 -0
- package/dist/runtime/commands/budget.js +192 -0
- package/dist/runtime/commands/config.js +231 -0
- package/dist/runtime/commands/privacy.js +107 -0
- package/dist/runtime/commands/skills.js +401 -0
- package/dist/runtime/commands/undo.js +329 -0
- package/dist/runtime/update-check.js +294 -0
- package/dist/tools/bash.js +660 -0
- package/dist/tools/file-tools.js +346 -0
- package/dist/tools/registry.js +25 -0
- package/dist/tools/web-fetch.js +535 -0
- package/dist/tui/agent-tree.js +66 -0
- package/dist/tui/conversation-pane.js +45 -0
- package/dist/tui/device-flow.js +142 -0
- package/dist/tui/input-box.js +474 -0
- package/dist/tui/login-picker.js +69 -0
- package/dist/tui/render.js +125 -0
- package/dist/tui/repl-render.js +240 -0
- package/dist/tui/repl-splash-art.js +64 -0
- package/dist/tui/repl-splash.js +111 -0
- package/dist/tui/repl.js +214 -0
- package/dist/tui/slash-palette.js +106 -0
- package/dist/tui/splash-data.js +61 -0
- package/dist/tui/splash.js +31 -0
- package/dist/tui/status-bar.js +71 -0
- package/dist/tui/update-banner.js +8 -0
- package/dist/tui/workspace-context.js +105 -0
- package/package.json +71 -0
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises';
|
|
2
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
5
|
+
import { globalAgentPath, installAgent, listAgents, removeAgent, workspaceAgentPath, } from '../../core/agents/loader.js';
|
|
6
|
+
import { assertValidSlug, parseSkillMarkdown } from '../../core/skills/loader.js';
|
|
7
|
+
import { cleanupTmp, fetchSource } from '../../core/skills/sources.js';
|
|
8
|
+
import { hashAgentFile, recordTrust, revokeTrust, verifyTrust, } from '../../core/skills/trust.js';
|
|
9
|
+
const USAGE = [
|
|
10
|
+
'Usage:',
|
|
11
|
+
' pugi agents list [--global|--workspace] Show installed agents.',
|
|
12
|
+
' pugi agents install <source> [--global|--workspace] [--yes] [--as <slug>]',
|
|
13
|
+
' Fetch + trust-prompt + activate an agent.',
|
|
14
|
+
' pugi agents info <slug> Show metadata + body preview.',
|
|
15
|
+
' pugi agents remove <slug> [--global|--workspace] Delete an installed agent.',
|
|
16
|
+
'',
|
|
17
|
+
'Source forms accepted by `install` (same as `pugi skills install`):',
|
|
18
|
+
' gh:owner/repo/path/to/agent.md[@ref]',
|
|
19
|
+
' https://github.com/...',
|
|
20
|
+
' anthropic:<slug>',
|
|
21
|
+
' npm:<package>',
|
|
22
|
+
' ./path/to/agent.md or /abs/path/to/agent.md',
|
|
23
|
+
' <slug> Catalog lookup (catalog.pugi.dev).',
|
|
24
|
+
].join('\n');
|
|
25
|
+
export async function runAgentsCommand(args, ctx) {
|
|
26
|
+
const sub = args[0];
|
|
27
|
+
if (!sub || sub === '--help' || sub === '-h') {
|
|
28
|
+
ctx.writeOutput({ command: 'agents', usage: USAGE.split('\n') }, USAGE);
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
switch (sub) {
|
|
32
|
+
case 'list':
|
|
33
|
+
return runAgentsList(args.slice(1), ctx);
|
|
34
|
+
case 'install':
|
|
35
|
+
return runAgentsInstall(args.slice(1), ctx);
|
|
36
|
+
case 'info':
|
|
37
|
+
return runAgentsInfo(args.slice(1), ctx);
|
|
38
|
+
case 'remove':
|
|
39
|
+
return runAgentsRemove(args.slice(1), ctx);
|
|
40
|
+
default:
|
|
41
|
+
throw new Error(`Unknown sub-command "pugi agents ${sub}". Expected list, install, info, or remove.`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function parseFlags(args) {
|
|
45
|
+
let scope = 'both';
|
|
46
|
+
let yes = false;
|
|
47
|
+
let asName;
|
|
48
|
+
const positional = [];
|
|
49
|
+
for (let i = 0; i < args.length; i++) {
|
|
50
|
+
const arg = args[i];
|
|
51
|
+
if (arg === '--global')
|
|
52
|
+
scope = 'global';
|
|
53
|
+
else if (arg === '--workspace')
|
|
54
|
+
scope = 'workspace';
|
|
55
|
+
else if (arg === '--yes' || arg === '-y')
|
|
56
|
+
yes = true;
|
|
57
|
+
else if (arg === '--as') {
|
|
58
|
+
asName = args[++i];
|
|
59
|
+
}
|
|
60
|
+
else if (arg && !arg.startsWith('--')) {
|
|
61
|
+
positional.push(arg);
|
|
62
|
+
}
|
|
63
|
+
else if (arg) {
|
|
64
|
+
throw new Error(`Unknown flag: ${arg}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return { scope, yes, asName, positional };
|
|
68
|
+
}
|
|
69
|
+
async function runAgentsList(args, ctx) {
|
|
70
|
+
const flags = parseFlags(args);
|
|
71
|
+
const includeGlobal = flags.scope === 'global' || flags.scope === 'both';
|
|
72
|
+
const includeWorkspace = flags.scope === 'workspace' || flags.scope === 'both';
|
|
73
|
+
const global = includeGlobal ? listAgents('global', ctx.workspaceRoot) : [];
|
|
74
|
+
const workspace = includeWorkspace ? listAgents('workspace', ctx.workspaceRoot) : [];
|
|
75
|
+
const all = [...global, ...workspace];
|
|
76
|
+
const verdicts = await Promise.all(all.map(async (agent) => {
|
|
77
|
+
const actual = hashAgentFile(agent.filePath);
|
|
78
|
+
const verdict = await verifyTrust('agent', agent.scope, agent.slug, actual);
|
|
79
|
+
return { agent, trust: verdict };
|
|
80
|
+
}));
|
|
81
|
+
if (ctx.json) {
|
|
82
|
+
ctx.writeOutput({
|
|
83
|
+
command: 'agents.list',
|
|
84
|
+
agents: verdicts.map(({ agent, trust }) => ({
|
|
85
|
+
slug: agent.slug,
|
|
86
|
+
scope: agent.scope,
|
|
87
|
+
filePath: agent.filePath,
|
|
88
|
+
description: agent.frontmatter.description,
|
|
89
|
+
model: agent.frontmatter.metadata.model ?? null,
|
|
90
|
+
tools: agent.frontmatter.metadata.tools ?? [],
|
|
91
|
+
trust: trust.status,
|
|
92
|
+
})),
|
|
93
|
+
}, '');
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (verdicts.length === 0) {
|
|
97
|
+
ctx.writeOutput({ command: 'agents.list', agents: [] }, 'No agents installed. Try `pugi agents install gh:anthropics/claude-code-agents/code-reviewer.md@main`.');
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const lines = ['Installed agents:'];
|
|
101
|
+
for (const { agent, trust } of verdicts) {
|
|
102
|
+
const model = agent.frontmatter.metadata.model ? ` model=${String(agent.frontmatter.metadata.model)}` : '';
|
|
103
|
+
const trustMark = trust.status === 'trusted' ? '[trusted]' : trust.status === 'unsigned' ? '[unsigned]' : '[mismatch]';
|
|
104
|
+
lines.push(` ${agent.slug.padEnd(28)} ${agent.scope.padEnd(10)} ${trustMark.padEnd(11)}${model}`);
|
|
105
|
+
lines.push(` ${truncate(agent.frontmatter.description, 80)}`);
|
|
106
|
+
}
|
|
107
|
+
ctx.writeOutput({ command: 'agents.list', agents: verdicts }, lines.join('\n'));
|
|
108
|
+
}
|
|
109
|
+
async function runAgentsInstall(args, ctx) {
|
|
110
|
+
const flags = parseFlags(args);
|
|
111
|
+
const source = flags.positional[0];
|
|
112
|
+
if (!source) {
|
|
113
|
+
throw new Error('pugi agents install requires a <source> argument. See `pugi agents --help`.');
|
|
114
|
+
}
|
|
115
|
+
const scope = flags.scope === 'workspace' ? 'workspace' : 'global';
|
|
116
|
+
let fetched;
|
|
117
|
+
try {
|
|
118
|
+
fetched = await fetchSource(source);
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
122
|
+
ctx.writeOutput({ command: 'agents.install', status: 'fetch_failed', source, error: message }, `Agent fetch failed: ${message}`);
|
|
123
|
+
process.exitCode = 1;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
// Pick the .md file inside the payload. fetchSource for local single
|
|
127
|
+
// files already wrote into a tmp dir; for gh: we need to look for the
|
|
128
|
+
// .md inside the payload root (one-file convention).
|
|
129
|
+
const stat = statSync(fetched.tmpDir);
|
|
130
|
+
let agentFile;
|
|
131
|
+
if (stat.isFile()) {
|
|
132
|
+
agentFile = fetched.tmpDir;
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
const candidates = readdirSync(fetched.tmpDir).filter((name) => name.toLowerCase().endsWith('.md'));
|
|
136
|
+
if (candidates.length === 0) {
|
|
137
|
+
cleanupTmp(fetched.tmpDir);
|
|
138
|
+
ctx.writeOutput({ command: 'agents.install', status: 'no_markdown', source }, `Agent fetch from ${source} contains no .md file at root.`);
|
|
139
|
+
process.exitCode = 1;
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (candidates.length > 1) {
|
|
143
|
+
cleanupTmp(fetched.tmpDir);
|
|
144
|
+
ctx.writeOutput({ command: 'agents.install', status: 'ambiguous', source, candidates }, `Agent fetch from ${source} contains ${candidates.length} .md files. Specify one with a subdir path.`);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const first = candidates[0];
|
|
149
|
+
if (!first) {
|
|
150
|
+
cleanupTmp(fetched.tmpDir);
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
agentFile = join(fetched.tmpDir, first);
|
|
155
|
+
}
|
|
156
|
+
let parsed;
|
|
157
|
+
try {
|
|
158
|
+
parsed = parseSkillMarkdown(readFileSync(agentFile, 'utf8'));
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
cleanupTmp(fetched.tmpDir);
|
|
162
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
163
|
+
ctx.writeOutput({ command: 'agents.install', status: 'parse_failed', source, error: message }, `Agent parse failed: ${message}`);
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
// Agents are single-file. If the frontmatter declares "skill" (Pugi
|
|
168
|
+
// native dialect) the operator picked the wrong command; refuse and
|
|
169
|
+
// hint. If the frontmatter omits type entirely (Anthropic flat
|
|
170
|
+
// dialect), the loader defaulted to "skill" — we override to "agent"
|
|
171
|
+
// since the operator explicitly chose `pugi agents install`. The
|
|
172
|
+
// single-file storage convention is the disambiguator.
|
|
173
|
+
if (parsed.frontmatter.metadata.type === 'skill' &&
|
|
174
|
+
Object.prototype.hasOwnProperty.call(parsed.frontmatter, 'metadata')) {
|
|
175
|
+
// Did the source explicitly declare type=skill? We detect via the
|
|
176
|
+
// raw frontmatter — when the type was inferred (no metadata block),
|
|
177
|
+
// we permit the install.
|
|
178
|
+
const sourceHasExplicitMetadata = /^metadata:\s*$/m.test(parsed.source);
|
|
179
|
+
if (sourceHasExplicitMetadata) {
|
|
180
|
+
cleanupTmp(fetched.tmpDir);
|
|
181
|
+
ctx.writeOutput({ command: 'agents.install', status: 'wrong_kind', source, declaredType: 'skill' }, `Source ${source} declares metadata.type="skill", not "agent". Use \`pugi skills install\` for skills.`);
|
|
182
|
+
process.exitCode = 1;
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const slug = flags.asName ?? parsed.frontmatter.name;
|
|
187
|
+
// Fail-fast before trust prompt + FS write: a hostile `--as` flag or
|
|
188
|
+
// hostile frontmatter `name` must never reach path.join.
|
|
189
|
+
try {
|
|
190
|
+
assertValidSlug(slug, 'agent');
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
cleanupTmp(fetched.tmpDir);
|
|
194
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
195
|
+
ctx.writeOutput({ command: 'agents.install', status: 'invalid_slug', source, slug, error: message }, message);
|
|
196
|
+
process.exitCode = 1;
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const sha256 = hashAgentFile(agentFile);
|
|
200
|
+
const targetPath = scope === 'global' ? globalAgentPath(slug) : workspaceAgentPath(ctx.workspaceRoot, slug);
|
|
201
|
+
const summary = {
|
|
202
|
+
name: slug,
|
|
203
|
+
description: parsed.frontmatter.description,
|
|
204
|
+
version: parsed.frontmatter.metadata.version
|
|
205
|
+
? String(parsed.frontmatter.metadata.version)
|
|
206
|
+
: null,
|
|
207
|
+
tools: (parsed.frontmatter.metadata.tools ?? []),
|
|
208
|
+
files: 1,
|
|
209
|
+
source: fetched.sourceUrl,
|
|
210
|
+
targetDir: targetPath,
|
|
211
|
+
sha256,
|
|
212
|
+
};
|
|
213
|
+
if (!flags.yes) {
|
|
214
|
+
const decision = await promptTrust('agent', summary, parsed.body);
|
|
215
|
+
if (decision === 'no') {
|
|
216
|
+
cleanupTmp(fetched.tmpDir);
|
|
217
|
+
ctx.writeOutput({ command: 'agents.install', status: 'declined', source, slug }, 'Install declined. Nothing changed.');
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
installAgent({
|
|
222
|
+
payloadDir: agentFile,
|
|
223
|
+
slug,
|
|
224
|
+
scope,
|
|
225
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
226
|
+
});
|
|
227
|
+
await recordTrust({
|
|
228
|
+
kind: 'agent',
|
|
229
|
+
scope,
|
|
230
|
+
name: slug,
|
|
231
|
+
sha256,
|
|
232
|
+
source: fetched.sourceUrl,
|
|
233
|
+
signedBy: signerIdentity(),
|
|
234
|
+
});
|
|
235
|
+
cleanupTmp(fetched.tmpDir);
|
|
236
|
+
ctx.writeOutput({
|
|
237
|
+
command: 'agents.install',
|
|
238
|
+
status: 'installed',
|
|
239
|
+
slug,
|
|
240
|
+
scope,
|
|
241
|
+
path: targetPath,
|
|
242
|
+
sha256,
|
|
243
|
+
source: fetched.sourceUrl,
|
|
244
|
+
}, [
|
|
245
|
+
`Installed agent "${slug}" (${scope}).`,
|
|
246
|
+
` Path: ${targetPath}`,
|
|
247
|
+
` Source: ${fetched.sourceUrl}`,
|
|
248
|
+
` sha256: ${sha256}`,
|
|
249
|
+
].join('\n'));
|
|
250
|
+
}
|
|
251
|
+
async function runAgentsInfo(args, ctx) {
|
|
252
|
+
const flags = parseFlags(args);
|
|
253
|
+
const slug = flags.positional[0];
|
|
254
|
+
if (!slug) {
|
|
255
|
+
throw new Error('pugi agents info requires a <slug> argument.');
|
|
256
|
+
}
|
|
257
|
+
const matches = findAgent(slug, flags.scope, ctx.workspaceRoot);
|
|
258
|
+
if (matches.length === 0) {
|
|
259
|
+
ctx.writeOutput({ command: 'agents.info', status: 'not_found', slug }, `No agent "${slug}" installed. Try \`pugi agents list\`.`);
|
|
260
|
+
process.exitCode = 1;
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
const agent = matches[0];
|
|
264
|
+
if (!agent) {
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
const actualSha = hashAgentFile(agent.filePath);
|
|
269
|
+
const trust = await verifyTrust('agent', agent.scope, agent.slug, actualSha);
|
|
270
|
+
const preview = agent.body.slice(0, 800);
|
|
271
|
+
const lines = [
|
|
272
|
+
`Agent: ${agent.slug}`,
|
|
273
|
+
` Scope: ${agent.scope}`,
|
|
274
|
+
` Path: ${agent.filePath}`,
|
|
275
|
+
` Description: ${agent.frontmatter.description}`,
|
|
276
|
+
` Model: ${String(agent.frontmatter.metadata.model ?? '(default)')}`,
|
|
277
|
+
` Tools: ${(agent.frontmatter.metadata.tools ?? []).join(', ') || '(none)'}`,
|
|
278
|
+
` Trust: ${trust.status}`,
|
|
279
|
+
` sha256: ${actualSha}`,
|
|
280
|
+
'',
|
|
281
|
+
'Body preview:',
|
|
282
|
+
preview,
|
|
283
|
+
agent.body.length > 800 ? `... (${agent.body.length - 800} more chars)` : '',
|
|
284
|
+
]
|
|
285
|
+
.filter((line) => line !== '')
|
|
286
|
+
.join('\n');
|
|
287
|
+
ctx.writeOutput({
|
|
288
|
+
command: 'agents.info',
|
|
289
|
+
slug: agent.slug,
|
|
290
|
+
scope: agent.scope,
|
|
291
|
+
filePath: agent.filePath,
|
|
292
|
+
frontmatter: agent.frontmatter,
|
|
293
|
+
sha256: actualSha,
|
|
294
|
+
trust,
|
|
295
|
+
bodyPreview: preview,
|
|
296
|
+
bodyLength: agent.body.length,
|
|
297
|
+
}, lines);
|
|
298
|
+
}
|
|
299
|
+
async function runAgentsRemove(args, ctx) {
|
|
300
|
+
const flags = parseFlags(args);
|
|
301
|
+
const slug = flags.positional[0];
|
|
302
|
+
if (!slug) {
|
|
303
|
+
throw new Error('pugi agents remove requires a <slug> argument.');
|
|
304
|
+
}
|
|
305
|
+
const matches = findAgent(slug, flags.scope, ctx.workspaceRoot);
|
|
306
|
+
if (matches.length === 0) {
|
|
307
|
+
ctx.writeOutput({ command: 'agents.remove', status: 'not_found', slug }, `No agent "${slug}" installed.`);
|
|
308
|
+
process.exitCode = 1;
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const agent of matches) {
|
|
312
|
+
removeAgent(agent.slug, agent.scope, ctx.workspaceRoot);
|
|
313
|
+
await revokeTrust('agent', agent.scope, agent.slug);
|
|
314
|
+
}
|
|
315
|
+
ctx.writeOutput({
|
|
316
|
+
command: 'agents.remove',
|
|
317
|
+
status: 'removed',
|
|
318
|
+
slug,
|
|
319
|
+
removed: matches.map((m) => ({ slug: m.slug, scope: m.scope, filePath: m.filePath })),
|
|
320
|
+
}, `Removed ${matches.length} agent install(s) named "${slug}".`);
|
|
321
|
+
}
|
|
322
|
+
/* ----------------------------- shared helpers ----------------------------- */
|
|
323
|
+
function findAgent(slug, scope, workspaceRoot) {
|
|
324
|
+
const scopes = scope === 'both' ? ['global', 'workspace'] : [scope];
|
|
325
|
+
const out = [];
|
|
326
|
+
for (const s of scopes) {
|
|
327
|
+
const all = listAgents(s, workspaceRoot);
|
|
328
|
+
for (const agent of all) {
|
|
329
|
+
if (agent.slug === slug)
|
|
330
|
+
out.push(agent);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
function truncate(text, max) {
|
|
336
|
+
if (text.length <= max)
|
|
337
|
+
return text;
|
|
338
|
+
return `${text.slice(0, max - 1)}…`;
|
|
339
|
+
}
|
|
340
|
+
function signerIdentity() {
|
|
341
|
+
return (process.env.PUGI_TRUSTED_BY?.trim() ||
|
|
342
|
+
process.env.USER?.trim() ||
|
|
343
|
+
process.env.USERNAME?.trim() ||
|
|
344
|
+
'cli');
|
|
345
|
+
}
|
|
346
|
+
async function promptTrust(kind, summary, body) {
|
|
347
|
+
const banner = [
|
|
348
|
+
'',
|
|
349
|
+
`About to install ${kind} "${summary.name}"`,
|
|
350
|
+
` Description: ${summary.description}`,
|
|
351
|
+
` Version: ${summary.version ?? '(unset)'}`,
|
|
352
|
+
` Tools: ${summary.tools.join(', ') || '(none)'}`,
|
|
353
|
+
` Files: ${summary.files}`,
|
|
354
|
+
` Source: ${summary.source}`,
|
|
355
|
+
` Target: ${summary.targetDir}`,
|
|
356
|
+
` sha256: ${summary.sha256}`,
|
|
357
|
+
'',
|
|
358
|
+
'This payload becomes part of the system prompt and may influence agent decisions.',
|
|
359
|
+
'Trust this install? [y/N/info] ',
|
|
360
|
+
].join('\n');
|
|
361
|
+
process.stdout.write(banner);
|
|
362
|
+
if (!input.isTTY) {
|
|
363
|
+
process.stdout.write('\n(non-interactive stdin; declining install)\n');
|
|
364
|
+
return 'no';
|
|
365
|
+
}
|
|
366
|
+
const rl = createInterface({ input, output });
|
|
367
|
+
try {
|
|
368
|
+
while (true) {
|
|
369
|
+
const answer = (await rl.question('')).trim().toLowerCase();
|
|
370
|
+
if (answer === 'y' || answer === 'yes')
|
|
371
|
+
return 'yes';
|
|
372
|
+
if (answer === 'info') {
|
|
373
|
+
process.stdout.write('\n');
|
|
374
|
+
process.stdout.write(body);
|
|
375
|
+
process.stdout.write('\n\nTrust this install? [y/N] ');
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
return 'no';
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
rl.close();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
//# sourceMappingURL=agents.js.map
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
const RATE_INPUT_USD_PER_TOKEN = 0.000003;
|
|
4
|
+
const RATE_OUTPUT_USD_PER_TOKEN = 0.000015;
|
|
5
|
+
export async function runBudgetCommand(args, ctx) {
|
|
6
|
+
const flags = parseFlags(args);
|
|
7
|
+
const eventsPath = resolve(ctx.workspaceRoot, '.pugi/events.jsonl');
|
|
8
|
+
if (!existsSync(eventsPath)) {
|
|
9
|
+
ctx.writeOutput({
|
|
10
|
+
command: 'budget',
|
|
11
|
+
status: 'noop',
|
|
12
|
+
reason: 'no_session',
|
|
13
|
+
tokens: 0,
|
|
14
|
+
dollars: 0,
|
|
15
|
+
}, 'No session events found. Run a Pugi command first.');
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
const events = readEvents(eventsPath);
|
|
19
|
+
const cutoff = flags.sinceMs === null ? 0 : Date.now() - flags.sinceMs;
|
|
20
|
+
const summary = summarise(events, cutoff);
|
|
21
|
+
const dollars = estimateDollars(summary.tokens);
|
|
22
|
+
const payload = {
|
|
23
|
+
command: 'budget',
|
|
24
|
+
status: 'ok',
|
|
25
|
+
workspaceRoot: ctx.workspaceRoot,
|
|
26
|
+
window: flags.sinceMs === null ? 'session' : `last ${args.find((a) => a.startsWith('--since='))?.slice('--since='.length) ?? ''}`,
|
|
27
|
+
tokens: summary.tokens,
|
|
28
|
+
dollars,
|
|
29
|
+
perCommand: summary.perCommand,
|
|
30
|
+
perPersona: summary.perPersona,
|
|
31
|
+
rate: {
|
|
32
|
+
inputUsdPerToken: RATE_INPUT_USD_PER_TOKEN,
|
|
33
|
+
outputUsdPerToken: RATE_OUTPUT_USD_PER_TOKEN,
|
|
34
|
+
assumedSplit: 'event log does not break tokens into in/out; rate uses output-token assumption',
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const text = [
|
|
38
|
+
'Pugi budget',
|
|
39
|
+
`Window: ${payload.window}`,
|
|
40
|
+
`Tokens: ${summary.tokens}`,
|
|
41
|
+
`Estimated cost: $${dollars.toFixed(4)} (output-token rate)`,
|
|
42
|
+
summary.perCommand.length > 0
|
|
43
|
+
? `Per command:\n${summary.perCommand
|
|
44
|
+
.map((entry) => ` ${entry.command.padEnd(12)} ${entry.tokens} tokens`)
|
|
45
|
+
.join('\n')}`
|
|
46
|
+
: 'Per command: (no entries)',
|
|
47
|
+
summary.perPersona.length > 0
|
|
48
|
+
? `Per persona:\n${summary.perPersona
|
|
49
|
+
.map((entry) => ` ${entry.persona.padEnd(20)} ${entry.tokens} tokens`)
|
|
50
|
+
.join('\n')}`
|
|
51
|
+
: 'Per persona: (no entries)',
|
|
52
|
+
].join('\n');
|
|
53
|
+
ctx.writeOutput(payload, text);
|
|
54
|
+
}
|
|
55
|
+
function summarise(events, cutoffMs) {
|
|
56
|
+
let tokens = 0;
|
|
57
|
+
const perCommand = new Map();
|
|
58
|
+
const perPersona = new Map();
|
|
59
|
+
// Track the active command via command_started / command_completed
|
|
60
|
+
// bookends so we can attribute tool_result tokens to the surrounding
|
|
61
|
+
// command. Multiple commands per session are normal.
|
|
62
|
+
let activeCommand = null;
|
|
63
|
+
for (const event of events) {
|
|
64
|
+
if (!matchesWindow(event, cutoffMs))
|
|
65
|
+
continue;
|
|
66
|
+
if (event.type === 'session' && event.name === 'command_started' && typeof event.command === 'string') {
|
|
67
|
+
activeCommand = event.command;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (event.type === 'session' && event.name === 'command_completed') {
|
|
71
|
+
activeCommand = null;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const eventTokens = extractTokens(event);
|
|
75
|
+
if (eventTokens > 0) {
|
|
76
|
+
tokens += eventTokens;
|
|
77
|
+
if (activeCommand) {
|
|
78
|
+
perCommand.set(activeCommand, (perCommand.get(activeCommand) ?? 0) + eventTokens);
|
|
79
|
+
}
|
|
80
|
+
const persona = extractPersona(event);
|
|
81
|
+
if (persona) {
|
|
82
|
+
perPersona.set(persona, (perPersona.get(persona) ?? 0) + eventTokens);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
tokens,
|
|
88
|
+
perCommand: Array.from(perCommand.entries())
|
|
89
|
+
.map(([command, value]) => ({ command, tokens: value }))
|
|
90
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
91
|
+
perPersona: Array.from(perPersona.entries())
|
|
92
|
+
.map(([persona, value]) => ({ persona, tokens: value }))
|
|
93
|
+
.sort((a, b) => b.tokens - a.tokens),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function matchesWindow(event, cutoffMs) {
|
|
97
|
+
if (cutoffMs === 0)
|
|
98
|
+
return true;
|
|
99
|
+
const ts = typeof event.timestamp === 'string' ? Date.parse(event.timestamp) : NaN;
|
|
100
|
+
if (!Number.isFinite(ts))
|
|
101
|
+
return false;
|
|
102
|
+
return ts >= cutoffMs;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Extract a token count from any event shape we know about.
|
|
106
|
+
*
|
|
107
|
+
* - `tool_result.tokensUsed` — engine adapter emits this.
|
|
108
|
+
* - `subagent.completed.tokensUsed` — α5.4 subagent runner.
|
|
109
|
+
* - `engine.turn.tokensIn/tokensOut` — Anvil F1 metric mirror, future.
|
|
110
|
+
*
|
|
111
|
+
* Unknown events return 0 so adding new event types upstream never
|
|
112
|
+
* breaks `pugi budget`.
|
|
113
|
+
*/
|
|
114
|
+
function extractTokens(event) {
|
|
115
|
+
const direct = numericField(event, 'tokensUsed');
|
|
116
|
+
if (direct > 0)
|
|
117
|
+
return direct;
|
|
118
|
+
const tIn = numericField(event, 'tokensIn');
|
|
119
|
+
const tOut = numericField(event, 'tokensOut');
|
|
120
|
+
if (tIn + tOut > 0)
|
|
121
|
+
return tIn + tOut;
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
function numericField(event, key) {
|
|
125
|
+
const raw = event[key];
|
|
126
|
+
if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)
|
|
127
|
+
return 0;
|
|
128
|
+
return Math.floor(raw);
|
|
129
|
+
}
|
|
130
|
+
function extractPersona(event) {
|
|
131
|
+
const candidate = event['persona'] ?? event['personaSlug'] ?? event['subagent'];
|
|
132
|
+
if (typeof candidate === 'string' && candidate.length > 0)
|
|
133
|
+
return candidate;
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
function estimateDollars(totalTokens) {
|
|
137
|
+
// Use output-token rate as the worst case so a user planning to upgrade
|
|
138
|
+
// a tier is not surprised by an under-estimate.
|
|
139
|
+
return Number((totalTokens * RATE_OUTPUT_USD_PER_TOKEN).toFixed(6));
|
|
140
|
+
}
|
|
141
|
+
function parseFlags(args) {
|
|
142
|
+
const flags = { json: false, sinceMs: null };
|
|
143
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
144
|
+
const arg = args[i] ?? '';
|
|
145
|
+
if (arg === '--json')
|
|
146
|
+
flags.json = true;
|
|
147
|
+
else if (arg.startsWith('--since='))
|
|
148
|
+
flags.sinceMs = parseDuration(arg.slice('--since='.length));
|
|
149
|
+
else if (arg === '--since') {
|
|
150
|
+
const value = args[i + 1];
|
|
151
|
+
if (!value)
|
|
152
|
+
throw new Error('--since requires a duration like 24h, 30m, or 7d.');
|
|
153
|
+
flags.sinceMs = parseDuration(value);
|
|
154
|
+
i += 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return flags;
|
|
158
|
+
}
|
|
159
|
+
function parseDuration(raw) {
|
|
160
|
+
const match = /^(\d+)(h|m|d|s)?$/.exec(raw.trim());
|
|
161
|
+
if (!match) {
|
|
162
|
+
throw new Error(`Invalid --since duration "${raw}". Expected forms: 24h, 30m, 7d, 90s.`);
|
|
163
|
+
}
|
|
164
|
+
const amount = Number(match[1]);
|
|
165
|
+
const unit = match[2] ?? 'h';
|
|
166
|
+
const multiplier = unit === 'h'
|
|
167
|
+
? 60 * 60 * 1000
|
|
168
|
+
: unit === 'm'
|
|
169
|
+
? 60 * 1000
|
|
170
|
+
: unit === 'd'
|
|
171
|
+
? 24 * 60 * 60 * 1000
|
|
172
|
+
: 1000;
|
|
173
|
+
return amount * multiplier;
|
|
174
|
+
}
|
|
175
|
+
function readEvents(path) {
|
|
176
|
+
const raw = readFileSync(path, 'utf8');
|
|
177
|
+
const lines = raw.split('\n').filter((line) => line.trim().length > 0);
|
|
178
|
+
const out = [];
|
|
179
|
+
for (const line of lines) {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(line);
|
|
182
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
183
|
+
out.push(parsed);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
catch {
|
|
187
|
+
// partial-write lines are ignored
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return out;
|
|
191
|
+
}
|
|
192
|
+
//# sourceMappingURL=budget.js.map
|