pi-managed-axi 0.1.0
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 +38 -0
- package/package.json +25 -0
- package/src/backends/agent-reach.ts +24 -0
- package/src/backends/opencli.ts +121 -0
- package/src/core.ts +132 -0
- package/src/format.ts +79 -0
- package/src/hook.ts +155 -0
- package/src/index.ts +14 -0
- package/src/managed-tools.ts +135 -0
- package/src/results.ts +164 -0
- package/src/runner.ts +80 -0
- package/src/settings.ts +322 -0
package/README.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# pi-agent-reach-axi
|
|
2
|
+
|
|
3
|
+
User-managed output capture and recovery for Pi.
|
|
4
|
+
|
|
5
|
+
AXI adds a small, controlled path for running reviewed read-only integrations and for keeping large shell-tool results available after Pi compacts them.
|
|
6
|
+
|
|
7
|
+
## How it works
|
|
8
|
+
|
|
9
|
+
AXI has two separate paths:
|
|
10
|
+
|
|
11
|
+
- **AXI tools** expose exactly `axi_help`, `axi_run`, and `axi_read`. `axi_run` is fail-closed. It runs only reviewed read-only contracts: `agent-reach/doctor`, `opencli/hackernews/search`, and `opencli/hackernews/read`.
|
|
12
|
+
- **The result hook** watches Pi's `tool_result` events. It handles only simple Bash or PowerShell commands for tools the user selected. Selection allows capture and presentation only. It does not authorize, block, or execute commands.
|
|
13
|
+
|
|
14
|
+
For large managed results, AXI stores raw stdout, raw stderr, and formatted output in private temporary files. Pi receives a bounded preview and a session-scoped result ID. Call `axi_read` to page any stream by UTF-8 byte offset.
|
|
15
|
+
|
|
16
|
+
If AXI cannot validate or read Pi's spill file, it leaves the native result unchanged. The default managed-tool selection is empty.
|
|
17
|
+
|
|
18
|
+
## Configure capture
|
|
19
|
+
|
|
20
|
+
Run this in interactive Pi:
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
/axi settings
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The settings overlay scans a fixed list of known command names without running them. It lists commands found on `PATH`, supports fuzzy search, and saves selections to `pi-managed-axi.json` under `PI_CODING_AGENT_DIR` or `~/.pi/agent`.
|
|
27
|
+
|
|
28
|
+
Use `Space` to toggle, `Enter` or `Ctrl+S` to save, and `Esc` to close or discard. AXI does not install, authenticate, update, or configure tools. Shell operators, wrappers, and unknown executables remain native.
|
|
29
|
+
|
|
30
|
+
## Use the AXI tools
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
axi_help
|
|
34
|
+
axi_run {"command":"opencli/hackernews/search","args":{"query":"sqlite","limit":3}}
|
|
35
|
+
axi_read {"result":"...","stream":"formatted","offset":0,"limit":8192}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Agent Reach and OpenCLI must already be installed. AXI does not provide global capture for every command.
|
package/package.json
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-managed-axi",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "User-managed output capture and recovery for Pi",
|
|
5
|
+
"files": ["src", "README.md"],
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": ["pi-package", "axi", "toon"],
|
|
8
|
+
"license": "MIT",
|
|
9
|
+
"engines": { "node": ">=22.19.0" },
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --experimental-strip-types --test test/*.test.ts",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"pack:check": "npm pack --dry-run"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": { "@toon-format/toon": "^4.1.1" },
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
18
|
+
"@earendil-works/pi-ai": "*",
|
|
19
|
+
"@earendil-works/pi-tui": "*",
|
|
20
|
+
"typebox": "*"
|
|
21
|
+
},
|
|
22
|
+
"pi": { "extensions": ["./src/index.ts"] },
|
|
23
|
+
"exports": { "./core": "./src/core.ts" },
|
|
24
|
+
"devDependencies": { "@types/node": "^22.0.0", "typescript": "^5.7.0" }
|
|
25
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ProcessResult } from "../runner.ts";
|
|
2
|
+
import { runProcess } from "../runner.ts";
|
|
3
|
+
import type { AxiBackend, CommandSpec } from "../core.ts";
|
|
4
|
+
|
|
5
|
+
export function createAgentReachBackend(run: (request: { executable: string; argv: string[]; timeoutMs?: number; signal?: AbortSignal }) => Promise<ProcessResult> = runProcess): AxiBackend {
|
|
6
|
+
const command: CommandSpec = {
|
|
7
|
+
id: "agent-reach/doctor",
|
|
8
|
+
description: "Diagnose Agent Reach backends",
|
|
9
|
+
access: "read",
|
|
10
|
+
format: "json",
|
|
11
|
+
args: [],
|
|
12
|
+
executable: "agent-reach",
|
|
13
|
+
buildArgv: (args) => {
|
|
14
|
+
if (Object.keys(args).length) throw new Error("agent-reach/doctor accepts no arguments");
|
|
15
|
+
return ["doctor", "--json"];
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
return {
|
|
19
|
+
id: "agent-reach",
|
|
20
|
+
description: "Agent Reach health report",
|
|
21
|
+
async discover() { return [command]; },
|
|
22
|
+
async execute(request) { return run({ ...request, executable: "agent-reach", argv: command.buildArgv({}) }); },
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { ProcessResult } from "../runner.ts";
|
|
2
|
+
import { runProcess } from "../runner.ts";
|
|
3
|
+
import type { AxiBackend, ArgumentSpec, CommandSpec } from "../core.ts";
|
|
4
|
+
|
|
5
|
+
type RawArg = { name?: unknown; type?: unknown; required?: unknown; valueRequired?: unknown; positional?: unknown; choices?: unknown; default?: unknown; help?: unknown; minimum?: unknown; maximum?: unknown };
|
|
6
|
+
type RawCommand = { command?: unknown; description?: unknown; access?: unknown; strategy?: unknown; browser?: unknown; args?: unknown };
|
|
7
|
+
|
|
8
|
+
const REVIEWED: Record<string, { args: ArgumentSpec[] }> = {
|
|
9
|
+
"hackernews/search": { args: [
|
|
10
|
+
{ name: "query", type: "string", required: true, positional: true, valueRequired: false },
|
|
11
|
+
{ name: "limit", type: "integer", positional: false, valueRequired: false, default: 20 },
|
|
12
|
+
{ name: "sort", type: "string", positional: false, valueRequired: false, choices: ["relevance", "date"], default: "relevance" },
|
|
13
|
+
] },
|
|
14
|
+
"hackernews/read": { args: [
|
|
15
|
+
{ name: "id", type: "string", required: true, positional: true, valueRequired: false },
|
|
16
|
+
{ name: "limit", type: "integer", positional: false, valueRequired: false, default: 25 },
|
|
17
|
+
{ name: "depth", type: "integer", positional: false, valueRequired: false, default: 2 },
|
|
18
|
+
{ name: "replies", type: "integer", positional: false, valueRequired: false, default: 5 },
|
|
19
|
+
{ name: "max-length", type: "integer", positional: false, valueRequired: false, default: 2000 },
|
|
20
|
+
] },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function validToken(value: unknown): value is string { return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value); }
|
|
24
|
+
function sameContract(actual: ArgumentSpec[], expected: ArgumentSpec[]) {
|
|
25
|
+
const shape = (items: ArgumentSpec[]) => items.map(({ name, type, required, positional, valueRequired, choices, default: defaultValue, minimum, maximum }) => ({ name, type, required: Boolean(required), positional: Boolean(positional), valueRequired: valueRequired !== false, choices: choices ?? [], default: defaultValue === undefined ? null : defaultValue, minimum: minimum === undefined ? null : minimum, maximum: maximum === undefined ? null : maximum }));
|
|
26
|
+
return JSON.stringify(shape(actual)) === JSON.stringify(shape(expected));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeOpenCliCommand(raw: RawCommand | null): CommandSpec | undefined {
|
|
30
|
+
if (!raw || typeof raw !== "object" || typeof raw.command !== "string" || !raw.command.includes("/")) return undefined;
|
|
31
|
+
const [site, name] = raw.command.split("/");
|
|
32
|
+
if (!validToken(site) || !validToken(name) || typeof raw.description !== "string" || raw.access !== "read" || raw.strategy !== "public" || raw.browser !== false || !Array.isArray(raw.args)) return undefined;
|
|
33
|
+
const args: ArgumentSpec[] = [];
|
|
34
|
+
for (const item of raw.args as RawArg[]) {
|
|
35
|
+
if (!item || !validToken(item.name) || typeof item.type !== "string") return undefined;
|
|
36
|
+
if ([item.required, item.valueRequired, item.positional].some((value) => value !== undefined && typeof value !== "boolean")) return undefined;
|
|
37
|
+
const type = item.type === "str" || item.type === "string" ? "string" : item.type === "bool" || item.type === "boolean" ? "boolean" : item.type === "int" ? "integer" : item.type === "number" ? "number" : undefined;
|
|
38
|
+
if (!type || (item.choices !== undefined && !Array.isArray(item.choices))) return undefined;
|
|
39
|
+
const choices = (item.choices ?? []).filter((value): value is string | number | boolean => ["string", "number", "boolean"].includes(typeof value));
|
|
40
|
+
if ((item.choices ?? []).length !== choices.length) return undefined;
|
|
41
|
+
if (item.minimum !== undefined && (typeof item.minimum !== "number" || !Number.isFinite(item.minimum))) return undefined;
|
|
42
|
+
if (item.maximum !== undefined && (typeof item.maximum !== "number" || !Number.isFinite(item.maximum))) return undefined;
|
|
43
|
+
const defaultValue = item.default === null ? undefined : item.default;
|
|
44
|
+
const spec: ArgumentSpec = { name: item.name, type, required: item.required === true, positional: item.positional === true, valueRequired: item.valueRequired !== false, choices, description: typeof item.help === "string" ? item.help : undefined, minimum: item.minimum as number | undefined, maximum: item.maximum as number | undefined, default: defaultValue };
|
|
45
|
+
try { if (defaultValue !== undefined) validateArgument(spec, defaultValue); } catch { return undefined; }
|
|
46
|
+
args.push(spec);
|
|
47
|
+
}
|
|
48
|
+
const reviewed = REVIEWED[raw.command];
|
|
49
|
+
if (!reviewed || !sameContract(args, reviewed.args)) return undefined;
|
|
50
|
+
return { id: `opencli/${raw.command}`, description: raw.description, access: "read", format: "json", args, executable: "opencli", buildArgv: (values) => compileArgs({ id: `opencli/${raw.command}`, description: raw.description as string, access: "read", format: "json", args, executable: "opencli", buildArgv: () => [] }, values) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function validateArgument(spec: ArgumentSpec, value: unknown) {
|
|
54
|
+
if (typeof value === "string" && value.includes("\0")) throw new Error(`argument ${spec.name} contains NUL`);
|
|
55
|
+
const valid = spec.type === "string" ? typeof value === "string" : spec.type === "boolean" ? typeof value === "boolean" : spec.type === "integer" ? typeof value === "number" && Number.isSafeInteger(value) : typeof value === "number" && Number.isFinite(value);
|
|
56
|
+
if (!valid) throw new Error(`argument ${spec.name} must be ${spec.type}`);
|
|
57
|
+
if (typeof value === "number" && (spec.minimum !== undefined && value < spec.minimum || spec.maximum !== undefined && value > spec.maximum)) throw new Error(`argument ${spec.name} is out of range`);
|
|
58
|
+
if (spec.choices?.length && !spec.choices.some((choice) => choice === value)) throw new Error(`argument ${spec.name} must be one of the allowed choices`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatArgValue(value: unknown): string {
|
|
62
|
+
return typeof value === "number" && Object.is(value, -0) ? "-0" : String(value);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function compileArgs(command: CommandSpec, values: Record<string, unknown>): string[] {
|
|
66
|
+
const known = new Set(command.args.map((arg) => arg.name));
|
|
67
|
+
for (const key of Object.keys(values)) if (!known.has(key)) throw new Error(`unknown argument: ${key}`);
|
|
68
|
+
const resolved: Record<string, unknown> = {};
|
|
69
|
+
for (const spec of command.args) {
|
|
70
|
+
if (!Object.hasOwn(values, spec.name)) {
|
|
71
|
+
if (spec.required) throw new Error(`missing required argument: ${spec.name}`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const value = values[spec.name];
|
|
75
|
+
validateArgument(spec, value);
|
|
76
|
+
resolved[spec.name] = value;
|
|
77
|
+
}
|
|
78
|
+
const positionals = command.args.filter((arg) => arg.positional);
|
|
79
|
+
let missing = false;
|
|
80
|
+
for (const spec of positionals) {
|
|
81
|
+
if (!Object.hasOwn(resolved, spec.name)) missing = true;
|
|
82
|
+
else if (missing) throw new Error("cannot represent a positional argument gap");
|
|
83
|
+
}
|
|
84
|
+
const options = command.args.filter((arg) => !arg.positional && Object.hasOwn(resolved, arg.name)).map((arg) => `--${arg.name}=${formatArgValue(resolved[arg.name])}`);
|
|
85
|
+
const positionalValues = positionals.filter((arg) => Object.hasOwn(resolved, arg.name)).map((arg) => formatArgValue(resolved[arg.name]));
|
|
86
|
+
return [command.id.slice("opencli/".length).split("/"), "--format=json", ...options, ...(positionalValues.length ? ["--", ...positionalValues] : [])].flat();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function createOpenCliBackend(options: { run?: (request: { executable: string; argv: string[]; timeoutMs?: number; signal?: AbortSignal }) => Promise<ProcessResult> } = {}): AxiBackend {
|
|
90
|
+
const run = options.run ?? runProcess;
|
|
91
|
+
let report = { excluded: 0, write: 0, unreviewed: 0, changedContract: 0, unsupportedSchema: 0 };
|
|
92
|
+
return {
|
|
93
|
+
id: "opencli",
|
|
94
|
+
description: "Reviewed read-only OpenCLI commands",
|
|
95
|
+
async discover(signal) {
|
|
96
|
+
const result = await run({ executable: "opencli", argv: ["list", "--format=json"], timeoutMs: 15_000, signal });
|
|
97
|
+
if (result.reason && result.reason !== "exit" || result.code !== 0) throw new Error(`OpenCLI discovery failed: ${result.error ?? result.code ?? result.reason}`);
|
|
98
|
+
let raw: unknown;
|
|
99
|
+
try { raw = JSON.parse(result.stdout); } catch { throw new Error("OpenCLI discovery returned invalid JSON"); }
|
|
100
|
+
if (!Array.isArray(raw)) throw new Error("OpenCLI discovery returned an invalid catalog");
|
|
101
|
+
const commands: CommandSpec[] = [];
|
|
102
|
+
let excluded = 0, write = 0, unreviewed = 0, changedContract = 0, unsupportedSchema = 0;
|
|
103
|
+
for (const item of raw) {
|
|
104
|
+
if (!item || typeof item !== "object") { excluded++; continue; }
|
|
105
|
+
const rawCommand = item as RawCommand;
|
|
106
|
+
const candidate = normalizeOpenCliCommand(rawCommand);
|
|
107
|
+
if (candidate) { commands.push(candidate); continue; }
|
|
108
|
+
excluded++;
|
|
109
|
+
if (rawCommand?.access !== "read") { write++; continue; }
|
|
110
|
+
const commandId = typeof rawCommand?.command === "string" ? rawCommand.command : "";
|
|
111
|
+
if (!Object.hasOwn(REVIEWED, commandId)) { unreviewed++; continue; }
|
|
112
|
+
if (!Array.isArray(rawCommand?.args) || (rawCommand.args as RawArg[]).some((arg) => !arg || typeof arg.name !== "string" || typeof arg.type !== "string" || !["str", "string", "bool", "boolean", "int", "number"].includes(arg.type))) unsupportedSchema++;
|
|
113
|
+
else changedContract++;
|
|
114
|
+
}
|
|
115
|
+
report = { excluded, write, unreviewed, changedContract, unsupportedSchema };
|
|
116
|
+
return commands;
|
|
117
|
+
},
|
|
118
|
+
getReport() { return report; },
|
|
119
|
+
async execute(request) { return run(request); },
|
|
120
|
+
};
|
|
121
|
+
}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { formatCapture, truncateUtf8 } from "./format.ts";
|
|
4
|
+
import { processError, runProcess, type ProcessResult } from "./runner.ts";
|
|
5
|
+
import { findResult, readResultPage, saveResult, type ResultReference, type ResultStream } from "./results.ts";
|
|
6
|
+
|
|
7
|
+
export type ArgumentType = "string" | "boolean" | "integer" | "number";
|
|
8
|
+
export interface ArgumentSpec { name: string; type: ArgumentType; required?: boolean; positional?: boolean; valueRequired?: boolean; choices?: Array<string | number | boolean>; default?: unknown; description?: string; minimum?: number; maximum?: number; }
|
|
9
|
+
export interface CommandSpec { id: string; description: string; access: string; format: "json" | "text"; args: ArgumentSpec[]; executable: string; buildArgv: (args: Record<string, unknown>) => string[]; }
|
|
10
|
+
export interface AxiExecutionRequest { executable: string; argv: string[]; signal?: AbortSignal; timeoutMs?: number; }
|
|
11
|
+
export interface AxiBackend { id: string; description: string; discover: (signal?: AbortSignal) => Promise<CommandSpec[]>; execute?: (request: AxiExecutionRequest) => Promise<ProcessResult>; getReport?: () => unknown; }
|
|
12
|
+
|
|
13
|
+
function text(text: string) { return { content: [{ type: "text" as const, text }], details: undefined }; }
|
|
14
|
+
function details(reference: ResultReference, command: CommandSpec, result: ProcessResult, previewComplete: boolean) { return { result: reference.id, command: command.id, format: command.format, exitCode: result.code, captureComplete: result.captureComplete, previewComplete, bytes: reference.bytes, artifacts: { stdout: reference.stdoutPath, stderr: reference.stderrPath, formatted: reference.formattedPath } }; }
|
|
15
|
+
function commandExample(command: CommandSpec) {
|
|
16
|
+
const args = Object.fromEntries(command.args.map((arg) => [arg.name, arg.default ?? arg.choices?.[0] ?? (arg.type === "string" ? "example" : arg.type === "boolean" ? false : 1)]));
|
|
17
|
+
return JSON.stringify({ command: command.id, args });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerAxiTools(pi: ExtensionAPI, backends: AxiBackend[]) {
|
|
21
|
+
const backendMap = new Map(backends.map((backend) => [backend.id, backend]));
|
|
22
|
+
const cache = new Map<string, { generation: number; promise: Promise<CommandSpec[]> }>();
|
|
23
|
+
const reset = () => cache.clear();
|
|
24
|
+
const discover = (backend: AxiBackend, refresh = false, signal?: AbortSignal) => {
|
|
25
|
+
const old = cache.get(backend.id);
|
|
26
|
+
if (refresh) cache.delete(backend.id);
|
|
27
|
+
const current = refresh ? undefined : cache.get(backend.id);
|
|
28
|
+
if (current) return current;
|
|
29
|
+
const record = { generation: (old?.generation ?? 0) + 1, promise: Promise.resolve([] as CommandSpec[]) };
|
|
30
|
+
record.promise = backend.discover(signal).then((commands) => { if (cache.get(backend.id) === record) return commands; throw new Error("discovery was superseded"); }).catch((error) => { if (cache.get(backend.id) === record) cache.delete(backend.id); throw error; });
|
|
31
|
+
cache.set(backend.id, record);
|
|
32
|
+
return record;
|
|
33
|
+
};
|
|
34
|
+
const findCommand = async (id: string, signal?: AbortSignal) => {
|
|
35
|
+
const slash = id.indexOf("/");
|
|
36
|
+
if (slash < 1) throw new Error(`unknown AXI command: ${id}`);
|
|
37
|
+
const backend = backendMap.get(id.slice(0, slash));
|
|
38
|
+
if (!backend) throw new Error(`unknown AXI backend: ${id.slice(0, slash)}`);
|
|
39
|
+
const record = discover(backend, false, signal);
|
|
40
|
+
const commands = await record.promise;
|
|
41
|
+
if (cache.get(backend.id) !== record) throw new Error("command discovery changed before execution");
|
|
42
|
+
const command = commands.find((candidate) => candidate.id === id);
|
|
43
|
+
if (!command) throw new Error(`unknown or unavailable AXI command: ${id}`);
|
|
44
|
+
return { backend, command, record };
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
pi.registerTool({
|
|
48
|
+
name: "axi_help", label: "AXI help", description: "List trusted read-only AXI commands and argument schemas.",
|
|
49
|
+
parameters: Type.Object({ backend: Type.Optional(Type.String()), command: Type.Optional(Type.String()), refresh: Type.Optional(Type.Boolean()) }),
|
|
50
|
+
async execute(_id: string, params: { backend?: string; command?: string; refresh?: boolean }, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
51
|
+
if (params.refresh && !params.backend && !params.command) throw new Error("refresh requires a backend or command");
|
|
52
|
+
if (!params.backend && !params.command) return text(JSON.stringify({ backends: [...backendMap.values()].map((backend) => ({ id: backend.id, description: backend.description })) }));
|
|
53
|
+
let backendIds = params.backend ? [params.backend] : [params.command!.slice(0, params.command!.indexOf("/"))];
|
|
54
|
+
if (params.command && !params.command.includes("/")) throw new Error(`invalid AXI command: ${params.command}`);
|
|
55
|
+
if (params.backend && params.command && !params.command.startsWith(`${params.backend}/`)) throw new Error("backend and command do not agree");
|
|
56
|
+
const output: Record<string, unknown> = { backends: [] };
|
|
57
|
+
const listed: unknown[] = [];
|
|
58
|
+
for (const id of backendIds) {
|
|
59
|
+
const backend = backendMap.get(id);
|
|
60
|
+
if (!backend) throw new Error(`unknown AXI backend: ${id}`);
|
|
61
|
+
const record = discover(backend, Boolean(params.refresh), signal);
|
|
62
|
+
const commands = await record.promise;
|
|
63
|
+
const selected = params.command ? commands.filter((command) => command.id === params.command) : commands;
|
|
64
|
+
if (params.command && selected.length === 0) throw new Error(`unknown or unavailable AXI command: ${params.command}`);
|
|
65
|
+
listed.push({ id, description: backend.description, commands: selected.map((command) => ({ id: command.id, description: command.description, access: command.access, format: command.format, args: command.args, example: commandExample(command) })), report: backend.getReport?.() });
|
|
66
|
+
}
|
|
67
|
+
output.backends = listed;
|
|
68
|
+
const catalog = JSON.stringify(output);
|
|
69
|
+
if (Buffer.byteLength(catalog) <= 12 * 1024 && catalog.split("\n").length <= 200) return text(catalog);
|
|
70
|
+
const reference = await saveResult({ command: `axi-help/${backendIds.join(",")}`, format: "text", stdout: catalog, stderr: "", formatted: catalog, captureComplete: true, append: (value) => pi.appendEntry("axi-result", value) });
|
|
71
|
+
const footer = `\n\n[result=${reference.id} previewComplete=false; call axi_read with {"result":"${reference.id}"}]`;
|
|
72
|
+
return { ...text(`${truncateUtf8(catalog, 12 * 1024 - Buffer.byteLength(footer), 197)}${footer}`), details: { result: reference.id, format: "text", bytes: reference.bytes, captureComplete: true, previewComplete: false } };
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
pi.registerTool({
|
|
77
|
+
name: "axi_run", label: "AXI run", description: "Run one trusted read-only AXI command and save its complete output.",
|
|
78
|
+
parameters: Type.Object({ command: Type.String(), args: Type.Optional(Type.Record(Type.String(), Type.Unknown())) }),
|
|
79
|
+
async execute(_id: string, params: { command: string; args?: Record<string, unknown> }, signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
80
|
+
const { backend, command } = await findCommand(params.command, signal);
|
|
81
|
+
if (command.access !== "read") throw new Error(`AXI command is not read-only: ${command.id}`);
|
|
82
|
+
const argv = command.buildArgv(params.args ?? {});
|
|
83
|
+
const result = await (backend.execute ?? runProcess)({ executable: command.executable, argv, signal, timeoutMs: 60_000 });
|
|
84
|
+
const formatted = formatCapture(result.stdout, result.stderr, command.format);
|
|
85
|
+
const previewBody = `command=${command.id}\nformat=${command.format}\nexitCode=${result.code ?? "unknown"}\ncaptureComplete=${result.captureComplete}\n\n${formatted.full}`;
|
|
86
|
+
let reference: ResultReference | undefined;
|
|
87
|
+
try {
|
|
88
|
+
reference = await saveResult({ command: command.id, format: command.format, stdout: result.stdout, stderr: result.stderr, formatted: formatted.full, captureComplete: result.captureComplete, append: (value) => pi.appendEntry("axi-result", value) });
|
|
89
|
+
} catch (error) {
|
|
90
|
+
const incomplete = result.captureComplete ? "" : " missing output cannot be recovered from the saved prefix";
|
|
91
|
+
const failure = truncateUtf8(`${previewBody}\n\n[error] ${error instanceof Error ? error.message : String(error)}${incomplete}\n[recovery unavailable; previewComplete=false]`, 12 * 1024, 197, "preview truncated; recovery unavailable");
|
|
92
|
+
throw new Error(failure);
|
|
93
|
+
}
|
|
94
|
+
let footer = `\n\n[result=${reference.id} captureComplete=${result.captureComplete} previewComplete=false]`;
|
|
95
|
+
let previewComplete = Buffer.byteLength(previewBody + footer) <= 12 * 1024 && (previewBody + footer).split("\n").length <= 200;
|
|
96
|
+
footer = `\n\n[result=${reference.id} captureComplete=${result.captureComplete} previewComplete=${previewComplete}]`;
|
|
97
|
+
previewComplete = Buffer.byteLength(previewBody + footer) <= 12 * 1024 && (previewBody + footer).split("\n").length <= 200;
|
|
98
|
+
if (!previewComplete) footer = `\n\n[result=${reference.id} captureComplete=${result.captureComplete} previewComplete=false]`;
|
|
99
|
+
const preview = previewComplete ? previewBody : truncateUtf8(previewBody, 12 * 1024 - Buffer.byteLength(footer), 197);
|
|
100
|
+
const resultText = `${preview}${footer}`;
|
|
101
|
+
const resultDetails = details(reference, command, result, previewComplete);
|
|
102
|
+
if (result.code !== 0 || (result.reason !== undefined && result.reason !== "exit")) {
|
|
103
|
+
const incomplete = result.captureComplete ? "" : " missing output cannot be recovered from the saved prefix";
|
|
104
|
+
const failureFooter = `\n\n[error=${processError(result)}${incomplete} result=${reference.id} captureComplete=${result.captureComplete} previewComplete=false; call axi_read with {"result":"${reference.id}"} to recover saved output]`;
|
|
105
|
+
const failurePreview = truncateUtf8(previewBody, 12 * 1024 - Buffer.byteLength(failureFooter), 197);
|
|
106
|
+
throw new Error(`${failurePreview}${failureFooter}`);
|
|
107
|
+
}
|
|
108
|
+
return { ...text(resultText), details: resultDetails };
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
pi.registerTool({
|
|
113
|
+
name: "axi_read", label: "AXI read", description: "Read pages from a saved AXI result.",
|
|
114
|
+
parameters: Type.Object({ result: Type.String(), stream: Type.Optional(Type.Union([Type.Literal("stdout"), Type.Literal("stderr"), Type.Literal("formatted")])), offset: Type.Optional(Type.Integer({ minimum: 0 })), limit: Type.Optional(Type.Integer({ minimum: 4, maximum: 32 * 1024 })) }),
|
|
115
|
+
async execute(_id: string, params: { result: string; stream?: ResultStream; offset?: number; limit?: number }, _signal: AbortSignal | undefined, _onUpdate: unknown, ctx: ExtensionContext) {
|
|
116
|
+
const reference = findResult(ctx.sessionManager.getEntries(), params.result);
|
|
117
|
+
if (!reference) throw new Error("result is not available in this session");
|
|
118
|
+
const stream = params.stream ?? "formatted";
|
|
119
|
+
const page = await readResultPage(reference, stream, { offset: params.offset, limit: params.limit });
|
|
120
|
+
const { text: _pageText, ...pageDetails } = page;
|
|
121
|
+
Object.assign(pageDetails, { format: reference.format, captureComplete: reference.captureComplete });
|
|
122
|
+
const marker = `[axi_read result=${reference.id} stream=${stream} format=${reference.format} offset=${page.offset} bytes=${page.bytes} totalBytes=${page.totalBytes} nextOffset=${page.nextOffset ?? "null"} eof=${page.eof} captureComplete=${reference.captureComplete}]`;
|
|
123
|
+
return { ...text(page.text + marker), details: pageDetails };
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
pi.on("session_start", () => reset());
|
|
128
|
+
pi.on("session_shutdown", () => reset());
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export { findResult, readResultPage, saveResult } from "./results.ts";
|
|
132
|
+
export { registerAxiHook, compactAxiToolResult } from "./hook.ts";
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { encode } from "@toon-format/toon";
|
|
2
|
+
|
|
3
|
+
export type OutputFormat = "json" | "text";
|
|
4
|
+
|
|
5
|
+
export interface FormatResult {
|
|
6
|
+
full: string;
|
|
7
|
+
preview: string;
|
|
8
|
+
warning?: string;
|
|
9
|
+
format: OutputFormat;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function numericSafeJson(raw: string): unknown {
|
|
13
|
+
let lost = false;
|
|
14
|
+
const reviver = (_key: string, value: unknown, context?: { source?: string }) => {
|
|
15
|
+
if (typeof value === "number") {
|
|
16
|
+
const source = context?.source;
|
|
17
|
+
if (!source || !Number.isFinite(value) || (Number.isInteger(value) && !Number.isSafeInteger(value)) || String(value) !== source) lost = true;
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
};
|
|
21
|
+
const value = JSON.parse(raw, reviver as (key: string, value: unknown) => unknown);
|
|
22
|
+
if (lost) throw new Error("JSON number changed during parsing");
|
|
23
|
+
return value;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function encodeJson(raw: string): { text: string; warning?: string } {
|
|
27
|
+
try {
|
|
28
|
+
return { text: encode(numericSafeJson(raw)) };
|
|
29
|
+
} catch (error) {
|
|
30
|
+
return {
|
|
31
|
+
text: raw,
|
|
32
|
+
warning: `TOON formatting skipped: ${error instanceof Error ? error.message : String(error)}`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function truncateUtf8(text: string, maxBytes: number, maxLines = 200, notice = "preview truncated") {
|
|
38
|
+
const source = Buffer.from(text, "utf8");
|
|
39
|
+
if (source.length <= maxBytes && (text.match(/\n/g)?.length ?? 0) + 1 <= maxLines) return text;
|
|
40
|
+
const suffix = `\n[${notice}]`;
|
|
41
|
+
const suffixBytes = Buffer.byteLength(suffix);
|
|
42
|
+
let end = Math.max(0, maxBytes - suffixBytes);
|
|
43
|
+
end = Math.min(end, source.length);
|
|
44
|
+
while (end > 0) {
|
|
45
|
+
try {
|
|
46
|
+
const candidate = new TextDecoder("utf-8", { fatal: true }).decode(source.subarray(0, end));
|
|
47
|
+
const lines = candidate.split("\n");
|
|
48
|
+
if (lines.length > maxLines) {
|
|
49
|
+
end = Buffer.byteLength(lines.slice(0, maxLines).join("\n"));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
return candidate + suffix;
|
|
53
|
+
} catch {
|
|
54
|
+
end--;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return suffix.slice(0, maxBytes);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function formatCapture(stdout: string, stderr: string, format: OutputFormat, options: { previewBytes?: number; previewLines?: number } = {}): FormatResult {
|
|
61
|
+
let full = stdout;
|
|
62
|
+
let warning: string | undefined;
|
|
63
|
+
if (format === "json") {
|
|
64
|
+
if (!stdout.trim()) {
|
|
65
|
+
full = encode({ stdout: "" });
|
|
66
|
+
} else {
|
|
67
|
+
const encoded = encodeJson(stdout);
|
|
68
|
+
full = encoded.text;
|
|
69
|
+
warning = encoded.warning;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (stderr) {
|
|
73
|
+
const label = `\n\n[stderr]\n${stderr}`;
|
|
74
|
+
full += label;
|
|
75
|
+
}
|
|
76
|
+
if (warning) full = `${full}\n\n[warning] ${warning}`;
|
|
77
|
+
const preview = truncateUtf8(full, options.previewBytes ?? 12 * 1024, options.previewLines ?? 200);
|
|
78
|
+
return { full, preview, warning, format };
|
|
79
|
+
}
|
package/src/hook.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import type { ExtensionAPI, ToolResultEvent } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { isBashToolResult, isPowerShellToolResult } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { open } from "node:fs/promises";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { basename, dirname, resolve } from "node:path";
|
|
7
|
+
import { formatCapture, truncateUtf8 } from "./format.ts";
|
|
8
|
+
import { saveResult, type ResultReference } from "./results.ts";
|
|
9
|
+
import { createManagedToolPolicy, type ManagedToolPolicy } from "./managed-tools.ts";
|
|
10
|
+
|
|
11
|
+
const PREVIEW_BYTES = 12 * 1024;
|
|
12
|
+
const PREVIEW_LINES = 200;
|
|
13
|
+
const FOOTER_SLACK = 128;
|
|
14
|
+
const MAX_CAPTURE_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
|
|
16
|
+
interface AxiHookDetails {
|
|
17
|
+
truncation?: { truncated?: boolean };
|
|
18
|
+
fullOutputPath?: string;
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ToolResultEventResult { content?: Array<{ type: "text"; text: string }>; details?: unknown; isError?: boolean; }
|
|
23
|
+
|
|
24
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
25
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function tailUtf8(text: string, maxBytes: number): string {
|
|
29
|
+
const bytes = Buffer.from(text, "utf8");
|
|
30
|
+
let start = Math.max(0, bytes.length - maxBytes);
|
|
31
|
+
while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
|
|
32
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(start));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validSpillPath(path: string): boolean {
|
|
36
|
+
const root = resolve(tmpdir());
|
|
37
|
+
const resolved = resolve(path);
|
|
38
|
+
return dirname(resolved) === root && /^pi-(?:bash|powershell)-[0-9a-f]{16}\.log$/i.test(basename(resolved));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shellCommand(event: ToolResultEvent): string | undefined {
|
|
42
|
+
if (!isBashToolResult(event) && !isPowerShellToolResult(event)) return undefined;
|
|
43
|
+
return typeof event.input.command === "string" ? event.input.command : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function isTarget(event: ToolResultEvent, policy: ManagedToolPolicy): boolean {
|
|
47
|
+
const command = shellCommand(event);
|
|
48
|
+
return command !== undefined && policy.isManagedCommand(command);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function capture(event: ToolResultEvent): Promise<{ stdout: string; stderr: string; captureComplete: boolean } | undefined> {
|
|
52
|
+
const details = record(event.details) as AxiHookDetails | undefined;
|
|
53
|
+
const incomplete = details?.truncation?.truncated === true;
|
|
54
|
+
const nativeText = event.content[0]?.type === "text" ? event.content[0].text : "";
|
|
55
|
+
const hasSpillPath = details !== undefined && Object.hasOwn(details, "fullOutputPath");
|
|
56
|
+
if (hasSpillPath && (typeof details?.fullOutputPath !== "string" || !validSpillPath(details.fullOutputPath))) return undefined;
|
|
57
|
+
// A native error can omit its spill path from details, but a supplied path still
|
|
58
|
+
// contains useful output. Keep the thrown diagnostic as the recoverable stderr.
|
|
59
|
+
if (event.isError && typeof details?.fullOutputPath !== "string") return undefined;
|
|
60
|
+
if ((isBashToolResult(event) || isPowerShellToolResult(event)) && typeof details?.fullOutputPath === "string") {
|
|
61
|
+
let file: Awaited<ReturnType<typeof open>> | undefined;
|
|
62
|
+
try {
|
|
63
|
+
file = await open(details.fullOutputPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
64
|
+
const stat = await file.stat();
|
|
65
|
+
if (!stat.isFile() || stat.size === 0 || stat.size > MAX_CAPTURE_BYTES) return undefined;
|
|
66
|
+
const chunks: Buffer[] = [];
|
|
67
|
+
let position = 0;
|
|
68
|
+
while (position < stat.size) {
|
|
69
|
+
const length = Math.min(64 * 1024, stat.size - position);
|
|
70
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
71
|
+
const read = await file.read(buffer, 0, length, position);
|
|
72
|
+
if (read.bytesRead !== length) return undefined;
|
|
73
|
+
chunks.push(buffer);
|
|
74
|
+
position += read.bytesRead;
|
|
75
|
+
}
|
|
76
|
+
const after = await file.stat();
|
|
77
|
+
if (after.size !== stat.size) return undefined;
|
|
78
|
+
return {
|
|
79
|
+
stdout: new TextDecoder("utf-8", { fatal: true }).decode(Buffer.concat(chunks)),
|
|
80
|
+
stderr: event.isError ? nativeText : "",
|
|
81
|
+
captureComplete: true,
|
|
82
|
+
};
|
|
83
|
+
} catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
} finally {
|
|
86
|
+
await file?.close().catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (incomplete) return undefined;
|
|
90
|
+
return { stdout: nativeText, stderr: "", captureComplete: true };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function chooseFormat(stdout: string, stderr: string): { full: string; format: "json" | "text" } {
|
|
94
|
+
if (!stdout.trim()) return { full: formatCapture(stdout, stderr, "text").full, format: "text" };
|
|
95
|
+
const json = formatCapture(stdout, stderr, "json");
|
|
96
|
+
if (!json.warning && Buffer.byteLength(json.full) < Buffer.byteLength(stdout)) return { full: json.full, format: "json" };
|
|
97
|
+
return { full: formatCapture(stdout, stderr, "text").full, format: "text" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function hookDetails(original: unknown, reference: ResultReference, previewComplete: boolean) {
|
|
101
|
+
return {
|
|
102
|
+
...(record(original) ?? {}),
|
|
103
|
+
axi: {
|
|
104
|
+
result: reference.id,
|
|
105
|
+
stream: "formatted" as const,
|
|
106
|
+
bytes: reference.bytes,
|
|
107
|
+
captureComplete: reference.captureComplete,
|
|
108
|
+
previewComplete,
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function compactAxiToolResult(event: ToolResultEvent, append: (reference: ResultReference) => void, policy: ManagedToolPolicy = createManagedToolPolicy()): Promise<ToolResultEventResult | undefined> {
|
|
114
|
+
if (!isTarget(event, policy) || event.content.length !== 1 || event.content[0]?.type !== "text") return undefined;
|
|
115
|
+
const captured = await capture(event);
|
|
116
|
+
if (!captured) return undefined;
|
|
117
|
+
const { stdout, stderr, captureComplete } = captured;
|
|
118
|
+
const chosen = chooseFormat(stdout, stderr);
|
|
119
|
+
const preview = truncateUtf8(chosen.full, PREVIEW_BYTES, PREVIEW_LINES);
|
|
120
|
+
const changed = !captureComplete || chosen.full !== stdout || preview !== chosen.full;
|
|
121
|
+
if (!changed) return undefined;
|
|
122
|
+
if (captureComplete && preview === chosen.full && Buffer.byteLength(stdout) - Buffer.byteLength(chosen.full) < FOOTER_SLACK) return undefined;
|
|
123
|
+
|
|
124
|
+
let reference: ResultReference;
|
|
125
|
+
try {
|
|
126
|
+
reference = await saveResult({
|
|
127
|
+
command: event.toolName,
|
|
128
|
+
format: chosen.format,
|
|
129
|
+
stdout,
|
|
130
|
+
stderr,
|
|
131
|
+
formatted: chosen.full,
|
|
132
|
+
captureComplete,
|
|
133
|
+
append,
|
|
134
|
+
});
|
|
135
|
+
} catch {
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const errorSummary = event.isError && stderr ? JSON.stringify(tailUtf8(stderr, 512)) : undefined;
|
|
139
|
+
const footerBase = (previewComplete: boolean) =>
|
|
140
|
+
`\n\n[axi result=${reference.id} captureComplete=${captureComplete} previewComplete=${previewComplete}${errorSummary ? ` error=${errorSummary}` : ""}; call axi_read with {"result":"${reference.id}","stream":"formatted"}]`;
|
|
141
|
+
const fits = Buffer.byteLength(chosen.full + footerBase(true)) <= PREVIEW_BYTES && (chosen.full + footerBase(true)).split("\n").length <= PREVIEW_LINES;
|
|
142
|
+
const footer = footerBase(fits);
|
|
143
|
+
const body = fits ? chosen.full : truncateUtf8(chosen.full, PREVIEW_BYTES - Buffer.byteLength(footer), PREVIEW_LINES - 5);
|
|
144
|
+
const text = body + footer;
|
|
145
|
+
const previewComplete = fits && Buffer.byteLength(text) <= PREVIEW_BYTES && text.split("\n").length <= PREVIEW_LINES;
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text }],
|
|
148
|
+
details: hookDetails(event.details, reference, previewComplete),
|
|
149
|
+
isError: event.isError,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function registerAxiHook(pi: ExtensionAPI, policy: ManagedToolPolicy = createManagedToolPolicy()): void {
|
|
154
|
+
pi.on("tool_result", async (event, _ctx) => compactAxiToolResult(event, (reference) => pi.appendEntry("axi-result", reference), policy));
|
|
155
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerAxiTools } from "./core.ts";
|
|
3
|
+
import { registerAxiHook } from "./hook.ts";
|
|
4
|
+
import { createAgentReachBackend } from "./backends/agent-reach.ts";
|
|
5
|
+
import { createOpenCliBackend } from "./backends/opencli.ts";
|
|
6
|
+
import { createManagedToolPolicy, loadManagedSettings } from "./managed-tools.ts";
|
|
7
|
+
import { registerAxiSettings } from "./settings.ts";
|
|
8
|
+
|
|
9
|
+
export default async function (pi: ExtensionAPI) {
|
|
10
|
+
const policy = createManagedToolPolicy(await loadManagedSettings());
|
|
11
|
+
registerAxiHook(pi, policy);
|
|
12
|
+
registerAxiSettings(pi, policy);
|
|
13
|
+
registerAxiTools(pi, [createAgentReachBackend(), createOpenCliBackend()]);
|
|
14
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, chmod, mkdir, readFile, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
// Built-ins plus original command names from the community AXI catalog at github.com/kunchenguid/axi#community.
|
|
8
|
+
export const MANAGED_TOOL_CANDIDATES = [
|
|
9
|
+
"agent-reach", "opencli", "curl", "mcporter", "gh", "bili", "yt-dlp", "python", "python3",
|
|
10
|
+
"jj", "npm", "sqlite", "slack", "gws", "harvest", "specops",
|
|
11
|
+
"gitsheets", "metabase", "otter", "notion", "clickup", "databricks",
|
|
12
|
+
"aws", "docker", "doctl", "dynamodb", "pg", "mongodb", "elasticsearch",
|
|
13
|
+
"kubernetes", "redis", "celery", "cyber-mux", "oracle", "glab", "reactive",
|
|
14
|
+
"obsidian", "comfy-cloud", "mobbin", "calendly", "remarkable", "ado",
|
|
15
|
+
"supabase", "homebrew", "pypi", "cargo", "forgejo", "mastra", "axi",
|
|
16
|
+
"jira", "confluence", "az", "mssql", "superbee", "figma", "trello",
|
|
17
|
+
"railway", "netlify", "vercel", "cloudflare", "coolify", "porkbun",
|
|
18
|
+
"wrangler", "chezmoi", "mem", "fal", "canva", "ha",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
const CANDIDATE_NAMES = new Set<string>(MANAGED_TOOL_CANDIDATES);
|
|
22
|
+
|
|
23
|
+
export interface ManagedSettings {
|
|
24
|
+
managedTools: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DetectedTool {
|
|
28
|
+
name: string;
|
|
29
|
+
path: string;
|
|
30
|
+
detected: boolean;
|
|
31
|
+
managed: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ManagedToolPolicy {
|
|
35
|
+
getManagedTools(): string[];
|
|
36
|
+
setManagedTools(names: Iterable<string>): void;
|
|
37
|
+
isManagedCommand(command: string): boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function normalize(names: Iterable<string>): string[] {
|
|
41
|
+
return [...new Set([...names].filter((name) => CANDIDATE_NAMES.has(name)))];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function commandParts(command: string): string[] | undefined {
|
|
45
|
+
const trimmed = command.trim();
|
|
46
|
+
if (!trimmed || ["|", "&", ";", "`", "$", "(", ")", "<", ">", "\n", "\r", "\0"].some((token) => trimmed.includes(token))) return undefined;
|
|
47
|
+
const parts = trimmed.split(/\s+/);
|
|
48
|
+
let first = 0;
|
|
49
|
+
while (/^[A-Za-z_][A-Za-z0-9_]*=/.test(parts[first] ?? "")) first++;
|
|
50
|
+
return parts.slice(first);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function executableName(command: string): string | undefined {
|
|
54
|
+
const parts = commandParts(command);
|
|
55
|
+
const executable = parts?.[0];
|
|
56
|
+
return executable?.split(/[\\/]/).pop()?.replace(/\.exe$/i, "").toLowerCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isManagedCommand(command: string, settings: ManagedSettings | ManagedToolPolicy): boolean {
|
|
60
|
+
const name = executableName(command);
|
|
61
|
+
if (!name) return false;
|
|
62
|
+
const managed = "getManagedTools" in settings ? settings.getManagedTools() : settings.managedTools;
|
|
63
|
+
return managed.includes(name);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function createManagedToolPolicy(settings: ManagedSettings = { managedTools: [] }): ManagedToolPolicy {
|
|
67
|
+
let managedTools = normalize(settings.managedTools);
|
|
68
|
+
return {
|
|
69
|
+
getManagedTools: () => [...managedTools],
|
|
70
|
+
setManagedTools(names) { managedTools = normalize(names); },
|
|
71
|
+
isManagedCommand(command) { return isManagedCommand(command, { managedTools }); },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function settingsDirectory(env: NodeJS.ProcessEnv = process.env): string {
|
|
76
|
+
const configured = env.PI_CODING_AGENT_DIR;
|
|
77
|
+
if (!configured) return join(homedir(), ".pi", "agent");
|
|
78
|
+
if (configured === "~") return homedir();
|
|
79
|
+
if (configured.startsWith("~/") || configured.startsWith("~\\")) return join(homedir(), configured.slice(2));
|
|
80
|
+
return configured;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function managedSettingsPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
84
|
+
return join(settingsDirectory(env), "pi-managed-axi.json");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function loadManagedSettings(env: NodeJS.ProcessEnv = process.env): Promise<ManagedSettings> {
|
|
88
|
+
try {
|
|
89
|
+
const value: unknown = JSON.parse(await readFile(managedSettingsPath(env), "utf8"));
|
|
90
|
+
if (!value || typeof value !== "object" || !Array.isArray((value as { managedTools?: unknown }).managedTools)) return { managedTools: [] };
|
|
91
|
+
return { managedTools: normalize((value as { managedTools: unknown[] }).managedTools.filter((name): name is string => typeof name === "string")) };
|
|
92
|
+
} catch {
|
|
93
|
+
return { managedTools: [] };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function saveManagedSettings(settings: ManagedSettings, env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
|
98
|
+
const path = managedSettingsPath(env);
|
|
99
|
+
const directory = settingsDirectory(env);
|
|
100
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
101
|
+
await chmod(directory, 0o700);
|
|
102
|
+
const temporary = join(directory, ".pi-managed-axi-" + randomUUID() + ".tmp");
|
|
103
|
+
try {
|
|
104
|
+
await writeFile(temporary, JSON.stringify({ managedTools: normalize(settings.managedTools) }, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
105
|
+
await chmod(temporary, 0o600);
|
|
106
|
+
await rename(temporary, path);
|
|
107
|
+
await chmod(path, 0o600);
|
|
108
|
+
} finally {
|
|
109
|
+
await unlink(temporary).catch(() => undefined);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function findOnPath(name: string, env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
114
|
+
for (const directory of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
115
|
+
const candidates = process.platform === "win32" ? [name, ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").map((extension) => name + extension)] : [name];
|
|
116
|
+
for (const candidate of candidates) {
|
|
117
|
+
const path = join(directory, candidate);
|
|
118
|
+
try {
|
|
119
|
+
await access(path, constants.X_OK);
|
|
120
|
+
if ((await stat(path)).isFile()) return path;
|
|
121
|
+
} catch { /* continue */ }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function discoverManagedTools(env: NodeJS.ProcessEnv = process.env): Promise<DetectedTool[]> {
|
|
128
|
+
const tools: DetectedTool[] = [];
|
|
129
|
+
const settings = await loadManagedSettings(env);
|
|
130
|
+
for (const name of MANAGED_TOOL_CANDIDATES) {
|
|
131
|
+
const path = await findOnPath(name, env);
|
|
132
|
+
if (path) tools.push({ name, path, detected: true, managed: settings.managedTools.includes(name) });
|
|
133
|
+
}
|
|
134
|
+
return tools;
|
|
135
|
+
}
|
package/src/results.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { chmod, mkdtemp, open, writeFile, lstat } from "node:fs/promises";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { basename, join, relative, resolve } from "node:path";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
6
|
+
|
|
7
|
+
export type ResultStream = "stdout" | "stderr" | "formatted";
|
|
8
|
+
|
|
9
|
+
export interface ResultReference {
|
|
10
|
+
id: string;
|
|
11
|
+
command: string;
|
|
12
|
+
format: "json" | "text";
|
|
13
|
+
stdoutPath: string;
|
|
14
|
+
stderrPath: string;
|
|
15
|
+
formattedPath: string;
|
|
16
|
+
bytes: { stdout: number; stderr: number; formatted: number };
|
|
17
|
+
captureComplete: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface Page {
|
|
21
|
+
text: string;
|
|
22
|
+
offset: number;
|
|
23
|
+
bytes: number;
|
|
24
|
+
totalBytes: number;
|
|
25
|
+
nextOffset: number | null;
|
|
26
|
+
eof: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readPage(data: Buffer, options: { offset?: number; limit?: number; maxLines?: number } = {}): Page {
|
|
30
|
+
const offset = options.offset ?? 0;
|
|
31
|
+
const limit = options.limit ?? 8192;
|
|
32
|
+
const maxLines = options.maxLines ?? 200;
|
|
33
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > data.length) throw new Error("invalid byte offset");
|
|
34
|
+
if (!Number.isSafeInteger(limit) || limit < 4 || limit > 32 * 1024) throw new Error("limit must be between 4 and 32768 bytes");
|
|
35
|
+
if (offset < data.length && (data[offset] & 0xc0) === 0x80) throw new Error("offset is inside a UTF-8 character");
|
|
36
|
+
let end = Math.min(data.length, offset + limit);
|
|
37
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
38
|
+
while (end > offset) {
|
|
39
|
+
try {
|
|
40
|
+
const text = decoder.decode(data.subarray(offset, end));
|
|
41
|
+
const newlinePositions: number[] = [];
|
|
42
|
+
for (let i = 0; i < text.length; i++) if (text.charCodeAt(i) === 10) newlinePositions.push(i);
|
|
43
|
+
const lineCount = text.endsWith("\n") ? newlinePositions.length : newlinePositions.length + 1;
|
|
44
|
+
if (lineCount > maxLines) {
|
|
45
|
+
const chars = newlinePositions[maxLines - 1] + 1;
|
|
46
|
+
end = offset + Buffer.byteLength(text.slice(0, chars));
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const bytes = end - offset;
|
|
50
|
+
return { text, offset, bytes, totalBytes: data.length, nextOffset: end < data.length ? end : null, eof: end >= data.length };
|
|
51
|
+
} catch {
|
|
52
|
+
end--;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
if (offset < data.length) throw new Error("artifact contains invalid UTF-8");
|
|
56
|
+
return { text: "", offset, bytes: 0, totalBytes: data.length, nextOffset: null, eof: true };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function safePath(path: string, root: string, expectedName: string) {
|
|
60
|
+
const temp = resolve(tmpdir());
|
|
61
|
+
const directory = resolve(root);
|
|
62
|
+
const resolved = resolve(path);
|
|
63
|
+
const relativeTemp = relative(temp, directory);
|
|
64
|
+
if (!relativeTemp || relativeTemp.startsWith("..") || !basename(directory).startsWith("axi-result-") || resolved !== join(directory, expectedName)) throw new Error("result expired or missing");
|
|
65
|
+
return resolved;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function writePrivate(path: string, content: string) {
|
|
69
|
+
await writeFile(path, content, { encoding: "utf8", mode: 0o600, flag: "wx" });
|
|
70
|
+
await chmod(path, 0o600);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function saveResult(input: {
|
|
74
|
+
command: string;
|
|
75
|
+
format: "json" | "text";
|
|
76
|
+
stdout: string;
|
|
77
|
+
stderr: string;
|
|
78
|
+
formatted: string;
|
|
79
|
+
captureComplete: boolean;
|
|
80
|
+
append: (reference: ResultReference) => void;
|
|
81
|
+
}): Promise<ResultReference> {
|
|
82
|
+
const directory = await mkdtemp(join(tmpdir(), "axi-result-"));
|
|
83
|
+
await chmod(directory, 0o700);
|
|
84
|
+
const id = randomUUID();
|
|
85
|
+
const reference: ResultReference = {
|
|
86
|
+
id,
|
|
87
|
+
command: input.command,
|
|
88
|
+
format: input.format,
|
|
89
|
+
stdoutPath: join(directory, "stdout"),
|
|
90
|
+
stderrPath: join(directory, "stderr"),
|
|
91
|
+
formattedPath: join(directory, "formatted"),
|
|
92
|
+
bytes: { stdout: Buffer.byteLength(input.stdout), stderr: Buffer.byteLength(input.stderr), formatted: Buffer.byteLength(input.formatted) },
|
|
93
|
+
captureComplete: input.captureComplete,
|
|
94
|
+
};
|
|
95
|
+
try {
|
|
96
|
+
await Promise.all([
|
|
97
|
+
writePrivate(reference.stdoutPath, input.stdout),
|
|
98
|
+
writePrivate(reference.stderrPath, input.stderr),
|
|
99
|
+
writePrivate(reference.formattedPath, input.formatted),
|
|
100
|
+
]);
|
|
101
|
+
input.append(reference);
|
|
102
|
+
return reference;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
throw new Error(`could not save AXI result: ${error instanceof Error ? error.message : String(error)}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isReference(value: unknown): value is ResultReference {
|
|
109
|
+
if (!value || typeof value !== "object") return false;
|
|
110
|
+
const reference = value as Partial<ResultReference>;
|
|
111
|
+
const bytes = reference.bytes;
|
|
112
|
+
return typeof reference.id === "string" && /^[-0-9a-f]{36}$/.test(reference.id)
|
|
113
|
+
&& typeof reference.command === "string" && (reference.format === "json" || reference.format === "text")
|
|
114
|
+
&& typeof reference.stdoutPath === "string" && typeof reference.stderrPath === "string" && typeof reference.formattedPath === "string"
|
|
115
|
+
&& !!bytes && ["stdout", "stderr", "formatted"].every((key) => Number.isSafeInteger(bytes[key as keyof typeof bytes]) && bytes[key as keyof typeof bytes] >= 0)
|
|
116
|
+
&& typeof reference.captureComplete === "boolean";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function findResult(entries: unknown[], id: string): ResultReference | undefined {
|
|
120
|
+
// ponytail: linear session-entry scan; add an index only if lookup cost is measurable.
|
|
121
|
+
for (const entry of entries) {
|
|
122
|
+
if (!entry || typeof entry !== "object") continue;
|
|
123
|
+
const item = entry as { type?: string; customType?: string; data?: unknown };
|
|
124
|
+
if (item.type !== "custom" || item.customType !== "axi-result") continue;
|
|
125
|
+
if (isReference(item.data) && item.data.id === id) return item.data;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function readResultPage(reference: ResultReference, stream: ResultStream, options: { offset?: number; limit?: number } = {}): Promise<Page> {
|
|
131
|
+
const paths = { stdout: reference.stdoutPath, stderr: reference.stderrPath, formatted: reference.formattedPath };
|
|
132
|
+
const path = paths[stream];
|
|
133
|
+
if (!reference.id || !/^[-0-9a-f]{36}$/.test(reference.id) || !Object.values(paths).every((item) => typeof item === "string")) throw new Error("result expired or missing");
|
|
134
|
+
const directories = Object.values(paths).map((item) => resolve(item, ".."));
|
|
135
|
+
if (new Set(directories).size !== 1) throw new Error("result expired or missing");
|
|
136
|
+
const safe = safePath(path, directories[0], stream);
|
|
137
|
+
const directoryStat = await lstat(directories[0]).catch(() => undefined);
|
|
138
|
+
const stat = await lstat(safe).catch(() => undefined);
|
|
139
|
+
if (!directoryStat || !directoryStat.isDirectory() || directoryStat.isSymbolicLink() || !stat || !stat.isFile() || stat.isSymbolicLink()) throw new Error("result expired or missing");
|
|
140
|
+
const handle = await open(safe, constants.O_RDONLY | constants.O_NOFOLLOW).catch(() => undefined);
|
|
141
|
+
if (!handle) throw new Error("result expired or missing");
|
|
142
|
+
try {
|
|
143
|
+
const initial = await handle.stat();
|
|
144
|
+
const totalBytes = initial.size;
|
|
145
|
+
const expectedBytes = reference.bytes[stream];
|
|
146
|
+
if (!initial.isFile() || totalBytes !== expectedBytes || totalBytes > 64 * 1024 * 1024) throw new Error("result expired or missing");
|
|
147
|
+
const offset = options.offset ?? 0;
|
|
148
|
+
if (!Number.isSafeInteger(offset) || offset < 0 || offset > totalBytes) throw new Error("invalid byte offset");
|
|
149
|
+
const length = Math.min(options.limit ?? 8192, totalBytes - offset);
|
|
150
|
+
const data = Buffer.alloc(length);
|
|
151
|
+
let position = 0;
|
|
152
|
+
while (position < length) {
|
|
153
|
+
const read = await handle.read(data, position, length - position, offset + position);
|
|
154
|
+
if (read.bytesRead === 0) throw new Error("result expired or missing");
|
|
155
|
+
position += read.bytesRead;
|
|
156
|
+
}
|
|
157
|
+
const final = await handle.stat();
|
|
158
|
+
if (final.size !== totalBytes) throw new Error("result expired or missing");
|
|
159
|
+
if (length > 0 && (data[0] & 0xc0) === 0x80) throw new Error("offset is inside a UTF-8 character");
|
|
160
|
+
const page = readPage(data, { ...options, offset: 0 });
|
|
161
|
+
return { ...page, offset, totalBytes, nextOffset: offset + page.bytes < totalBytes ? offset + page.bytes : null, eof: offset + page.bytes >= totalBytes };
|
|
162
|
+
}
|
|
163
|
+
finally { await handle.close(); }
|
|
164
|
+
}
|
package/src/runner.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { execFile, type ExecFileException } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export interface ProcessRequest {
|
|
4
|
+
executable: string;
|
|
5
|
+
argv: string[];
|
|
6
|
+
signal?: AbortSignal;
|
|
7
|
+
timeoutMs?: number;
|
|
8
|
+
maxBuffer?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ProcessResult {
|
|
12
|
+
stdout: string;
|
|
13
|
+
stderr: string;
|
|
14
|
+
code: number | null;
|
|
15
|
+
captureComplete: boolean;
|
|
16
|
+
reason?: "exit" | "spawn" | "timeout" | "cancelled" | "overflow";
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function runProcess(request: ProcessRequest): Promise<ProcessResult> {
|
|
21
|
+
const timeoutMs = request.timeoutMs ?? 60_000;
|
|
22
|
+
// ponytail: 16 MiB per stream; use file streaming only when a real command needs more.
|
|
23
|
+
const maxBuffer = request.maxBuffer ?? 16 * 1024 * 1024;
|
|
24
|
+
return new Promise((resolve) => {
|
|
25
|
+
let done = false;
|
|
26
|
+
let timedOut = false;
|
|
27
|
+
let cancelled = Boolean(request.signal?.aborted);
|
|
28
|
+
let timer: NodeJS.Timeout | undefined;
|
|
29
|
+
const child = execFile(
|
|
30
|
+
request.executable,
|
|
31
|
+
request.argv,
|
|
32
|
+
{
|
|
33
|
+
shell: false,
|
|
34
|
+
encoding: "utf8",
|
|
35
|
+
timeout: 0,
|
|
36
|
+
killSignal: "SIGKILL",
|
|
37
|
+
maxBuffer,
|
|
38
|
+
signal: request.signal,
|
|
39
|
+
},
|
|
40
|
+
(error: ExecFileException | null, stdout, stderr) => {
|
|
41
|
+
done = true;
|
|
42
|
+
if (timer) clearTimeout(timer);
|
|
43
|
+
const err = error as (ExecFileException & { code?: string | number }) | null;
|
|
44
|
+
const overflow = err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
|
|
45
|
+
const reason = overflow ? "overflow" : timedOut ? "timeout" : cancelled || err?.name === "AbortError" || err?.code === "ABORT_ERR" ? "cancelled" : err ? (typeof err.code === "string" ? "spawn" : "exit") : "exit";
|
|
46
|
+
const code = typeof err?.code === "number" ? err.code : err && !overflow && reason === "exit" ? null : err ? null : 0;
|
|
47
|
+
resolve({
|
|
48
|
+
stdout: String(stdout ?? ""),
|
|
49
|
+
stderr: String(stderr ?? ""),
|
|
50
|
+
code,
|
|
51
|
+
captureComplete: !overflow && !timedOut && !cancelled && reason !== "spawn",
|
|
52
|
+
reason,
|
|
53
|
+
error: err?.message,
|
|
54
|
+
});
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
child.stdin?.end();
|
|
58
|
+
timer = setTimeout(() => {
|
|
59
|
+
if (done) return;
|
|
60
|
+
timedOut = true;
|
|
61
|
+
child.kill("SIGKILL");
|
|
62
|
+
}, timeoutMs);
|
|
63
|
+
if (request.signal) {
|
|
64
|
+
const cancel = () => {
|
|
65
|
+
cancelled = true;
|
|
66
|
+
child.kill("SIGKILL");
|
|
67
|
+
};
|
|
68
|
+
if (request.signal.aborted) cancel();
|
|
69
|
+
else request.signal.addEventListener("abort", cancel, { once: true });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function processError(result: ProcessResult): string {
|
|
75
|
+
if (result.reason === "spawn") return `executable unavailable: ${result.error ?? "spawn failed"}`;
|
|
76
|
+
if (result.reason === "timeout") return "command timed out";
|
|
77
|
+
if (result.reason === "cancelled") return "command cancelled";
|
|
78
|
+
if (result.reason === "overflow") return "command output exceeded the 16 MiB capture limit";
|
|
79
|
+
return result.code === null ? "command failed" : `command exited with code ${result.code}`;
|
|
80
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Key, matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import { discoverManagedTools, saveManagedSettings, type DetectedTool, type ManagedToolPolicy } from "./managed-tools.ts";
|
|
4
|
+
|
|
5
|
+
type Done = (result?: string[]) => void;
|
|
6
|
+
|
|
7
|
+
function pad(text: string, width: number): string {
|
|
8
|
+
const clipped = truncateToWidth(text, Math.max(1, width), "…");
|
|
9
|
+
return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function frame(lines: string[], width: number, theme: Theme, title: string): string[] {
|
|
13
|
+
const inner = Math.max(1, width - 2);
|
|
14
|
+
const border = (value: string) => theme.fg("borderAccent", value);
|
|
15
|
+
const titleText = ` ${truncateToWidth(title, Math.max(1, inner - 6), "…")} `;
|
|
16
|
+
const titleWidth = visibleWidth(titleText);
|
|
17
|
+
const top = `${border("╭──")}${theme.fg("accent", theme.bold(titleText))}${border("─".repeat(Math.max(1, inner - 2 - titleWidth)))}${border("╮")}`;
|
|
18
|
+
const body = lines.map((line) => `${border("│")} ${pad(line, inner - 2)} ${border("│")}`);
|
|
19
|
+
return [top, ...body, `${border("╰")}${border("─".repeat(inner))}${border("╯")}`];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fuzzyMatch(query: string, value: string): boolean {
|
|
23
|
+
let cursor = 0;
|
|
24
|
+
for (const character of query) {
|
|
25
|
+
cursor = value.indexOf(character, cursor);
|
|
26
|
+
if (cursor < 0) return false;
|
|
27
|
+
cursor += 1;
|
|
28
|
+
}
|
|
29
|
+
return true;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function hint(theme: Theme, key: string, label: string): string {
|
|
33
|
+
return `${theme.fg("accent", key)} ${theme.fg("dim", label)}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The old selector remains a small reusable checkbox component. The command
|
|
38
|
+
* uses ManagedToolSettingsOverlay below for the full settings experience.
|
|
39
|
+
*/
|
|
40
|
+
export class ManagedToolSelector {
|
|
41
|
+
private index = 0;
|
|
42
|
+
private readonly selected: Set<string>;
|
|
43
|
+
private readonly tools: DetectedTool[];
|
|
44
|
+
private readonly done: Done;
|
|
45
|
+
constructor(tools: DetectedTool[], selected: Iterable<string>, done: Done) {
|
|
46
|
+
this.tools = tools;
|
|
47
|
+
this.done = done;
|
|
48
|
+
this.selected = new Set(selected);
|
|
49
|
+
}
|
|
50
|
+
handleInput(data: string): void {
|
|
51
|
+
if (matchesKey(data, Key.up)) this.index = Math.max(0, this.index - 1);
|
|
52
|
+
else if (matchesKey(data, Key.down)) this.index = Math.min(this.tools.length - 1, this.index + 1);
|
|
53
|
+
else if (data === " ") {
|
|
54
|
+
const name = this.tools[this.index]?.name;
|
|
55
|
+
if (name) this.selected.has(name) ? this.selected.delete(name) : this.selected.add(name);
|
|
56
|
+
} else if (matchesKey(data, Key.enter)) {
|
|
57
|
+
this.done([...this.selected]);
|
|
58
|
+
} else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) this.done();
|
|
59
|
+
}
|
|
60
|
+
invalidate(): void {}
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
const lines = ["", " AXI managed tools", "", "Space toggles • Enter saves • Esc cancels", ""];
|
|
63
|
+
for (const [index, tool] of this.tools.entries()) {
|
|
64
|
+
const cursor = index === this.index ? "▌" : " ";
|
|
65
|
+
const value = this.selected.has(tool.name) ? "[✓] on" : "[ ] off";
|
|
66
|
+
lines.push(`${cursor} ${tool.name} — ${value} (${tool.path})`);
|
|
67
|
+
}
|
|
68
|
+
return lines.map((line) => line.length > width ? line.slice(0, width) : line);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export class ManagedToolSettingsOverlay {
|
|
73
|
+
private index = 0;
|
|
74
|
+
private search = "";
|
|
75
|
+
private confirming = false;
|
|
76
|
+
private confirmIndex = 0;
|
|
77
|
+
private saving = false;
|
|
78
|
+
private readonly selected: Set<string>;
|
|
79
|
+
private readonly initial: Set<string>;
|
|
80
|
+
private readonly tools: DetectedTool[];
|
|
81
|
+
private readonly done: Done;
|
|
82
|
+
private readonly tui: { requestRender?: () => void };
|
|
83
|
+
private readonly theme: Theme;
|
|
84
|
+
private readonly onSave?: (names: string[]) => void | Promise<void>;
|
|
85
|
+
private readonly onError?: (error: unknown) => void;
|
|
86
|
+
|
|
87
|
+
constructor(
|
|
88
|
+
tools: DetectedTool[],
|
|
89
|
+
selected: Iterable<string>,
|
|
90
|
+
done: Done,
|
|
91
|
+
tui: { requestRender?: () => void },
|
|
92
|
+
theme: Theme,
|
|
93
|
+
onSave?: (names: string[]) => void | Promise<void>,
|
|
94
|
+
onError?: (error: unknown) => void,
|
|
95
|
+
) {
|
|
96
|
+
this.tools = tools;
|
|
97
|
+
this.initial = new Set(selected);
|
|
98
|
+
this.selected = new Set(selected);
|
|
99
|
+
this.done = done;
|
|
100
|
+
this.tui = tui;
|
|
101
|
+
this.theme = theme;
|
|
102
|
+
this.onSave = onSave;
|
|
103
|
+
this.onError = onError;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private visibleTools(): DetectedTool[] {
|
|
107
|
+
const query = this.search.trim().toLowerCase();
|
|
108
|
+
if (!query) return this.tools;
|
|
109
|
+
return this.tools.filter((tool) => fuzzyMatch(query, `${tool.name} ${tool.path}`.toLowerCase()));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private dirty(): boolean {
|
|
113
|
+
return this.selected.size !== this.initial.size || [...this.selected].some((name) => !this.initial.has(name));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private requestRender(): void {
|
|
117
|
+
this.tui.requestRender?.();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
private async save(): Promise<void> {
|
|
121
|
+
if (this.saving) return;
|
|
122
|
+
const names = [...this.selected];
|
|
123
|
+
if (!this.onSave) {
|
|
124
|
+
this.done(names);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
this.saving = true;
|
|
128
|
+
this.requestRender();
|
|
129
|
+
try {
|
|
130
|
+
if (this.onSave) await this.onSave(names);
|
|
131
|
+
this.done(names);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
this.saving = false;
|
|
134
|
+
this.onError?.(error);
|
|
135
|
+
this.requestRender();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private close(): void {
|
|
140
|
+
if (this.dirty()) {
|
|
141
|
+
this.confirming = true;
|
|
142
|
+
this.confirmIndex = 1;
|
|
143
|
+
this.requestRender();
|
|
144
|
+
} else {
|
|
145
|
+
this.done();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private toggle(): void {
|
|
150
|
+
const tool = this.visibleTools()[this.index];
|
|
151
|
+
if (!tool) return;
|
|
152
|
+
if (!tool.detected) {
|
|
153
|
+
if (this.selected.delete(tool.name)) this.requestRender();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
this.selected.has(tool.name) ? this.selected.delete(tool.name) : this.selected.add(tool.name);
|
|
157
|
+
this.requestRender();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
handleInput(data: string): void {
|
|
161
|
+
if (this.saving) return;
|
|
162
|
+
if (this.confirming) {
|
|
163
|
+
if (matchesKey(data, Key.up) || matchesKey(data, Key.left) || matchesKey(data, Key.down) || matchesKey(data, Key.right)) {
|
|
164
|
+
this.confirmIndex = this.confirmIndex === 0 ? 1 : 0;
|
|
165
|
+
this.requestRender();
|
|
166
|
+
} else if (matchesKey(data, Key.enter) || data === " ") {
|
|
167
|
+
if (this.confirmIndex === 0) this.done();
|
|
168
|
+
else {
|
|
169
|
+
this.confirming = false;
|
|
170
|
+
this.requestRender();
|
|
171
|
+
}
|
|
172
|
+
} else if (data === "y" || data === "Y") this.done();
|
|
173
|
+
else if (data === "n" || data === "N" || matchesKey(data, Key.escape)) {
|
|
174
|
+
this.confirming = false;
|
|
175
|
+
this.requestRender();
|
|
176
|
+
}
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (matchesKey(data, Key.up)) {
|
|
181
|
+
this.index = Math.max(0, this.index - 1);
|
|
182
|
+
} else if (matchesKey(data, Key.down)) {
|
|
183
|
+
this.index = Math.min(Math.max(0, this.visibleTools().length - 1), this.index + 1);
|
|
184
|
+
} else if (data === " ") {
|
|
185
|
+
this.toggle();
|
|
186
|
+
return;
|
|
187
|
+
} else if (matchesKey(data, Key.enter) || matchesKey(data, Key.ctrl("s"))) {
|
|
188
|
+
void this.save();
|
|
189
|
+
return;
|
|
190
|
+
} else if (matchesKey(data, Key.escape)) {
|
|
191
|
+
if (this.search) {
|
|
192
|
+
this.search = "";
|
|
193
|
+
this.index = 0;
|
|
194
|
+
this.requestRender();
|
|
195
|
+
} else this.close();
|
|
196
|
+
return;
|
|
197
|
+
} else if (matchesKey(data, Key.ctrl("c"))) {
|
|
198
|
+
this.close();
|
|
199
|
+
return;
|
|
200
|
+
} else if (matchesKey(data, Key.backspace) || matchesKey(data, Key.ctrl("h"))) {
|
|
201
|
+
this.search = this.search.slice(0, -1);
|
|
202
|
+
this.index = 0;
|
|
203
|
+
} else if (matchesKey(data, Key.ctrl("w"))) {
|
|
204
|
+
this.search = this.search.trimEnd().replace(/\\S+\\s*$/, "");
|
|
205
|
+
this.index = 0;
|
|
206
|
+
} else if (matchesKey(data, Key.ctrl("u"))) {
|
|
207
|
+
this.search = "";
|
|
208
|
+
this.index = 0;
|
|
209
|
+
} else if (data.length === 1 && data >= " " && data !== "\x7f") {
|
|
210
|
+
this.search += data;
|
|
211
|
+
this.index = 0;
|
|
212
|
+
}
|
|
213
|
+
this.requestRender();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
invalidate(): void {}
|
|
217
|
+
|
|
218
|
+
render(width: number): string[] {
|
|
219
|
+
const contentWidth = Math.max(1, width - 4);
|
|
220
|
+
const visible = this.visibleTools();
|
|
221
|
+
const selectedCount = this.selected.size;
|
|
222
|
+
const lines: string[] = [
|
|
223
|
+
this.theme.fg("muted", "Select tools whose complete output AXI may capture."),
|
|
224
|
+
this.theme.fg("dim", "Nothing runs or installs. Changes apply after save."),
|
|
225
|
+
"",
|
|
226
|
+
this.theme.bg("toolPendingBg", pad(` > ${this.search || "type to filter tools"}`, contentWidth)),
|
|
227
|
+
"",
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
if (visible.length === 0) {
|
|
231
|
+
lines.push(this.theme.fg("muted", "No matching tools."));
|
|
232
|
+
} else {
|
|
233
|
+
const valueWidth = 8;
|
|
234
|
+
const labelWidth = Math.max(1, contentWidth - valueWidth - 4);
|
|
235
|
+
const maxRows = 10;
|
|
236
|
+
const start = Math.min(Math.max(0, this.index - maxRows + 1), Math.max(0, visible.length - maxRows));
|
|
237
|
+
const rows = visible.slice(start, start + maxRows);
|
|
238
|
+
if (start > 0) lines.push(this.theme.fg("dim", " ↑ more tools"));
|
|
239
|
+
for (const [offset, tool] of rows.entries()) {
|
|
240
|
+
const row = start + offset;
|
|
241
|
+
const focused = row === this.index;
|
|
242
|
+
const enabled = this.selected.has(tool.name);
|
|
243
|
+
const pointer = focused ? this.theme.fg("accent", "▌") : " ";
|
|
244
|
+
const name = focused ? this.theme.fg("text", tool.name) : this.theme.fg("muted", tool.name);
|
|
245
|
+
const value = !tool.detected
|
|
246
|
+
? `[${this.theme.fg("warning", "✓")}] ${this.theme.fg("warning", "saved")}`
|
|
247
|
+
: enabled
|
|
248
|
+
? `[${this.theme.fg(focused ? "accent" : "success", "✓")}] ${this.theme.fg(focused ? "accent" : "success", "on")}`
|
|
249
|
+
: `[${this.theme.fg(focused ? "accent" : "muted", " ")}] ${this.theme.fg(focused ? "accent" : "muted", "off")}`;
|
|
250
|
+
const line = `${pointer} ${pad(name, labelWidth)} ${value}`;
|
|
251
|
+
const detail = this.theme.fg("dim", ` ${tool.detected ? tool.path : "not detected on PATH · Space removes saved tool"}`);
|
|
252
|
+
lines.push(focused ? this.theme.bg("selectedBg", pad(line, contentWidth)) : line);
|
|
253
|
+
lines.push(focused ? this.theme.bg("selectedBg", pad(detail, contentWidth)) : detail);
|
|
254
|
+
}
|
|
255
|
+
if (start + rows.length < visible.length) lines.push(this.theme.fg("dim", " ↓ more tools"));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
lines.push("", this.theme.fg("dim", `${selectedCount} selected`));
|
|
259
|
+
if (this.saving) {
|
|
260
|
+
lines.push("", this.theme.fg("accent", "Saving settings…"));
|
|
261
|
+
} else if (this.confirming) {
|
|
262
|
+
lines.push("", this.theme.fg("warning", "Discard unsaved changes?"));
|
|
263
|
+
lines.push(`${this.confirmIndex === 0 ? "▶" : " "} Discard ${this.confirmIndex === 1 ? "▶" : " "} Cancel`);
|
|
264
|
+
lines.push(hint(this.theme, "↑↓", "choose") + " " + hint(this.theme, "enter", "confirm"));
|
|
265
|
+
} else {
|
|
266
|
+
lines.push("", [hint(this.theme, "↑↓", "move"), hint(this.theme, "space", "toggle"), hint(this.theme, "enter", "save"), hint(this.theme, "esc", this.dirty() ? "discard" : "close")].join(this.theme.fg("dim", " · ")));
|
|
267
|
+
if (this.search) lines.push(hint(this.theme, "ctrl+u", "clear search"));
|
|
268
|
+
}
|
|
269
|
+
return frame(lines.map((line) => truncateToWidth(line, contentWidth, "…")), width, this.theme, `AXI settings${this.dirty() ? " • unsaved" : ""}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export function registerAxiSettings(pi: ExtensionAPI, policy: ManagedToolPolicy, env: NodeJS.ProcessEnv = process.env): void {
|
|
274
|
+
const prefixMatch = (value: string, prefix: string): boolean => value.toLowerCase().startsWith(prefix.toLowerCase());
|
|
275
|
+
const subcommands = [{ value: "settings", label: "Open AXI managed tool settings" }];
|
|
276
|
+
|
|
277
|
+
pi.registerCommand("axi", {
|
|
278
|
+
description: "Configure which detected tools AXI manages. Subcommand: [settings] open settings",
|
|
279
|
+
getArgumentCompletions: (prefix: string) => subcommands.filter((item) =>
|
|
280
|
+
!prefix || prefixMatch(item.value, prefix) ||
|
|
281
|
+
(item.value === "settings" && (prefixMatch("st", prefix) || prefixMatch("configure", prefix))),
|
|
282
|
+
),
|
|
283
|
+
handler: async (args, ctx) => {
|
|
284
|
+
const trimmed = args.trim();
|
|
285
|
+
if (trimmed !== "settings" && trimmed !== "configure") {
|
|
286
|
+
ctx.ui.notify("Usage: /axi settings", "error");
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
if (ctx.mode !== "tui") {
|
|
290
|
+
ctx.ui.notify("/axi settings requires interactive mode", "error");
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
const tools = await discoverManagedTools(env);
|
|
294
|
+
const detected = new Set(tools.map((tool) => tool.name));
|
|
295
|
+
for (const name of policy.getManagedTools()) {
|
|
296
|
+
if (!detected.has(name)) tools.push({ name, path: "not detected on PATH", detected: false, managed: true });
|
|
297
|
+
}
|
|
298
|
+
if (tools.length === 0) {
|
|
299
|
+
ctx.ui.notify("No allowlisted tools were detected on PATH", "info");
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const selected = await ctx.ui.custom<string[] | undefined>(
|
|
303
|
+
(tui, theme, _kb, done) => new ManagedToolSettingsOverlay(
|
|
304
|
+
tools,
|
|
305
|
+
policy.getManagedTools(),
|
|
306
|
+
done,
|
|
307
|
+
tui,
|
|
308
|
+
theme,
|
|
309
|
+
),
|
|
310
|
+
{ overlay: true, overlayOptions: { width: "72%", minWidth: 58, maxHeight: "85%", anchor: "center", margin: 2 } },
|
|
311
|
+
);
|
|
312
|
+
if (!selected) return;
|
|
313
|
+
try {
|
|
314
|
+
await saveManagedSettings({ managedTools: selected }, env);
|
|
315
|
+
policy.setManagedTools(selected);
|
|
316
|
+
ctx.ui.notify(`AXI now manages: ${selected.length ? selected.join(", ") : "none"}`, "info");
|
|
317
|
+
} catch (error) {
|
|
318
|
+
ctx.ui.notify(`Could not save AXI settings: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
}
|