decorated-pi 0.7.2 → 0.8.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 +21 -3
- package/commands/dp-settings.ts +3 -3
- package/commands/usage.ts +5 -1
- package/hooks/compaction.ts +52 -157
- package/hooks/externalize.ts +48 -35
- package/hooks/rtk.ts +24 -97
- package/hooks/skeleton.ts +57 -19
- package/hooks/smart-at.ts +69 -30
- package/hooks/thinking-label-strip.ts +79 -0
- package/hooks/wakatime.ts +17 -30
- package/index.ts +207 -170
- package/package.json +3 -1
- package/settings.ts +159 -1
- package/tools/ask/index.ts +1 -4
- package/tools/lsp/servers.ts +31 -15
- package/tools/mcp/config.ts +34 -15
- package/tools/patch/core.ts +113 -24
- package/tools/patch/encoding.ts +172 -0
- package/tsconfig.json +1 -0
- package/ui/ask.ts +72 -22
- package/ui/module-settings.ts +166 -16
- package/utils/which.ts +109 -0
package/settings.ts
CHANGED
|
@@ -7,6 +7,7 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import * as os from "node:os";
|
|
9
9
|
import type { Model } from "@earendil-works/pi-ai";
|
|
10
|
+
import { which } from "./utils/which.js";
|
|
10
11
|
|
|
11
12
|
const CONFIG_DIR = path.join(os.homedir(), ".pi", "agent");
|
|
12
13
|
const CONFIG_FILE = path.join(CONFIG_DIR, "decorated-pi.json");
|
|
@@ -69,15 +70,46 @@ export interface UsageIndexEntry {
|
|
|
69
70
|
mtime: number;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
/** Per-binary dependency override.
|
|
74
|
+
*
|
|
75
|
+
* Keyed by binary name (e.g. "rtk", "wakatime-cli", "gopls",
|
|
76
|
+
* "codegraph"). `path` injects an extra search location into which();
|
|
77
|
+
* `dontBother` silences missing-dependency notifications for this binary. */
|
|
78
|
+
export interface DependencySettings {
|
|
79
|
+
/** Absolute path to the binary (file) or a directory to search. Injected
|
|
80
|
+
* into which()'s extendPath, so it's tried before $PATH. */
|
|
81
|
+
path?: string;
|
|
82
|
+
/** When true, skeleton's missing-dependency notification skips this
|
|
83
|
+
* binary. Use for binaries the user doesn't care about. */
|
|
84
|
+
dontBother?: boolean;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DependencyView extends DependencySettings {
|
|
88
|
+
/** Runtime-only shadow value: what the resolver actually found.
|
|
89
|
+
* Not persisted to decorated-pi.json and not written by /dp-settings. */
|
|
90
|
+
resolvedPath?: string;
|
|
91
|
+
/** Runtime-only shadow value: whether the resolver checked/found it. */
|
|
92
|
+
resolvedState?: "ok" | "missing";
|
|
93
|
+
}
|
|
94
|
+
|
|
72
95
|
export interface DecoratedPiConfig {
|
|
73
96
|
imageModelKey?: string | null;
|
|
74
97
|
compactModelKey?: string | null;
|
|
98
|
+
dependencies?: Record<string, DependencySettings>;
|
|
75
99
|
providers?: Record<string, ProviderCache>;
|
|
76
100
|
modules?: ModuleSettings;
|
|
77
101
|
mcpServers?: Record<string, McpServerEntry>;
|
|
78
102
|
usageIndex?: Record<string, UsageIndexEntry>;
|
|
79
103
|
}
|
|
80
104
|
|
|
105
|
+
/** Runtime view = real config plus in-memory shadow fields. The shadow has
|
|
106
|
+
* the same top-level shape as config, but may enrich selected leaves with
|
|
107
|
+
* runtime-only data. It is never persisted and /dp-settings writes only the
|
|
108
|
+
* real config through setter functions. */
|
|
109
|
+
export interface DecoratedPiConfigView extends Omit<DecoratedPiConfig, "dependencies"> {
|
|
110
|
+
dependencies?: Record<string, DependencyView>;
|
|
111
|
+
}
|
|
112
|
+
|
|
81
113
|
export function loadConfig(): DecoratedPiConfig {
|
|
82
114
|
try {
|
|
83
115
|
if (fs.existsSync(CONFIG_FILE)) {
|
|
@@ -99,6 +131,11 @@ export function saveConfig(config: Partial<DecoratedPiConfig>) {
|
|
|
99
131
|
fs.writeFileSync(CONFIG_FILE, JSON.stringify({ ...current, ...config }, null, 2), "utf-8");
|
|
100
132
|
}
|
|
101
133
|
|
|
134
|
+
// Runtime-only shadow for the whole config. It mirrors DecoratedPiConfig's
|
|
135
|
+
// top-level shape but is never persisted; runtime modules update it, and
|
|
136
|
+
// /dp-settings reads the merged view for display only.
|
|
137
|
+
const configShadow: DecoratedPiConfigView = {};
|
|
138
|
+
|
|
102
139
|
// ─── 辅助 ──────────────────────────────────────────────────────────────────
|
|
103
140
|
|
|
104
141
|
export function formatModelKey(m: Model<any>): string {
|
|
@@ -165,6 +202,77 @@ export function getCompactModelKey(): string | null {
|
|
|
165
202
|
return loadConfig().compactModelKey ?? null;
|
|
166
203
|
}
|
|
167
204
|
|
|
205
|
+
/** Look up a binary's configured path override. Returns null when not set. */
|
|
206
|
+
export function getDependencyPath(name: string): string | null {
|
|
207
|
+
return loadConfig().dependencies?.[name]?.path ?? null;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Whether missing-dependency notifications are silenced for this binary. */
|
|
211
|
+
export function isDontBother(name: string): boolean {
|
|
212
|
+
return loadConfig().dependencies?.[name]?.dontBother === true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function getConfigView(): DecoratedPiConfigView {
|
|
216
|
+
const real = loadConfig();
|
|
217
|
+
const dependencyNames = new Set([
|
|
218
|
+
...Object.keys(real.dependencies ?? {}),
|
|
219
|
+
...Object.keys(configShadow.dependencies ?? {}),
|
|
220
|
+
]);
|
|
221
|
+
const dependencies: Record<string, DependencyView> = {};
|
|
222
|
+
for (const name of dependencyNames) {
|
|
223
|
+
dependencies[name] = {
|
|
224
|
+
...(real.dependencies?.[name] ?? {}),
|
|
225
|
+
...(configShadow.dependencies?.[name] ?? {}),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
return {
|
|
229
|
+
...real,
|
|
230
|
+
...configShadow,
|
|
231
|
+
dependencies: Object.keys(dependencies).length ? dependencies : undefined,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function getDependencyView(name: string): DependencyView {
|
|
236
|
+
return getConfigView().dependencies?.[name] ?? {};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function listDependencyViewNames(extraNames: string[] = []): string[] {
|
|
240
|
+
return [...new Set([
|
|
241
|
+
...extraNames,
|
|
242
|
+
...Object.keys(getConfigView().dependencies ?? {}),
|
|
243
|
+
])].sort();
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function recordDependencyResolution(name: string, resolvedPath: string | null): void {
|
|
247
|
+
if (!configShadow.dependencies) configShadow.dependencies = {};
|
|
248
|
+
configShadow.dependencies[name] = {
|
|
249
|
+
...(configShadow.dependencies[name] ?? {}),
|
|
250
|
+
...(resolvedPath
|
|
251
|
+
? { resolvedState: "ok" as const, resolvedPath }
|
|
252
|
+
: { resolvedState: "missing" as const, resolvedPath: undefined }),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Resolve a binary using dependency config plus caller-specific search
|
|
257
|
+
* locations. Modules call this at startup/runtime; it records a shadow
|
|
258
|
+
* view for /dp-settings but does not persist anything.
|
|
259
|
+
*
|
|
260
|
+
* Order:
|
|
261
|
+
* 1. dependencies[name].path (file or directory)
|
|
262
|
+
* 2. opts.extendPath entries (file or directory)
|
|
263
|
+
* 3. $PATH
|
|
264
|
+
*/
|
|
265
|
+
export function resolveDependency(name: string, opts?: { extendPath?: string[] }): string | null {
|
|
266
|
+
const override = getDependencyPath(name);
|
|
267
|
+
const extendPath = [
|
|
268
|
+
...(override ? [override] : []),
|
|
269
|
+
...(opts?.extendPath ?? []),
|
|
270
|
+
];
|
|
271
|
+
const resolved = which(name, { extendPath });
|
|
272
|
+
recordDependencyResolution(name, resolved);
|
|
273
|
+
return resolved;
|
|
274
|
+
}
|
|
275
|
+
|
|
168
276
|
// ─── Setter ─────────────────────────────────────────────────────────────────
|
|
169
277
|
|
|
170
278
|
export function setImageModelKey(key: string | null) {
|
|
@@ -175,6 +283,47 @@ export function setCompactModelKey(key: string | null) {
|
|
|
175
283
|
saveConfig({ compactModelKey: key });
|
|
176
284
|
}
|
|
177
285
|
|
|
286
|
+
/** Set or clear a binary's path override. Pass null to remove the path.
|
|
287
|
+
* Preserves any dontBother flag on the same entry (精准修改不覆盖). */
|
|
288
|
+
export function setDependencyPath(name: string, path: string | null) {
|
|
289
|
+
const current = loadConfig();
|
|
290
|
+
const deps = { ...(current.dependencies ?? {}) };
|
|
291
|
+
const existing = deps[name] ?? {};
|
|
292
|
+
if (path === null) {
|
|
293
|
+
const { path: _drop, ...rest } = existing;
|
|
294
|
+
if (Object.keys(rest).length === 0) {
|
|
295
|
+
delete deps[name];
|
|
296
|
+
} else {
|
|
297
|
+
deps[name] = rest;
|
|
298
|
+
}
|
|
299
|
+
} else {
|
|
300
|
+
deps[name] = { ...existing, path };
|
|
301
|
+
}
|
|
302
|
+
// Path edits are config writes; invalidate runtime-only resolution for
|
|
303
|
+
// this binary so /dp-settings never shows a stale pre-edit shadow path.
|
|
304
|
+
if (configShadow.dependencies?.[name]) delete configShadow.dependencies[name];
|
|
305
|
+
saveConfig({ dependencies: deps });
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Set or clear a binary's dontBother flag. Pass false to re-enable
|
|
309
|
+
* missing-dependency notifications. Preserves any path override. */
|
|
310
|
+
export function setDontBother(name: string, dontBother: boolean) {
|
|
311
|
+
const current = loadConfig();
|
|
312
|
+
const deps = { ...(current.dependencies ?? {}) };
|
|
313
|
+
const existing = deps[name] ?? {};
|
|
314
|
+
if (dontBother) {
|
|
315
|
+
deps[name] = { ...existing, dontBother: true };
|
|
316
|
+
} else {
|
|
317
|
+
const { dontBother: _drop, ...rest } = existing;
|
|
318
|
+
if (Object.keys(rest).length === 0) {
|
|
319
|
+
delete deps[name];
|
|
320
|
+
} else {
|
|
321
|
+
deps[name] = rest;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
saveConfig({ dependencies: deps });
|
|
325
|
+
}
|
|
326
|
+
|
|
178
327
|
// ─── Module Switches ──────────────────────────────────────────────────────────
|
|
179
328
|
|
|
180
329
|
const DEFAULT_MODULES: Required<ModuleSettings> = {
|
|
@@ -321,14 +470,23 @@ export function getAllModuleSettings(): Required<ModuleSettings> {
|
|
|
321
470
|
* reload is actually necessary.
|
|
322
471
|
*/
|
|
323
472
|
let loadedModuleSnapshot: Required<ModuleSettings> | null = null;
|
|
473
|
+
let loadedDependenciesSnapshot: Record<string, DependencySettings> | null = null;
|
|
324
474
|
|
|
325
475
|
export function captureModuleSnapshot(): void {
|
|
326
476
|
loadedModuleSnapshot = getAllModuleSettings();
|
|
477
|
+
loadedDependenciesSnapshot = loadConfig().dependencies ?? {};
|
|
327
478
|
}
|
|
328
479
|
|
|
329
480
|
export function moduleSnapshotChanged(): boolean {
|
|
330
481
|
if (!loadedModuleSnapshot) return true;
|
|
331
|
-
|
|
482
|
+
if (JSON.stringify(loadedModuleSnapshot) !== JSON.stringify(getAllModuleSettings())) return true;
|
|
483
|
+
// Dependencies changes don't strictly require reload (which() reads the
|
|
484
|
+
// config file on every call), but prompt for consistency — the user
|
|
485
|
+
// might be mid-session and expect the new path to be picked up by hooks
|
|
486
|
+
// that captured the binary path at startup (rtk/wakatime).
|
|
487
|
+
const currentDeps = loadConfig().dependencies ?? {};
|
|
488
|
+
if (JSON.stringify(loadedDependenciesSnapshot) !== JSON.stringify(currentDeps)) return true;
|
|
489
|
+
return false;
|
|
332
490
|
}
|
|
333
491
|
|
|
334
492
|
// ─── Usage index (增量同步元数据) ─────────────────────────────────────────────
|
package/tools/ask/index.ts
CHANGED
|
@@ -17,11 +17,8 @@ const askQuestionSchema = Type.Object({
|
|
|
17
17
|
{ description: "text = free input, single = one option, multi = many options" },
|
|
18
18
|
),
|
|
19
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." })),
|
|
20
|
+
options: Type.Optional(Type.Array(Type.String(), { description: "Options for single or multi choice. Each option MUST be a plain string (not an object). Example: [\"选项A\", \"选项B\", \"选项C\"]. The user picks by index; do NOT pass {id,text} objects." })),
|
|
21
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
22
|
});
|
|
26
23
|
|
|
27
24
|
function formatAnswer(q: AskQuestion, value: string | string[]): string {
|
package/tools/lsp/servers.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* LSP Server Config — language detection, server commands, workspace roots.
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, readdirSync, type Dirent } from "node:fs";
|
|
5
|
-
import { spawnSync } from "node:child_process";
|
|
6
5
|
import { dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
7
6
|
import type { DependencyStatus } from "../../hooks/skeleton.js";
|
|
7
|
+
import { getDependencyPath, resolveDependency } from "../../settings.js";
|
|
8
8
|
|
|
9
9
|
// ─── File extension → language mapping ────────────────────────────────────
|
|
10
10
|
|
|
@@ -97,6 +97,16 @@ export function listSupportedLanguages(): string[] {
|
|
|
97
97
|
return Object.keys(LANGUAGE_SERVERS).sort();
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
+
/** Unique binary names used by builtin LSP servers. Used by the
|
|
101
|
+
* /dp-settings Dependencies UI to know which binaries are configurable. */
|
|
102
|
+
export function listLspBinaryNames(): string[] {
|
|
103
|
+
const seen = new Set<string>();
|
|
104
|
+
for (const lang of Object.keys(LANGUAGE_SERVERS)) {
|
|
105
|
+
seen.add(LANGUAGE_SERVERS[lang].command);
|
|
106
|
+
}
|
|
107
|
+
return [...seen].sort();
|
|
108
|
+
}
|
|
109
|
+
|
|
100
110
|
export function getServerConfig(
|
|
101
111
|
language: string,
|
|
102
112
|
cwd = process.cwd(),
|
|
@@ -104,6 +114,12 @@ export function getServerConfig(
|
|
|
104
114
|
const base = LANGUAGE_SERVERS[language];
|
|
105
115
|
if (!base) return undefined;
|
|
106
116
|
|
|
117
|
+
const override = getDependencyPath(base.command);
|
|
118
|
+
if (override) {
|
|
119
|
+
const resolvedOverride = resolveDependency(base.command, { extendPath: [] });
|
|
120
|
+
return { ...base, command: resolvedOverride ?? override, is_project_local: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
107
123
|
const resolved = resolveLocalBinary(base.command, cwd);
|
|
108
124
|
return { ...base, command: resolved.command, is_project_local: resolved.is_project_local };
|
|
109
125
|
}
|
|
@@ -147,14 +163,16 @@ export function collectLspDependencyStatuses(cwd: string): DependencyStatus[] {
|
|
|
147
163
|
const statuses: DependencyStatus[] = [];
|
|
148
164
|
const seen = new Set<string>();
|
|
149
165
|
for (const language of listSupportedLanguages()) {
|
|
150
|
-
const
|
|
151
|
-
if (!
|
|
152
|
-
seen.add(
|
|
166
|
+
const base = LANGUAGE_SERVERS[language];
|
|
167
|
+
if (!base || seen.has(base.command)) continue;
|
|
168
|
+
seen.add(base.command);
|
|
169
|
+
const path = resolveLspBinary(base.command, cwd);
|
|
153
170
|
statuses.push({
|
|
154
171
|
module: "lsp",
|
|
155
|
-
label:
|
|
156
|
-
state:
|
|
157
|
-
detail:
|
|
172
|
+
label: base.command,
|
|
173
|
+
state: path ? "ok" : "missing",
|
|
174
|
+
detail: base.install_hint,
|
|
175
|
+
path: path ?? undefined,
|
|
158
176
|
});
|
|
159
177
|
}
|
|
160
178
|
return statuses;
|
|
@@ -174,14 +192,12 @@ export function findWorkspaceRoot(
|
|
|
174
192
|
|
|
175
193
|
// ─── Internal helpers ─────────────────────────────────────────────────────
|
|
176
194
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
: spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
|
|
184
|
-
return result.status === 0;
|
|
195
|
+
export function lspDependencyExtendPath(cwd = process.cwd()): string[] {
|
|
196
|
+
return [...ancestorDirs(cwd)].map((dir) => join(dir, "node_modules", ".bin"));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function resolveLspBinary(command: string, cwd = process.cwd()): string | null {
|
|
200
|
+
return resolveDependency(command, { extendPath: lspDependencyExtendPath(cwd) });
|
|
185
201
|
}
|
|
186
202
|
|
|
187
203
|
function resolveLocalBinary(
|
package/tools/mcp/config.ts
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
import * as fs from "node:fs";
|
|
16
16
|
import * as os from "node:os";
|
|
17
17
|
import * as path from "node:path";
|
|
18
|
-
import {
|
|
19
|
-
import { isModuleEnabled } from "../../settings.js";
|
|
18
|
+
import { isModuleEnabled, resolveDependency } from "../../settings.js";
|
|
20
19
|
import type { DependencyStatus } from "../../hooks/skeleton.js";
|
|
21
20
|
import { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
|
|
22
21
|
|
|
22
|
+
|
|
23
23
|
export { BUILTIN_MCP_SERVERS } from "./builtin/index.js";
|
|
24
24
|
|
|
25
25
|
export interface McpServerConfig {
|
|
@@ -33,6 +33,8 @@ export interface McpServerConfig {
|
|
|
33
33
|
source: "builtin" | "global" | "project";
|
|
34
34
|
/** Optional predicate: return false if this server cannot be used in the given project. */
|
|
35
35
|
canUseInProject?: (cwd: string) => boolean;
|
|
36
|
+
/** Binary/config key before dependency path resolution. */
|
|
37
|
+
dependencyName?: string;
|
|
36
38
|
}
|
|
37
39
|
|
|
38
40
|
function globalMcpJsonPath(): string {
|
|
@@ -217,33 +219,50 @@ export function resolveMcpConfigs(cwd: string): McpServerConfig[] {
|
|
|
217
219
|
}
|
|
218
220
|
}
|
|
219
221
|
|
|
220
|
-
return [...byName.values()]
|
|
222
|
+
return [...byName.values()]
|
|
223
|
+
.filter((s) => s.url || s.command)
|
|
224
|
+
.map((s) => {
|
|
225
|
+
// Apply user-configured binary path override (dependencies[cmd].path)
|
|
226
|
+
// last, so it wins over builtin/global/project configs. Only applies
|
|
227
|
+
// to servers that use a command (not URL-based servers).
|
|
228
|
+
if (!s.command) return s;
|
|
229
|
+
const dependencyName = s.command;
|
|
230
|
+
const resolved = resolveMcpBinary(dependencyName);
|
|
231
|
+
return { ...s, dependencyName, command: resolved ?? s.command };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Unique binary names used by builtin MCP servers that have a `command`
|
|
236
|
+
* (URL-based servers are excluded — they don't need a binary). */
|
|
237
|
+
export function listMcpBinaryNames(): string[] {
|
|
238
|
+
const seen = new Set<string>();
|
|
239
|
+
for (const s of BUILTIN_MCP_SERVERS) {
|
|
240
|
+
if (s.command) seen.add(s.command);
|
|
241
|
+
}
|
|
242
|
+
return [...seen].sort();
|
|
221
243
|
}
|
|
222
244
|
|
|
223
245
|
export function collectMcpDependencyStatuses(cwd: string): DependencyStatus[] {
|
|
224
246
|
const seen = new Set<string>();
|
|
225
247
|
const statuses: DependencyStatus[] = [];
|
|
226
248
|
for (const cfg of resolveMcpConfigs(cwd)) {
|
|
227
|
-
|
|
228
|
-
|
|
249
|
+
const depName = cfg.dependencyName ?? cfg.command;
|
|
250
|
+
if (!cfg.enabled || !cfg.command || !depName || seen.has(depName)) continue;
|
|
251
|
+
seen.add(depName);
|
|
252
|
+
const resolved = resolveMcpBinary(depName);
|
|
229
253
|
statuses.push({
|
|
230
254
|
module: `mcp:${cfg.name}`,
|
|
231
|
-
label:
|
|
232
|
-
state:
|
|
255
|
+
label: depName,
|
|
256
|
+
state: resolved ? "ok" : "missing",
|
|
233
257
|
detail: `Install the MCP server command for \"${cfg.name}\" or update its config.`,
|
|
258
|
+
path: resolved ?? undefined,
|
|
234
259
|
});
|
|
235
260
|
}
|
|
236
261
|
return statuses;
|
|
237
262
|
}
|
|
238
263
|
|
|
239
|
-
function
|
|
240
|
-
|
|
241
|
-
return fs.existsSync(command);
|
|
242
|
-
}
|
|
243
|
-
const result = process.platform === "win32"
|
|
244
|
-
? spawnSync("where", [command], { encoding: "utf-8" })
|
|
245
|
-
: spawnSync(process.env.SHELL || "sh", ["-lc", `command -v '${command.replace(/'/g, `'"'"'`)}'`], { encoding: "utf-8" });
|
|
246
|
-
return result.status === 0;
|
|
264
|
+
export function resolveMcpBinary(command: string): string | null {
|
|
265
|
+
return resolveDependency(command);
|
|
247
266
|
}
|
|
248
267
|
|
|
249
268
|
function readMcpJsonSafe(filePath: string): Record<string, any> | null {
|
package/tools/patch/core.ts
CHANGED
|
@@ -11,7 +11,12 @@
|
|
|
11
11
|
|
|
12
12
|
import * as fs from "node:fs";
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
-
import
|
|
14
|
+
import {
|
|
15
|
+
detectFileEncoding,
|
|
16
|
+
readFileDecoded,
|
|
17
|
+
writeFileEncoded,
|
|
18
|
+
type FileEncoding,
|
|
19
|
+
} from "./encoding.js";
|
|
15
20
|
|
|
16
21
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
17
22
|
// Types
|
|
@@ -63,6 +68,12 @@ export interface ReplacementInfo {
|
|
|
63
68
|
oldLines: string[];
|
|
64
69
|
/** The new lines that replaced them */
|
|
65
70
|
newLines: string[];
|
|
71
|
+
/** Verbatim normalized replacement text (for byte-faithful writeback). */
|
|
72
|
+
newStr?: string;
|
|
73
|
+
/** Offset in normalized content where the matched region starts (writeback). */
|
|
74
|
+
normStart?: number;
|
|
75
|
+
/** Offset one past the matched region in normalized content (writeback). */
|
|
76
|
+
normEnd?: number;
|
|
66
77
|
/** Optional anchor text (first line only, for hunk display) */
|
|
67
78
|
anchor?: string;
|
|
68
79
|
/** Anchor was provided but not found, and patch fell back to global old_str search */
|
|
@@ -156,13 +167,20 @@ function applyOverwrite(
|
|
|
156
167
|
content: string,
|
|
157
168
|
result: PatchResult,
|
|
158
169
|
): void {
|
|
159
|
-
|
|
170
|
+
// Detect encoding from the existing file so we round-trip in the same
|
|
171
|
+
// bytes. New files default to UTF-8 (no BOM).
|
|
172
|
+
const enc: FileEncoding | null = fs.existsSync(absPath)
|
|
173
|
+
? detectFileEncoding(absPath)
|
|
174
|
+
: null;
|
|
175
|
+
const oldContent = enc ? readFileDecoded(absPath, enc) : "";
|
|
160
176
|
|
|
161
177
|
// Write to temp file in the same directory (same filesystem → mv is atomic)
|
|
162
178
|
ensureParentDir(absPath);
|
|
163
179
|
const dir = path.dirname(absPath);
|
|
164
180
|
const tmpName = path.join(dir, `.pi-patch-${randomId()}.tmp`);
|
|
165
|
-
|
|
181
|
+
// Overwrite semantics: write exactly what the caller passed, in the
|
|
182
|
+
// detected encoding (UTF-8 for new files).
|
|
183
|
+
writeFileEncoded(tmpName, content, enc ?? { encoding: "utf-8", hasBOM: false, isUtf8: true });
|
|
166
184
|
fs.renameSync(tmpName, absPath);
|
|
167
185
|
|
|
168
186
|
if (oldContent) {
|
|
@@ -287,8 +305,8 @@ async function applyEdits(
|
|
|
287
305
|
throw new ApplyError(`Cannot patch directory: ${displayPath}`);
|
|
288
306
|
}
|
|
289
307
|
|
|
290
|
-
const
|
|
291
|
-
const
|
|
308
|
+
const enc = detectFileEncoding(absPath);
|
|
309
|
+
const rawContent = readFileDecoded(absPath, enc);
|
|
292
310
|
const originalContent = normalizeLineEndings(rawContent);
|
|
293
311
|
|
|
294
312
|
// Precompute line offsets for the original file (used throughout)
|
|
@@ -349,6 +367,8 @@ async function applyEdits(
|
|
|
349
367
|
|
|
350
368
|
const oldStartLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset);
|
|
351
369
|
const oldEndLine = lineAtOffset(lineOffsets, matchIdx - cumulativeOffset + oldNorm.length - 1);
|
|
370
|
+
const normStart = matchIdx - cumulativeOffset;
|
|
371
|
+
const normEnd = normStart + oldNorm.length;
|
|
352
372
|
|
|
353
373
|
content =
|
|
354
374
|
content.substring(0, matchIdx) +
|
|
@@ -364,6 +384,9 @@ async function applyEdits(
|
|
|
364
384
|
newEndLine: 0,
|
|
365
385
|
oldLines: oldNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
366
386
|
newLines: newNorm.split("\n").filter((l, i, arr) => !(i === arr.length - 1 && l === "")),
|
|
387
|
+
newStr: newNorm,
|
|
388
|
+
normStart,
|
|
389
|
+
normEnd,
|
|
367
390
|
anchor: displayAnchor ? displayAnchor.split("\n")[0] : undefined,
|
|
368
391
|
anchorMissing,
|
|
369
392
|
});
|
|
@@ -392,12 +415,18 @@ async function applyEdits(
|
|
|
392
415
|
result.diff = fileDiff;
|
|
393
416
|
}
|
|
394
417
|
|
|
395
|
-
const finalContent =
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
418
|
+
const finalContent = spliceOntoRaw(
|
|
419
|
+
rawContent,
|
|
420
|
+
cleanReplacements
|
|
421
|
+
.map((r) => ({
|
|
422
|
+
normStart: r.normStart ?? 0,
|
|
423
|
+
normEnd: r.normEnd ?? 0,
|
|
424
|
+
newStr: r.newStr ?? r.newLines.join("\n"),
|
|
425
|
+
}))
|
|
426
|
+
.sort((a, b) => a.normStart - b.normStart),
|
|
427
|
+
);
|
|
399
428
|
|
|
400
|
-
|
|
429
|
+
writeFileEncoded(absPath, finalContent, enc);
|
|
401
430
|
result.modified.push(displayPath);
|
|
402
431
|
result.replacements.set(displayPath, cleanReplacements);
|
|
403
432
|
return;
|
|
@@ -495,14 +524,16 @@ async function applyEdits(
|
|
|
495
524
|
result.diff = fileDiff;
|
|
496
525
|
}
|
|
497
526
|
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
527
|
+
const finalContent = spliceOntoRaw(
|
|
528
|
+
rawContent,
|
|
529
|
+
sorted.map((p) => ({
|
|
530
|
+
normStart: p.matchIdx,
|
|
531
|
+
normEnd: p.matchIdx + p.oldNorm.length,
|
|
532
|
+
newStr: p.newNorm,
|
|
533
|
+
})),
|
|
534
|
+
);
|
|
504
535
|
|
|
505
|
-
|
|
536
|
+
writeFileEncoded(absPath, finalContent, enc);
|
|
506
537
|
result.modified.push(displayPath);
|
|
507
538
|
result.replacements.set(displayPath, replacements);
|
|
508
539
|
}
|
|
@@ -541,7 +572,8 @@ export async function computePatchPreview(
|
|
|
541
572
|
return { error: "File not found" };
|
|
542
573
|
}
|
|
543
574
|
|
|
544
|
-
const
|
|
575
|
+
const enc = detectFileEncoding(absPath);
|
|
576
|
+
const rawContent = readFileDecoded(absPath, enc);
|
|
545
577
|
const lineOffsets = buildLineOffsets(rawContent);
|
|
546
578
|
const totalLines = lineOffsets.length - 1;
|
|
547
579
|
let content = normalizeLineEndings(rawContent);
|
|
@@ -1053,16 +1085,68 @@ function ensureParentDir(absPath: string): void {
|
|
|
1053
1085
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1054
1086
|
}
|
|
1055
1087
|
|
|
1056
|
-
function detectLineEnding(content: string): string {
|
|
1057
|
-
return content.includes("\r\n") ? "\r\n" : "\n";
|
|
1058
|
-
}
|
|
1059
|
-
|
|
1060
1088
|
function normalizeLineEndings(text: string): string {
|
|
1061
1089
|
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1062
1090
|
}
|
|
1063
1091
|
|
|
1064
|
-
|
|
1065
|
-
|
|
1092
|
+
/** A replacement expressed in coordinates of the normalized (\n-only) content,
|
|
1093
|
+
* paired with the exact new_str to drop in. Used by spliceOntoRaw to rebuild
|
|
1094
|
+
* the file byte-for-byte on the original rawContent. */
|
|
1095
|
+
interface RawSplice {
|
|
1096
|
+
/** Offset in the normalized content where the matched text starts. */
|
|
1097
|
+
normStart: number;
|
|
1098
|
+
/** Offset in the normalized content one past the matched text. */
|
|
1099
|
+
normEnd: number;
|
|
1100
|
+
/** Verbatim replacement text (already normalized to \n). */
|
|
1101
|
+
newStr: string;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
/** Map every index of the normalized content to its offset in rawContent.
|
|
1105
|
+
* The two strings differ only by `\r` bytes (CRLF→LF normalization removed
|
|
1106
|
+
* them), so we walk both in lockstep. O(n). */
|
|
1107
|
+
function buildNormToRawMap(raw: string, norm: string): Int32Array {
|
|
1108
|
+
const map = new Int32Array(norm.length + 1);
|
|
1109
|
+
let ri = 0;
|
|
1110
|
+
for (let ni = 0; ni <= norm.length; ni++) {
|
|
1111
|
+
// Skip any `\r` in raw that the normalization folded into `\n`.
|
|
1112
|
+
// norm[ni] corresponds to raw[ri]; when norm advances past a `\n` that
|
|
1113
|
+
// came from `\r\n`, raw must skip the `\r` first.
|
|
1114
|
+
if (ni < norm.length) {
|
|
1115
|
+
map[ni] = ri;
|
|
1116
|
+
const ch = norm.charCodeAt(ni);
|
|
1117
|
+
const rawCh = raw.charCodeAt(ri);
|
|
1118
|
+
if (ch === 10 /* \n */ && rawCh === 13 /* \r */) {
|
|
1119
|
+
// raw had \r\n; advance past \r then \n
|
|
1120
|
+
ri += 2;
|
|
1121
|
+
} else {
|
|
1122
|
+
ri += 1;
|
|
1123
|
+
}
|
|
1124
|
+
} else {
|
|
1125
|
+
map[ni] = raw.length;
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
return map;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Rebuild the file on top of the original rawContent: untouched regions keep
|
|
1132
|
+
* their original bytes (including CRLF / mixed endings), edited regions get
|
|
1133
|
+
* the verbatim newStr the caller supplied. Splices must be sorted by
|
|
1134
|
+
* normStart and non-overlapping. */
|
|
1135
|
+
function spliceOntoRaw(rawContent: string, splices: RawSplice[]): string {
|
|
1136
|
+
if (splices.length === 0) return rawContent;
|
|
1137
|
+
const norm = normalizeLineEndings(rawContent);
|
|
1138
|
+
const map = buildNormToRawMap(rawContent, norm);
|
|
1139
|
+
let out = "";
|
|
1140
|
+
let rawCursor = 0;
|
|
1141
|
+
for (const s of splices) {
|
|
1142
|
+
const rawStart = map[s.normStart] ?? 0;
|
|
1143
|
+
const rawEnd = map[s.normEnd] ?? rawContent.length;
|
|
1144
|
+
if (rawStart > rawCursor) out += rawContent.substring(rawCursor, rawStart);
|
|
1145
|
+
out += s.newStr;
|
|
1146
|
+
rawCursor = rawEnd;
|
|
1147
|
+
}
|
|
1148
|
+
if (rawCursor < rawContent.length) out += rawContent.substring(rawCursor);
|
|
1149
|
+
return out;
|
|
1066
1150
|
}
|
|
1067
1151
|
|
|
1068
1152
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -1203,6 +1287,9 @@ function collapseSequentialReplacements(
|
|
|
1203
1287
|
newEndLine: merged.oldStartLine + next.newLines.length - 1,
|
|
1204
1288
|
oldLines: merged.oldLines,
|
|
1205
1289
|
newLines: next.newLines,
|
|
1290
|
+
newStr: next.newStr,
|
|
1291
|
+
normStart: merged.normStart,
|
|
1292
|
+
normEnd: merged.normEnd,
|
|
1206
1293
|
anchor: undefined,
|
|
1207
1294
|
anchorMissing: merged.anchorMissing || next.anchorMissing,
|
|
1208
1295
|
};
|
|
@@ -1530,4 +1617,6 @@ export const __patchCoreTest = {
|
|
|
1530
1617
|
truncate,
|
|
1531
1618
|
collapseSequentialReplacements,
|
|
1532
1619
|
generateReplacementDiff,
|
|
1620
|
+
spliceOntoRaw,
|
|
1621
|
+
buildNormToRawMap,
|
|
1533
1622
|
};
|