From 95c38b71171127435e6af8c45169360b488a039b Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:08:45 +0530 Subject: [PATCH 1/8] feat(skills): add skills URI resolution --- src/skills.test.ts | 26 +++++++++++++++++++---- src/skills.ts | 51 ++++++++++++++++++++++++++-------------------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 357c417db..8e167a767 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { effectiveSkillPaths, - formatPathForPrompt, + formatSkillUri, loadWorkspaceSkills, resolveSkillReadPath, } from "./skills.js"; @@ -247,14 +247,32 @@ try { const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill"); assert.ok(projectSkill); - assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/); + assert.equal(formatSkillUri(projectSkill), "skills://agent-project-skill"); - const skillFileRead = resolveSkillReadPath(loaded.skills, projectSkill.filePath); + const skillFileRead = resolveSkillReadPath( + loaded.skills, + "skills://agent-project-skill", + ); assert.equal(skillFileRead?.absolutePath, projectSkill.filePath); const resourcePath = join(projectSkill.baseDir, "references.md"); await writeFile(resourcePath, "reference\n"); - assert.equal(resolveSkillReadPath(loaded.skills, resourcePath)?.absolutePath, resourcePath); + assert.equal( + resolveSkillReadPath( + loaded.skills, + "skills://agent-project-skill/references.md", + )?.absolutePath, + resourcePath, + ); + assert.equal(resolveSkillReadPath(loaded.skills, projectSkill.filePath), undefined); + assert.throws( + () => resolveSkillReadPath(loaded.skills, "skills://missing"), + /Unknown skill/, + ); + assert.throws( + () => resolveSkillReadPath(loaded.skills, "skills://agent-project-skill/../secret"), + /outside skill directory/, + ); } finally { if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome; diff --git a/src/skills.ts b/src/skills.ts index 8f67d8e07..c03044303 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -8,7 +8,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve, sep } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { loadSkills, @@ -29,6 +29,7 @@ export interface SkillReadResolution { skill: Skill; } +const SKILL_URI_PREFIX = "skills://"; const SUBAGENTS_SKILL_NAME = "subagents"; const SUBAGENTS_SKILL = join(SUBAGENTS_SKILL_NAME, "SKILL.md"); @@ -139,33 +140,39 @@ export function resolveSkillReadPath( skills: Skill[], inputPath: string, ): SkillReadResolution | undefined { - const absolutePath = resolve(expandHomePath(inputPath)); + if (!inputPath.startsWith(SKILL_URI_PREFIX)) return undefined; + + const skillReference = inputPath.slice(SKILL_URI_PREFIX.length); + const separatorIndex = skillReference.indexOf("/"); + const skillName = separatorIndex === -1 + ? skillReference + : skillReference.slice(0, separatorIndex); + const resourcePath = separatorIndex === -1 + ? undefined + : skillReference.slice(separatorIndex + 1); + + if (!skillName) { + throw new Error(`Invalid skill URI: ${inputPath}`); + } - for (const skill of skills) { - const skillFilePath = resolve(skill.filePath); - if (absolutePath === skillFilePath) { - return { absolutePath, skill }; - } + const skill = skills.find((candidate) => candidate.name === skillName); + if (!skill) { + throw new Error(`Unknown skill: ${skillName}`); } - for (const skill of skills) { - const baseDir = resolve(skill.baseDir); - if (!isPathInsideRoot(absolutePath, baseDir)) continue; + if (!resourcePath) { + return { absolutePath: resolve(skill.filePath), skill }; + } - return { absolutePath, skill }; + const baseDir = resolve(skill.baseDir); + const absolutePath = resolve(baseDir, resourcePath); + if (!isPathInsideRoot(absolutePath, baseDir)) { + throw new Error(`Skill resource is outside skill directory: ${inputPath}`); } - return undefined; + return { absolutePath, skill }; } -export function formatPathForPrompt(path: string): string { - const home = resolve(homedir()); - const resolvedPath = resolve(path); - - if (resolvedPath === home) return "~"; - if (resolvedPath.startsWith(`${home}${sep}`)) { - return `~/${resolvedPath.slice(home.length + 1).split(sep).join("/")}`; - } - - return resolvedPath.split(sep).join("/"); +export function formatSkillUri(skill: Skill): string { + return `${SKILL_URI_PREFIX}${skill.name}`; } From 72a0484a0f98026029290f88319d2f20a7c598af Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:08:45 +0530 Subject: [PATCH 2/8] feat(skills): expose logical skill paths --- src/server.test.ts | 4 +++- src/server.ts | 12 ++++++------ src/workspaces.test.ts | 14 ++++++++++++-- src/workspaces.ts | 17 ++++++----------- 4 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/server.test.ts b/src/server.test.ts index 59bcc5d37..f05bdf43b 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -435,7 +435,9 @@ test("open_workspace advertises subagent instructions on demand by default", asy const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); const skills = opened.skills as Array>; - assert.equal(skills.some((skill) => skill.name === "subagents"), true); + const subagents = skills.find((skill) => skill.name === "subagents"); + assert.ok(subagents); + assert.equal(subagents.path, "skills://subagents"); assert.doesNotMatch(String(opened.instruction), /# DevSpace subagents/); }); diff --git a/src/server.ts b/src/server.ts index 0a2792f3f..0c073e76e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,7 +43,7 @@ import { ProcessSessionManager } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { conversationScopeIdFromRequestMeta } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; -import { formatPathForPrompt } from "./skills.js"; +import { formatSkillUri } from "./skills.js"; import { DEVSPACE_VERSION } from "./version.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -131,7 +131,7 @@ function serverInstructions( const showChangesInstruction = " If files are modified, call show_changes once after the final related change and before the final response."; const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skill path before proceeding. ` + ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skills:// URI before proceeding. ` : ""; const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; @@ -467,7 +467,7 @@ function registerMcpSurface( .map((skill) => ({ name: skill.name, description: skill.description, - path: formatPathForPrompt(skill.filePath), + path: formatSkillUri(skill), })); const agentCatalog = buildLocalAgentCatalog( config.subagents, @@ -496,7 +496,7 @@ function registerMcpSurface( const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled - ? "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." + ? "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file. When a task matches an available skill in skills, read its skills:// URI before proceeding." : "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file."; const workspaceInstruction = workspaceReused ? [ @@ -622,7 +622,7 @@ function registerMcpSurface( "Read all or part of a file in a workspace.", "Use this tool to inspect relevant AGENTS.md or CLAUDE.md files listed by open_workspace before working in nested directories.", config.skillsEnabled - ? "If available skills were returned and a task matches one, read the returned skill path before proceeding." + ? "If available skills were returned and a task matches one, read the returned skills:// URI before proceeding." : "", ] .filter(Boolean) @@ -635,7 +635,7 @@ function registerMcpSurface( .string() .describe( config.skillsEnabled - ? "File path relative to the workspace root, or a skill path returned by open_workspace." + ? "File path relative to the workspace root, or a skills:// URI returned by open_workspace." : "File path to read, relative to the workspace root.", ), offset: z diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 914837502..d0fd5efee 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -362,7 +362,12 @@ test("workspace cache evicts old contexts without losing advertised skill reads" const registry = new WorkspaceRegistry(config, store); const first = await registry.openWorkspace(context.root); assert.equal( - (await registry.resolveReadPath(first.workspace, resourceFile)).absolutePath, + ( + await registry.resolveReadPath( + first.workspace, + "skills://cache-skill/reference.md", + ) + ).absolutePath, await realpath(resourceFile), ); @@ -373,7 +378,12 @@ test("workspace cache evicts old contexts without losing advertised skill reads" const restored = await registry.getWorkspace(first.workspace.id); assert.notEqual(restored, first.workspace); assert.equal( - (await registry.resolveReadPath(restored, resourceFile)).absolutePath, + ( + await registry.resolveReadPath( + restored, + "skills://cache-skill/reference.md", + ) + ).absolutePath, await realpath(resourceFile), ); } finally { diff --git a/src/workspaces.ts b/src/workspaces.ts index 385e47cf2..aad6b5097 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -414,17 +414,8 @@ export class WorkspaceRegistry { } async resolveReadPath(workspace: Workspace, inputPath: string): Promise { - try { - return { - absolutePath: await this.resolvePath(workspace, inputPath), - }; - } catch (workspaceError) { - const skillRead = resolveSkillReadPath( - workspace.skills, - inputPath, - ); - if (!skillRead) throw workspaceError; - + const skillRead = resolveSkillReadPath(workspace.skills, inputPath); + if (skillRead) { return { absolutePath: await resolveCanonicalAllowedPath( skillRead.absolutePath, @@ -434,6 +425,10 @@ export class WorkspaceRegistry { skillRead, }; } + + return { + absolutePath: await this.resolvePath(workspace, inputPath), + }; } async resolveWorkingDirectory(workspace: Workspace, workingDirectory: string | undefined): Promise { From 4e1d9a30c9958a397f382682f76cb1499a8b0b34 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:08:56 +0530 Subject: [PATCH 3/8] docs: document skills URI paths --- docs/chatgpt-coding-workflow.md | 11 +++++------ docs/configuration.md | 5 +++++ docs/gotchas.md | 7 ++++--- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 4e001e46c..390a298b7 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -136,12 +136,11 @@ before use. Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. -When `open_workspace` returns matching skills, the model should read the -advertised `SKILL.md` before following that skill. - -Skill paths may be outside the workspace. DevSpace only permits reading: - -- files within advertised skill directories +When `open_workspace` returns matching skills, the model reads the advertised +`skills://` URI before following that skill. The bare URI loads the +skill entry file, while bundled resources use +`skills:///`. Physical skill paths stay internal to +DevSpace. Set `skills.enabled` to `false` to hide skills from workspace output. Enable Subagents and choose providers through `devspace init` or the persisted provider diff --git a/docs/configuration.md b/docs/configuration.md index 408018c7f..7fd7d08e5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,6 +114,11 @@ DevSpace discovers standard Agent Skills from `~/.agents/skills`, project `skills.agentDir/skills` and each path in `skills.paths`. Relative custom paths are resolved from the active workspace. +Discovered skills are exposed to MCP hosts through logical +`skills://` URIs instead of their filesystem locations. The bare URI +loads the skill entry file; files bundled with a skill are addressed as +`skills:///`. + When Subagents are enabled for MCP workspaces, DevSpace keeps its bundled `subagents` skill synchronized at `~/.devspace/skills/subagents/SKILL.md`. That managed copy is the authoritative `subagents` skill for DevSpace and is diff --git a/docs/gotchas.md b/docs/gotchas.md index cb660c732..49a9cd7d0 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -260,9 +260,10 @@ Copy or adapt them into one of the active profile directories before use. Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. -If a skill appears in `open_workspace`, the model should read that skill's -`SKILL.md` before following it. DevSpace permits reads within advertised skill -directories without tracking whether `SKILL.md` was read first. +If a skill appears in `open_workspace`, the model reads its advertised +`skills://` URI before following it. The bare URI resolves to the +skill's entry file; resources within that skill use +`skills:///`. ## Review Card Does Not Appear From f6fa60f95fa91b45d6151db770742e283c9a0ba4 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:32:35 +0530 Subject: [PATCH 4/8] fix(skills): reject unroutable skill names --- src/skills.test.ts | 27 +++++++++++++++++++++++++++ src/skills.ts | 22 +++++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/skills.test.ts b/src/skills.test.ts index 8e167a767..b7ca8b6c4 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -35,6 +35,7 @@ try { await mkdir(join(agentDir, "skills", "subagents"), { recursive: true }); await mkdir(join(explicitSkills, "duplicate"), { recursive: true }); await mkdir(join(explicitSkills, "disabled"), { recursive: true }); + await mkdir(join(explicitSkills, "invalid-name"), { recursive: true }); await mkdir(join(explicitSkills, "subagents"), { recursive: true }); await mkdir(join(devspaceSkills, "devspace-local-skill"), { recursive: true }); @@ -160,6 +161,17 @@ try { "# Hidden Skill", ].join("\n"), ); + await writeFile( + join(explicitSkills, "invalid-name", "SKILL.md"), + [ + "---", + "name: foo/bar", + "description: Invalid skill name.", + "---", + "", + "# Invalid Skill", + ].join("\n"), + ); const configDir = join(root, ".devspace"); const disabledConfig = loadConfig(writeTestDevspaceConfig(configDir, { @@ -187,7 +199,14 @@ try { assert.equal(loaded.skills.some((skill) => skill.name === "subagents"), false); assert.equal(loaded.skills.filter((skill) => skill.name === "duplicate-skill").length, 1); assert.equal(loaded.skills.some((skill) => skill.name === "hidden-skill"), true); + assert.equal(loaded.skills.some((skill) => skill.name === "foo/bar"), false); assert.equal(loaded.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true); + assert.equal( + loaded.diagnostics.some( + (diagnostic) => diagnostic.message.includes("name contains invalid characters"), + ), + true, + ); assert.equal( loaded.diagnostics.some( (diagnostic) => diagnostic.collision?.name === "subagents", @@ -248,6 +267,10 @@ try { const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill"); assert.ok(projectSkill); assert.equal(formatSkillUri(projectSkill), "skills://agent-project-skill"); + assert.throws( + () => formatSkillUri({ ...projectSkill, name: "foo/bar" }), + /Invalid skill name/, + ); const skillFileRead = resolveSkillReadPath( loaded.skills, @@ -269,6 +292,10 @@ try { () => resolveSkillReadPath(loaded.skills, "skills://missing"), /Unknown skill/, ); + assert.throws( + () => resolveSkillReadPath(loaded.skills, "skills://foo%2Fbar"), + /Invalid skill URI/, + ); assert.throws( () => resolveSkillReadPath(loaded.skills, "skills://agent-project-skill/../secret"), /outside skill directory/, diff --git a/src/skills.ts b/src/skills.ts index c03044303..e63ac5578 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -30,6 +30,8 @@ export interface SkillReadResolution { } const SKILL_URI_PREFIX = "skills://"; +const MAX_SKILL_NAME_LENGTH = 64; +const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; const SUBAGENTS_SKILL_NAME = "subagents"; const SUBAGENTS_SKILL = join(SUBAGENTS_SKILL_NAME, "SKILL.md"); @@ -109,7 +111,11 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk }); const withoutSubagents = withoutSubagentsSkill(result); - if (!config.subagents.enabled) return withoutSubagents; + const routable = { + skills: withoutSubagents.skills.filter((skill) => isRoutableSkillName(skill.name)), + diagnostics: withoutSubagents.diagnostics, + }; + if (!config.subagents.enabled) return routable; const managedDir = dirname(join(config.devspaceSkillsDir, SUBAGENTS_SKILL)); const managed = loadSkillsFromDir({ @@ -121,8 +127,8 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk } return { - skills: [...withoutSubagents.skills, managed], - diagnostics: withoutSubagents.diagnostics, + skills: [...routable.skills, managed], + diagnostics: routable.diagnostics, }; } @@ -154,6 +160,9 @@ export function resolveSkillReadPath( if (!skillName) { throw new Error(`Invalid skill URI: ${inputPath}`); } + if (!isRoutableSkillName(skillName)) { + throw new Error(`Invalid skill URI: ${inputPath}`); + } const skill = skills.find((candidate) => candidate.name === skillName); if (!skill) { @@ -174,5 +183,12 @@ export function resolveSkillReadPath( } export function formatSkillUri(skill: Skill): string { + if (!isRoutableSkillName(skill.name)) { + throw new Error(`Invalid skill name for skills:// URI: ${skill.name}`); + } return `${SKILL_URI_PREFIX}${skill.name}`; } + +function isRoutableSkillName(name: string): boolean { + return name.length <= MAX_SKILL_NAME_LENGTH && SKILL_NAME_PATTERN.test(name); +} From a1a8131045d64b0c0e7aa90bf5aac43b6edb7742 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 03:38:27 +0530 Subject: [PATCH 5/8] test: keep native prebuilds in package smoke --- test/package-install-smoke.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/test/package-install-smoke.test.ts b/test/package-install-smoke.test.ts index c53477fd8..b6be06600 100644 --- a/test/package-install-smoke.test.ts +++ b/test/package-install-smoke.test.ts @@ -30,7 +30,6 @@ function testPackedPackageLaunchers(): void { "--no-fund", "--no-package-lock", "--no-save", - "--omit=optional", join(root, archive), ], { cwd: installRoot, From 3715a478e72e42acf3516718dc6f257d723373be Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:07:46 +0530 Subject: [PATCH 6/8] feat(skills): gate URI routing behind env flag --- docs/chatgpt-coding-workflow.md | 8 +++--- docs/configuration.md | 11 ++++--- docs/gotchas.md | 6 ++-- src/config.test.ts | 5 ++++ src/config.ts | 2 ++ src/server.test.ts | 25 ++++++++++++++-- src/server.ts | 18 ++++++++---- src/skills.test.ts | 41 ++++++++++++++++++++++---- src/skills.ts | 51 +++++++++++++++++++++++++++------ src/workspaces.test.ts | 8 ++++-- src/workspaces.ts | 32 +++++++++++++++++---- 11 files changed, 166 insertions(+), 41 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 390a298b7..09d85aef8 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -137,10 +137,10 @@ before use. Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. When `open_workspace` returns matching skills, the model reads the advertised -`skills://` URI before following that skill. The bare URI loads the -skill entry file, while bundled resources use -`skills:///`. Physical skill paths stay internal to -DevSpace. +skill path before following that skill. For early testing, +`DEVSPACE_EXPERIMENTAL_SKILL_URIS=1` replaces physical skill paths with +`skills://` URIs; bundled resources then use +`skills:///`. Set `skills.enabled` to `false` to hide skills from workspace output. Enable Subagents and choose providers through `devspace init` or the persisted provider diff --git a/docs/configuration.md b/docs/configuration.md index 7fd7d08e5..3e4bc9921 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -114,10 +114,13 @@ DevSpace discovers standard Agent Skills from `~/.agents/skills`, project `skills.agentDir/skills` and each path in `skills.paths`. Relative custom paths are resolved from the active workspace. -Discovered skills are exposed to MCP hosts through logical -`skills://` URIs instead of their filesystem locations. The bare URI -loads the skill entry file; files bundled with a skill are addressed as -`skills:///`. +By default, discovered skills keep their filesystem paths for compatibility +with existing MCP hosts. Set `DEVSPACE_EXPERIMENTAL_SKILL_URIS=1` to expose +logical `skills://` URIs instead. The bare URI loads the skill entry +file; bundled resources use `skills:///`. + +Skill URIs are experimental and may be removed in favor of the MCP Skills +extension as host support matures. When Subagents are enabled for MCP workspaces, DevSpace keeps its bundled `subagents` skill synchronized at `~/.devspace/skills/subagents/SKILL.md`. diff --git a/docs/gotchas.md b/docs/gotchas.md index 49a9cd7d0..4ad28d5b3 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -260,9 +260,9 @@ Copy or adapt them into one of the active profile directories before use. Legacy project paths such as `.pi/skills` can be added to `skills.paths` when needed. -If a skill appears in `open_workspace`, the model reads its advertised -`skills://` URI before following it. The bare URI resolves to the -skill's entry file; resources within that skill use +If a skill appears in `open_workspace`, the model reads its advertised path +before following it. With `DEVSPACE_EXPERIMENTAL_SKILL_URIS=1`, DevSpace +instead advertises `skills://`; bundled resources use `skills:///`. ## Review Card Does Not Appear diff --git a/src/config.test.ts b/src/config.test.ts index ddb2fc1c3..e276d0a11 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -21,6 +21,7 @@ try { assert.equal(defaults.toolMode, "codex"); assert.equal(defaults.uiEnabled, true); assert.equal(defaults.skillsEnabled, true); + assert.equal(defaults.experimentalSkillUris, false); assert.equal(defaults.artifactsEnabled, false); assert.deepEqual(defaults.subagents, { enabled: false, @@ -121,6 +122,10 @@ try { }); assert.equal(loadConfig(env).oauth.ownerToken, env.DEVSPACE_OAUTH_OWNER_TOKEN); + assert.equal( + loadConfig({ ...env, DEVSPACE_EXPERIMENTAL_SKILL_URIS: "1" }).experimentalSkillUris, + true, + ); } finally { rmSync(configDir, { recursive: true, force: true }); } diff --git a/src/config.ts b/src/config.ts index 34fcdfc25..7fc753787 100644 --- a/src/config.ts +++ b/src/config.ts @@ -23,6 +23,7 @@ export interface ServerConfig { artifactsEnabled: boolean; artifactMaxFileBytes: number; skillsEnabled: boolean; + experimentalSkillUris: boolean; skillPaths: string[]; devspaceSkillsDir: string; devspaceAgentsDir: string; @@ -72,6 +73,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): ServerConfig { artifactsEnabled: stored.artifacts.enabled, artifactMaxFileBytes: stored.artifacts.maxFileBytes, skillsEnabled: stored.skills.enabled, + experimentalSkillUris: env.DEVSPACE_EXPERIMENTAL_SKILL_URIS === "1", skillPaths: stored.skills.paths, devspaceSkillsDir: devspaceSkillsDir(env), devspaceAgentsDir: devspaceAgentsDir(env), diff --git a/src/server.test.ts b/src/server.test.ts index f05bdf43b..6774a9338 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -433,6 +433,20 @@ test("open_workspace advertises subagent instructions on demand by default", asy localAgentProviders: [{ name: "codex", available: true }], }); + const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); + const skills = opened.skills as Array>; + const subagents = skills.find((skill) => skill.name === "subagents"); + assert.ok(subagents); + assert.match(String(subagents.path), /\/skills\/subagents\/SKILL\.md$/); + assert.doesNotMatch(String(opened.instruction), /# DevSpace subagents/); +}); + +test("open_workspace advertises experimental skill URIs when enabled", async (t) => { + const context = await fixture(t, { + localAgentProviders: [{ name: "codex", available: true }], + experimentalSkillUris: true, + }); + const opened = structuredContent(await callOpen(context.client, context.project, "chat-1")); const skills = opened.skills as Array>; const subagents = skills.find((skill) => skill.name === "subagents"); @@ -724,6 +738,7 @@ async function fixture( subagents?: SubagentsConfig; toolMode?: ToolMode; uiEnabled?: boolean; + experimentalSkillUris?: boolean; } = {}, ): Promise { const root = await mkdtemp(join(tmpdir(), "devspace-server-test-")); @@ -756,7 +771,7 @@ async function fixture( const initialProviderAvailability = typeof options.localAgentProviders === "function" ? options.localAgentProviders() : options.localAgentProviders ?? []; - const loadedConfig = loadConfig(writeTestDevspaceConfig(join(root, ".config"), { + const configEnv = writeTestDevspaceConfig(join(root, ".config"), { server: { port: 1 }, workspaces: { allowedRoots: [root], worktreeRoot: join(root, ".worktrees") }, skills: { agentDir }, @@ -765,7 +780,13 @@ async function fixture( instructions: "on-demand", providers: [], }, - })); + }); + const loadedConfig = loadConfig({ + ...configEnv, + ...(options.experimentalSkillUris + ? { DEVSPACE_EXPERIMENTAL_SKILL_URIS: "1" } + : {}), + }); const modeConfig: ServerConfig = { ...loadedConfig, toolMode: options.toolMode ?? loadedConfig.toolMode, diff --git a/src/server.ts b/src/server.ts index 0c073e76e..4d5831de9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -43,7 +43,7 @@ import { ProcessSessionManager } from "./process-sessions.js"; import { createReviewCheckpointManager } from "./review-checkpoints.js"; import { conversationScopeIdFromRequestMeta } from "./request-meta.js"; import { shutdownHttpServer } from "./server-shutdown.js"; -import { formatSkillUri } from "./skills.js"; +import { formatPathForPrompt, formatSkillUri } from "./skills.js"; import { DEVSPACE_VERSION } from "./version.js"; import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; @@ -131,7 +131,7 @@ function serverInstructions( const showChangesInstruction = " If files are modified, call show_changes once after the final related change and before the final response."; const skills = config.skillsEnabled - ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned skills:// URI before proceeding. ` + ? `When ${toolNames.openWorkspace} returns available skills and a task matches one, use ${toolNames.read} with the returned ${skillReferenceLabel(config)} before proceeding. ` : ""; const agents = `Follow instructions returned by ${toolNames.openWorkspace}. Before working under a path listed in available_agents_files, use ${toolNames.read} to inspect that instruction file and follow it. `; const common = `Call ${toolNames.openWorkspace} when starting work in a project folder or isolated worktree without a usable workspace_id, then reuse the returned workspace_id for subsequent operations in that workspace.`; @@ -139,6 +139,10 @@ function serverInstructions( return `${common} ${toolSurface.instructions({ agents, skills })}${artifactInstruction}${showChangesInstruction}`; } +function skillReferenceLabel(config: ServerConfig): string { + return config.experimentalSkillUris ? "skills:// URI" : "skill path"; +} + function formatVisibleAgent(agent: { name: string; provider: string; @@ -467,7 +471,9 @@ function registerMcpSurface( .map((skill) => ({ name: skill.name, description: skill.description, - path: formatSkillUri(skill), + path: config.experimentalSkillUris + ? formatSkillUri(skill) + : formatPathForPrompt(skill.filePath), })); const agentCatalog = buildLocalAgentCatalog( config.subagents, @@ -496,7 +502,7 @@ function registerMcpSurface( const loadedAgentsFiles = includeBootstrapContext ? cardAgentsFiles : []; const availableAgentsFileOutputs = includeBootstrapContext ? cardAvailableAgentsFiles : []; const cardInstruction = config.skillsEnabled - ? "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file. When a task matches an available skill in skills, read its skills:// URI before proceeding." + ? `Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file. When a task matches an available skill in skills, read its ${skillReferenceLabel(config)} before proceeding.` : "Use this workspace_id for subsequent work in this project. Keep reusing it while working in this project. Follow loaded agents_files instructions. Before working under a path listed in available_agents_files, read that instruction file."; const workspaceInstruction = workspaceReused ? [ @@ -622,7 +628,7 @@ function registerMcpSurface( "Read all or part of a file in a workspace.", "Use this tool to inspect relevant AGENTS.md or CLAUDE.md files listed by open_workspace before working in nested directories.", config.skillsEnabled - ? "If available skills were returned and a task matches one, read the returned skills:// URI before proceeding." + ? `If available skills were returned and a task matches one, read the returned ${skillReferenceLabel(config)} before proceeding.` : "", ] .filter(Boolean) @@ -635,7 +641,7 @@ function registerMcpSurface( .string() .describe( config.skillsEnabled - ? "File path relative to the workspace root, or a skills:// URI returned by open_workspace." + ? `File path relative to the workspace root, or a ${skillReferenceLabel(config)} returned by open_workspace.` : "File path to read, relative to the workspace root.", ), offset: z diff --git a/src/skills.test.ts b/src/skills.test.ts index b7ca8b6c4..017359876 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import { loadConfig } from "./config.js"; import { effectiveSkillPaths, + formatPathForPrompt, formatSkillUri, loadWorkspaceSkills, resolveSkillReadPath, @@ -181,14 +182,22 @@ try { })); assert.deepEqual(loadWorkspaceSkills(disabledConfig, projectRoot).skills, []); - const config = loadConfig(writeTestDevspaceConfig(configDir, { + const configEnv = writeTestDevspaceConfig(configDir, { server: { port: 1 }, workspaces: { allowedRoots: [projectRoot] }, skills: { agentDir, paths: [explicitSkills, "~/.claude/skills", "./.claude/skills"], }, - })); + }); + const defaultConfig = loadConfig(configEnv); + const defaultLoaded = loadWorkspaceSkills(defaultConfig, projectRoot); + assert.equal(defaultLoaded.skills.some((skill) => skill.name === "foo/bar"), true); + + const config = loadConfig({ + ...configEnv, + DEVSPACE_EXPERIMENTAL_SKILL_URIS: "1", + }); const loaded = loadWorkspaceSkills(config, projectRoot); assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true); assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true); @@ -266,6 +275,20 @@ try { const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill"); assert.ok(projectSkill); + const defaultProjectSkill = defaultLoaded.skills.find( + (skill) => skill.name === "agent-project-skill", + ); + assert.ok(defaultProjectSkill); + assert.match(formatPathForPrompt(defaultProjectSkill.filePath), /SKILL\.md$/); + assert.equal( + resolveSkillReadPath(defaultLoaded.skills, defaultProjectSkill.filePath, false)?.absolutePath, + defaultProjectSkill.filePath, + ); + assert.equal( + resolveSkillReadPath(defaultLoaded.skills, "skills://agent-project-skill", false), + undefined, + ); + assert.equal(formatSkillUri(projectSkill), "skills://agent-project-skill"); assert.throws( () => formatSkillUri({ ...projectSkill, name: "foo/bar" }), @@ -275,6 +298,7 @@ try { const skillFileRead = resolveSkillReadPath( loaded.skills, "skills://agent-project-skill", + true, ); assert.equal(skillFileRead?.absolutePath, projectSkill.filePath); @@ -284,20 +308,25 @@ try { resolveSkillReadPath( loaded.skills, "skills://agent-project-skill/references.md", + true, )?.absolutePath, resourcePath, ); - assert.equal(resolveSkillReadPath(loaded.skills, projectSkill.filePath), undefined); + assert.equal(resolveSkillReadPath(loaded.skills, projectSkill.filePath, true), undefined); assert.throws( - () => resolveSkillReadPath(loaded.skills, "skills://missing"), + () => resolveSkillReadPath(loaded.skills, "skills://missing", true), /Unknown skill/, ); assert.throws( - () => resolveSkillReadPath(loaded.skills, "skills://foo%2Fbar"), + () => resolveSkillReadPath(loaded.skills, "skills://foo%2Fbar", true), /Invalid skill URI/, ); assert.throws( - () => resolveSkillReadPath(loaded.skills, "skills://agent-project-skill/../secret"), + () => resolveSkillReadPath( + loaded.skills, + "skills://agent-project-skill/../secret", + true, + ), /outside skill directory/, ); } finally { diff --git a/src/skills.ts b/src/skills.ts index e63ac5578..acf8d2046 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -8,7 +8,7 @@ import { writeFileSync, } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { loadSkills, @@ -111,11 +111,13 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk }); const withoutSubagents = withoutSubagentsSkill(result); - const routable = { - skills: withoutSubagents.skills.filter((skill) => isRoutableSkillName(skill.name)), - diagnostics: withoutSubagents.diagnostics, - }; - if (!config.subagents.enabled) return routable; + const available = config.experimentalSkillUris + ? { + skills: withoutSubagents.skills.filter((skill) => isRoutableSkillName(skill.name)), + diagnostics: withoutSubagents.diagnostics, + } + : withoutSubagents; + if (!config.subagents.enabled) return available; const managedDir = dirname(join(config.devspaceSkillsDir, SUBAGENTS_SKILL)); const managed = loadSkillsFromDir({ @@ -127,8 +129,8 @@ export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSk } return { - skills: [...routable.skills, managed], - diagnostics: routable.diagnostics, + skills: [...available.skills, managed], + diagnostics: available.diagnostics, }; } @@ -145,7 +147,28 @@ function withoutSubagentsSkill(result: LoadSkillsResult): LoadedSkills { export function resolveSkillReadPath( skills: Skill[], inputPath: string, + experimentalSkillUris: boolean, ): SkillReadResolution | undefined { + if (!experimentalSkillUris) { + const absolutePath = resolve(expandHomePath(inputPath)); + + for (const skill of skills) { + const skillFilePath = resolve(skill.filePath); + if (absolutePath === skillFilePath) { + return { absolutePath, skill }; + } + } + + for (const skill of skills) { + const baseDir = resolve(skill.baseDir); + if (!isPathInsideRoot(absolutePath, baseDir)) continue; + + return { absolutePath, skill }; + } + + return undefined; + } + if (!inputPath.startsWith(SKILL_URI_PREFIX)) return undefined; const skillReference = inputPath.slice(SKILL_URI_PREFIX.length); @@ -189,6 +212,18 @@ export function formatSkillUri(skill: Skill): string { return `${SKILL_URI_PREFIX}${skill.name}`; } +export function formatPathForPrompt(path: string): string { + const home = resolve(homedir()); + const resolvedPath = resolve(path); + + if (resolvedPath === home) return "~"; + if (resolvedPath.startsWith(`${home}${sep}`)) { + return `~/${resolvedPath.slice(home.length + 1).split(sep).join("/")}`; + } + + return resolvedPath.split(sep).join("/"); +} + function isRoutableSkillName(name: string): boolean { return name.length <= MAX_SKILL_NAME_LENGTH && SKILL_NAME_PATTERN.test(name); } diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index d0fd5efee..7383f6c18 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -344,7 +344,7 @@ test("workspace cache evicts old contexts without losing advertised skill reads" ); await writeFile(resourceFile, "reference\n"); - const config = loadConfig(writeTestDevspaceConfig( + const configEnv = writeTestDevspaceConfig( join(context.root, ".bounded-home"), { server: { port: 1 }, @@ -355,7 +355,11 @@ test("workspace cache evicts old contexts without losing advertised skill reads" skills: { agentDir }, subagents: { enabled: true, instructions: "on-demand", providers: [] }, }, - )); + ); + const config = loadConfig({ + ...configEnv, + DEVSPACE_EXPERIMENTAL_SKILL_URIS: "1", + }); const store = new SqliteWorkspaceStore(stateDir); try { diff --git a/src/workspaces.ts b/src/workspaces.ts index aad6b5097..293e82c25 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -414,8 +414,32 @@ export class WorkspaceRegistry { } async resolveReadPath(workspace: Workspace, inputPath: string): Promise { - const skillRead = resolveSkillReadPath(workspace.skills, inputPath); - if (skillRead) { + if (this.config.experimentalSkillUris) { + const skillRead = resolveSkillReadPath(workspace.skills, inputPath, true); + if (skillRead) { + return { + absolutePath: await resolveCanonicalAllowedPath( + skillRead.absolutePath, + workspace.root, + [skillRead.skill.baseDir], + ), + skillRead, + }; + } + + return { + absolutePath: await this.resolvePath(workspace, inputPath), + }; + } + + try { + return { + absolutePath: await this.resolvePath(workspace, inputPath), + }; + } catch (workspaceError) { + const skillRead = resolveSkillReadPath(workspace.skills, inputPath, false); + if (!skillRead) throw workspaceError; + return { absolutePath: await resolveCanonicalAllowedPath( skillRead.absolutePath, @@ -425,10 +449,6 @@ export class WorkspaceRegistry { skillRead, }; } - - return { - absolutePath: await this.resolvePath(workspace, inputPath), - }; } async resolveWorkingDirectory(workspace: Workspace, workingDirectory: string | undefined): Promise { From 8c80fb7e354c2fa2eab8c502473e1550be6a9f09 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:52:11 +0530 Subject: [PATCH 7/8] feat(skills): resolve URIs in shell commands --- docs/configuration.md | 2 ++ src/server.test.ts | 30 +++++++++++++++++++++++ src/tool-surfaces/claude.ts | 16 +++++++++++-- src/tool-surfaces/codex.ts | 16 +++++++++++-- src/tool-surfaces/shared.ts | 47 +++++++++++++++++++++++++++++++++++++ 5 files changed, 107 insertions(+), 4 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3e4bc9921..9c2d6e9ae 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -118,6 +118,8 @@ By default, discovered skills keep their filesystem paths for compatibility with existing MCP hosts. Set `DEVSPACE_EXPERIMENTAL_SKILL_URIS=1` to expose logical `skills://` URIs instead. The bare URI loads the skill entry file; bundled resources use `skills:///`. +As an experimental compatibility workaround, standalone skill URI arguments in +the shell tools are resolved to their local files immediately before execution. Skill URIs are experimental and may be removed in favor of the MCP Skills extension as host support matures. diff --git a/src/server.test.ts b/src/server.test.ts index 6774a9338..8fefa8289 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -455,6 +455,36 @@ test("open_workspace advertises experimental skill URIs when enabled", async (t) assert.doesNotMatch(String(opened.instruction), /# DevSpace subagents/); }); +test("experimental skill URIs work as shell command arguments", async (t) => { + for (const toolMode of ["codex", "claude"] as const) { + await t.test(toolMode, async (t) => { + const context = await fixture(t, { + toolMode, + localAgentProviders: [{ name: "codex", available: true }], + experimentalSkillUris: true, + }); + const workspaceId = structuredContent( + await callOpen(context.client, context.project, `skill-shell-${toolMode}`), + ).workspace_id; + assert.equal(typeof workspaceId, "string"); + + const skillArgument = toolMode === "codex" + ? "skills://subagents" + : '"skills://subagents"'; + const command = + `node -p "require('node:fs').readFileSync(process.argv[1],'utf8')" ${skillArgument}`; + const result = structuredContent(await context.client.callTool({ + name: toolMode === "codex" ? "exec_command" : "bash", + arguments: toolMode === "codex" + ? { workspace_id: workspaceId, cmd: command } + : { workspace_id: workspaceId, command }, + })); + + assert.match(String(result.result), /# DevSpace subagents/); + }); + } +}); + test("open_workspace preloads subagent instructions when configured", async (t) => { const context = await fixture(t, { localAgentProviders: [{ name: "codex", available: true }], diff --git a/src/tool-surfaces/claude.ts b/src/tool-surfaces/claude.ts index ffb083ab5..241397cb6 100644 --- a/src/tool-surfaces/claude.ts +++ b/src/tool-surfaces/claude.ts @@ -16,6 +16,7 @@ import { import { contentText, countDiffStats, + expandSkillUrisInShellCommand, logFailedToolResponse, logToolCall, resultOutputSchema, @@ -182,7 +183,11 @@ function registerShellTool(context: ToolRegistrationContext): void { toolNames.shell, { title: "Bash", - description: CLAUDE_SHELL_DESCRIPTION, + description: + CLAUDE_SHELL_DESCRIPTION + + (config.experimentalSkillUris + ? " Standalone skills:// arguments are resolved before execution." + : ""), inputSchema: { workspace_id: z.string().describe(workspaceIdDescription), command: z @@ -213,7 +218,14 @@ function registerShellTool(context: ToolRegistrationContext): void { workspace, workingDirectory, ); - const response = await runShellTool(input, { + const command = await expandSkillUrisInShellCommand( + config, + workspaces, + workspace, + input.command, + "bash", + ); + const response = await runShellTool({ ...input, command }, { cwd, }); diff --git a/src/tool-surfaces/codex.ts b/src/tool-surfaces/codex.ts index 9770897cb..13f15d352 100644 --- a/src/tool-surfaces/codex.ts +++ b/src/tool-surfaces/codex.ts @@ -14,6 +14,7 @@ import { } from "./types.js"; import { contentText, + expandSkillUrisInShellCommand, resultOutputSchema, runLoggedToolOperation, textBlock, @@ -147,7 +148,11 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { { title: "Execute command", description: - "Run a shell command in a workspace with the user's local permissions. Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin.", + "Run a shell command in a workspace with the user's local permissions. " + + (config.experimentalSkillUris + ? "Standalone skills:// arguments are resolved before execution. " + : "") + + "Returns the result when it exits during the yield window, otherwise returns a session_id for write_stdin.", inputSchema: { workspace_id: z.string().describe(workspaceIdDescription), cmd: z.string().min(1).describe("Shell command to execute."), @@ -228,9 +233,16 @@ function registerCodexProcessTools(context: ToolRegistrationContext): void { workspace, workingDirectory, ); + const command = await expandSkillUrisInShellCommand( + config, + workspaces, + workspace, + cmd, + "native", + ); return processSessions.start({ workspaceId, - command: cmd, + command, cwd, workspaceRoot: workspace.root, tty, diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts index 1366d5a97..86a551aed 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -1,6 +1,7 @@ import * as z from "zod/v4"; import { logEvent, commandPreview } from "../logger.js"; import type { ServerConfig } from "../config.js"; +import type { Workspace, WorkspaceRegistry } from "../workspaces.js"; import { WORKSPACE_APP_URI, type DiffStats, @@ -107,6 +108,52 @@ export function textBlock(text: string): ToolContent { return { type: "text", text }; } +const SKILL_URI_SHELL_ARGUMENT = + /"(skills:\/\/[^"\r\n]+)"|'(skills:\/\/[^'\r\n]+)'|(skills:\/\/[^\s"'\`|&;()<>]+)/g; + +export async function expandSkillUrisInShellCommand( + config: ServerConfig, + workspaces: WorkspaceRegistry, + workspace: Workspace, + command: string, + shell: "native" | "bash", +): Promise { + if (!config.experimentalSkillUris || !command.includes("skills://")) { + return command; + } + + // Experimental workaround: shell tools cannot consume skills:// URIs themselves. + // Rewrite only standalone URI arguments until MCP Skills/resources are broadly host-native. + const matches = Array.from(command.matchAll(SKILL_URI_SHELL_ARGUMENT)); + if (matches.length === 0) return command; + + let expanded = ""; + let offset = 0; + for (const match of matches) { + const index = match.index; + const uri = match[1] ?? match[2] ?? match[3]; + if (index === undefined || uri === undefined) continue; + + const resolved = await workspaces.resolveReadPath(workspace, uri); + expanded += command.slice(offset, index); + expanded += quoteShellPath(resolved.absolutePath, shell); + offset = index + match[0].length; + } + + return expanded + command.slice(offset); +} + +function quoteShellPath(path: string, shell: "native" | "bash"): string { + if (shell === "native" && process.platform === "win32") { + return `"${path}"`; + } + + const shellPath = process.platform === "win32" + ? path.replace(/\\/g, "/") + : path; + return `'${shellPath.replace(/'/g, `'\\''`)}'`; +} + export function countDiffStats(diff: string | undefined): DiffStats { if (!diff) return { additions: 0, removals: 0 }; From 44d88ece28fbb3487be1c81650f37cf7dff4b538 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 19 Sep 2026 22:58:51 +0530 Subject: [PATCH 8/8] fix(skills): bound shell URI rewrites --- src/server.test.ts | 9 +++++++++ src/tool-surfaces/shared.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/server.test.ts b/src/server.test.ts index 8fefa8289..552c36512 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -481,6 +481,15 @@ test("experimental skill URIs work as shell command arguments", async (t) => { })); assert.match(String(result.result), /# DevSpace subagents/); + + const literalCommand = `node -p "process.argv[1]" "prefixskills://subagents"`; + const literal = structuredContent(await context.client.callTool({ + name: toolMode === "codex" ? "exec_command" : "bash", + arguments: toolMode === "codex" + ? { workspace_id: workspaceId, cmd: literalCommand } + : { workspace_id: workspaceId, command: literalCommand }, + })); + assert.match(String(literal.result), /prefixskills:\/\/subagents/); }); } }); diff --git a/src/tool-surfaces/shared.ts b/src/tool-surfaces/shared.ts index 86a551aed..c3d02a357 100644 --- a/src/tool-surfaces/shared.ts +++ b/src/tool-surfaces/shared.ts @@ -109,7 +109,7 @@ export function textBlock(text: string): ToolContent { } const SKILL_URI_SHELL_ARGUMENT = - /"(skills:\/\/[^"\r\n]+)"|'(skills:\/\/[^'\r\n]+)'|(skills:\/\/[^\s"'\`|&;()<>]+)/g; + /"(skills:\/\/[^"\r\n]+)"|'(skills:\/\/[^'\r\n]+)'|(?])(skills:\/\/[^\s"'\`|&;()<>]+)/g; export async function expandSkillUrisInShellCommand( config: ServerConfig,