decorated-pi 0.4.1 → 0.5.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.
@@ -0,0 +1,244 @@
1
+ /**
2
+ * RTK integration — Rewrite bash commands through system-installed RTK
3
+ *
4
+ * Uses `rtk rewrite` as a preflight step. If RTK is not installed on PATH,
5
+ * this module stays inactive. When a rewritten RTK command fails, the original
6
+ * command is executed once as a fallback.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { createBashToolDefinition, createLocalBashOperations, isToolCallEventType } from "@earendil-works/pi-coding-agent";
11
+ import { Text } from "@earendil-works/pi-tui";
12
+ import { execFileSync, spawnSync } from "node:child_process";
13
+ import * as fs from "node:fs";
14
+ import * as os from "node:os";
15
+ import * as path from "node:path";
16
+
17
+ let rtkBinary: string | null = null;
18
+
19
+ interface PiShellSettings {
20
+ shellPath?: string;
21
+ shellCommandPrefix?: string;
22
+ }
23
+
24
+ export interface DependencyStatus {
25
+ module: string;
26
+ label: string;
27
+ state: "ok" | "missing" | "n/a";
28
+ detail?: string;
29
+ }
30
+
31
+ function readJsonObject(filePath: string): Record<string, unknown> {
32
+ try {
33
+ if (!fs.existsSync(filePath)) return {};
34
+ const parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
35
+ return parsed && typeof parsed === "object" ? parsed : {};
36
+ } catch {
37
+ return {};
38
+ }
39
+ }
40
+
41
+ function loadPiShellSettings(cwd: string): PiShellSettings {
42
+ const agentDir = process.env.PI_CODING_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
43
+ const globalSettings = readJsonObject(path.join(agentDir, "settings.json"));
44
+ const projectSettings = readJsonObject(path.join(cwd, ".pi", "settings.json"));
45
+ const merged = { ...globalSettings, ...projectSettings } as Record<string, unknown>;
46
+ const result: PiShellSettings = {};
47
+ if (typeof merged.shellPath === "string" && merged.shellPath.trim()) result.shellPath = merged.shellPath;
48
+ if (typeof merged.shellCommandPrefix === "string" && merged.shellCommandPrefix.trim()) {
49
+ result.shellCommandPrefix = merged.shellCommandPrefix;
50
+ }
51
+ return result;
52
+ }
53
+
54
+ export function findSystemRtk(): string | null {
55
+ try {
56
+ if (process.platform === "win32") {
57
+ const output = execFileSync("where", ["rtk"], { encoding: "utf-8" }).trim();
58
+ return output.split(/\r?\n/)[0] || null;
59
+ }
60
+ const shell = process.env.SHELL || "sh";
61
+ return execFileSync(shell, ["-lc", "command -v rtk"], { encoding: "utf-8" }).trim() || null;
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function shellQuote(value: string): string {
68
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
69
+ }
70
+
71
+ export function buildRtkCommand(raw: string, rtkBinaryPath: string): string {
72
+ const binDir = path.dirname(rtkBinaryPath);
73
+ return `export PATH=${shellQuote(binDir)}:$PATH && ${raw}`;
74
+ }
75
+
76
+ export function extractMainCommand(command: string): string {
77
+ let cmd = command.trim().toLowerCase();
78
+ cmd = cmd.replace(/^cd\s+\S+\s*(&&|;|\n)\s*/, "");
79
+ cmd = cmd.replace(/^(?:[a-z_][a-z0-9_]*=\S*\s+)+/, "");
80
+ const prefixes = ["sudo ", "time ", "nohup ", "nice ", "env "];
81
+ let changed = true;
82
+ while (changed) {
83
+ changed = false;
84
+ for (const prefix of prefixes) {
85
+ if (cmd.startsWith(prefix)) {
86
+ cmd = cmd.slice(prefix.length);
87
+ changed = true;
88
+ }
89
+ }
90
+ }
91
+ return cmd;
92
+ }
93
+
94
+ export function shouldBypassRtkRewrite(command: string): boolean {
95
+ const main = extractMainCommand(command);
96
+ if (!main.startsWith("find ") && main !== "find") return false;
97
+ return /(^|\s)(-o|-or|-a|-and|-not|!|\(|\)|-exec|-ok|-delete|-prune|-printf|-print0)(\s|$)/.test(main);
98
+ }
99
+
100
+ export function rewriteWithRtk(command: string, rtkPath: string): string | null {
101
+ if (shouldBypassRtkRewrite(command)) return null;
102
+
103
+ // NOTE:
104
+ // Some RTK versions return a non-zero exit code even when `rtk rewrite`
105
+ // successfully prints a rewritten command to stdout (observed locally with
106
+ // RTK 0.42.0 returning exit code 3 on success). Because of that, we treat
107
+ // non-empty stdout as the source of truth and ignore the process exit code
108
+ // here. Empty stdout still means “no rewrite available”.
109
+ const result = spawnSync(rtkPath, ["rewrite", command], {
110
+ encoding: "utf-8",
111
+ timeout: 2000,
112
+ });
113
+ const raw = (result.stdout ?? "").trim();
114
+ if (!raw) return null;
115
+ return buildRtkCommand(raw, rtkPath);
116
+ }
117
+
118
+ export function appendStatus(text: string, status: string): string {
119
+ return text ? `${text}\n\n${status}` : status;
120
+ }
121
+
122
+ function formatBashCallWithTag(args: { command?: unknown; timeout?: unknown }, theme: any, showTag: boolean): string {
123
+ const command = typeof args?.command === "string" ? args.command : null;
124
+ const timeout = typeof args?.timeout === "number" ? args.timeout : undefined;
125
+ const timeoutSuffix = timeout ? theme.fg("muted", ` (timeout ${timeout}s)`) : "";
126
+ const commandDisplay = command === null ? theme.fg("error", "<invalid command>") : command || theme.fg("toolOutput", "...");
127
+ const tag = showTag ? theme.fg("borderAccent", " [RTK]") : "";
128
+ return theme.fg("toolTitle", theme.bold(`$ ${commandDisplay}`)) + timeoutSuffix + tag;
129
+ }
130
+
131
+ export async function executeOriginalBash(command: string, cwd: string, timeout: number | undefined, signal?: AbortSignal) {
132
+ const ops = createLocalBashOperations();
133
+ const chunks: Buffer[] = [];
134
+ const onData = (data: Buffer) => chunks.push(Buffer.from(data));
135
+ const getOutput = () => Buffer.concat(chunks).toString("utf-8");
136
+
137
+ try {
138
+ const result = await ops.exec(command, cwd, { onData, signal, timeout });
139
+ const output = getOutput() || "(no output)";
140
+ if (result.exitCode !== 0 && result.exitCode !== null) {
141
+ return {
142
+ content: [{ type: "text" as const, text: appendStatus(output, `Command exited with code ${result.exitCode}`) }],
143
+ details: undefined,
144
+ isError: true,
145
+ };
146
+ }
147
+ return {
148
+ content: [{ type: "text" as const, text: output }],
149
+ details: undefined,
150
+ isError: false,
151
+ };
152
+ } catch (err) {
153
+ const output = getOutput();
154
+ if (err instanceof Error && err.message === "aborted") {
155
+ return {
156
+ content: [{ type: "text" as const, text: appendStatus(output, "Command aborted") }],
157
+ details: undefined,
158
+ isError: true,
159
+ };
160
+ }
161
+ if (err instanceof Error && err.message.startsWith("timeout:")) {
162
+ const timeoutSecs = err.message.split(":")[1];
163
+ return {
164
+ content: [{ type: "text" as const, text: appendStatus(output, `Command timed out after ${timeoutSecs} seconds`) }],
165
+ details: undefined,
166
+ isError: true,
167
+ };
168
+ }
169
+ return {
170
+ content: [{ type: "text" as const, text: appendStatus(output, err instanceof Error ? err.message : "Command failed") }],
171
+ details: undefined,
172
+ isError: true,
173
+ };
174
+ }
175
+ }
176
+
177
+ export function getRtkDependencyStatuses(): DependencyStatus[] {
178
+ return [{
179
+ module: "rtk",
180
+ label: "rtk",
181
+ state: findSystemRtk() ? "ok" : "missing",
182
+ detail: "Install RTK so bash rewrite/tagging can activate.",
183
+ }];
184
+ }
185
+
186
+ export function setupRtkIntegration(pi: ExtensionAPI) {
187
+ rtkBinary = findSystemRtk();
188
+ if (!rtkBinary) return;
189
+
190
+ const rewrittenCommands = new Map<string, { originalCommand: string; timeout?: number }>();
191
+ const rewriteabilityCache = new Map<string, boolean>();
192
+ const shellSettings = loadPiShellSettings(process.cwd());
193
+ const bashTool = createBashToolDefinition(process.cwd(), {
194
+ shellPath: shellSettings.shellPath,
195
+ commandPrefix: shellSettings.shellCommandPrefix,
196
+ });
197
+ const baseRenderCall = bashTool.renderCall?.bind(bashTool);
198
+
199
+ if (baseRenderCall) {
200
+ bashTool.renderCall = (args, theme, context) => {
201
+ const component = baseRenderCall(args, theme, context);
202
+ const command = typeof args?.command === "string" ? args.command : "";
203
+ const predicted = command
204
+ ? (rewriteabilityCache.get(command) ?? (() => {
205
+ const value = rewriteWithRtk(command, rtkBinary!) !== null;
206
+ rewriteabilityCache.set(command, value);
207
+ return value;
208
+ })())
209
+ : false;
210
+ const rewritten = rewrittenCommands.has(context.toolCallId) || predicted;
211
+ if (component instanceof Text) {
212
+ component.setText(formatBashCallWithTag(args as any, theme, rewritten));
213
+ }
214
+ return component;
215
+ };
216
+ }
217
+
218
+ pi.registerTool(bashTool);
219
+
220
+ pi.on("tool_call", (event) => {
221
+ if (!isToolCallEventType("bash", event)) return;
222
+ const command = event.input.command;
223
+ if (!command.trim()) return;
224
+ const rewritten = rewriteWithRtk(command, rtkBinary!);
225
+ rewriteabilityCache.set(command, rewritten !== null);
226
+ if (!rewritten) return;
227
+ rewrittenCommands.set(event.toolCallId, { originalCommand: command, timeout: event.input.timeout });
228
+ event.input.command = rewritten;
229
+ });
230
+
231
+ pi.on("tool_result", async (event, ctx) => {
232
+ if (event.toolName !== "bash") return;
233
+ const pending = rewrittenCommands.get(event.toolCallId);
234
+ if (!pending) return;
235
+ rewrittenCommands.delete(event.toolCallId);
236
+ if (!event.isError) return;
237
+ return executeOriginalBash(pending.originalCommand, ctx.cwd, pending.timeout, ctx.signal);
238
+ });
239
+
240
+ pi.on("session_shutdown", () => {
241
+ rewrittenCommands.clear();
242
+ rewriteabilityCache.clear();
243
+ });
244
+ }
@@ -26,8 +26,12 @@ export interface ProviderCache {
26
26
  }
27
27
 
28
28
  export interface McpServerEntry {
29
- url: string;
29
+ url?: string;
30
+ command?: string;
31
+ args?: string[];
32
+ env?: Record<string, string>;
30
33
  enabled?: boolean;
34
+ description?: string;
31
35
  }
32
36
 
33
37
  export interface ModuleSettings {
@@ -36,11 +40,15 @@ export interface ModuleSettings {
36
40
  "smart-at"?: boolean;
37
41
  patch?: boolean;
38
42
  mcp?: boolean;
43
+ wakatime?: boolean;
44
+ "rtk"?: boolean;
39
45
  }
40
46
 
41
47
  export interface DecoratedPiConfig {
42
48
  imageModelKey?: string | null;
43
49
  compactModelKey?: string | null;
50
+ mcpBrokerModelKey?: string | null;
51
+ mcpDescriptions?: Record<string, string>;
44
52
  providers?: Record<string, ProviderCache>;
45
53
  modules?: ModuleSettings;
46
54
  mcpServers?: Record<string, McpServerEntry>;
@@ -93,6 +101,28 @@ export function removeProvider(name: string) {
93
101
  }
94
102
  }
95
103
 
104
+ // ─── Project-level config ────────────────────────────────────────────────
105
+
106
+ function projectConfigPath(cwd: string): string {
107
+ return path.join(cwd, ".pi", "agent", "decorated-pi.json");
108
+ }
109
+
110
+ function loadProjectConfig(cwd: string): DecoratedPiConfig {
111
+ try {
112
+ const p = projectConfigPath(cwd);
113
+ if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, "utf-8"));
114
+ } catch {}
115
+ return {};
116
+ }
117
+
118
+ function saveProjectConfig(cwd: string, partial: Partial<DecoratedPiConfig>) {
119
+ const p = projectConfigPath(cwd);
120
+ const dir = path.dirname(p);
121
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
122
+ const current = loadProjectConfig(cwd);
123
+ fs.writeFileSync(p, JSON.stringify({ ...current, ...partial }, null, 2), "utf-8");
124
+ }
125
+
96
126
  // ─── Getter ─────────────────────────────────────────────────────────────────
97
127
 
98
128
  export function getImageModelKey(): string | null {
@@ -113,6 +143,36 @@ export function setCompactModelKey(key: string | null) {
113
143
  saveConfig({ compactModelKey: key });
114
144
  }
115
145
 
146
+ export function getMcpBrokerModelKey(): string | null {
147
+ return loadConfig().mcpBrokerModelKey ?? null;
148
+ }
149
+
150
+ export function setMcpBrokerModelKey(key: string | null) {
151
+ saveConfig({ mcpBrokerModelKey: key });
152
+ }
153
+
154
+ export function getMcpDescription(name: string, cwd?: string): string | undefined {
155
+ if (cwd) {
156
+ const projectCfg = loadProjectConfig(cwd);
157
+ if (projectCfg.mcpDescriptions?.[name]) return projectCfg.mcpDescriptions[name];
158
+ }
159
+ return loadConfig().mcpDescriptions?.[name];
160
+ }
161
+
162
+ export function setMcpDescription(name: string, description: string, cwd?: string) {
163
+ if (cwd) {
164
+ const cfg = loadProjectConfig(cwd);
165
+ const descriptions = { ...cfg.mcpDescriptions };
166
+ descriptions[name] = description;
167
+ saveProjectConfig(cwd, { mcpDescriptions: descriptions });
168
+ return;
169
+ }
170
+ const cfg = loadConfig();
171
+ const descriptions = { ...cfg.mcpDescriptions };
172
+ descriptions[name] = description;
173
+ saveConfig({ mcpDescriptions: descriptions });
174
+ }
175
+
116
176
  // ─── Module Switches ──────────────────────────────────────────────────────────
117
177
 
118
178
  const DEFAULT_MODULES: Required<ModuleSettings> = {
@@ -121,6 +181,8 @@ const DEFAULT_MODULES: Required<ModuleSettings> = {
121
181
  "smart-at": true,
122
182
  patch: true,
123
183
  mcp: true,
184
+ wakatime: true,
185
+ "rtk": true,
124
186
  };
125
187
 
126
188
  export function isModuleEnabled(name: keyof ModuleSettings): boolean {