diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 3b0efaf..13269f5 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -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. diff --git a/docs/configuration.md b/docs/configuration.md index fa3a61d..3229363 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 ``` diff --git a/docs/gotchas.md b/docs/gotchas.md index 33823b5..c6ef1d2 100644 --- a/docs/gotchas.md +++ b/docs/gotchas.md @@ -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. diff --git a/src/config.ts b/src/config.ts index 5bf96f8..d065b17 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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) ?? [] ); } diff --git a/src/skills.test.ts b/src/skills.test.ts index 1b0ebae..16a49c8 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -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"), [ @@ -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$/); @@ -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 }); } diff --git a/src/skills.ts b/src/skills.ts index 20a3520..e3da62f 100644 --- a/src/skills.ts +++ b/src/skills.ts @@ -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, @@ -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(); + 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, }); }