@f5-sales-demo/xcsh 19.68.0 → 19.70.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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5-sales-demo/xcsh",
4
- "version": "19.68.0",
4
+ "version": "19.70.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5-sales-demo/xcsh",
7
7
  "author": "Can Boluk",
@@ -56,13 +56,13 @@
56
56
  "dependencies": {
57
57
  "@agentclientprotocol/sdk": "0.16.1",
58
58
  "@mozilla/readability": "^0.6",
59
- "@f5-sales-demo/xcsh-stats": "19.68.0",
60
- "@f5-sales-demo/pi-agent-core": "19.68.0",
61
- "@f5-sales-demo/pi-ai": "19.68.0",
62
- "@f5-sales-demo/pi-natives": "19.68.0",
63
- "@f5-sales-demo/pi-resource-management": "19.68.0",
64
- "@f5-sales-demo/pi-tui": "19.68.0",
65
- "@f5-sales-demo/pi-utils": "19.68.0",
59
+ "@f5-sales-demo/xcsh-stats": "19.70.0",
60
+ "@f5-sales-demo/pi-agent-core": "19.70.0",
61
+ "@f5-sales-demo/pi-ai": "19.70.0",
62
+ "@f5-sales-demo/pi-natives": "19.70.0",
63
+ "@f5-sales-demo/pi-resource-management": "19.70.0",
64
+ "@f5-sales-demo/pi-tui": "19.70.0",
65
+ "@f5-sales-demo/pi-utils": "19.70.0",
66
66
  "@sinclair/typebox": "^0.34",
67
67
  "@xterm/headless": "^6.0",
68
68
  "ajv": "^8.20",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.68.0",
21
- "commit": "84f5d14512c1e3c5afc1c2e21815c27c91e958e3",
22
- "shortCommit": "84f5d14",
20
+ "version": "19.70.0",
21
+ "commit": "f3ee43f02bed815fe6236fa03883d3782d8a7969",
22
+ "shortCommit": "f3ee43f",
23
23
  "branch": "main",
24
- "tag": "v19.68.0",
25
- "commitDate": "2026-07-21T12:18:05Z",
26
- "buildDate": "2026-07-21T12:45:20.567Z",
24
+ "tag": "v19.70.0",
25
+ "commitDate": "2026-07-21T20:55:11Z",
26
+ "buildDate": "2026-07-21T21:16:15.358Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5-sales-demo/xcsh",
30
30
  "repoSlug": "f5-sales-demo/xcsh",
31
- "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/84f5d14512c1e3c5afc1c2e21815c27c91e958e3",
32
- "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.68.0"
31
+ "commitUrl": "https://github.com/f5-sales-demo/xcsh/commit/f3ee43f02bed815fe6236fa03883d3782d8a7969",
32
+ "releaseUrl": "https://github.com/f5-sales-demo/xcsh/releases/tag/v19.70.0"
33
33
  };
@@ -35,6 +35,7 @@ export * from "./local-protocol";
35
35
  export * from "./mcp-protocol";
36
36
  export * from "./memory-protocol";
37
37
  export * from "./parse";
38
+ export * from "./plugin-resolve";
38
39
  export * from "./profile-collectors";
39
40
  export * from "./router";
40
41
  export * from "./rule-protocol";
@@ -0,0 +1,86 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { parseInternalUrl } from "./parse";
6
+ import { createPluginResolver, type PluginRootLike } from "./plugin-resolve";
7
+ import type { InternalUrl } from "./types";
8
+
9
+ let root: string;
10
+ let roots: PluginRootLike[];
11
+
12
+ function u(input: string): InternalUrl {
13
+ return parseInternalUrl(input) as InternalUrl;
14
+ }
15
+
16
+ beforeAll(async () => {
17
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "plugin-resolve-"));
18
+ await fs.mkdir(path.join(root, ".xcsh-plugin"), { recursive: true });
19
+ await fs.mkdir(path.join(root, "schema"), { recursive: true });
20
+ await fs.mkdir(path.join(root, "engine"), { recursive: true });
21
+ await fs.writeFile(
22
+ path.join(root, ".xcsh-plugin", "resources.json"),
23
+ JSON.stringify({
24
+ schema: "schema/s.json",
25
+ engine: { runtime: "bun", entry: "engine/cli.ts", commands: ["validate"] },
26
+ }),
27
+ );
28
+ await fs.writeFile(path.join(root, "schema", "s.json"), `{"title":"x"}`);
29
+ await fs.writeFile(path.join(root, "engine", "cli.ts"), `console.log("hi")`);
30
+ // Symlink inside the plugin root pointing outside it (for escape-via-symlink test).
31
+ await fs.symlink(os.tmpdir(), path.join(root, "escape"));
32
+ roots = [{ plugin: "demo", version: "9.9.9", path: root }];
33
+ });
34
+
35
+ afterAll(async () => {
36
+ await fs.rm(root, { recursive: true, force: true });
37
+ });
38
+
39
+ describe("PluginResolver", () => {
40
+ const resolver = () => createPluginResolver(async () => roots);
41
+
42
+ test("schema returns application/json with a sourcePath inside the plugin root", async () => {
43
+ const r = await resolver().resolve(u("xcsh://plugin/demo/schema"));
44
+ expect(r.contentType).toBe("application/json");
45
+ expect(r.content).toContain(`"title"`);
46
+ expect(r.sourcePath).toBe(path.join(root, "schema", "s.json"));
47
+ });
48
+
49
+ test("contract returns the manifest verbatim with a sourcePath", async () => {
50
+ const r = await resolver().resolve(u("xcsh://plugin/demo/contract"));
51
+ expect(r.contentType).toBe("application/json");
52
+ expect(JSON.parse(r.content).schema).toBe("schema/s.json");
53
+ expect(r.sourcePath).toBe(path.join(root, ".xcsh-plugin", "resources.json"));
54
+ });
55
+
56
+ test("file/<relpath> resolves an arbitrary root-relative file", async () => {
57
+ const r = await resolver().resolve(u("xcsh://plugin/demo/file/engine/cli.ts"));
58
+ expect(r.sourcePath).toBe(path.join(root, "engine", "cli.ts"));
59
+ expect(r.content).toContain("hi");
60
+ });
61
+
62
+ test("engine returns the resolved entry path", async () => {
63
+ const r = await resolver().resolve(u("xcsh://plugin/demo/engine"));
64
+ const body = JSON.parse(r.content);
65
+ expect(body.entryPath).toBe(path.join(root, "engine", "cli.ts"));
66
+ expect(r.sourcePath).toBe(path.join(root, "engine", "cli.ts"));
67
+ });
68
+
69
+ test("uninstalled plugin throws listing installed names", async () => {
70
+ await expect(resolver().resolve(u("xcsh://plugin/nope/schema"))).rejects.toThrow(/not installed/i);
71
+ });
72
+
73
+ test("path traversal throws", async () => {
74
+ await expect(resolver().resolve(u("xcsh://plugin/demo/file/../../etc/hosts"))).rejects.toThrow(/traversal/i);
75
+ });
76
+
77
+ test("resolves purely from the injected getPluginRoots (no dependency on external flags)", async () => {
78
+ // Flag-independence at the sdk layer is guaranteed by listXcshPluginRoots not consulting enableXcshPlugins.
79
+ const r = await createPluginResolver(async () => roots).resolve(u("xcsh://plugin/demo"));
80
+ expect(JSON.parse(r.content).name).toBe("demo");
81
+ });
82
+
83
+ test("rejects escape via a symlink inside the plugin root", async () => {
84
+ await expect(resolver().resolve(u("xcsh://plugin/demo/file/escape/x"))).rejects.toThrow(/traversal/i);
85
+ });
86
+ });
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Generic resolver for xcsh://plugin/<name>/... URLs.
3
+ *
4
+ * Framework-agnostic: it reads a plugin-declared manifest
5
+ * (.xcsh-plugin/resources.json) and maps declared keys to files on disk. It has
6
+ * NO knowledge of any specific plugin's domain.
7
+ *
8
+ * URL forms (host = "plugin", first path segment = plugin name):
9
+ * - xcsh://plugin -> list installed plugins
10
+ * - xcsh://plugin/<name> -> plugin summary (version, declared keys)
11
+ * - xcsh://plugin/<name>/contract -> the manifest verbatim
12
+ * - xcsh://plugin/<name>/schema -> file at manifest key "schema"
13
+ * - xcsh://plugin/<name>/engine -> manifest engine block, entry resolved to abs path
14
+ * - xcsh://plugin/<name>/file/<relpath> -> any root-relative file
15
+ */
16
+ import * as fs from "node:fs/promises";
17
+ import * as path from "node:path";
18
+ import { validateRelativePath } from "./skill-protocol";
19
+ import type { InternalResource, InternalUrl } from "./types";
20
+
21
+ /** Minimal shape the resolver needs from a discovered plugin root. */
22
+ export interface PluginRootLike {
23
+ plugin: string;
24
+ version: string;
25
+ path: string;
26
+ }
27
+
28
+ export type GetPluginRoots = () => Promise<readonly PluginRootLike[]>;
29
+
30
+ /** Manifest location, relative to a plugin root. */
31
+ export const PLUGIN_MANIFEST_RELPATH = ".xcsh-plugin/resources.json";
32
+
33
+ interface EngineBlock {
34
+ runtime?: string;
35
+ entry?: string;
36
+ commands?: string[];
37
+ }
38
+
39
+ interface ResourcesManifest {
40
+ engine?: EngineBlock;
41
+ [key: string]: unknown;
42
+ }
43
+
44
+ function contentTypeFor(filePath: string): InternalResource["contentType"] {
45
+ const ext = path.extname(filePath).toLowerCase();
46
+ if (ext === ".json") return "application/json";
47
+ if (ext === ".md") return "text/markdown";
48
+ return "text/plain";
49
+ }
50
+
51
+ /** realpath that tolerates a missing leaf by resolving the nearest existing ancestor. */
52
+ async function realpathAllowingMissing(p: string): Promise<string> {
53
+ try {
54
+ return await fs.realpath(p);
55
+ } catch (err) {
56
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
57
+ const parent = path.dirname(p);
58
+ if (parent === p) throw err;
59
+ const realParent = await realpathAllowingMissing(parent);
60
+ return path.join(realParent, path.basename(p));
61
+ }
62
+ }
63
+
64
+ /** Join a root-relative path safely inside the plugin root; throws on traversal (incl. via symlink). */
65
+ async function safeJoin(root: string, relativePath: string): Promise<string> {
66
+ try {
67
+ validateRelativePath(relativePath);
68
+ } catch {
69
+ // validateRelativePath's message names skill:// URLs; re-throw with the correct scheme.
70
+ throw new Error("Path traversal (..) or absolute paths are not allowed in xcsh://plugin/ URLs");
71
+ }
72
+ const lexicalTarget = path.resolve(path.join(root, relativePath));
73
+ const resolvedRoot = await fs.realpath(root);
74
+ const realTarget = await realpathAllowingMissing(path.join(resolvedRoot, relativePath));
75
+ if (realTarget !== resolvedRoot && !realTarget.startsWith(resolvedRoot + path.sep)) {
76
+ throw new Error("Path traversal is not allowed in xcsh://plugin/ URLs");
77
+ }
78
+ return lexicalTarget;
79
+ }
80
+
81
+ export class PluginResolver {
82
+ constructor(private readonly getPluginRoots: GetPluginRoots) {}
83
+
84
+ async resolve(url: InternalUrl): Promise<InternalResource> {
85
+ // rawPathname preserves traversal markers; strip leading slash.
86
+ const rawPath = (url.rawPathname ?? url.pathname ?? "").replace(/^\/+/, "");
87
+ const segments = rawPath ? rawPath.split("/").map(s => decodeURIComponent(s)) : [];
88
+
89
+ const roots = await this.getPluginRoots();
90
+
91
+ // xcsh://plugin -> list installed plugins
92
+ if (segments.length === 0) {
93
+ return this.#json(url, await this.#listPlugins(roots));
94
+ }
95
+
96
+ const name = segments[0];
97
+ const root = roots.find(r => r.plugin === name);
98
+ if (!root) {
99
+ const installed = roots.map(r => r.plugin).join(", ") || "none";
100
+ throw new Error(`Plugin not installed: ${name}\nInstalled: ${installed}`);
101
+ }
102
+
103
+ const selector = segments.slice(1);
104
+
105
+ // xcsh://plugin/<name> -> summary
106
+ if (selector.length === 0) {
107
+ const manifest = await this.#readManifest(root.path);
108
+ return this.#json(url, {
109
+ name: root.plugin,
110
+ version: root.version,
111
+ resources: Object.keys(manifest),
112
+ });
113
+ }
114
+
115
+ const kind = selector[0];
116
+
117
+ if (kind === "contract") {
118
+ const manifestPath = path.join(root.path, PLUGIN_MANIFEST_RELPATH);
119
+ const content = await this.#readFile(manifestPath);
120
+ return {
121
+ url: url.href,
122
+ content,
123
+ contentType: "application/json",
124
+ size: Buffer.byteLength(content, "utf-8"),
125
+ sourcePath: manifestPath,
126
+ notes: [],
127
+ };
128
+ }
129
+
130
+ if (kind === "engine") {
131
+ const manifest = await this.#readManifest(root.path);
132
+ const engine = manifest.engine;
133
+ if (!engine?.entry) {
134
+ throw new Error(`Plugin ${name} declares no engine.entry in ${PLUGIN_MANIFEST_RELPATH}`);
135
+ }
136
+ const entryPath = await safeJoin(root.path, engine.entry);
137
+ await this.#assertExists(entryPath);
138
+ const body = JSON.stringify({
139
+ runtime: engine.runtime ?? null,
140
+ entry: engine.entry,
141
+ entryPath,
142
+ commands: engine.commands ?? [],
143
+ });
144
+ return {
145
+ url: url.href,
146
+ content: body,
147
+ contentType: "application/json",
148
+ size: Buffer.byteLength(body, "utf-8"),
149
+ sourcePath: entryPath,
150
+ notes: [],
151
+ };
152
+ }
153
+
154
+ if (kind === "file") {
155
+ const relativePath = selector.slice(1).join("/");
156
+ if (!relativePath) throw new Error("xcsh://plugin/<name>/file/ requires a relative path");
157
+ const target = await safeJoin(root.path, relativePath);
158
+ const content = await this.#readFile(target);
159
+ return {
160
+ url: url.href,
161
+ content,
162
+ contentType: contentTypeFor(target),
163
+ size: Buffer.byteLength(content, "utf-8"),
164
+ sourcePath: target,
165
+ notes: [],
166
+ };
167
+ }
168
+
169
+ // Named resource key from the manifest (e.g. "schema", "example").
170
+ const manifest = await this.#readManifest(root.path);
171
+ const value = manifest[kind];
172
+ if (typeof value !== "string") {
173
+ const keys = Object.keys(manifest).filter(k => typeof manifest[k] === "string");
174
+ throw new Error(`Unknown resource "${kind}" for plugin ${name}\nAvailable: ${keys.join(", ") || "none"}`);
175
+ }
176
+ const target = await safeJoin(root.path, value);
177
+ const content = await this.#readFile(target);
178
+ return {
179
+ url: url.href,
180
+ content,
181
+ contentType: contentTypeFor(target),
182
+ size: Buffer.byteLength(content, "utf-8"),
183
+ sourcePath: target,
184
+ notes: [],
185
+ };
186
+ }
187
+
188
+ async #listPlugins(
189
+ roots: readonly PluginRootLike[],
190
+ ): Promise<Array<{ name: string; version: string; hasContract: boolean }>> {
191
+ const out: Array<{ name: string; version: string; hasContract: boolean }> = [];
192
+ for (const r of roots) {
193
+ const manifestPath = path.join(r.path, PLUGIN_MANIFEST_RELPATH);
194
+ const hasContract = await Bun.file(manifestPath).exists();
195
+ out.push({ name: r.plugin, version: r.version, hasContract });
196
+ }
197
+ return out;
198
+ }
199
+
200
+ async #readManifest(root: string): Promise<ResourcesManifest> {
201
+ const manifestPath = path.join(root, PLUGIN_MANIFEST_RELPATH);
202
+ const file = Bun.file(manifestPath);
203
+ if (!(await file.exists())) {
204
+ throw new Error(`Plugin manifest not found: ${manifestPath} (expected ${PLUGIN_MANIFEST_RELPATH})`);
205
+ }
206
+ return JSON.parse(await file.text()) as ResourcesManifest;
207
+ }
208
+
209
+ async #readFile(absPath: string): Promise<string> {
210
+ const file = Bun.file(absPath);
211
+ if (!(await file.exists())) throw new Error(`File not found: ${absPath}`);
212
+ return file.text();
213
+ }
214
+
215
+ async #assertExists(absPath: string): Promise<void> {
216
+ if (!(await Bun.file(absPath).exists())) throw new Error(`File not found: ${absPath}`);
217
+ }
218
+
219
+ #json(url: InternalUrl, data: unknown): InternalResource {
220
+ const content = JSON.stringify(data);
221
+ return {
222
+ url: url.href,
223
+ content,
224
+ contentType: "application/json",
225
+ size: Buffer.byteLength(content, "utf-8"),
226
+ notes: [],
227
+ };
228
+ }
229
+ }
230
+
231
+ export function createPluginResolver(getPluginRoots: GetPluginRoots): PluginResolver {
232
+ return new PluginResolver(getPluginRoots);
233
+ }
@@ -0,0 +1,31 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+ import { parseInternalUrl } from "./parse";
6
+ import type { InternalUrl } from "./types";
7
+ import { InternalDocsProtocolHandler } from "./xcsh-protocol";
8
+
9
+ let root: string;
10
+
11
+ beforeAll(async () => {
12
+ root = await fs.mkdtemp(path.join(os.tmpdir(), "xcsh-plugin-"));
13
+ await fs.mkdir(path.join(root, ".xcsh-plugin"), { recursive: true });
14
+ await fs.writeFile(path.join(root, ".xcsh-plugin", "resources.json"), JSON.stringify({ schema: "s.json" }));
15
+ await fs.writeFile(path.join(root, "s.json"), `{"ok":true}`);
16
+ });
17
+ afterAll(async () => {
18
+ await fs.rm(root, { recursive: true, force: true });
19
+ });
20
+
21
+ describe("InternalDocsProtocolHandler plugin host", () => {
22
+ const handler = new InternalDocsProtocolHandler({
23
+ getPluginRoots: async () => [{ plugin: "demo", version: "1.0.0", path: root }],
24
+ });
25
+
26
+ test("routes xcsh://plugin/<name>/schema", async () => {
27
+ const r = await handler.resolve(parseInternalUrl("xcsh://plugin/demo/schema") as InternalUrl);
28
+ expect(r.contentType).toBe("application/json");
29
+ expect(r.sourcePath).toBe(path.join(root, "s.json"));
30
+ });
31
+ });
@@ -44,6 +44,7 @@ import { type ConsoleFieldMetadataData, EMPTY_CONSOLE_FIELD_METADATA } from "./c
44
44
  import { type ConsoleResolver, createConsoleResolver } from "./console-resolve";
45
45
  import { EMBEDDED_DOC_FILENAMES, EMBEDDED_DOCS } from "./docs-index.generated";
46
46
  import extensionApiContent from "./extension-api.md" with { type: "text" };
47
+ import { createPluginResolver, type GetPluginRoots, type PluginResolver } from "./plugin-resolve";
47
48
  import { createTerraformResolver, type TerraformResolver } from "./terraform-resolve";
48
49
  import type { TerraformIndex } from "./terraform-types";
49
50
  import type { InternalResource, InternalUrl, ProtocolHandler } from "./types";
@@ -60,6 +61,7 @@ const USER_ROUTE = "user";
60
61
  const COMPUTER_ROUTE = "computer";
61
62
  const CONSOLE_HOST = "console";
62
63
  const EXTENSION_HOST = "extension";
64
+ const PLUGIN_HOST = "plugin";
63
65
  const EMPTY_INDEX: ApiSpecIndex = { version: "unavailable", timestamp: "", domains: [] };
64
66
  const EMPTY_CATALOG_INDEX: ApiCatalogIndex = {
65
67
  version: "unavailable",
@@ -265,6 +267,7 @@ export interface InternalDocsProtocolOptions {
265
267
  readonly getContextStatus?: () => ContextStatus | null;
266
268
  readonly apiSpecResolver?: ApiSpecResolver;
267
269
  readonly apiCatalogResolver?: ApiCatalogResolver;
270
+ readonly getPluginRoots?: GetPluginRoots;
268
271
  }
269
272
 
270
273
  export class InternalDocsProtocolHandler implements ProtocolHandler {
@@ -275,6 +278,8 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
275
278
  #apiCatalogResolver: ApiCatalogResolver | null;
276
279
  #terraformResolver: TerraformResolver | null;
277
280
  #consoleResolver: ConsoleResolver | null = null;
281
+ #pluginResolver: PluginResolver | null = null;
282
+ readonly #getPluginRoots: GetPluginRoots | undefined;
278
283
 
279
284
  constructor(options: InternalDocsProtocolOptions = {}) {
280
285
  this.#resolveBuildInfo = options.resolveBuildInfo ?? getRuntimeBuildInfo;
@@ -282,6 +287,7 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
282
287
  this.#apiSpecResolver = options.apiSpecResolver ?? null;
283
288
  this.#apiCatalogResolver = options.apiCatalogResolver ?? null;
284
289
  this.#terraformResolver = null;
290
+ this.#getPluginRoots = options.getPluginRoots;
285
291
  }
286
292
 
287
293
  #getApiSpecResolver(): ApiSpecResolver {
@@ -325,6 +331,14 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
325
331
  return this.#consoleResolver;
326
332
  }
327
333
 
334
+ #getPluginResolver(): PluginResolver {
335
+ if (!this.#pluginResolver) {
336
+ const getRoots = this.#getPluginRoots ?? (async () => []);
337
+ this.#pluginResolver = createPluginResolver(getRoots);
338
+ }
339
+ return this.#pluginResolver;
340
+ }
341
+
328
342
  async resolve(url: InternalUrl): Promise<InternalResource> {
329
343
  const host = url.rawHost || url.hostname;
330
344
 
@@ -344,6 +358,10 @@ export class InternalDocsProtocolHandler implements ProtocolHandler {
344
358
  return this.#getTerraformResolver().resolve(url);
345
359
  }
346
360
 
361
+ if (host === PLUGIN_HOST) {
362
+ return this.#getPluginResolver().resolve(url);
363
+ }
364
+
347
365
  if (host === BRANDING_HOST) {
348
366
  return this.#resolveBranding(url);
349
367
  }
package/src/sdk.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as os from "node:os";
1
2
  import {
2
3
  Agent,
3
4
  type AgentEvent,
@@ -44,6 +45,7 @@ import { CursorExecHandlers } from "./cursor";
44
45
  import "./discovery";
45
46
  import { resolveConfigValue } from "./config/resolve-config-value";
46
47
  import { initializeWithSettings } from "./discovery";
48
+ import { listXcshPluginRoots } from "./discovery/helpers";
47
49
  import { TtsrManager } from "./export/ttsr";
48
50
  import {
49
51
  type CustomCommandsLoadResult,
@@ -1136,6 +1138,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1136
1138
  return null;
1137
1139
  }
1138
1140
  },
1141
+ getPluginRoots: () => listXcshPluginRoots(os.homedir(), cwd).then(r => r.roots),
1139
1142
  }),
1140
1143
  );
1141
1144
  internalRouter.register(new JobsProtocolHandler({ getAsyncJobManager: () => asyncJobManager }));
@@ -11,9 +11,9 @@ const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|ski
11
11
 
12
12
  /** Regex to find supported internal URL tokens in command text. */
13
13
  const INTERNAL_URL_PATTERN =
14
- /'(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule|local):\/\/[^\s'")`\\]+/g;
14
+ /'(?:skill|agent|artifact|plan|memory|rule|local|xcsh):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|memory|rule|local|xcsh):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|memory|rule|local|xcsh):\/\/[^\s'")`\\]+/g;
15
15
 
16
- const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "memory", "rule", "local"] as const;
16
+ const SUPPORTED_INTERNAL_SCHEMES = ["skill", "agent", "artifact", "plan", "memory", "rule", "local", "xcsh"] as const;
17
17
 
18
18
  type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
19
19
 
@@ -0,0 +1,41 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { InternalResource } from "../internal-urls/types";
3
+ import { expandInternalUrls } from "./bash-skill-urls";
4
+
5
+ describe("expandInternalUrls xcsh scheme", () => {
6
+ const router = {
7
+ canHandle: (input: string) => input.startsWith("xcsh://"),
8
+ resolve: async (input: string): Promise<InternalResource> => ({
9
+ url: input,
10
+ content: "",
11
+ contentType: "application/json",
12
+ sourcePath: "/abs/plugin/engine/cli.ts",
13
+ }),
14
+ };
15
+
16
+ test("expands an xcsh:// token to a shell-escaped absolute path", async () => {
17
+ const out = await expandInternalUrls("bun xcsh://plugin/demo/file/engine/cli.ts score deal.json", {
18
+ skills: [],
19
+ internalRouter: router,
20
+ });
21
+ expect(out).toBe("bun '/abs/plugin/engine/cli.ts' score deal.json");
22
+ });
23
+
24
+ test("throws a friendly error when the resolved resource has no sourcePath", async () => {
25
+ const routerWithoutSourcePath = {
26
+ canHandle: (input: string) => input.startsWith("xcsh://"),
27
+ resolve: async (input: string): Promise<InternalResource> => ({
28
+ url: input,
29
+ content: "",
30
+ contentType: "application/json",
31
+ // list/summary endpoints (xcsh://plugin, xcsh://plugin/<name>) return no sourcePath
32
+ }),
33
+ };
34
+ await expect(
35
+ expandInternalUrls("bun xcsh://plugin/demo score x", {
36
+ skills: [],
37
+ internalRouter: routerWithoutSourcePath,
38
+ }),
39
+ ).rejects.toThrow();
40
+ });
41
+ });