decorated-pi 0.6.0 → 0.7.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,93 @@
1
+ /**
2
+ * ask — ask the user one or more questions and return their answers.
3
+ *
4
+ * Supports free text, single-choice, and multiple-choice questions.
5
+ * Uses a custom TUI component so multiple questions can be presented
6
+ * together and navigated with Tab / Shift+Tab.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Type } from "typebox";
11
+ import { AskComponent, type AskAnswer, type AskQuestion } from "../../ui/ask.js";
12
+
13
+ const askQuestionSchema = Type.Object({
14
+ id: Type.String({ description: "Unique identifier for this question in the result." }),
15
+ type: Type.Union(
16
+ [Type.Literal("text"), Type.Literal("single"), Type.Literal("multi")],
17
+ { description: "text = free input, single = one option, multi = many options" },
18
+ ),
19
+ question: Type.String({ description: "Question text shown to the user." }),
20
+ options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice." })),
21
+ default: Type.Optional(Type.String({ description: "Default answer. For multi, comma-separated values." })),
22
+ allowCustom: Type.Optional(Type.Boolean({
23
+ description: "For single/multi: append an \"Other\" row that toggles into a free-text input. Lets the user enter a custom answer not in the preset list.",
24
+ })),
25
+ });
26
+
27
+ function formatAnswer(q: AskQuestion, value: string | string[]): string {
28
+ if (q.type === "text") return value as string;
29
+ if (q.type === "single") return value as string;
30
+ return (value as string[]).join(", ");
31
+ }
32
+
33
+ export function formatAnswers(questions: AskQuestion[], answers: { id: string; value: string | string[] }[]): string {
34
+ const lines: string[] = [];
35
+ for (const q of questions) {
36
+ const a = answers.find((x) => x.id === q.id);
37
+ if (!a) continue;
38
+ lines.push(`${q.question}: ${formatAnswer(q, a.value)}`);
39
+ }
40
+ return lines.join("\n");
41
+ }
42
+
43
+ export function registerAskTool(pi: ExtensionAPI): void {
44
+ pi.registerTool({
45
+ name: "ask",
46
+ label: "Ask user",
47
+ description: "Ask the user one or more questions and return their answers. Supports free text, single choice, and multiple choice. Use when you need clarification or a decision before continuing.",
48
+ promptSnippet: "Ask the user one or more clarifying questions",
49
+ promptGuidelines: [
50
+ "Before acting on a prompt, ensure you fully understand the user's intent — if ambiguous, ask clarifying questions using the ask tool.",
51
+ ],
52
+ parameters: Type.Object({
53
+ questions: Type.Array(askQuestionSchema, { minItems: 1, description: "Questions to ask. User navigates between them with Tab / Shift+Tab." }),
54
+ }),
55
+ execute: async (_id, params, _signal, _update, ctx) => {
56
+ if (!ctx.hasUI) {
57
+ return {
58
+ content: [{ type: "text", text: "ask tool requires an interactive UI session." }],
59
+ isError: true,
60
+ details: {},
61
+ };
62
+ }
63
+
64
+ // Validate options for single/multi.
65
+ for (const q of params.questions) {
66
+ if ((q.type === "single" || q.type === "multi") && (!q.options || q.options.length === 0)) {
67
+ return {
68
+ content: [{ type: "text", text: `Question "${q.id}" (${q.type}) requires at least one option.` }],
69
+ isError: true,
70
+ details: {},
71
+ };
72
+ }
73
+ }
74
+
75
+ const answers = await ctx.ui.custom<AskAnswer[] | undefined>(
76
+ (tui, theme, _kb, done) => new AskComponent(tui, theme, params.questions, done),
77
+ );
78
+
79
+ if (!answers) {
80
+ return {
81
+ content: [{ type: "text", text: "User cancelled the questions." }],
82
+ isError: false,
83
+ details: { cancelled: true },
84
+ };
85
+ }
86
+
87
+ return {
88
+ content: [{ type: "text", text: formatAnswers(params.questions, answers) }],
89
+ details: { answers },
90
+ };
91
+ },
92
+ });
93
+ }
@@ -1,45 +1,21 @@
1
1
  /**
2
- * codegraph builtin MCP server — config + system-prompt guidance.
2
+ * codegraph builtin MCP server — config + project artefact check.
3
3
  *
4
- * Injected by `index.ts` `buildGuidelines()` when the codegraph
5
- * module is enabled (see `isModuleEnabled("codegraph")` in settings).
4
+ * Enabled state is controlled like any other MCP server: through the
5
+ * MCP config (global `~/.pi/agent/mcp.json` under `mcpServers`, or
6
+ * project `.pi/agent/mcp.json`) or via the `/mcp` command. There is no
7
+ * separate /dp-settings toggle; codegraph is just one MCP server.
6
8
  */
9
+ import * as fs from "node:fs";
10
+ import * as path from "node:path";
7
11
  import type { McpServerConfig } from "../config.js";
8
- import { isCodegraphModuleEnabled } from "../../../settings.js";
9
12
 
10
13
  export const CODEGRAPH_BUILTIN: Omit<McpServerConfig, "source"> = {
11
14
  name: "codegraph",
12
15
  command: "codegraph",
13
16
  args: ["serve", "--mcp"],
14
- enabled: false, // overridden by isCodegraphModuleEnabled() at resolve time
17
+ enabled: false,
15
18
  description:
16
- "Local code knowledge graph (colbymchenry/codegraph). Enable via /dp-settings.",
19
+ "Local code knowledge graph (colbymchenry/codegraph). Enable via /mcp.",
20
+ canUseInProject: (cwd: string) => fs.existsSync(path.join(cwd, ".codegraph")),
17
21
  };
18
-
19
- /** Predicate for `resolveMcpConfigs` to gate the codegraph server. */
20
- export function codegraphEnabled(): boolean {
21
- return isCodegraphModuleEnabled();
22
- }
23
-
24
- export const CODEGRAPH_GUIDANCE = [
25
- "### CodeGraph, code source map",
26
- "- This project's `codegraph_*` MCP tools are enabled. The graph is a pre-built index; grep/glob/Read of source code is repeating work the index already did.",
27
- "",
28
- "#### When to reach for it",
29
- '- Starting any task that touches code → `codegraph_explore("how does X work")` or `codegraph_files`',
30
- "- Looking for where a symbol is defined → `codegraph_search <name>`",
31
- "- Reading a function's body → `codegraph_node <name>` (or `codegraph_explore`)",
32
- "- Tracing call flow → `codegraph_callers` / `codegraph_callees`",
33
- "- Assessing refactor risk → `codegraph_impact <name>`",
34
- "",
35
- "#### Do NOT do this",
36
- "- `ls`, `find`, `grep -rn`, `rg` to discover symbols → use `codegraph_search`",
37
- "- `read` of an entire file to find a function → use `codegraph_explore` first",
38
- '- Reading 3+ files to understand a module → use `codegraph_explore("how does X work")`',
39
- "- `bash` with `cat`, `head`, `sed` to view source → use `codegraph_node` or `read` (single file only)",
40
- "",
41
- "#### If it errors",
42
- '- "Project not initialized" → ask the user to run `codegraph init -i` in their terminal',
43
- "- Empty results → fall back to grep/Read (the index is best-effort, not authoritative)",
44
- "- Tool timeout → `codegraph_status` to check; if indexer is dead, fall back",
45
- ].join("\n");
@@ -5,11 +5,11 @@
5
5
  import type { McpServerConfig } from "../config.js";
6
6
  import { CONTEXT7_BUILTIN } from "./context7.js";
7
7
  import { EXA_BUILTIN } from "./exa.js";
8
- import { CODEGRAPH_BUILTIN, CODEGRAPH_GUIDANCE } from "./codegraph.js";
8
+ import { CODEGRAPH_BUILTIN } from "./codegraph.js";
9
9
 
10
10
  export { CONTEXT7_BUILTIN } from "./context7.js";
11
11
  export { EXA_BUILTIN } from "./exa.js";
12
- export { CODEGRAPH_BUILTIN, CODEGRAPH_GUIDANCE, codegraphEnabled } from "./codegraph.js";
12
+ export { CODEGRAPH_BUILTIN } from "./codegraph.js";
13
13
 
14
14
  /** All builtin servers — flat list for `resolveMcpConfigs` to merge. */
15
15
  export const BUILTIN_MCP_SERVERS: Omit<McpServerConfig, "source">[] = [
@@ -5,16 +5,20 @@
5
5
  * hit sets defaults; later sources can override specific fields.
6
6
  *
7
7
  * Persistence locations:
8
- * - global: `~/.pi/agent/decorated-pi.json` (under `mcpServers`)
8
+ * - global: `~/.pi/agent/mcp.json` (under `mcpServers`)
9
9
  * - project: `<cwd>/.pi/agent/mcp.json` (under `mcpServers`)
10
+ *
11
+ * Legacy: global MCP servers used to live in `~/.pi/agent/decorated-pi.json`
12
+ * under `mcpServers`. `loadGlobalMcpConfigs()` automatically migrates that
13
+ * data to `~/.pi/agent/mcp.json` on first read.
10
14
  */
11
15
  import * as fs from "node:fs";
12
16
  import * as os from "node:os";
13
17
  import * as path from "node:path";
14
18
  import { spawnSync } from "node:child_process";
15
- import { loadConfig } from "../../settings.js";
19
+ import { isModuleEnabled } from "../../settings.js";
16
20
  import type { DependencyStatus } from "../../hooks/skeleton.js";
17
- import { BUILTIN_MCP_SERVERS, codegraphEnabled } from "./builtin/index.js";
21
+ import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
18
22
 
19
23
  export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
20
24
 
@@ -27,6 +31,12 @@ export interface McpServerConfig {
27
31
  description?: string;
28
32
  enabled: boolean;
29
33
  source: "builtin" | "global" | "project";
34
+ /** Optional predicate: return false if this server cannot be used in the given project. */
35
+ canUseInProject?: (cwd: string) => boolean;
36
+ }
37
+
38
+ function globalMcpJsonPath(): string {
39
+ return path.join(os.homedir(), ".pi", "agent", "mcp.json");
30
40
  }
31
41
 
32
42
  function readMcpJson(filePath: string): Record<string, { url?: string; command?: string; args?: string[]; env?: Record<string, string>; enabled?: boolean; description?: string }> | null {
@@ -41,6 +51,64 @@ function readMcpJson(filePath: string): Record<string, { url?: string; command?:
41
51
  }
42
52
  }
43
53
 
54
+ /**
55
+ * One-time migration: older versions stored global MCP servers in
56
+ * `~/.pi/agent/decorated-pi.json` under `mcpServers`. Move that data to
57
+ * the dedicated `~/.pi/agent/mcp.json` file so global and project configs
58
+ * are symmetric.
59
+ *
60
+ * Rules:
61
+ * - Only runs when the legacy file has at least one `mcpServers` entry.
62
+ * - For each legacy server name, if `mcp.json` already has an entry with
63
+ * the same name, the legacy entry is skipped (new file wins).
64
+ * - Idempotent: after running once, the legacy file no longer has
65
+ * `mcpServers`, so a second call is a no-op.
66
+ */
67
+ export function migrateLegacyGlobalMcpConfig(): void {
68
+ const legacyPath = path.join(os.homedir(), ".pi", "agent", "decorated-pi.json");
69
+ const newPath = globalMcpJsonPath();
70
+
71
+ let legacy: Record<string, any> | null = null;
72
+ try {
73
+ legacy = JSON.parse(fs.readFileSync(legacyPath, "utf-8"));
74
+ } catch {
75
+ return;
76
+ }
77
+ if (!legacy || !legacy.mcpServers || typeof legacy.mcpServers !== "object") return;
78
+ if (Object.keys(legacy.mcpServers).length === 0) return;
79
+
80
+ // Load existing new file (if any). If it is corrupt, leave everything
81
+ // alone — safer than clobbering.
82
+ let newConfig: Record<string, any> = { mcpServers: {} };
83
+ if (fs.existsSync(newPath)) {
84
+ try {
85
+ const parsed = JSON.parse(fs.readFileSync(newPath, "utf-8"));
86
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
87
+ newConfig = parsed;
88
+ if (!newConfig.mcpServers || typeof newConfig.mcpServers !== "object") {
89
+ newConfig.mcpServers = {};
90
+ }
91
+ }
92
+ } catch {
93
+ return;
94
+ }
95
+ }
96
+
97
+ // Merge: only add legacy entries that don't already exist in the new file.
98
+ for (const [name, entry] of Object.entries(legacy.mcpServers)) {
99
+ if (!(name in newConfig.mcpServers)) {
100
+ newConfig.mcpServers[name] = entry;
101
+ }
102
+ }
103
+
104
+ const dir = path.dirname(newPath);
105
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
106
+ fs.writeFileSync(newPath, JSON.stringify(newConfig, null, 2) + "\n", "utf-8");
107
+
108
+ delete legacy.mcpServers;
109
+ fs.writeFileSync(legacyPath, JSON.stringify(legacy, null, 2) + "\n", "utf-8");
110
+ }
111
+
44
112
  /** Load project-level MCP configs from cwd only. */
45
113
  export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
46
114
  const configs: McpServerConfig[] = [];
@@ -69,18 +137,18 @@ export function loadProjectMcpConfigs(cwd: string): McpServerConfig[] {
69
137
  return configs;
70
138
  }
71
139
 
72
- /** Load global MCP configs from ~/.pi/agent/decorated-pi.json. */
140
+ /** Load global MCP configs from ~/.pi/agent/mcp.json. */
73
141
  export function loadGlobalMcpConfigs(): McpServerConfig[] {
74
- const config = loadConfig();
75
- if (!config.mcpServers) return [];
142
+ const servers = readMcpJson(globalMcpJsonPath());
143
+ if (!servers) return [];
76
144
 
77
- return Object.entries(config.mcpServers).map(([name, entry]) => ({
145
+ return Object.entries(servers).map(([name, entry]) => ({
78
146
  name,
79
147
  url: entry.url,
80
148
  command: entry.command,
81
149
  args: entry.args,
82
150
  env: entry.env,
83
- description: (entry as any).description as string | undefined,
151
+ description: entry.description,
84
152
  enabled: entry.enabled !== false,
85
153
  source: "global" as const,
86
154
  }));
@@ -96,16 +164,17 @@ export function isSseUrl(url: string): boolean {
96
164
  * Later sources override earlier ones for the same server name.
97
165
  */
98
166
  export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
167
+ // The mcp module switch is the master switch. When it is off, no MCP
168
+ // server config is considered active — this keeps the chain simple:
169
+ // mcp off → all servers off → no codegraph guidance.
170
+ if (!isModuleEnabled("mcp")) return [];
171
+
99
172
  const byName = new Map<string, McpServerConfig>();
100
173
 
101
- // Builtin (lowest priority). The codegraph entry's `enabled` flag is
102
- // computed at resolve time from the dp-settings module toggle.
174
+ // Builtin (lowest priority). Use the builtin's own `enabled` default.
175
+ // Servers can be enabled via the `/mcp` command or by editing mcp.json.
103
176
  for (const s of BUILTIN_MCP_SERVERS) {
104
- if (s.name === "codegraph") {
105
- byName.set(s.name, { ...s, enabled: codegraphEnabled(), source: "builtin" });
106
- } else {
107
- byName.set(s.name, { ...s, source: "builtin" });
108
- }
177
+ byName.set(s.name, { ...s, source: "builtin" });
109
178
  }
110
179
 
111
180
  // Global — preserve url/command/description from builtin if not overridden
@@ -121,6 +190,7 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
121
190
  env: s.env ?? existing.env,
122
191
  description: s.description ?? existing.description,
123
192
  source: "global",
193
+ canUseInProject: s.canUseInProject ?? existing.canUseInProject,
124
194
  });
125
195
  } else {
126
196
  byName.set(s.name, s);
@@ -140,6 +210,7 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
140
210
  env: s.env ?? existing.env,
141
211
  description: s.description ?? existing.description,
142
212
  source: "project",
213
+ canUseInProject: s.canUseInProject ?? existing.canUseInProject,
143
214
  });
144
215
  } else {
145
216
  byName.set(s.name, s);
@@ -201,13 +272,14 @@ export function toggleMcpServerEnabled(
201
272
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
202
273
  fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
203
274
  } else {
204
- const config = loadConfig();
205
- config.mcpServers = config.mcpServers || {};
206
- config.mcpServers[serverName] = { ...(config.mcpServers[serverName] || {}), enabled };
207
- const configPath = path.join(os.homedir(), ".pi/agent/decorated-pi.json");
208
- const dir = path.dirname(configPath);
275
+ const filePath = globalMcpJsonPath();
276
+ const raw = readMcpJsonSafe(filePath) || { mcpServers: {} };
277
+ const servers = raw.mcpServers ?? {};
278
+ servers[serverName] = { ...(servers[serverName] || {}), enabled };
279
+ raw.mcpServers = servers;
280
+ const dir = path.dirname(filePath);
209
281
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
210
- fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
282
+ fs.writeFileSync(filePath, JSON.stringify(raw, null, 2) + "\n", "utf-8");
211
283
  }
212
284
  return true;
213
285
  } catch {