@oh-my-pi/pi-coding-agent 15.5.11 → 15.5.13
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/CHANGELOG.md +28 -0
- package/dist/types/cli-commands.d.ts +19 -0
- package/dist/types/commands/install.d.ts +51 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/discovery/omp-extension-roots.d.ts +43 -0
- package/dist/types/discovery/omp-plugins.d.ts +1 -0
- package/dist/types/edit/file-snapshot-store.d.ts +19 -0
- package/dist/types/extensibility/plugins/loader.d.ts +12 -2
- package/dist/types/tools/todo-write.d.ts +30 -0
- package/package.json +8 -8
- package/src/cli-commands.ts +44 -0
- package/src/cli.ts +2 -32
- package/src/commands/install.ts +107 -0
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-extension-roots.ts +190 -0
- package/src/discovery/omp-plugins.ts +383 -0
- package/src/edit/file-snapshot-store.ts +34 -0
- package/src/edit/hashline/diff.ts +3 -8
- package/src/edit/renderer.ts +1 -1
- package/src/extensibility/plugins/loader.ts +43 -18
- package/src/internal-urls/docs-index.generated.ts +1 -1
- package/src/main.ts +12 -0
- package/src/modes/interactive-mode.ts +243 -12
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +6 -17
- package/src/tools/read.ts +23 -33
- package/src/tools/search.ts +12 -21
- package/src/tools/todo-write.ts +64 -0
- package/src/tools/write.ts +1 -3
- package/src/utils/file-mentions.ts +1 -3
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP extension package roots.
|
|
3
|
+
*
|
|
4
|
+
* An "extension package root" is a directory configured via either
|
|
5
|
+
* `extensions:` in user/project settings or the `--extension`/`-e` CLI flag
|
|
6
|
+
* that points to a packaged extension on disk. The package's standard
|
|
7
|
+
* sub-directories (`skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
|
|
8
|
+
* `prompts/`, `.mcp.json`) are wired into discovery by `omp-plugins.ts`.
|
|
9
|
+
*
|
|
10
|
+
* CLI-provided paths are injected via {@link injectOmpExtensionCliRoots}
|
|
11
|
+
* before discovery runs; settings paths are read lazily from
|
|
12
|
+
* `<scope>/settings.json` in {@link listOmpExtensionRoots} to mirror what
|
|
13
|
+
* `loadExtensionModules` already does.
|
|
14
|
+
*
|
|
15
|
+
* @see ./omp-plugins.ts
|
|
16
|
+
* @see ./builtin.ts `loadExtensionModules`
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { isEnoent, logger, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { readDirEntries, readFile } from "../capability/fs";
|
|
22
|
+
import type { LoadContext } from "../capability/types";
|
|
23
|
+
import { getEnabledPlugins } from "../extensibility/plugins/loader";
|
|
24
|
+
import { expandTilde } from "../tools/path-utils";
|
|
25
|
+
|
|
26
|
+
/** A resolved extension package directory wired into the discovery surfaces. */
|
|
27
|
+
export interface OmpExtensionRoot {
|
|
28
|
+
/** Absolute path to the package directory. */
|
|
29
|
+
path: string;
|
|
30
|
+
/** Stable display name (basename of the package directory). */
|
|
31
|
+
name: string;
|
|
32
|
+
/** Scope from which the path was sourced. */
|
|
33
|
+
level: "user" | "project";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface InjectedRoot {
|
|
37
|
+
path: string;
|
|
38
|
+
level: "user" | "project";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let injectedCliRoots: InjectedRoot[] = [];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Register CLI-provided extension package paths (e.g. from `--extension`/`-e`)
|
|
45
|
+
* so the sub-discovery providers can find their sibling `skills/`, `hooks/`,
|
|
46
|
+
* etc. Paths that do not resolve to a directory are silently dropped — file
|
|
47
|
+
* entrypoints have no package sub-tree to scan.
|
|
48
|
+
*
|
|
49
|
+
* Call once during startup before any capability load. Repeated calls extend
|
|
50
|
+
* the registered set; {@link clearOmpExtensionCliRoots} resets for tests.
|
|
51
|
+
*/
|
|
52
|
+
export function injectOmpExtensionCliRoots(paths: readonly string[], home: string, cwd: string): void {
|
|
53
|
+
if (paths.length === 0) return;
|
|
54
|
+
const expanded = paths.map(raw => {
|
|
55
|
+
const tilde = expandTilde(raw, home);
|
|
56
|
+
return path.isAbsolute(tilde) ? tilde : path.resolve(cwd, tilde);
|
|
57
|
+
});
|
|
58
|
+
const merged = new Map<string, InjectedRoot>();
|
|
59
|
+
for (const root of injectedCliRoots) merged.set(root.path, root);
|
|
60
|
+
for (const resolved of expanded) {
|
|
61
|
+
// CLI scope mirrors how `--extension` is treated elsewhere — user-level overrides win.
|
|
62
|
+
if (!merged.has(resolved)) merged.set(resolved, { path: resolved, level: "user" });
|
|
63
|
+
}
|
|
64
|
+
injectedCliRoots = [...merged.values()];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Drop every CLI-injected root. Tests use this between cases. */
|
|
68
|
+
export function clearOmpExtensionCliRoots(): void {
|
|
69
|
+
injectedCliRoots = [];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inspect currently-injected CLI roots (read-only). Exposed for diagnostics + tests. */
|
|
73
|
+
export function getInjectedOmpExtensionCliRoots(): readonly OmpExtensionRoot[] {
|
|
74
|
+
return injectedCliRoots.map(({ path: p, level }) => ({ path: p, level, name: path.basename(p) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface ScopeDirs {
|
|
78
|
+
project: string;
|
|
79
|
+
user: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function scopeDirs(ctx: LoadContext): ScopeDirs {
|
|
83
|
+
return {
|
|
84
|
+
project: path.join(ctx.cwd, ".omp"),
|
|
85
|
+
user: path.join(ctx.home, ".omp", "agent"),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function readSettingsExtensions(settingsPath: string): Promise<string[]> {
|
|
90
|
+
const content = await readFile(settingsPath);
|
|
91
|
+
if (!content) return [];
|
|
92
|
+
const parsed = tryParseJson<{ extensions?: unknown }>(content);
|
|
93
|
+
const raw = parsed?.extensions;
|
|
94
|
+
if (!Array.isArray(raw)) return [];
|
|
95
|
+
return raw.filter((entry): entry is string => typeof entry === "string" && entry.length > 0);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveAgainst(raw: string, ctx: LoadContext): string {
|
|
99
|
+
const tilde = expandTilde(raw, ctx.home);
|
|
100
|
+
return path.isAbsolute(tilde) ? tilde : path.resolve(ctx.cwd, tilde);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function isDirectory(p: string): Promise<boolean> {
|
|
104
|
+
const entries = await readDirEntries(p);
|
|
105
|
+
if (entries.length > 0) return true;
|
|
106
|
+
// Empty directory still counts; cache returns [] for both empty and missing.
|
|
107
|
+
// Disambiguate with a single stat — only hit when the cached listing is empty.
|
|
108
|
+
try {
|
|
109
|
+
const stat = await fs.stat(p);
|
|
110
|
+
return stat.isDirectory();
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (isEnoent(err)) return false;
|
|
113
|
+
throw err;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Resolve every configured extension package directory for the given context.
|
|
119
|
+
*
|
|
120
|
+
* Sources, in order of precedence (later entries with the same absolute path
|
|
121
|
+
* are dropped):
|
|
122
|
+
*
|
|
123
|
+
* 1. CLI roots injected via {@link injectOmpExtensionCliRoots}
|
|
124
|
+
* 2. Project `<cwd>/.omp/settings.json#extensions`
|
|
125
|
+
* 3. User `~/.omp/agent/settings.json#extensions`
|
|
126
|
+
* 4. Enabled plugins installed under `<plugins>/node_modules/` (e.g. via
|
|
127
|
+
* `omp install <pkg>` / `omp plugin install` / `omp plugin link`)
|
|
128
|
+
*
|
|
129
|
+
* Only entries that resolve to a directory on disk are returned; file
|
|
130
|
+
* entrypoints contribute zero sub-discovery surface and are filtered out.
|
|
131
|
+
* Installed-plugin enumeration failures (missing lockfile, unreadable
|
|
132
|
+
* `package.json`, etc.) are logged at `debug` and degrade gracefully — the
|
|
133
|
+
* other sources still surface.
|
|
134
|
+
*/
|
|
135
|
+
export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtensionRoot[]> {
|
|
136
|
+
const { project, user } = scopeDirs(ctx);
|
|
137
|
+
const [projectExtensions, userExtensions, installedPlugins] = await Promise.all([
|
|
138
|
+
readSettingsExtensions(path.join(project, "settings.json")),
|
|
139
|
+
readSettingsExtensions(path.join(user, "settings.json")),
|
|
140
|
+
listInstalledPluginRoots(ctx),
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const candidates: InjectedRoot[] = [
|
|
144
|
+
...injectedCliRoots,
|
|
145
|
+
...projectExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "project" })),
|
|
146
|
+
...userExtensions.map((raw): InjectedRoot => ({ path: resolveAgainst(raw, ctx), level: "user" })),
|
|
147
|
+
...installedPlugins,
|
|
148
|
+
];
|
|
149
|
+
|
|
150
|
+
// First-seen-wins dedup preserves CLI > project-settings > user-settings > installed precedence.
|
|
151
|
+
const seen = new Set<string>();
|
|
152
|
+
const unique: InjectedRoot[] = [];
|
|
153
|
+
for (const candidate of candidates) {
|
|
154
|
+
if (seen.has(candidate.path)) continue;
|
|
155
|
+
seen.add(candidate.path);
|
|
156
|
+
unique.push(candidate);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const directoryFlags = await Promise.all(unique.map(c => isDirectory(c.path)));
|
|
160
|
+
const roots: OmpExtensionRoot[] = [];
|
|
161
|
+
for (let i = 0; i < unique.length; i++) {
|
|
162
|
+
if (!directoryFlags[i]) continue;
|
|
163
|
+
const { path: p, level } = unique[i];
|
|
164
|
+
roots.push({ path: p, level, name: path.basename(p) });
|
|
165
|
+
}
|
|
166
|
+
return roots;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Enumerate every enabled installed plugin's package directory so its
|
|
171
|
+
* conventional `skills/`, `hooks/`, `tools/`, `commands/`, `rules/`,
|
|
172
|
+
* `prompts/`, and `.mcp.json` are wired into discovery — mirrors how
|
|
173
|
+
* `getAllPluginExtensionPaths` already feeds the extension factory loader.
|
|
174
|
+
*
|
|
175
|
+
* Marketplace and `omp plugin link` installs write to the plugin manager's
|
|
176
|
+
* `node_modules` (or symlink into it) rather than to `extensions:` in
|
|
177
|
+
* settings; without this branch the sub-discovery provider would still miss
|
|
178
|
+
* everything those install paths produce.
|
|
179
|
+
*/
|
|
180
|
+
async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
|
|
181
|
+
try {
|
|
182
|
+
const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
|
|
183
|
+
// Installed plugins are always user-scope; project disablement is already
|
|
184
|
+
// honored by `getEnabledPlugins` via `loadProjectOverrides`.
|
|
185
|
+
return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
|
|
186
|
+
} catch (err) {
|
|
187
|
+
logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OMP extension-package sub-discovery provider.
|
|
3
|
+
*
|
|
4
|
+
* When a user configures an extension via `extensions:` (in settings) or
|
|
5
|
+
* `--extension`/`-e` (on the CLI), the docs promise that the package's
|
|
6
|
+
* sibling directories — `skills/`, `hooks/pre|post/`, `tools/`, `commands/`,
|
|
7
|
+
* `rules/`, `prompts/`, and `.mcp.json` — are picked up by omp's standard
|
|
8
|
+
* discovery surfaces. The native `omp` provider in `builtin.ts` only walks
|
|
9
|
+
* `.omp/` and `~/.omp/agent/`, so without this provider those sub-trees are
|
|
10
|
+
* silently ignored.
|
|
11
|
+
*
|
|
12
|
+
* Provider priority is set below the native `omp` provider (100) so an
|
|
13
|
+
* extension package never shadows the user's own `.omp/` configuration on
|
|
14
|
+
* dedup.
|
|
15
|
+
*
|
|
16
|
+
* @see ./omp-extension-roots.ts
|
|
17
|
+
* @see ../../docs/extension-loading.md
|
|
18
|
+
*/
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { logger, parseFrontmatter, tryParseJson } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { registerProvider } from "../capability";
|
|
22
|
+
import { readDirEntries, readFile } from "../capability/fs";
|
|
23
|
+
import { type Hook, hookCapability } from "../capability/hook";
|
|
24
|
+
import { type MCPServer, mcpCapability } from "../capability/mcp";
|
|
25
|
+
import { type Prompt, promptCapability } from "../capability/prompt";
|
|
26
|
+
import { type Rule, ruleCapability } from "../capability/rule";
|
|
27
|
+
import { type Skill, skillCapability } from "../capability/skill";
|
|
28
|
+
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
|
|
29
|
+
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
30
|
+
import type { LoadContext, LoadResult } from "../capability/types";
|
|
31
|
+
import { buildRuleFromMarkdown, createSourceMeta, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
|
|
32
|
+
import { listOmpExtensionRoots, type OmpExtensionRoot } from "./omp-extension-roots";
|
|
33
|
+
|
|
34
|
+
const PROVIDER_ID = "omp-plugins";
|
|
35
|
+
const DISPLAY_NAME = "OMP Extension Packages";
|
|
36
|
+
const DESCRIPTION =
|
|
37
|
+
"Sub-discovery (skills, hooks, tools, commands, rules, prompts, .mcp.json) inside extension packages";
|
|
38
|
+
const PRIORITY = 90;
|
|
39
|
+
|
|
40
|
+
// =============================================================================
|
|
41
|
+
// Skills
|
|
42
|
+
// =============================================================================
|
|
43
|
+
|
|
44
|
+
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
45
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
46
|
+
const results = await Promise.all(
|
|
47
|
+
roots.map(root =>
|
|
48
|
+
scanSkillsFromDir(ctx, {
|
|
49
|
+
dir: path.join(root.path, "skills"),
|
|
50
|
+
providerId: PROVIDER_ID,
|
|
51
|
+
level: root.level,
|
|
52
|
+
requireDescription: true,
|
|
53
|
+
}),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
return {
|
|
57
|
+
items: results.flatMap(r => r.items),
|
|
58
|
+
warnings: results.flatMap(r => r.warnings ?? []),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// =============================================================================
|
|
63
|
+
// Slash Commands
|
|
64
|
+
// =============================================================================
|
|
65
|
+
|
|
66
|
+
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
|
|
67
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
68
|
+
const results = await Promise.all(
|
|
69
|
+
roots.map(root =>
|
|
70
|
+
loadFilesFromDir<SlashCommand>(ctx, path.join(root.path, "commands"), PROVIDER_ID, root.level, {
|
|
71
|
+
extensions: ["md"],
|
|
72
|
+
transform: (name, content, filePath, source) => ({
|
|
73
|
+
name: name.replace(/\.md$/, ""),
|
|
74
|
+
path: filePath,
|
|
75
|
+
content,
|
|
76
|
+
level: root.level,
|
|
77
|
+
_source: source,
|
|
78
|
+
}),
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
);
|
|
82
|
+
return {
|
|
83
|
+
items: results.flatMap(r => r.items),
|
|
84
|
+
warnings: results.flatMap(r => r.warnings ?? []),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// =============================================================================
|
|
89
|
+
// Rules
|
|
90
|
+
// =============================================================================
|
|
91
|
+
|
|
92
|
+
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
93
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
94
|
+
const results = await Promise.all(
|
|
95
|
+
roots.map(root =>
|
|
96
|
+
loadFilesFromDir<Rule>(ctx, path.join(root.path, "rules"), PROVIDER_ID, root.level, {
|
|
97
|
+
extensions: ["md", "mdc"],
|
|
98
|
+
transform: (name, content, filePath, source) =>
|
|
99
|
+
buildRuleFromMarkdown(name, content, filePath, source, { stripNamePattern: /\.(md|mdc)$/ }),
|
|
100
|
+
}),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
return {
|
|
104
|
+
items: results.flatMap(r => r.items),
|
|
105
|
+
warnings: results.flatMap(r => r.warnings ?? []),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// =============================================================================
|
|
110
|
+
// Prompts
|
|
111
|
+
// =============================================================================
|
|
112
|
+
|
|
113
|
+
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
|
114
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
115
|
+
const results = await Promise.all(
|
|
116
|
+
roots.map(root =>
|
|
117
|
+
loadFilesFromDir<Prompt>(ctx, path.join(root.path, "prompts"), PROVIDER_ID, root.level, {
|
|
118
|
+
extensions: ["md"],
|
|
119
|
+
transform: (name, content, filePath, source) => ({
|
|
120
|
+
name: name.replace(/\.md$/, ""),
|
|
121
|
+
path: filePath,
|
|
122
|
+
content,
|
|
123
|
+
_source: source,
|
|
124
|
+
}),
|
|
125
|
+
}),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
return {
|
|
129
|
+
items: results.flatMap(r => r.items),
|
|
130
|
+
warnings: results.flatMap(r => r.warnings ?? []),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// =============================================================================
|
|
135
|
+
// Hooks
|
|
136
|
+
// =============================================================================
|
|
137
|
+
|
|
138
|
+
const HOOK_TYPES: ReadonlyArray<"pre" | "post"> = ["pre", "post"];
|
|
139
|
+
|
|
140
|
+
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
141
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
142
|
+
const tasks: Array<{ root: OmpExtensionRoot; hookType: "pre" | "post" }> = [];
|
|
143
|
+
for (const root of roots) {
|
|
144
|
+
for (const hookType of HOOK_TYPES) {
|
|
145
|
+
tasks.push({ root, hookType });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const results = await Promise.all(
|
|
149
|
+
tasks.map(({ root, hookType }) =>
|
|
150
|
+
loadFilesFromDir<Hook>(ctx, path.join(root.path, "hooks", hookType), PROVIDER_ID, root.level, {
|
|
151
|
+
transform: (name, _content, filePath, source) => {
|
|
152
|
+
const baseName = name.includes(".") ? name.slice(0, name.lastIndexOf(".")) : name;
|
|
153
|
+
const tool = baseName === "*" ? "*" : baseName;
|
|
154
|
+
return {
|
|
155
|
+
name,
|
|
156
|
+
path: filePath,
|
|
157
|
+
type: hookType,
|
|
158
|
+
tool,
|
|
159
|
+
level: root.level,
|
|
160
|
+
_source: source,
|
|
161
|
+
};
|
|
162
|
+
},
|
|
163
|
+
}),
|
|
164
|
+
),
|
|
165
|
+
);
|
|
166
|
+
return {
|
|
167
|
+
items: results.flatMap(r => r.items),
|
|
168
|
+
warnings: results.flatMap(r => r.warnings ?? []),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// =============================================================================
|
|
173
|
+
// Custom Tools
|
|
174
|
+
// =============================================================================
|
|
175
|
+
|
|
176
|
+
const TOOL_EXTENSIONS = ["json", "md", "ts", "js", "sh", "bash", "py"];
|
|
177
|
+
|
|
178
|
+
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
|
179
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
180
|
+
const perRoot = await Promise.all(
|
|
181
|
+
roots.map(async root => {
|
|
182
|
+
const toolsDir = path.join(root.path, "tools");
|
|
183
|
+
const [filesResult, entries] = await Promise.all([
|
|
184
|
+
loadFilesFromDir<CustomTool>(ctx, toolsDir, PROVIDER_ID, root.level, {
|
|
185
|
+
extensions: TOOL_EXTENSIONS,
|
|
186
|
+
transform: (name, content, filePath, source) => {
|
|
187
|
+
if (name.endsWith(".json")) {
|
|
188
|
+
const data = tryParseJson<{ name?: string; description?: string }>(content);
|
|
189
|
+
const toolName = data?.name || name.replace(/\.json$/, "");
|
|
190
|
+
const description =
|
|
191
|
+
typeof data?.description === "string" && data.description.trim()
|
|
192
|
+
? data.description
|
|
193
|
+
: `${toolName} custom tool`;
|
|
194
|
+
return { name: toolName, path: filePath, description, level: root.level, _source: source };
|
|
195
|
+
}
|
|
196
|
+
if (name.endsWith(".md")) {
|
|
197
|
+
const { frontmatter } = parseFrontmatter(content, { source: filePath });
|
|
198
|
+
const toolName = (frontmatter.name as string) || name.replace(/\.md$/, "");
|
|
199
|
+
const description =
|
|
200
|
+
typeof frontmatter.description === "string" && frontmatter.description.trim()
|
|
201
|
+
? String(frontmatter.description)
|
|
202
|
+
: `${toolName} custom tool`;
|
|
203
|
+
return { name: toolName, path: filePath, description, level: root.level, _source: source };
|
|
204
|
+
}
|
|
205
|
+
const toolName = name.replace(/\.(ts|js|sh|bash|py)$/, "");
|
|
206
|
+
return {
|
|
207
|
+
name: toolName,
|
|
208
|
+
path: filePath,
|
|
209
|
+
description: `${toolName} custom tool`,
|
|
210
|
+
level: root.level,
|
|
211
|
+
_source: source,
|
|
212
|
+
};
|
|
213
|
+
},
|
|
214
|
+
}),
|
|
215
|
+
readDirEntries(toolsDir),
|
|
216
|
+
]);
|
|
217
|
+
|
|
218
|
+
// `<tools>/<name>/index.ts` sub-directory tools, mirroring `builtin.ts:loadTools`.
|
|
219
|
+
const indexCandidates = entries
|
|
220
|
+
.filter(e => !e.name.startsWith(".") && e.isDirectory())
|
|
221
|
+
.map(e => path.join(toolsDir, e.name, "index.ts"));
|
|
222
|
+
const indexContents = await Promise.all(indexCandidates.map(p => readFile(p)));
|
|
223
|
+
const indexItems: CustomTool[] = [];
|
|
224
|
+
for (let i = 0; i < indexCandidates.length; i++) {
|
|
225
|
+
if (indexContents[i] === null) continue;
|
|
226
|
+
const indexPath = indexCandidates[i];
|
|
227
|
+
const toolName = path.basename(path.dirname(indexPath));
|
|
228
|
+
indexItems.push({
|
|
229
|
+
name: toolName,
|
|
230
|
+
path: indexPath,
|
|
231
|
+
description: `${toolName} custom tool`,
|
|
232
|
+
level: root.level,
|
|
233
|
+
_source: createSourceMeta(PROVIDER_ID, indexPath, root.level),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return { filesResult, indexItems };
|
|
238
|
+
}),
|
|
239
|
+
);
|
|
240
|
+
|
|
241
|
+
const items: CustomTool[] = [];
|
|
242
|
+
const warnings: string[] = [];
|
|
243
|
+
for (const { filesResult, indexItems } of perRoot) {
|
|
244
|
+
items.push(...filesResult.items, ...indexItems);
|
|
245
|
+
if (filesResult.warnings) warnings.push(...filesResult.warnings);
|
|
246
|
+
}
|
|
247
|
+
return { items, warnings };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// =============================================================================
|
|
251
|
+
// MCP Servers
|
|
252
|
+
// =============================================================================
|
|
253
|
+
|
|
254
|
+
const MCP_FILENAMES = [".mcp.json", "mcp.json"] as const;
|
|
255
|
+
|
|
256
|
+
interface RawMcpServer {
|
|
257
|
+
enabled?: boolean;
|
|
258
|
+
timeout?: number;
|
|
259
|
+
command?: string;
|
|
260
|
+
args?: string[];
|
|
261
|
+
env?: Record<string, string>;
|
|
262
|
+
cwd?: string;
|
|
263
|
+
url?: string;
|
|
264
|
+
headers?: Record<string, string>;
|
|
265
|
+
auth?: MCPServer["auth"];
|
|
266
|
+
oauth?: MCPServer["oauth"];
|
|
267
|
+
type?: MCPServer["transport"];
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
|
|
271
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
272
|
+
const items: MCPServer[] = [];
|
|
273
|
+
const warnings: string[] = [];
|
|
274
|
+
|
|
275
|
+
const tasks: Array<{ root: OmpExtensionRoot; mcpPath: string }> = [];
|
|
276
|
+
for (const root of roots) {
|
|
277
|
+
for (const filename of MCP_FILENAMES) {
|
|
278
|
+
tasks.push({ root, mcpPath: path.join(root.path, filename) });
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
const contents = await Promise.all(tasks.map(({ mcpPath }) => readFile(mcpPath)));
|
|
282
|
+
|
|
283
|
+
for (let i = 0; i < tasks.length; i++) {
|
|
284
|
+
const raw = contents[i];
|
|
285
|
+
if (raw === null) continue;
|
|
286
|
+
const { root, mcpPath } = tasks[i];
|
|
287
|
+
|
|
288
|
+
const parsed = tryParseJson<{ mcpServers?: Record<string, unknown> }>(raw);
|
|
289
|
+
if (!parsed) {
|
|
290
|
+
warnings.push(`[omp-plugins] Invalid JSON in ${mcpPath}`);
|
|
291
|
+
logger.warn(`[omp-plugins] Invalid JSON in ${mcpPath}`);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const servers = parsed.mcpServers;
|
|
295
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) continue;
|
|
296
|
+
|
|
297
|
+
for (const [serverName, serverCfg] of Object.entries(servers)) {
|
|
298
|
+
if (!serverCfg || typeof serverCfg !== "object" || Array.isArray(serverCfg)) continue;
|
|
299
|
+
const cfg = serverCfg as RawMcpServer;
|
|
300
|
+
if (typeof cfg.command !== "string" && typeof cfg.url !== "string") {
|
|
301
|
+
warnings.push(`[omp-plugins] Skipping MCP server "${serverName}" in ${mcpPath}: missing command or url`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
items.push({
|
|
305
|
+
name: serverName,
|
|
306
|
+
...(cfg.enabled !== undefined && { enabled: cfg.enabled }),
|
|
307
|
+
...(cfg.timeout !== undefined && { timeout: cfg.timeout }),
|
|
308
|
+
...(cfg.command !== undefined && { command: cfg.command }),
|
|
309
|
+
...(cfg.args !== undefined && { args: cfg.args }),
|
|
310
|
+
...(cfg.env !== undefined && { env: cfg.env }),
|
|
311
|
+
...(cfg.cwd !== undefined && { cwd: cfg.cwd }),
|
|
312
|
+
...(cfg.url !== undefined && { url: cfg.url }),
|
|
313
|
+
...(cfg.headers !== undefined && { headers: cfg.headers }),
|
|
314
|
+
...(cfg.auth !== undefined && { auth: cfg.auth }),
|
|
315
|
+
...(cfg.oauth !== undefined && { oauth: cfg.oauth }),
|
|
316
|
+
...(cfg.type !== undefined && { transport: cfg.type }),
|
|
317
|
+
_source: createSourceMeta(PROVIDER_ID, mcpPath, root.level),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { items, warnings };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// =============================================================================
|
|
326
|
+
// Provider Registration
|
|
327
|
+
// =============================================================================
|
|
328
|
+
|
|
329
|
+
registerProvider<Skill>(skillCapability.id, {
|
|
330
|
+
id: PROVIDER_ID,
|
|
331
|
+
displayName: DISPLAY_NAME,
|
|
332
|
+
description: DESCRIPTION,
|
|
333
|
+
priority: PRIORITY,
|
|
334
|
+
load: loadSkills,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
registerProvider<SlashCommand>(slashCommandCapability.id, {
|
|
338
|
+
id: PROVIDER_ID,
|
|
339
|
+
displayName: DISPLAY_NAME,
|
|
340
|
+
description: DESCRIPTION,
|
|
341
|
+
priority: PRIORITY,
|
|
342
|
+
load: loadSlashCommands,
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
registerProvider<Rule>(ruleCapability.id, {
|
|
346
|
+
id: PROVIDER_ID,
|
|
347
|
+
displayName: DISPLAY_NAME,
|
|
348
|
+
description: DESCRIPTION,
|
|
349
|
+
priority: PRIORITY,
|
|
350
|
+
load: loadRules,
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
registerProvider<Prompt>(promptCapability.id, {
|
|
354
|
+
id: PROVIDER_ID,
|
|
355
|
+
displayName: DISPLAY_NAME,
|
|
356
|
+
description: DESCRIPTION,
|
|
357
|
+
priority: PRIORITY,
|
|
358
|
+
load: loadPrompts,
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
registerProvider<Hook>(hookCapability.id, {
|
|
362
|
+
id: PROVIDER_ID,
|
|
363
|
+
displayName: DISPLAY_NAME,
|
|
364
|
+
description: DESCRIPTION,
|
|
365
|
+
priority: PRIORITY,
|
|
366
|
+
load: loadHooks,
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
registerProvider<CustomTool>(toolCapability.id, {
|
|
370
|
+
id: PROVIDER_ID,
|
|
371
|
+
displayName: DISPLAY_NAME,
|
|
372
|
+
description: DESCRIPTION,
|
|
373
|
+
priority: PRIORITY,
|
|
374
|
+
load: loadTools,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
registerProvider<MCPServer>(mcpCapability.id, {
|
|
378
|
+
id: PROVIDER_ID,
|
|
379
|
+
displayName: DISPLAY_NAME,
|
|
380
|
+
description: DESCRIPTION,
|
|
381
|
+
priority: PRIORITY,
|
|
382
|
+
load: loadMCPServers,
|
|
383
|
+
});
|
|
@@ -9,6 +9,15 @@
|
|
|
9
9
|
* is wiring it onto the per-session owner object.
|
|
10
10
|
*/
|
|
11
11
|
import { InMemorySnapshotStore } from "@oh-my-pi/hashline";
|
|
12
|
+
import { normalizeToLF } from "./normalize";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Upper bound on the file size we snapshot. A section tag is a content hash of
|
|
16
|
+
* the *whole* file, so minting one means holding the full normalized text in
|
|
17
|
+
* the store. Files above this cap emit no `¶path#tag` header — line-anchored
|
|
18
|
+
* editing of multi-megabyte files is out of scope under the full-content model.
|
|
19
|
+
*/
|
|
20
|
+
export const SNAPSHOT_MAX_BYTES = 4 * 1024 * 1024;
|
|
12
21
|
|
|
13
22
|
interface FileSnapshotStoreOwner {
|
|
14
23
|
fileSnapshotStore?: InMemorySnapshotStore;
|
|
@@ -23,3 +32,28 @@ export function getFileSnapshotStore(session: FileSnapshotStoreOwner): InMemoryS
|
|
|
23
32
|
if (!session.fileSnapshotStore) session.fileSnapshotStore = new InMemorySnapshotStore();
|
|
24
33
|
return session.fileSnapshotStore;
|
|
25
34
|
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Read the full text of `absolutePath` (within {@link SNAPSHOT_MAX_BYTES}),
|
|
38
|
+
* record it as a version snapshot, and return its content-hash tag. Returns
|
|
39
|
+
* `undefined` when the file exceeds the cap or cannot be read — callers then
|
|
40
|
+
* omit the section header so the model never sees a tag it can't anchor against.
|
|
41
|
+
*
|
|
42
|
+
* Producers that only displayed a slice of the file (range reads, search hits)
|
|
43
|
+
* use this to mint a whole-file tag: the displayed lines stay partial, but the
|
|
44
|
+
* tag fingerprints the entire file so a follow-up edit anchored at any line
|
|
45
|
+
* validates whenever the live file is byte-identical to what was read.
|
|
46
|
+
*/
|
|
47
|
+
export async function recordFileSnapshot(
|
|
48
|
+
session: FileSnapshotStoreOwner,
|
|
49
|
+
absolutePath: string,
|
|
50
|
+
): Promise<string | undefined> {
|
|
51
|
+
try {
|
|
52
|
+
const file = Bun.file(absolutePath);
|
|
53
|
+
if (file.size > SNAPSHOT_MAX_BYTES) return undefined;
|
|
54
|
+
const normalized = normalizeToLF(await file.text());
|
|
55
|
+
return getFileSnapshotStore(session).record(absolutePath, normalized);
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -44,14 +44,9 @@ function hasAnchorScoped(section: PatchSection): boolean {
|
|
|
44
44
|
return section.hasAnchorScopedEdit;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string
|
|
48
|
-
|
|
49
|
-
for (const lineNumber of anchorLines) {
|
|
50
|
-
if (snapshot.get(lineNumber) === undefined) return false;
|
|
51
|
-
}
|
|
52
|
-
return snapshot.matchesLiveFile(currentText.split("\n"));
|
|
47
|
+
function snapshotMatchesCurrent(snapshot: Snapshot, currentText: string): boolean {
|
|
48
|
+
return snapshot.text === currentText;
|
|
53
49
|
}
|
|
54
|
-
|
|
55
50
|
function validateSectionHash(
|
|
56
51
|
section: PatchSection,
|
|
57
52
|
absolutePath: string,
|
|
@@ -64,7 +59,7 @@ function validateSectionHash(
|
|
|
64
59
|
: null;
|
|
65
60
|
}
|
|
66
61
|
const snapshot = snapshots.byHash(absolutePath, section.fileHash);
|
|
67
|
-
if (snapshot && snapshotMatchesCurrent(snapshot, text
|
|
62
|
+
if (snapshot && snapshotMatchesCurrent(snapshot, text)) return null;
|
|
68
63
|
return `Hashline snapshot tag mismatch for ${section.path}: section is bound to #${section.fileHash}, but current file does not match that snapshot; re-read and try again.`;
|
|
69
64
|
}
|
|
70
65
|
|
package/src/edit/renderer.ts
CHANGED
|
@@ -312,7 +312,7 @@ const MISSING_APPLY_PATCH_END_ERROR = "The last line of the patch must be '*** E
|
|
|
312
312
|
|
|
313
313
|
function normalizeHashlineInputPreviewPath(rawPath: string): string {
|
|
314
314
|
const trimmed = rawPath.trim();
|
|
315
|
-
const hashStart = /#[0-9a-fA-F]{
|
|
315
|
+
const hashStart = /#[0-9a-fA-F]{4}$/u.exec(trimmed)?.index;
|
|
316
316
|
const withoutHash = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
|
|
317
317
|
if (withoutHash.length < 2) return withoutHash;
|
|
318
318
|
const first = withoutHash[0];
|