@oh-my-pi/pi-coding-agent 17.2.10 → 17.2.11
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 +40 -0
- package/dist/{CHANGELOG-rhwzpexa.md → CHANGELOG-d9xenpn9.md} +40 -0
- package/dist/cli.js +3025 -3016
- package/dist/types/capability/mcp.d.ts +11 -0
- package/dist/types/capability/skill.d.ts +6 -0
- package/dist/types/cli/command-help.d.ts +3 -0
- package/dist/types/commands/share.d.ts +25 -0
- package/dist/types/commit/agentic/index.d.ts +3 -1
- package/dist/types/commit/execute.d.ts +32 -0
- package/dist/types/commit/index.d.ts +2 -1
- package/dist/types/commit/pipeline.d.ts +7 -1
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/settings-schema.d.ts +1 -34
- package/dist/types/discovery/agent-plugin-format.d.ts +143 -0
- package/dist/types/discovery/agent-plugins.d.ts +1 -0
- package/dist/types/discovery/contained-path.d.ts +34 -0
- package/dist/types/discovery/index.d.ts +1 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/loader.d.ts +7 -0
- package/dist/types/extensibility/extensions/types.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +3 -2
- package/dist/types/extensibility/skills.d.ts +5 -0
- package/dist/types/mcp/transports/header-policy.d.ts +45 -0
- package/dist/types/mcp/types.d.ts +15 -0
- package/dist/types/modes/components/agent-hub.d.ts +2 -0
- package/dist/types/modes/components/agent-transcript-viewer.d.ts +2 -0
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +1 -2
- package/dist/types/modes/components/status-line/types.d.ts +2 -0
- package/dist/types/modes/components/tool-execution.d.ts +2 -0
- package/dist/types/modes/utils/transcript-render-helpers.d.ts +3 -2
- package/dist/types/secrets/index.d.ts +23 -1
- package/dist/types/session/agent-session-events.d.ts +2 -2
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/messages.d.ts +1 -0
- package/dist/types/session/prewalk.d.ts +3 -1
- package/dist/types/session/session-tools.d.ts +8 -0
- package/dist/types/session/turn-recovery.d.ts +7 -2
- package/dist/types/task/index.d.ts +2 -0
- package/dist/types/tools/shell-tokenize.d.ts +16 -0
- package/package.json +13 -13
- package/scripts/legacy-pi-virtual-module.ts +4 -1
- package/src/capability/mcp.ts +11 -0
- package/src/capability/skill.ts +6 -0
- package/src/cli/command-help.ts +4 -0
- package/src/cli/gallery-cli.ts +1 -1
- package/src/cli-commands.ts +5 -0
- package/src/commands/commit.ts +16 -3
- package/src/commands/share.ts +71 -0
- package/src/commit/agentic/index.ts +39 -21
- package/src/commit/execute.ts +56 -0
- package/src/commit/index.ts +2 -1
- package/src/commit/pipeline.ts +20 -7
- package/src/config/model-discovery.ts +1 -1
- package/src/config/model-registry.ts +21 -1
- package/src/config/settings-schema.ts +2 -33
- package/src/config/settings.ts +46 -0
- package/src/discovery/agent-plugin-format.ts +551 -0
- package/src/discovery/agent-plugins.ts +341 -0
- package/src/discovery/claude-plugins.ts +21 -5
- package/src/discovery/contained-path.ts +78 -0
- package/src/discovery/helpers.ts +23 -9
- package/src/discovery/index.ts +1 -0
- package/src/discovery/omp-plugins.ts +20 -7
- package/src/exec/non-interactive-env.ts +1 -0
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/loader.ts +78 -14
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +12 -0
- package/src/extensibility/plugins/marketplace/manager.ts +2 -1
- package/src/extensibility/shared-events.ts +3 -2
- package/src/extensibility/skills.ts +9 -0
- package/src/internal-urls/skill-protocol.ts +14 -0
- package/src/internal-urls/vault-protocol.ts +2 -2
- package/src/launch/client.ts +11 -1
- package/src/launch/protocol.ts +7 -2
- package/src/main.ts +3 -2
- package/src/mcp/config.ts +3 -0
- package/src/mcp/manager.ts +12 -9
- package/src/mcp/transports/header-policy.ts +95 -0
- package/src/mcp/transports/http.ts +35 -52
- package/src/mcp/transports/sse.ts +20 -27
- package/src/mcp/types.ts +15 -0
- package/src/modes/acp/acp-agent.ts +15 -4
- package/src/modes/components/agent-dashboard.ts +2 -0
- package/src/modes/components/agent-hub.ts +5 -0
- package/src/modes/components/agent-transcript-viewer.ts +3 -0
- package/src/modes/components/chat-transcript-builder.ts +3 -0
- package/src/modes/components/custom-editor.ts +0 -9
- package/src/modes/components/status-line/component.ts +1 -0
- package/src/modes/components/status-line/segments.ts +6 -4
- package/src/modes/components/status-line/types.ts +2 -0
- package/src/modes/components/tool-execution.ts +13 -16
- package/src/modes/components/tree-selector.ts +62 -3
- package/src/modes/controllers/command-controller.ts +4 -1
- package/src/modes/controllers/event-controller.ts +15 -15
- package/src/modes/controllers/input-controller.ts +20 -2
- package/src/modes/controllers/selector-controller.ts +1 -0
- package/src/modes/utils/transcript-render-helpers.ts +4 -2
- package/src/modes/utils/ui-helpers.ts +1 -0
- package/src/sdk.ts +6 -49
- package/src/secrets/index.ts +52 -1
- package/src/session/agent-session-events.ts +2 -2
- package/src/session/agent-session.ts +33 -14
- package/src/session/messages.ts +1 -0
- package/src/session/prewalk.ts +57 -17
- package/src/session/session-context.ts +3 -5
- package/src/session/session-handoff.ts +5 -1
- package/src/session/session-tools.ts +37 -3
- package/src/session/todo-tracker.ts +11 -1
- package/src/session/turn-recovery.ts +94 -72
- package/src/slash-commands/builtin-registry.ts +12 -8
- package/src/task/index.ts +21 -6
- package/src/tools/bash-skill-urls.ts +15 -0
- package/src/tools/bash.ts +10 -11
- package/src/tools/debug.ts +1 -1
- package/src/tools/jtd-to-json-schema.ts +123 -21
- package/src/tools/read.ts +5 -4
- package/src/tools/shell-tokenize.ts +98 -0
- package/src/web/search/providers/exa.ts +1 -1
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Plugins discovery provider (https://agent-plugins.org).
|
|
3
|
+
*
|
|
4
|
+
* Loads the two portable component types defined by Agent Plugins 1.0.0 from
|
|
5
|
+
* plugin roots whose root `plugin.json` targets the standard:
|
|
6
|
+
*
|
|
7
|
+
* - Skills: immediate children of `skills/` containing a regular `SKILL.md`
|
|
8
|
+
* (spec §7.1), validated against the Agent Skills conventions.
|
|
9
|
+
* - MCP servers: the closed `mcp.json` document at the plugin root (spec §7.2),
|
|
10
|
+
* with `${PLUGIN_ROOT}`/`${PLUGIN_DATA}` expansion and reserved subprocess
|
|
11
|
+
* environment variables (spec §9).
|
|
12
|
+
*
|
|
13
|
+
* Roots come from the shared plugin registries (marketplace installs,
|
|
14
|
+
* `--plugin-dir`) and from configured extension packages. Legacy providers
|
|
15
|
+
* skip roots governed by this standard via `legacyProviderAllowed`.
|
|
16
|
+
*/
|
|
17
|
+
import type { Dirent, Stats } from "node:fs";
|
|
18
|
+
import * as fs from "node:fs/promises";
|
|
19
|
+
import * as path from "node:path";
|
|
20
|
+
import { getPluginsDir, isEnoent, normalizeFrontmatterKeys, parseFrontmatter } from "@oh-my-pi/pi-utils";
|
|
21
|
+
import { registerProvider } from "../capability";
|
|
22
|
+
import { readFile } from "../capability/fs";
|
|
23
|
+
import { type MCPServer, mcpCapability } from "../capability/mcp";
|
|
24
|
+
import { type Skill, type SkillFrontmatter, skillCapability } from "../capability/skill";
|
|
25
|
+
import type { LoadContext, LoadResult } from "../capability/types";
|
|
26
|
+
import {
|
|
27
|
+
type AgentPluginManifest,
|
|
28
|
+
classifyAgentPluginRoot,
|
|
29
|
+
parseAgentPluginMcp,
|
|
30
|
+
validateAgentSkillFrontmatter,
|
|
31
|
+
} from "./agent-plugin-format";
|
|
32
|
+
import { resolveContainedPath } from "./contained-path";
|
|
33
|
+
import { compareSkillOrder, createSourceMeta, listClaudePluginRoots } from "./helpers";
|
|
34
|
+
import { listOmpExtensionRoots } from "./omp-extension-roots";
|
|
35
|
+
|
|
36
|
+
const PROVIDER_ID = "agent-plugins";
|
|
37
|
+
const DISPLAY_NAME = "Agent Plugins";
|
|
38
|
+
const DESCRIPTION = "Portable Agent Plugins packages (plugin.json, skills/, mcp.json) per agent-plugins.org";
|
|
39
|
+
// Above claude-plugins (70) so the standard's semantics win for packages that
|
|
40
|
+
// declare it; below claude.ts (80) so user-level .claude/ overrides still apply.
|
|
41
|
+
const PRIORITY = 75;
|
|
42
|
+
|
|
43
|
+
interface CandidateRoot {
|
|
44
|
+
path: string;
|
|
45
|
+
level: "user" | "project";
|
|
46
|
+
/**
|
|
47
|
+
* Stable identity of this installed plugin instance (§9.1). Registry
|
|
48
|
+
* installs key on plugin id + scope so the persistent data directory
|
|
49
|
+
* survives version-path changes across updates; local `--plugin-dir` and
|
|
50
|
+
* configured extension roots key on their configured directory.
|
|
51
|
+
*/
|
|
52
|
+
instanceKey: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Every directory that may contain an Agent Plugin: marketplace and
|
|
57
|
+
* `--plugin-dir` roots from the shared registries, plus configured extension
|
|
58
|
+
* package roots. First occurrence wins on duplicates.
|
|
59
|
+
*/
|
|
60
|
+
async function listCandidateRoots(ctx: LoadContext): Promise<CandidateRoot[]> {
|
|
61
|
+
const [marketplace, extensionRoots] = await Promise.all([
|
|
62
|
+
listClaudePluginRoots(ctx.home, ctx.cwd),
|
|
63
|
+
listOmpExtensionRoots(ctx),
|
|
64
|
+
]);
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
const candidates: CandidateRoot[] = [];
|
|
67
|
+
for (const root of marketplace.roots) {
|
|
68
|
+
if (seen.has(root.path)) continue;
|
|
69
|
+
seen.add(root.path);
|
|
70
|
+
candidates.push({
|
|
71
|
+
path: root.path,
|
|
72
|
+
level: root.scope,
|
|
73
|
+
instanceKey: root.marketplace === "__local__" ? `dir:${root.path}` : `${root.id}#${root.scope}`,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const root of extensionRoots) {
|
|
77
|
+
if (seen.has(root.path)) continue;
|
|
78
|
+
seen.add(root.path);
|
|
79
|
+
candidates.push({ path: root.path, level: root.level, instanceKey: `ext:${root.path}` });
|
|
80
|
+
}
|
|
81
|
+
return candidates;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Client-managed persistent data directory dedicated to one installed plugin
|
|
86
|
+
* instance (§9.1). The manifest name keeps it readable; the SHA-256 digest of
|
|
87
|
+
* the instance identity keeps same-name installs (different marketplaces,
|
|
88
|
+
* scopes, or local directories) from aliasing each other's state.
|
|
89
|
+
*/
|
|
90
|
+
function pluginDataDir(home: string, manifestName: string, instanceKey: string): string {
|
|
91
|
+
const digest = new Bun.CryptoHasher("sha256").update(instanceKey).digest("hex").slice(0, 16);
|
|
92
|
+
return path.join(getPluginsDir(home), "data", `${manifestName}-${digest}`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// =============================================================================
|
|
96
|
+
// Skills
|
|
97
|
+
// =============================================================================
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Discover skills per spec §7.1: each immediate child of `skills/` whose
|
|
101
|
+
* `SKILL.md` resolves to a regular file inside the plugin root is one skill;
|
|
102
|
+
* deeper descendants are never searched. Invalid skills are skipped with a
|
|
103
|
+
* warning while the rest keep loading.
|
|
104
|
+
*/
|
|
105
|
+
async function scanStandardSkills(realRoot: string, level: "user" | "project"): Promise<LoadResult<Skill>> {
|
|
106
|
+
const items: Skill[] = [];
|
|
107
|
+
const warnings: string[] = [];
|
|
108
|
+
|
|
109
|
+
// §4.1: resolve the fixed location and prove containment BEFORE listing it,
|
|
110
|
+
// so a symlinked skills/ can never lead the scan outside the package.
|
|
111
|
+
const skillsDir = await resolveContainedPath(realRoot, path.join(realRoot, "skills"));
|
|
112
|
+
// §6.2: an absent fixed location is not an error.
|
|
113
|
+
if (skillsDir.status === "missing") return { items, warnings };
|
|
114
|
+
if (skillsDir.status === "outside") {
|
|
115
|
+
warnings.push(`skills/ resolves outside the plugin root`);
|
|
116
|
+
return { items, warnings };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let entries: Dirent[];
|
|
120
|
+
try {
|
|
121
|
+
entries = await fs.readdir(skillsDir.realPath, { withFileTypes: true });
|
|
122
|
+
} catch {
|
|
123
|
+
// §6.2: a present location of the wrong filesystem kind invalidates only
|
|
124
|
+
// this component type.
|
|
125
|
+
warnings.push(`skills/ does not resolve to a directory`);
|
|
126
|
+
return { items, warnings };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
await Promise.all(
|
|
130
|
+
entries.map(async entry => {
|
|
131
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) return;
|
|
132
|
+
// Resolve and contain each SKILL.md before any stat/read can follow a
|
|
133
|
+
// symlink out of the package (§4.1 failure boundary 3).
|
|
134
|
+
const resolved = await resolveContainedPath(realRoot, path.join(skillsDir.realPath, entry.name, "SKILL.md"));
|
|
135
|
+
if (resolved.status === "missing") return; // no SKILL.md → not a skill directory
|
|
136
|
+
if (resolved.status === "outside") {
|
|
137
|
+
warnings.push(`Skipping skill "${entry.name}": SKILL.md resolves outside the plugin root`);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const skillPath = resolved.realPath;
|
|
141
|
+
let stat: Stats;
|
|
142
|
+
try {
|
|
143
|
+
stat = await fs.stat(skillPath);
|
|
144
|
+
} catch {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!stat.isFile()) return;
|
|
148
|
+
const content = await readFile(skillPath);
|
|
149
|
+
if (content === null) {
|
|
150
|
+
warnings.push(`Skipping skill "${entry.name}": failed to read SKILL.md`);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Strict parse: malformed YAML must be rejected, not repaired — no
|
|
154
|
+
// scalar quoting, tab replacement, comment stripping, or key aliasing.
|
|
155
|
+
let rawFrontmatter: Record<string, unknown>;
|
|
156
|
+
let body: string;
|
|
157
|
+
try {
|
|
158
|
+
({ frontmatter: rawFrontmatter, body } = parseFrontmatter(content, {
|
|
159
|
+
source: skillPath,
|
|
160
|
+
level: "fatal",
|
|
161
|
+
repair: false,
|
|
162
|
+
rawKeys: true,
|
|
163
|
+
}));
|
|
164
|
+
} catch {
|
|
165
|
+
warnings.push(`Skipping skill "${entry.name}": malformed YAML frontmatter`);
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// §7.1: the Agent Skills specification is the source of truth for skill
|
|
169
|
+
// validity; the frontmatter schema is closed per skills-ref, so client
|
|
170
|
+
// conventions like `enabled` reject the skill as an unexpected field.
|
|
171
|
+
// Non-conforming skills are skipped without affecting other components.
|
|
172
|
+
const violation = validateAgentSkillFrontmatter(rawFrontmatter, entry.name);
|
|
173
|
+
if (violation !== null) {
|
|
174
|
+
warnings.push(`Skipping skill "${entry.name}": ${violation}`);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// Validation guarantees the frontmatter name matches the directory
|
|
178
|
+
// (NFKC-normalized), so the on-disk directory name is the identity.
|
|
179
|
+
// Store with the codebase's camelCase key convention (skill:// consumers,
|
|
180
|
+
// prompt hiding via disableModelInvocation, …).
|
|
181
|
+
const frontmatter = normalizeFrontmatterKeys(rawFrontmatter) as SkillFrontmatter;
|
|
182
|
+
items.push({
|
|
183
|
+
name: entry.name,
|
|
184
|
+
containRoot: realRoot,
|
|
185
|
+
path: skillPath,
|
|
186
|
+
content: body,
|
|
187
|
+
frontmatter,
|
|
188
|
+
level,
|
|
189
|
+
_source: createSourceMeta(PROVIDER_ID, skillPath, level),
|
|
190
|
+
});
|
|
191
|
+
}),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
items.sort((a, b) => compareSkillOrder(a.name, a.path, b.name, b.path));
|
|
195
|
+
return { items, warnings };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
199
|
+
const candidates = await listCandidateRoots(ctx);
|
|
200
|
+
const results = await Promise.all(
|
|
201
|
+
candidates.map(async (candidate): Promise<LoadResult<Skill>> => {
|
|
202
|
+
const status = await classifyAgentPluginRoot(candidate.path);
|
|
203
|
+
if (status.kind === "none") return { items: [] };
|
|
204
|
+
if (status.kind === "invalid") {
|
|
205
|
+
// Fatal manifest violations reject the whole plugin (spec §5.2);
|
|
206
|
+
// reported once here rather than from every capability loader.
|
|
207
|
+
return { items: [], warnings: [`[agent-plugins] Rejected plugin at ${candidate.path}: ${status.reason}`] };
|
|
208
|
+
}
|
|
209
|
+
const scan = await scanStandardSkills(status.realRoot, candidate.level);
|
|
210
|
+
return {
|
|
211
|
+
items: scan.items,
|
|
212
|
+
warnings: [...status.warnings, ...(scan.warnings ?? [])].map(
|
|
213
|
+
warning => `[agent-plugins] ${status.manifest.name}: ${warning}`,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
return {
|
|
219
|
+
items: results.flatMap(result => result.items),
|
|
220
|
+
warnings: results.flatMap(result => result.warnings ?? []),
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// =============================================================================
|
|
225
|
+
// MCP Servers
|
|
226
|
+
// =============================================================================
|
|
227
|
+
|
|
228
|
+
async function loadPluginMCPServers(
|
|
229
|
+
realRoot: string,
|
|
230
|
+
manifest: AgentPluginManifest,
|
|
231
|
+
candidate: CandidateRoot,
|
|
232
|
+
home: string,
|
|
233
|
+
): Promise<LoadResult<MCPServer>> {
|
|
234
|
+
const items: MCPServer[] = [];
|
|
235
|
+
const warnings: string[] = [];
|
|
236
|
+
// §4.1: resolve and contain mcp.json before any stat/read can follow a
|
|
237
|
+
// symlink out of the package (failure boundary 2).
|
|
238
|
+
const resolved = await resolveContainedPath(realRoot, path.join(realRoot, "mcp.json"));
|
|
239
|
+
if (resolved.status === "missing") return { items, warnings };
|
|
240
|
+
if (resolved.status === "outside") {
|
|
241
|
+
warnings.push(`mcp.json resolves outside the plugin root`);
|
|
242
|
+
return { items, warnings };
|
|
243
|
+
}
|
|
244
|
+
const mcpPath = resolved.realPath;
|
|
245
|
+
|
|
246
|
+
let stat: Stats;
|
|
247
|
+
try {
|
|
248
|
+
stat = await fs.stat(mcpPath);
|
|
249
|
+
} catch (err) {
|
|
250
|
+
if (!isEnoent(err)) warnings.push(`Failed to read mcp.json: ${String(err)}`);
|
|
251
|
+
return { items, warnings };
|
|
252
|
+
}
|
|
253
|
+
if (!stat.isFile()) {
|
|
254
|
+
warnings.push(`mcp.json does not resolve to a regular file`);
|
|
255
|
+
return { items, warnings };
|
|
256
|
+
}
|
|
257
|
+
const raw = await readFile(mcpPath);
|
|
258
|
+
if (raw === null) {
|
|
259
|
+
warnings.push(`Failed to read mcp.json`);
|
|
260
|
+
return { items, warnings };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Client-managed persistent data directory dedicated to this installed
|
|
264
|
+
// plugin instance; contents survive plugin updates (spec §9.1).
|
|
265
|
+
const pluginData = pluginDataDir(home, manifest.name, candidate.instanceKey);
|
|
266
|
+
const result = await parseAgentPluginMcp(raw, { pluginRoot: realRoot, pluginData });
|
|
267
|
+
if (result.status === "disabled") {
|
|
268
|
+
warnings.push(`MCP disabled: ${result.reason}`);
|
|
269
|
+
return { items, warnings };
|
|
270
|
+
}
|
|
271
|
+
warnings.push(...result.warnings);
|
|
272
|
+
|
|
273
|
+
if (result.servers.some(server => server.transport === "stdio")) {
|
|
274
|
+
// §9.1: the data directory must exist and be writable before any plugin
|
|
275
|
+
// subprocess launches.
|
|
276
|
+
await fs.mkdir(pluginData, { recursive: true });
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const server of result.servers) {
|
|
280
|
+
items.push({
|
|
281
|
+
// Namespace by plugin name so servers from different plugins cannot
|
|
282
|
+
// collide in the capability registry (matches claude-plugins).
|
|
283
|
+
name: `${manifest.name}:${server.name}`,
|
|
284
|
+
transport: server.transport,
|
|
285
|
+
...(server.command !== undefined && { command: server.command }),
|
|
286
|
+
...(server.args !== undefined && { args: server.args }),
|
|
287
|
+
...(server.env !== undefined && { env: server.env }),
|
|
288
|
+
// §9.2: env values are literal after the provider's ${PLUGIN_ROOT} /
|
|
289
|
+
// ${PLUGIN_DATA} expansion — exempt from any further resolution.
|
|
290
|
+
...(server.command !== undefined && { envPolicy: "literal" as const }),
|
|
291
|
+
...(server.cwd !== undefined && { cwd: server.cwd }),
|
|
292
|
+
...(server.url !== undefined && { url: server.url }),
|
|
293
|
+
...(server.headers !== undefined && { headers: server.headers }),
|
|
294
|
+
// §7.2.1: configured headers are literal, origin-locked package data —
|
|
295
|
+
// never expanded and never forwarded to a different origin.
|
|
296
|
+
...(server.url !== undefined && { headerPolicy: "origin-locked" as const }),
|
|
297
|
+
_source: createSourceMeta(PROVIDER_ID, mcpPath, candidate.level),
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
return { items, warnings };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
|
|
304
|
+
const candidates = await listCandidateRoots(ctx);
|
|
305
|
+
const results = await Promise.all(
|
|
306
|
+
candidates.map(async (candidate): Promise<LoadResult<MCPServer>> => {
|
|
307
|
+
const status = await classifyAgentPluginRoot(candidate.path);
|
|
308
|
+
// Invalid roots are rejected and reported by the skills loader.
|
|
309
|
+
if (status.kind !== "standard") return { items: [] };
|
|
310
|
+
const loaded = await loadPluginMCPServers(status.realRoot, status.manifest, candidate, ctx.home);
|
|
311
|
+
return {
|
|
312
|
+
items: loaded.items,
|
|
313
|
+
warnings: (loaded.warnings ?? []).map(warning => `[agent-plugins] ${status.manifest.name}: ${warning}`),
|
|
314
|
+
};
|
|
315
|
+
}),
|
|
316
|
+
);
|
|
317
|
+
return {
|
|
318
|
+
items: results.flatMap(result => result.items),
|
|
319
|
+
warnings: results.flatMap(result => result.warnings ?? []),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// =============================================================================
|
|
324
|
+
// Provider Registration
|
|
325
|
+
// =============================================================================
|
|
326
|
+
|
|
327
|
+
registerProvider<Skill>(skillCapability.id, {
|
|
328
|
+
id: PROVIDER_ID,
|
|
329
|
+
displayName: DISPLAY_NAME,
|
|
330
|
+
description: DESCRIPTION,
|
|
331
|
+
priority: PRIORITY,
|
|
332
|
+
load: loadSkills,
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
registerProvider<MCPServer>(mcpCapability.id, {
|
|
336
|
+
id: PROVIDER_ID,
|
|
337
|
+
displayName: DISPLAY_NAME,
|
|
338
|
+
description: DESCRIPTION,
|
|
339
|
+
priority: PRIORITY,
|
|
340
|
+
load: loadMCPServers,
|
|
341
|
+
});
|
|
@@ -15,6 +15,7 @@ import { type Skill, skillCapability } from "../capability/skill";
|
|
|
15
15
|
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
|
|
16
16
|
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
17
17
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
18
|
+
import { legacyProviderAllowed } from "./agent-plugin-format";
|
|
18
19
|
import {
|
|
19
20
|
type ClaudePluginRoot,
|
|
20
21
|
createSourceMeta,
|
|
@@ -30,6 +31,21 @@ const PROVIDER_ID = "claude-plugins";
|
|
|
30
31
|
const DISPLAY_NAME = "Claude Code Marketplace";
|
|
31
32
|
const PRIORITY = 70; // Below claude.ts (80) so user .claude/ overrides win
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Plugin roots this legacy provider may process for a given surface. Roots
|
|
36
|
+
* whose root `plugin.json` targets the Agent Plugins standard keep their
|
|
37
|
+
* portable components (skills, MCP) exclusive to the `agent-plugins` provider;
|
|
38
|
+
* fatally invalid Agent Plugins packages are skipped entirely.
|
|
39
|
+
*/
|
|
40
|
+
async function allowedRoots(
|
|
41
|
+
ctx: LoadContext,
|
|
42
|
+
surface: "skills" | "mcp" | "other",
|
|
43
|
+
): Promise<{ roots: ClaudePluginRoot[]; warnings: string[] }> {
|
|
44
|
+
const { roots, warnings } = await listClaudePluginRoots(ctx.home, ctx.cwd);
|
|
45
|
+
const flags = await Promise.all(roots.map(root => legacyProviderAllowed(root.path, surface)));
|
|
46
|
+
return { roots: roots.filter((_, i) => flags[i]), warnings };
|
|
47
|
+
}
|
|
48
|
+
|
|
33
49
|
interface ClaudePluginManifest {
|
|
34
50
|
skills?: string | string[];
|
|
35
51
|
"slash-commands"?: string | string[];
|
|
@@ -188,7 +204,7 @@ async function resolvePluginDir(
|
|
|
188
204
|
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
189
205
|
const items: Skill[] = [];
|
|
190
206
|
const warnings: string[] = [];
|
|
191
|
-
const { roots, warnings: rootWarnings } = await
|
|
207
|
+
const { roots, warnings: rootWarnings } = await allowedRoots(ctx, "skills");
|
|
192
208
|
warnings.push(...rootWarnings);
|
|
193
209
|
const results = await Promise.all(
|
|
194
210
|
roots.map(async root => {
|
|
@@ -235,7 +251,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
|
|
235
251
|
const items: SlashCommand[] = [];
|
|
236
252
|
const warnings: string[] = [];
|
|
237
253
|
|
|
238
|
-
const { roots, warnings: rootWarnings } = await
|
|
254
|
+
const { roots, warnings: rootWarnings } = await allowedRoots(ctx, "other");
|
|
239
255
|
warnings.push(...rootWarnings);
|
|
240
256
|
|
|
241
257
|
const results = await Promise.all(
|
|
@@ -309,7 +325,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
|
309
325
|
const items: Hook[] = [];
|
|
310
326
|
const warnings: string[] = [];
|
|
311
327
|
|
|
312
|
-
const { roots, warnings: rootWarnings } = await
|
|
328
|
+
const { roots, warnings: rootWarnings } = await allowedRoots(ctx, "other");
|
|
313
329
|
warnings.push(...rootWarnings);
|
|
314
330
|
|
|
315
331
|
const hookTypes = ["pre", "post"] as const;
|
|
@@ -356,7 +372,7 @@ async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
|
|
356
372
|
const items: CustomTool[] = [];
|
|
357
373
|
const warnings: string[] = [];
|
|
358
374
|
|
|
359
|
-
const { roots, warnings: rootWarnings } = await
|
|
375
|
+
const { roots, warnings: rootWarnings } = await allowedRoots(ctx, "other");
|
|
360
376
|
warnings.push(...rootWarnings);
|
|
361
377
|
|
|
362
378
|
const results = await Promise.all(
|
|
@@ -482,7 +498,7 @@ async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>>
|
|
|
482
498
|
const items: MCPServer[] = [];
|
|
483
499
|
const warnings: string[] = [];
|
|
484
500
|
|
|
485
|
-
const { roots, warnings: rootWarnings } = await
|
|
501
|
+
const { roots, warnings: rootWarnings } = await allowedRoots(ctx, "mcp");
|
|
486
502
|
warnings.push(...rootWarnings);
|
|
487
503
|
|
|
488
504
|
for (const root of roots) {
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Package-boundary containment for plugin directories (Agent Plugins §4.1).
|
|
3
|
+
*
|
|
4
|
+
* A file or directory supplied by a plugin package may only be discovered,
|
|
5
|
+
* read, or executed when its filesystem-resolved path stays within the
|
|
6
|
+
* filesystem-resolved plugin root. These helpers resolve paths WITHOUT reading
|
|
7
|
+
* them, so callers can prove containment before any read, readdir, or
|
|
8
|
+
* symlink-following stat touches content outside the package.
|
|
9
|
+
*
|
|
10
|
+
* Shared by the Agent Plugins format module and the plugin-root helpers;
|
|
11
|
+
* imports nothing from either to stay cycle-free.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
|
|
16
|
+
/** Lexical containment: `target` is `base` itself or a descendant of it. */
|
|
17
|
+
export function isContained(base: string, target: string): boolean {
|
|
18
|
+
const relative = path.relative(base, target);
|
|
19
|
+
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Resolve symlinks and equivalents; `null` when the path is missing or unresolvable. */
|
|
23
|
+
export async function realpathIfExists(p: string): Promise<string | null> {
|
|
24
|
+
try {
|
|
25
|
+
return await fs.promises.realpath(p);
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Outcome of resolving a fixed package path without reading it. */
|
|
32
|
+
export type ContainedPathResolution =
|
|
33
|
+
| { status: "missing" }
|
|
34
|
+
| { status: "outside" }
|
|
35
|
+
| { status: "ok"; realPath: string };
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve a fixed package path WITHOUT reading it: symlinks and equivalent
|
|
39
|
+
* mechanisms are resolved first, and a path escaping the filesystem-resolved
|
|
40
|
+
* plugin root is rejected before any I/O could consume outside content.
|
|
41
|
+
*/
|
|
42
|
+
export async function resolveContainedPath(realBase: string, target: string): Promise<ContainedPathResolution> {
|
|
43
|
+
if (!isContained(realBase, target)) return { status: "outside" };
|
|
44
|
+
const real = await realpathIfExists(target);
|
|
45
|
+
if (real === null) return { status: "missing" };
|
|
46
|
+
return isContained(realBase, real) ? { status: "ok", realPath: real } : { status: "outside" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verify a package path stays within the filesystem-resolved plugin root:
|
|
51
|
+
* lexically, and — when the target exists — after resolving symlinks. Unlike
|
|
52
|
+
* {@link resolveContainedPath}, a missing target passes. ONLY for configured
|
|
53
|
+
* paths that are validated but never read or executed at load time (stdio
|
|
54
|
+
* `command`, `cwd` — including `${PLUGIN_DATA}` paths created after
|
|
55
|
+
* validation); access paths MUST use {@link resolveContainedPath} instead,
|
|
56
|
+
* which fails closed on unresolvable targets.
|
|
57
|
+
*/
|
|
58
|
+
export async function isContainedResolved(realBase: string, target: string): Promise<boolean> {
|
|
59
|
+
if (!isContained(realBase, target)) return false;
|
|
60
|
+
const real = await realpathIfExists(target);
|
|
61
|
+
return real === null || isContained(realBase, real);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Sync variant of {@link resolveContainedPath} for synchronous callsites
|
|
66
|
+
* (bash `skill://` expansion).
|
|
67
|
+
*/
|
|
68
|
+
export function resolveContainedPathSync(realBase: string, target: string): ContainedPathResolution {
|
|
69
|
+
if (!isContained(realBase, target)) return { status: "outside" };
|
|
70
|
+
let real: string | null;
|
|
71
|
+
try {
|
|
72
|
+
real = fs.realpathSync(target);
|
|
73
|
+
} catch {
|
|
74
|
+
real = null;
|
|
75
|
+
}
|
|
76
|
+
if (real === null) return { status: "missing" };
|
|
77
|
+
return isContained(realBase, real) ? { status: "ok", realPath: real } : { status: "outside" };
|
|
78
|
+
}
|
package/src/discovery/helpers.ts
CHANGED
|
@@ -20,6 +20,7 @@ import type { MCPRequestIdFormat } from "../mcp/types";
|
|
|
20
20
|
import { type ConfiguredThinkingLevel, parseConfiguredThinkingLevel } from "../thinking";
|
|
21
21
|
import { normalizeToolNames } from "../tools/builtin-names";
|
|
22
22
|
|
|
23
|
+
import { realpathIfExists, resolveContainedPath } from "./contained-path";
|
|
23
24
|
import { buildPluginDirRoot } from "./plugin-dir-roots";
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -1141,17 +1142,30 @@ export async function injectPluginDirRoots(home: string, dirs: string[], cwd?: s
|
|
|
1141
1142
|
const injected: ClaudePluginRoot[] = [];
|
|
1142
1143
|
for (const dir of dirs) {
|
|
1143
1144
|
const resolved = path.resolve(dir);
|
|
1144
|
-
// Read plugin name from manifest
|
|
1145
|
+
// Read plugin name from manifest: Claude marketplace layout first, then
|
|
1146
|
+
// the Agent Plugins standard root manifest (agent-plugins.org). Each
|
|
1147
|
+
// manifest is resolved and proven inside the plugin directory BEFORE the
|
|
1148
|
+
// read (Agent Plugins §4.1) — an escaping symlink falls back to the
|
|
1149
|
+
// directory basename without consuming outside content.
|
|
1145
1150
|
let pluginName = path.basename(resolved);
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
const
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1151
|
+
const realRoot = await realpathIfExists(resolved);
|
|
1152
|
+
if (realRoot !== null) {
|
|
1153
|
+
for (const manifestPath of [
|
|
1154
|
+
path.join(realRoot, ".claude-plugin", "plugin.json"),
|
|
1155
|
+
path.join(realRoot, "plugin.json"),
|
|
1156
|
+
]) {
|
|
1157
|
+
const contained = await resolveContainedPath(realRoot, manifestPath);
|
|
1158
|
+
if (contained.status !== "ok") continue;
|
|
1159
|
+
try {
|
|
1160
|
+
const manifest = await Bun.file(contained.realPath).json();
|
|
1161
|
+
if (typeof manifest?.name === "string" && manifest.name) {
|
|
1162
|
+
pluginName = manifest.name;
|
|
1163
|
+
break;
|
|
1164
|
+
}
|
|
1165
|
+
} catch {
|
|
1166
|
+
// Invalid manifest — try next, fall back to directory name
|
|
1167
|
+
}
|
|
1152
1168
|
}
|
|
1153
|
-
} catch {
|
|
1154
|
-
// No manifest or invalid — use directory name
|
|
1155
1169
|
}
|
|
1156
1170
|
|
|
1157
1171
|
injected.push(buildPluginDirRoot(resolved, pluginName));
|
package/src/discovery/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ import "../capability/ssh";
|
|
|
20
20
|
import "../capability/system-prompt";
|
|
21
21
|
import "../capability/tool";
|
|
22
22
|
// Import providers (each registers itself on import)
|
|
23
|
+
import "./agent-plugins";
|
|
23
24
|
import "./agents-md";
|
|
24
25
|
import "./builtin";
|
|
25
26
|
import "./builtin-defaults";
|
|
@@ -28,6 +28,7 @@ import { type Skill, skillCapability } from "../capability/skill";
|
|
|
28
28
|
import { type SlashCommand, slashCommandCapability } from "../capability/slash-command";
|
|
29
29
|
import { type CustomTool, toolCapability } from "../capability/tool";
|
|
30
30
|
import type { LoadContext, LoadResult } from "../capability/types";
|
|
31
|
+
import { legacyProviderAllowed } from "./agent-plugin-format";
|
|
31
32
|
import {
|
|
32
33
|
buildRuleFromMarkdown,
|
|
33
34
|
createSourceMeta,
|
|
@@ -44,12 +45,24 @@ const DESCRIPTION =
|
|
|
44
45
|
"Sub-discovery (skills, hooks, tools, commands, rules, prompts, .mcp.json) inside extension packages";
|
|
45
46
|
const PRIORITY = 90;
|
|
46
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Extension roots this legacy provider may process for a given surface. Roots
|
|
50
|
+
* whose root `plugin.json` targets the Agent Plugins standard keep their
|
|
51
|
+
* portable components (skills, MCP) exclusive to the `agent-plugins` provider;
|
|
52
|
+
* fatally invalid Agent Plugins packages are skipped entirely.
|
|
53
|
+
*/
|
|
54
|
+
async function allowedRoots(ctx: LoadContext, surface: "skills" | "mcp" | "other"): Promise<OmpExtensionRoot[]> {
|
|
55
|
+
const roots = await listOmpExtensionRoots(ctx);
|
|
56
|
+
const flags = await Promise.all(roots.map(root => legacyProviderAllowed(root.path, surface)));
|
|
57
|
+
return roots.filter((_, i) => flags[i]);
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
// =============================================================================
|
|
48
61
|
// Skills
|
|
49
62
|
// =============================================================================
|
|
50
63
|
|
|
51
64
|
async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
52
|
-
const roots = await
|
|
65
|
+
const roots = await allowedRoots(ctx, "skills");
|
|
53
66
|
const results = await Promise.all(
|
|
54
67
|
roots.map(root =>
|
|
55
68
|
scanSkillsFromDir(ctx, {
|
|
@@ -71,7 +84,7 @@ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
|
|
|
71
84
|
// =============================================================================
|
|
72
85
|
|
|
73
86
|
async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashCommand>> {
|
|
74
|
-
const roots = await
|
|
87
|
+
const roots = await allowedRoots(ctx, "other");
|
|
75
88
|
const results = await Promise.all(
|
|
76
89
|
roots.map(root =>
|
|
77
90
|
loadFilesFromDir<SlashCommand>(ctx, path.join(root.path, "commands"), PROVIDER_ID, root.level, {
|
|
@@ -97,7 +110,7 @@ async function loadSlashCommands(ctx: LoadContext): Promise<LoadResult<SlashComm
|
|
|
97
110
|
// =============================================================================
|
|
98
111
|
|
|
99
112
|
async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
100
|
-
const roots = await
|
|
113
|
+
const roots = await allowedRoots(ctx, "other");
|
|
101
114
|
const results = await Promise.all(
|
|
102
115
|
roots.map(root =>
|
|
103
116
|
loadFilesFromDir<Rule>(ctx, path.join(root.path, "rules"), PROVIDER_ID, root.level, {
|
|
@@ -118,7 +131,7 @@ async function loadRules(ctx: LoadContext): Promise<LoadResult<Rule>> {
|
|
|
118
131
|
// =============================================================================
|
|
119
132
|
|
|
120
133
|
async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
|
121
|
-
const roots = await
|
|
134
|
+
const roots = await allowedRoots(ctx, "other");
|
|
122
135
|
const results = await Promise.all(
|
|
123
136
|
roots.map(root =>
|
|
124
137
|
loadFilesFromDir<Prompt>(ctx, path.join(root.path, "prompts"), PROVIDER_ID, root.level, {
|
|
@@ -145,7 +158,7 @@ async function loadPrompts(ctx: LoadContext): Promise<LoadResult<Prompt>> {
|
|
|
145
158
|
const HOOK_TYPES: ReadonlyArray<"pre" | "post"> = ["pre", "post"];
|
|
146
159
|
|
|
147
160
|
async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
148
|
-
const roots = await
|
|
161
|
+
const roots = await allowedRoots(ctx, "other");
|
|
149
162
|
const tasks: Array<{ root: OmpExtensionRoot; hookType: "pre" | "post" }> = [];
|
|
150
163
|
for (const root of roots) {
|
|
151
164
|
for (const hookType of HOOK_TYPES) {
|
|
@@ -183,7 +196,7 @@ async function loadHooks(ctx: LoadContext): Promise<LoadResult<Hook>> {
|
|
|
183
196
|
const TOOL_EXTENSIONS = ["json", "md", "ts", "js", "sh", "bash", "py"];
|
|
184
197
|
|
|
185
198
|
async function loadTools(ctx: LoadContext): Promise<LoadResult<CustomTool>> {
|
|
186
|
-
const roots = await
|
|
199
|
+
const roots = await allowedRoots(ctx, "other");
|
|
187
200
|
const perRoot = await Promise.all(
|
|
188
201
|
roots.map(async root => {
|
|
189
202
|
const toolsDir = path.join(root.path, "tools");
|
|
@@ -276,7 +289,7 @@ interface RawMcpServer {
|
|
|
276
289
|
}
|
|
277
290
|
|
|
278
291
|
async function loadMCPServers(ctx: LoadContext): Promise<LoadResult<MCPServer>> {
|
|
279
|
-
const roots = await
|
|
292
|
+
const roots = await allowedRoots(ctx, "mcp");
|
|
280
293
|
const items: MCPServer[] = [];
|
|
281
294
|
const warnings: string[] = [];
|
|
282
295
|
|
|
@@ -24,6 +24,7 @@ export const NON_INTERACTIVE_ENV: Readonly<Record<string, string>> = {
|
|
|
24
24
|
GIT_TERMINAL_PROMPT: "0",
|
|
25
25
|
SSH_ASKPASS: "/usr/bin/false",
|
|
26
26
|
CI: "1",
|
|
27
|
+
AGENT: "1",
|
|
27
28
|
// Package manager defaults for unattended execution.
|
|
28
29
|
npm_config_yes: "true",
|
|
29
30
|
npm_config_update_notifier: "false",
|
|
@@ -30,7 +30,7 @@ import type { LocalProtocolOptions } from "../../internal-urls/local-protocol";
|
|
|
30
30
|
import type { Theme } from "../../modes/theme/theme";
|
|
31
31
|
import type { ReadonlySessionManager } from "../../session/session-manager";
|
|
32
32
|
import type { TodoItem } from "../../tools/todo";
|
|
33
|
-
import type {
|
|
33
|
+
import type { RetryErrorUpdate } from "../shared-events";
|
|
34
34
|
|
|
35
35
|
/** Alias for clarity */
|
|
36
36
|
export type CustomToolUIContext = HookUIContext;
|
|
@@ -139,7 +139,7 @@ export type CustomToolSessionEvent =
|
|
|
139
139
|
success: boolean;
|
|
140
140
|
attempt: number;
|
|
141
141
|
finalError?: string;
|
|
142
|
-
|
|
142
|
+
retryErrors?: RetryErrorUpdate[];
|
|
143
143
|
}
|
|
144
144
|
| {
|
|
145
145
|
reason: "ttsr_triggered";
|