@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/bin/run.js +2 -0
  4. package/dist/commands/jobs.js +245 -0
  5. package/dist/core/agents/loader.js +104 -0
  6. package/dist/core/agents/registry.js +69 -0
  7. package/dist/core/auto-open-browser.js +128 -0
  8. package/dist/core/bash-classifier.js +1001 -0
  9. package/dist/core/clipboard.js +70 -0
  10. package/dist/core/context/builder.js +114 -0
  11. package/dist/core/context/compaction-events.js +99 -0
  12. package/dist/core/context/compaction.js +602 -0
  13. package/dist/core/context/invariants.js +250 -0
  14. package/dist/core/context/markdown-loader.js +270 -0
  15. package/dist/core/credentials.js +355 -0
  16. package/dist/core/engine/adapter-runner.js +8 -0
  17. package/dist/core/engine/anvil-client.js +156 -0
  18. package/dist/core/engine/compaction-hook.js +154 -0
  19. package/dist/core/engine/index.js +12 -0
  20. package/dist/core/engine/native-pugi.js +369 -0
  21. package/dist/core/engine/noop.js +27 -0
  22. package/dist/core/engine/prompts.js +118 -0
  23. package/dist/core/engine/tool-bridge.js +313 -0
  24. package/dist/core/file-cache.js +29 -0
  25. package/dist/core/hooks.js +415 -0
  26. package/dist/core/index-store.js +260 -0
  27. package/dist/core/jobs/registry.js +462 -0
  28. package/dist/core/mcp/client.js +316 -0
  29. package/dist/core/mcp/registry.js +171 -0
  30. package/dist/core/mcp/trust.js +91 -0
  31. package/dist/core/path-security.js +63 -0
  32. package/dist/core/permission.js +309 -0
  33. package/dist/core/repl/cap-warning.js +91 -0
  34. package/dist/core/repl/clipboard-read.js +174 -0
  35. package/dist/core/repl/history-search.js +175 -0
  36. package/dist/core/repl/history.js +172 -0
  37. package/dist/core/repl/kill-ring.js +138 -0
  38. package/dist/core/repl/session.js +618 -0
  39. package/dist/core/repl/slash-commands.js +227 -0
  40. package/dist/core/repl/workspace-context.js +113 -0
  41. package/dist/core/session.js +258 -0
  42. package/dist/core/settings.js +59 -0
  43. package/dist/core/skills/loader.js +454 -0
  44. package/dist/core/skills/sources.js +480 -0
  45. package/dist/core/skills/trust.js +172 -0
  46. package/dist/core/subagents/dispatcher.js +258 -0
  47. package/dist/core/subagents/index.js +26 -0
  48. package/dist/core/subagents/spawn.js +86 -0
  49. package/dist/core/trust.js +109 -0
  50. package/dist/index.js +8 -0
  51. package/dist/runtime/cli.js +3405 -0
  52. package/dist/runtime/commands/agents.js +385 -0
  53. package/dist/runtime/commands/budget.js +192 -0
  54. package/dist/runtime/commands/config.js +231 -0
  55. package/dist/runtime/commands/privacy.js +107 -0
  56. package/dist/runtime/commands/skills.js +401 -0
  57. package/dist/runtime/commands/undo.js +329 -0
  58. package/dist/runtime/update-check.js +294 -0
  59. package/dist/tools/bash.js +660 -0
  60. package/dist/tools/file-tools.js +346 -0
  61. package/dist/tools/registry.js +25 -0
  62. package/dist/tools/web-fetch.js +535 -0
  63. package/dist/tui/agent-tree.js +66 -0
  64. package/dist/tui/conversation-pane.js +45 -0
  65. package/dist/tui/device-flow.js +142 -0
  66. package/dist/tui/input-box.js +474 -0
  67. package/dist/tui/login-picker.js +69 -0
  68. package/dist/tui/render.js +125 -0
  69. package/dist/tui/repl-render.js +240 -0
  70. package/dist/tui/repl-splash-art.js +64 -0
  71. package/dist/tui/repl-splash.js +111 -0
  72. package/dist/tui/repl.js +214 -0
  73. package/dist/tui/slash-palette.js +106 -0
  74. package/dist/tui/splash-data.js +61 -0
  75. package/dist/tui/splash.js +31 -0
  76. package/dist/tui/status-bar.js +71 -0
  77. package/dist/tui/update-banner.js +8 -0
  78. package/dist/tui/workspace-context.js +105 -0
  79. package/package.json +71 -0
@@ -0,0 +1,313 @@
1
+ import { editTool, globTool, grepTool, readTool, writeTool, } from '../../tools/file-tools.js';
2
+ import { bashToolSync } from '../../tools/bash.js';
3
+ /**
4
+ * Tool-bridge: turns the abstract tool registry into:
5
+ * 1. An OpenAI-shaped tools schema for `EngineLoopClient.send`.
6
+ * 2. A single executor callback that dispatches each tool_call to the
7
+ * concrete `file-tools.ts` handler under workspace permissions.
8
+ *
9
+ * The bridge enforces two CLI-side invariants that the runtime cannot:
10
+ * - Plan-mode refusal. When `kind === 'plan'`, the executor refuses
11
+ * write/edit/bash by throwing `PLAN_MODE_REFUSED:<tool>` (sentinel
12
+ * recognised by `runEngineLoop` to terminate with status
13
+ * `tool_refused`). The schema also omits the mutating tools so the
14
+ * model is unlikely to attempt them in the first place.
15
+ * - Argument validation. Each call's `arguments` string is JSON-parsed
16
+ * and shape-checked here; bad JSON or missing fields are surfaced
17
+ * to the model as a tool error string so it can correct itself.
18
+ *
19
+ * The bridge does NOT touch session.ts directly — `file-tools.ts`
20
+ * already records every call. The engine adapter wires a hook layer on
21
+ * top that surfaces tool events into the engine's status stream.
22
+ */
23
+ /**
24
+ * Read-only subset surfaced to plan-mode. Mutating tools (write, edit,
25
+ * bash) are intentionally absent so the model rarely tries them.
26
+ */
27
+ const READ_ONLY_TOOLS = new Set(['read', 'grep', 'glob']);
28
+ /**
29
+ * Tools we actually wire today. The registry has more entries
30
+ * (task_*, skill, question) — those route through the runtime layer, not
31
+ * the local filesystem, so they ship in a follow-up PR. M1 cornerstone is
32
+ * the six core tools.
33
+ */
34
+ const WIRED_TOOLS = new Set(['read', 'write', 'edit', 'grep', 'glob', 'bash']);
35
+ export function buildToolsSchema(kind) {
36
+ const planMode = kind === 'plan';
37
+ const toolDefs = [
38
+ {
39
+ name: 'read',
40
+ description: 'Read the contents of a workspace file. Required before edit on a file. Returns the full UTF-8 text. Workspace-scoped: paths must be relative to the workspace root.',
41
+ parameters: {
42
+ type: 'object',
43
+ additionalProperties: false,
44
+ required: ['path'],
45
+ properties: {
46
+ path: { type: 'string', description: 'Workspace-relative file path.' },
47
+ },
48
+ },
49
+ },
50
+ {
51
+ name: 'grep',
52
+ description: 'Substring-match every workspace file. Returns up to 200 matches with {path, line, text}. Use this to locate code by symbol/keyword.',
53
+ parameters: {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ required: ['query'],
57
+ properties: {
58
+ query: { type: 'string', description: 'Substring to search for.' },
59
+ },
60
+ },
61
+ },
62
+ {
63
+ name: 'glob',
64
+ description: 'List files matching a glob pattern (workspace-scoped, node_modules / dist / .git / .pugi excluded). Up to 500 paths.',
65
+ parameters: {
66
+ type: 'object',
67
+ additionalProperties: false,
68
+ required: ['pattern'],
69
+ properties: {
70
+ pattern: { type: 'string', description: 'Glob pattern, e.g. "src/**/*.ts".' },
71
+ },
72
+ },
73
+ },
74
+ ];
75
+ if (!planMode) {
76
+ toolDefs.push({
77
+ name: 'write',
78
+ description: 'Create or overwrite a workspace file. Use for new files only — prefer edit for existing files. Workspace-scoped.',
79
+ parameters: {
80
+ type: 'object',
81
+ additionalProperties: false,
82
+ required: ['path', 'content'],
83
+ properties: {
84
+ path: { type: 'string', description: 'Workspace-relative file path.' },
85
+ content: { type: 'string', description: 'Full new file contents (UTF-8).' },
86
+ },
87
+ },
88
+ }, {
89
+ name: 'edit',
90
+ description: 'Replace exactly one occurrence of oldString with newString inside an already-read file. Fails if the file changed since you read it or if oldString is missing/duplicate.',
91
+ parameters: {
92
+ type: 'object',
93
+ additionalProperties: false,
94
+ required: ['path', 'oldString', 'newString'],
95
+ properties: {
96
+ path: { type: 'string' },
97
+ oldString: { type: 'string' },
98
+ newString: { type: 'string' },
99
+ },
100
+ },
101
+ }, {
102
+ name: 'bash',
103
+ description: 'Run a shell command inside the workspace root via /bin/sh -c. Inherits a sanitized env (PUGI_API_KEY/PUGI_LOGIN_TOKEN stripped). 30s timeout. Output capped at 64KB. Returns {exitCode, stdout, stderr, truncated}.',
104
+ parameters: {
105
+ type: 'object',
106
+ additionalProperties: false,
107
+ required: ['command'],
108
+ properties: {
109
+ command: { type: 'string', description: 'Single shell command to execute.' },
110
+ },
111
+ },
112
+ });
113
+ }
114
+ return toolDefs;
115
+ }
116
+ function parseArgs(raw) {
117
+ if (!raw || raw.trim() === '')
118
+ return {};
119
+ try {
120
+ const parsed = JSON.parse(raw);
121
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
122
+ throw new Error('tool arguments must be a JSON object');
123
+ }
124
+ return parsed;
125
+ }
126
+ catch (error) {
127
+ throw new Error(`invalid JSON in tool arguments: ${error.message}`);
128
+ }
129
+ }
130
+ function requireString(obj, key) {
131
+ const v = obj[key];
132
+ if (typeof v !== 'string') {
133
+ throw new Error(`tool argument "${key}" must be a string`);
134
+ }
135
+ return v;
136
+ }
137
+ export function buildExecutor(input) {
138
+ const { kind, ctx, hooks, sessionId } = input;
139
+ const planMode = kind === 'plan';
140
+ return async ({ name, arguments: argsRaw }) => {
141
+ if (!WIRED_TOOLS.has(name)) {
142
+ throw new Error(`unknown tool: ${name}`);
143
+ }
144
+ if (planMode && !READ_ONLY_TOOLS.has(name)) {
145
+ // Sentinel recognised by `runEngineLoop` — terminates the loop
146
+ // with status `tool_refused`. The CLI surfaces this as a blocked
147
+ // outcome, not a failure, because plan mode is doing its job.
148
+ throw new Error(`PLAN_MODE_REFUSED: ${name} is not allowed in plan mode`);
149
+ }
150
+ // Fire PreToolUse hooks. The match grammar takes the tool name and
151
+ // (when extractable) the target path. Each new tool dispatch starts a
152
+ // fresh dedup batch so a hook fires once per dispatch, not once per
153
+ // session.
154
+ if (hooks && sessionId) {
155
+ hooks.resetBatch();
156
+ const path = extractToolPath(name, argsRaw);
157
+ const preCtx = {
158
+ sessionId,
159
+ event: 'PreToolUse',
160
+ tool: name,
161
+ path,
162
+ payload: { tool: name, arguments: argsRaw },
163
+ };
164
+ // List the matching hooks BEFORE firing so we can correlate
165
+ // hook[i] with result[i] when checking onFailure: 'block'. The
166
+ // ordering of listMatching and fire is stable: fire iterates the
167
+ // same listMatching output internally.
168
+ const matchingPreHooks = hooks.listMatching(preCtx);
169
+ const preResults = await hooks.fire(preCtx);
170
+ for (let i = 0; i < matchingPreHooks.length; i += 1) {
171
+ const hook = matchingPreHooks[i];
172
+ const result = preResults[i];
173
+ if (hook && result && hook.onFailure === 'block' && !result.ok) {
174
+ throw new Error(`HOOK_BLOCKED: PreToolUse hook (${hook.run.slice(0, 80)}) refused ${name} (exit=${result.exitCode})`);
175
+ }
176
+ }
177
+ }
178
+ const args = parseArgs(argsRaw);
179
+ const dispatch = async () => {
180
+ return dispatchTool(name, args, ctx);
181
+ };
182
+ try {
183
+ const result = await dispatch();
184
+ if (hooks && sessionId) {
185
+ const path = extractToolPath(name, argsRaw);
186
+ await hooks.fire({
187
+ sessionId,
188
+ event: 'PostToolUse',
189
+ tool: name,
190
+ path,
191
+ payload: { tool: name, arguments: argsRaw, ok: true, result: result.slice(0, 1024) },
192
+ });
193
+ }
194
+ return result;
195
+ }
196
+ catch (error) {
197
+ if (hooks && sessionId) {
198
+ const path = extractToolPath(name, argsRaw);
199
+ await hooks.fire({
200
+ sessionId,
201
+ event: 'PostToolUseFailure',
202
+ tool: name,
203
+ path,
204
+ payload: {
205
+ tool: name,
206
+ arguments: argsRaw,
207
+ ok: false,
208
+ error: error instanceof Error ? error.message : String(error),
209
+ },
210
+ });
211
+ }
212
+ throw error;
213
+ }
214
+ };
215
+ }
216
+ /**
217
+ * Best-effort extraction of the file path a tool targets. Returns
218
+ * undefined when the tool does not take a path or the path cannot be
219
+ * parsed cleanly — match rules with a `pathGlob` will then skip this
220
+ * dispatch, which is the safe default.
221
+ */
222
+ function extractToolPath(name, argsRaw) {
223
+ if (name !== 'read' && name !== 'write' && name !== 'edit')
224
+ return undefined;
225
+ try {
226
+ const parsed = JSON.parse(argsRaw);
227
+ const path = parsed.path;
228
+ return typeof path === 'string' ? path : undefined;
229
+ }
230
+ catch {
231
+ return undefined;
232
+ }
233
+ }
234
+ function dispatchTool(name, args, ctx) {
235
+ switch (name) {
236
+ case 'read': {
237
+ const { path } = { path: requireString(args, 'path') };
238
+ const content = readTool(ctx, path);
239
+ // Cap the content surfaced back to the model so a 10MB file
240
+ // does not blow the context window. The model sees the head
241
+ // and a truncation marker; if it needs more it can grep.
242
+ const CAP = 32 * 1024;
243
+ if (content.length > CAP) {
244
+ return `${content.slice(0, CAP)}\n(...truncated at ${CAP} bytes; use grep or glob to narrow the read)`;
245
+ }
246
+ return content;
247
+ }
248
+ case 'write': {
249
+ const wargs = {
250
+ path: requireString(args, 'path'),
251
+ content: requireString(args, 'content'),
252
+ };
253
+ writeTool(ctx, wargs.path, wargs.content);
254
+ return `wrote ${wargs.path} (${wargs.content.length} bytes)`;
255
+ }
256
+ case 'edit': {
257
+ const eargs = {
258
+ path: requireString(args, 'path'),
259
+ oldString: requireString(args, 'oldString'),
260
+ newString: requireString(args, 'newString'),
261
+ };
262
+ editTool(ctx, eargs.path, eargs.oldString, eargs.newString);
263
+ return `edited ${eargs.path}`;
264
+ }
265
+ case 'grep': {
266
+ const gargs = { query: requireString(args, 'query') };
267
+ const matches = grepTool(ctx, gargs.query);
268
+ if (matches.length === 0)
269
+ return `no matches for ${gargs.query}`;
270
+ const head = matches.slice(0, 50);
271
+ const rendered = head.map((m) => `${m.path}:${m.line}: ${m.text}`).join('\n');
272
+ const more = matches.length > head.length ? `\n(... ${matches.length - head.length} more)` : '';
273
+ return `${matches.length} match(es):\n${rendered}${more}`;
274
+ }
275
+ case 'glob': {
276
+ const gargs = { pattern: requireString(args, 'pattern') };
277
+ const results = globTool(ctx, gargs.pattern);
278
+ if (results.length === 0)
279
+ return `no paths match ${gargs.pattern}`;
280
+ return `${results.length} path(s):\n${results.slice(0, 100).join('\n')}${results.length > 100 ? `\n(... ${results.length - 100} more)` : ''}`;
281
+ }
282
+ case 'bash': {
283
+ const bargs = { command: requireString(args, 'command') };
284
+ // The class-aware bash tool (sprint α5.2) replaces the legacy
285
+ // file-tools entry point. We use the sync variant here because
286
+ // dispatchTool's signature is sync; the async tool is reserved
287
+ // for the REPL path (sprint α5.7) where promises are first class.
288
+ const result = bashToolSync({ cmd: bargs.command }, {
289
+ root: ctx.root,
290
+ settings: ctx.settings,
291
+ session: ctx.session,
292
+ source: 'agent',
293
+ });
294
+ const parts = [
295
+ `exit=${result.exitCode}`,
296
+ result.stdout ? `stdout:\n${result.stdout}` : '',
297
+ result.stderr ? `stderr:\n${result.stderr}` : '',
298
+ ];
299
+ if (result.artifactRef)
300
+ parts.push(`artifactRef=${result.artifactRef}`);
301
+ if (result.truncated)
302
+ parts.push('truncated=true');
303
+ if (result.timedOut)
304
+ parts.push('timedOut=true');
305
+ const body = parts.filter(Boolean).join('\n');
306
+ return body || '(no output)';
307
+ }
308
+ default:
309
+ // Exhaustive; unreachable because of the WIRED_TOOLS guard above.
310
+ throw new Error(`unhandled tool: ${name}`);
311
+ }
312
+ }
313
+ //# sourceMappingURL=tool-bridge.js.map
@@ -0,0 +1,29 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { statSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ export class FileReadCache {
5
+ records = new Map();
6
+ set(record) {
7
+ this.records.set(record.resolvedPath, record);
8
+ }
9
+ get(root, path) {
10
+ return this.records.get(resolve(root, path));
11
+ }
12
+ }
13
+ export function hashContent(content) {
14
+ return createHash('sha256').update(content).digest('hex');
15
+ }
16
+ export function createReadRecord(root, path, content, source) {
17
+ const resolvedPath = resolve(root, path);
18
+ const stat = statSync(resolvedPath);
19
+ return {
20
+ path,
21
+ resolvedPath,
22
+ sha256: hashContent(content),
23
+ sizeBytes: stat.size,
24
+ mtimeMs: stat.mtimeMs,
25
+ readAt: new Date().toISOString(),
26
+ source,
27
+ };
28
+ }
29
+ //# sourceMappingURL=file-cache.js.map