Skip to content
Merged
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
14 changes: 10 additions & 4 deletions docs/chatgpt-coding-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,17 @@ new context during later tool calls.

Skills are enabled by default for coding-agent workflows.

DevSpace discovers skills from:
DevSpace discovers standard Agent Skills from:

- `DEVSPACE_AGENT_DIR`, which defaults to `~/.codex`
- project `.pi/skills`
- optional paths from `DEVSPACE_SKILL_PATHS`
- `~/.agents/skills`
- project `.agents/skills`

It also keeps compatibility with:

- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills`
- additional paths from `DEVSPACE_SKILL_PATHS`

Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed.

When `open_workspace` returns matching skills, the model should read the
advertised `SKILL.md` before following that skill.
Expand Down
18 changes: 15 additions & 3 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,25 @@ sessions.
| Variable | Purpose |
| --- | --- |
| `DEVSPACE_SKILLS` | Set to `0` to hide skills. Enabled by default. |
| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`. |
| `DEVSPACE_SKILL_PATHS` | Optional comma-separated skill directories. |
| `DEVSPACE_AGENT_DIR` | Defaults to `~/.codex`; its `skills` child is loaded for compatibility. |
| `DEVSPACE_SKILL_PATHS` | Optional comma-separated additional skill directories. |

DevSpace discovers standard Agent Skills from:

- `~/.agents/skills`
- project `.agents/skills`

It also keeps compatibility with:

- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills`
- additional paths from `DEVSPACE_SKILL_PATHS`

Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed.

Example:

```bash
DEVSPACE_SKILL_PATHS="$HOME/.codex/skills,$HOME/.claude/skills" \
DEVSPACE_SKILL_PATHS="$HOME/.claude/skills,$HOME/company/skills" \
npx @waishnav/devspace serve
```

Expand Down
14 changes: 10 additions & 4 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,11 +193,17 @@ Skills are enabled by default. Check:
DEVSPACE_SKILLS=1 npx @waishnav/devspace serve
```

DevSpace looks in:
DevSpace looks in standard Agent Skills locations:

- `DEVSPACE_AGENT_DIR`, defaulting to `~/.codex`
- project `.pi/skills`
- `DEVSPACE_SKILL_PATHS`
- `~/.agents/skills`
- project `.agents/skills`

It also checks compatibility and custom paths:

- `DEVSPACE_AGENT_DIR/skills`, defaulting to `~/.codex/skills`
- additional paths from `DEVSPACE_SKILL_PATHS`

Legacy project paths such as `.pi/skills` can be added through `DEVSPACE_SKILL_PATHS` when needed.

If a skill appears in `open_workspace`, the model must read that skill's
`SKILL.md` before reading other files inside the skill directory.
Expand Down
3 changes: 1 addition & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,7 @@ function parsePathList(value: string | undefined): string[] {
value
?.split(",")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => resolve(expandHomePath(entry))) ?? []
.filter(Boolean) ?? []
);
}

Expand Down
95 changes: 92 additions & 3 deletions src/skills.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,79 @@ import { join } from "node:path";
import assert from "node:assert/strict";
import { loadConfig } from "./config.js";
import {
effectiveSkillPaths,
formatPathForPrompt,
loadWorkspaceSkills,
resolveSkillReadPath,
} from "./skills.js";

const root = await mkdtemp(join(tmpdir(), "devspace-skills-test-"));
const originalHome = process.env.HOME;
const originalUserProfile = process.env.USERPROFILE;

try {
process.env.HOME = root;
process.env.USERPROFILE = root;
const projectRoot = join(root, "project");
const agentDir = join(root, "agent");
const explicitSkills = join(root, "explicit-skills");
const globalAgentsSkills = join(root, ".agents", "skills");
const projectAgentsSkills = join(projectRoot, ".agents", "skills");
const globalClaudeSkills = join(root, ".claude", "skills");
const projectClaudeSkills = join(projectRoot, ".claude", "skills");
await mkdir(join(globalAgentsSkills, "agent-global-skill"), { recursive: true });
await mkdir(join(projectAgentsSkills, "agent-project-skill"), { recursive: true });
await mkdir(join(globalClaudeSkills, "claude-global-skill"), { recursive: true });
await mkdir(join(projectClaudeSkills, "claude-project-skill"), { recursive: true });
await mkdir(join(projectRoot, ".pi", "skills", "project-skill"), { recursive: true });
await mkdir(join(agentDir, "skills", "global-skill"), { recursive: true });
await mkdir(join(explicitSkills, "duplicate"), { recursive: true });
await mkdir(join(explicitSkills, "disabled"), { recursive: true });

await writeFile(
join(globalAgentsSkills, "agent-global-skill", "SKILL.md"),
[
"---",
"name: agent-global-skill",
"description: Agent global skill description.",
"---",
"",
"# Agent Global Skill",
].join("\n"),
);
await writeFile(
join(projectAgentsSkills, "agent-project-skill", "SKILL.md"),
[
"---",
"name: agent-project-skill",
"description: Agent project skill description.",
"---",
"",
"# Agent Project Skill",
].join("\n"),
);
await writeFile(
join(globalClaudeSkills, "claude-global-skill", "SKILL.md"),
[
"---",
"name: claude-global-skill",
"description: Claude global skill description.",
"---",
"",
"# Claude Global Skill",
].join("\n"),
);
await writeFile(
join(projectClaudeSkills, "claude-project-skill", "SKILL.md"),
[
"---",
"name: claude-project-skill",
"description: Claude project skill description.",
"---",
"",
"# Claude Project Skill",
].join("\n"),
);
await writeFile(
join(projectRoot, ".pi", "skills", "project-skill", "SKILL.md"),
[
Expand Down Expand Up @@ -79,17 +136,45 @@ try {
const config = loadConfig({
DEVSPACE_ALLOWED_ROOTS: projectRoot,
DEVSPACE_AGENT_DIR: agentDir,
DEVSPACE_SKILL_PATHS: explicitSkills,
DEVSPACE_SKILL_PATHS: [explicitSkills, "~/.claude/skills", "./.claude/skills"].join(","),
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
PORT: "1",
});
const loaded = loadWorkspaceSkills(config, projectRoot);
assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), true);
assert.equal(loaded.skills.some((skill) => skill.name === "agent-global-skill"), true);
assert.equal(loaded.skills.some((skill) => skill.name === "agent-project-skill"), true);
assert.equal(loaded.skills.some((skill) => skill.name === "claude-global-skill"), true);
assert.equal(loaded.skills.some((skill) => skill.name === "claude-project-skill"), true);
assert.equal(loaded.skills.some((skill) => skill.name === "project-skill"), 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.diagnostics.some((diagnostic) => diagnostic.type === "collision"), true);

const projectSkill = loaded.skills.find((skill) => skill.name === "project-skill");
const duplicateConfig = loadConfig({
DEVSPACE_ALLOWED_ROOTS: projectRoot,
DEVSPACE_AGENT_DIR: agentDir,
DEVSPACE_SKILL_PATHS: [explicitSkills, "./.agents/skills"].join(","),
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
PORT: "1",
});
assert.equal(
effectiveSkillPaths(duplicateConfig, projectRoot).filter((path) => path === projectAgentsSkills).length,
1,
);

const legacyPiConfig = loadConfig({
DEVSPACE_ALLOWED_ROOTS: projectRoot,
DEVSPACE_AGENT_DIR: agentDir,
DEVSPACE_SKILL_PATHS: [explicitSkills, join(projectRoot, ".pi", "skills")].join(","),
DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough",
PORT: "1",
});
assert.equal(
loadWorkspaceSkills(legacyPiConfig, projectRoot).skills.some((skill) => skill.name === "project-skill"),
true,
);

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

Expand All @@ -106,5 +191,9 @@ try {
false,
);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
else process.env.USERPROFILE = originalUserProfile;
await rm(root, { recursive: true, force: true });
}
28 changes: 25 additions & 3 deletions src/skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { existsSync } from "node:fs";
import { homedir } from "node:os";
import { resolve, sep } from "node:path";
import { join, resolve, sep } from "node:path";
import {
loadSkills,
type Skill,
Expand All @@ -19,14 +20,35 @@ export interface SkillReadResolution {
isSkillFile: boolean;
}

export function effectiveSkillPaths(config: ServerConfig, cwd: string): string[] {
const defaultPaths = [
join(homedir(), ".agents", "skills"),
resolve(cwd, ".agents", "skills"),
join(config.agentDir, "skills"),
].filter((path) => existsSync(path));

const seen = new Set<string>();
return [...defaultPaths, ...config.skillPaths]
.map((path) => resolveSkillPath(path, cwd))
.filter((path) => {
if (seen.has(path)) return false;
seen.add(path);
return true;
});
}

function resolveSkillPath(path: string, cwd: string): string {
return resolve(cwd, expandHomePath(path));
}

export function loadWorkspaceSkills(config: ServerConfig, cwd: string): LoadedSkills {
if (!config.skillsEnabled) return { skills: [], diagnostics: [] };

return loadSkills({
cwd,
agentDir: config.agentDir,
skillPaths: config.skillPaths,
includeDefaults: true,
skillPaths: effectiveSkillPaths(config, cwd),
includeDefaults: false,
});
}

Expand Down
Loading