Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
skill path before following that skill. For early testing,
`DEVSPACE_EXPERIMENTAL_SKILL_URIS=1` replaces physical skill paths with
`skills://<name>` URIs; bundled resources then use
`skills://<name>/<relative-path>`.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Set `skills.enabled` to `false` to hide skills from workspace output. Enable
Subagents and choose providers through `devspace init` or the persisted provider
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ 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.

By default, discovered skills keep their filesystem paths for compatibility
with existing MCP hosts. Set `DEVSPACE_EXPERIMENTAL_SKILL_URIS=1` to expose
logical `skills://<name>` URIs instead. The bare URI loads the skill entry
file; bundled resources use `skills://<name>/<relative-path>`.
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.

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
Expand Down
7 changes: 4 additions & 3 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 path
before following it. With `DEVSPACE_EXPERIMENTAL_SKILL_URIS=1`, DevSpace
instead advertises `skills://<name>`; bundled resources use
`skills://<name>/<relative-path>`.

## Review Card Does Not Appear

Expand Down
5 changes: 5 additions & 0 deletions src/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}
Expand Down
2 changes: 2 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ServerConfig {
artifactsEnabled: boolean;
artifactMaxFileBytes: number;
skillsEnabled: boolean;
experimentalSkillUris: boolean;
skillPaths: string[];
devspaceSkillsDir: string;
devspaceAgentsDir: string;
Expand Down Expand Up @@ -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),
Expand Down
68 changes: 65 additions & 3 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,10 +435,65 @@ 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<Record<string, unknown>>;
assert.equal(skills.some((skill) => skill.name === "subagents"), true);
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<Record<string, unknown>>;
const subagents = skills.find((skill) => skill.name === "subagents");
assert.ok(subagents);
assert.equal(subagents.path, "skills://subagents");
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/);

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/);
});
}
});

test("open_workspace preloads subagent instructions when configured", async (t) => {
const context = await fixture(t, {
localAgentProviders: [{ name: "codex", available: true }],
Expand Down Expand Up @@ -722,6 +777,7 @@ async function fixture(
subagents?: SubagentsConfig;
toolMode?: ToolMode;
uiEnabled?: boolean;
experimentalSkillUris?: boolean;
} = {},
): Promise<ServerFixture> {
const root = await mkdtemp(join(tmpdir(), "devspace-server-test-"));
Expand Down Expand Up @@ -754,7 +810,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 },
Expand All @@ -763,7 +819,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,
Expand Down
18 changes: 12 additions & 6 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { formatPathForPrompt, formatSkillUri } from "./skills.js";
import { DEVSPACE_VERSION } from "./version.js";
import { createWorkspaceStore } from "./workspace-store.js";
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
Expand Down Expand Up @@ -131,14 +131,18 @@ 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 ${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.`;

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;
Expand Down Expand Up @@ -467,7 +471,9 @@ function registerMcpSurface(
.map((skill) => ({
name: skill.name,
description: skill.description,
path: formatPathForPrompt(skill.filePath),
path: config.experimentalSkillUris
? formatSkillUri(skill)
: formatPathForPrompt(skill.filePath),
}));
const agentCatalog = buildLocalAgentCatalog(
config.subagents,
Expand Down Expand Up @@ -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 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 ${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
? [
Expand Down Expand Up @@ -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 skill path before proceeding."
? `If available skills were returned and a task matches one, read the returned ${skillReferenceLabel(config)} before proceeding.`
: "",
]
.filter(Boolean)
Expand All @@ -635,7 +641,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 ${skillReferenceLabel(config)} returned by open_workspace.`
: "File path to read, relative to the workspace root.",
),
offset: z
Expand Down
84 changes: 79 additions & 5 deletions src/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { loadConfig } from "./config.js";
import {
effectiveSkillPaths,
formatPathForPrompt,
formatSkillUri,
loadWorkspaceSkills,
resolveSkillReadPath,
} from "./skills.js";
Expand Down Expand Up @@ -35,6 +36,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 });

Expand Down Expand Up @@ -160,6 +162,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, {
Expand All @@ -169,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);
Expand All @@ -187,7 +208,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",
Expand Down Expand Up @@ -247,14 +275,60 @@ try {

const projectSkill = loaded.skills.find((skill) => skill.name === "agent-project-skill");
assert.ok(projectSkill);
assert.match(formatPathForPrompt(projectSkill.filePath), /SKILL\.md$/);
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,
);

const skillFileRead = resolveSkillReadPath(loaded.skills, projectSkill.filePath);
assert.equal(formatSkillUri(projectSkill), "skills://agent-project-skill");
assert.throws(
() => formatSkillUri({ ...projectSkill, name: "foo/bar" }),
/Invalid skill name/,
);

const skillFileRead = resolveSkillReadPath(
loaded.skills,
"skills://agent-project-skill",
true,
);
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",
true,
)?.absolutePath,
resourcePath,
);
assert.equal(resolveSkillReadPath(loaded.skills, projectSkill.filePath, true), undefined);
assert.throws(
() => resolveSkillReadPath(loaded.skills, "skills://missing", true),
/Unknown skill/,
);
assert.throws(
() => resolveSkillReadPath(loaded.skills, "skills://foo%2Fbar", true),
/Invalid skill URI/,
);
assert.throws(
() => resolveSkillReadPath(
loaded.skills,
"skills://agent-project-skill/../secret",
true,
),
/outside skill directory/,
);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
Expand Down
Loading
Loading