Skip to content

Commit 3e0d46a

Browse files
macclaude
authored andcommitted
feat: add project-level team skills (.dex/skills/)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 1c5a865 commit 3e0d46a

9 files changed

Lines changed: 89 additions & 17 deletions

File tree

src/acp/__tests__/server.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ function makeSkill(name: string): LoadedSkill {
4545
},
4646
handler: async () => {},
4747
path: `/skills/${name}`,
48-
builtIn: true,
48+
source: "built-in",
4949
};
5050
}
5151

src/cli/commands/skill.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,12 @@ export function createSkillCommand(
5151

5252
console.log(chalk.bold("\nAvailable Skills:\n"));
5353
for (const skill of skills) {
54-
const tag = skill.builtIn
55-
? chalk.gray(" [built-in]")
56-
: chalk.cyan(" [user]");
54+
const tagMap = {
55+
"built-in": chalk.gray(" [built-in]"),
56+
user: chalk.cyan(" [user]"),
57+
project: chalk.magenta(" [project]"),
58+
};
59+
const tag = tagMap[skill.source];
5760
const aliases = skill.manifest.aliases?.length
5861
? chalk.gray(` (aliases: ${skill.manifest.aliases.join(", ")})`)
5962
: "";
@@ -76,7 +79,7 @@ export function createSkillCommand(
7679
console.log(chalk.bold(`\n${m.name} v${m.version}`));
7780
console.log(`${m.description}\n`);
7881
console.log(` Path: ${skill.path}`);
79-
console.log(` Built-in: ${skill.builtIn}`);
82+
console.log(` Source: ${skill.source}`);
8083
if (m.aliases?.length) {
8184
console.log(` Aliases: ${m.aliases.join(", ")}`);
8285
}

src/cli/program.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import chalk from "chalk";
66
import { loadConfig, getGlobalConfigDir } from "../core/config.js";
77
import { createLogger } from "../core/logger.js";
88
import { SkillRegistry } from "../skills/registry.js";
9-
import { loadBuiltInSkills, loadUserSkills } from "../skills/loader.js";
9+
import { loadBuiltInSkills, loadUserSkills, loadProjectSkills } from "../skills/loader.js";
1010
import { registerSkillShortcuts } from "./shortcuts.js";
1111
import { createRunCommand } from "./commands/run.js";
1212
import { createServeCommand } from "./commands/serve.js";
@@ -40,6 +40,9 @@ export async function createProgram(): Promise<Command> {
4040
];
4141
await loadUserSkills(registry, userSkillDirs, logger);
4242

43+
// Load project-level skills from .dex/skills/
44+
await loadProjectSkills(registry, process.cwd(), logger);
45+
4346
logger.debug(`Loaded ${registry.list().length} skills`);
4447

4548
// Build CLI program
@@ -89,12 +92,18 @@ export async function createProgram(): Promise<Command> {
8992
return;
9093
}
9194
await mkdir(dir, { recursive: true });
95+
await mkdir(join(dir, "skills"), { recursive: true });
9296
const { writeFile } = await import("node:fs/promises");
9397
await writeFile(
9498
join(dir, "config.json"),
9599
JSON.stringify({}, null, 2) + "\n",
96100
);
97101
console.log(chalk.green("Initialized .dex/ in current directory."));
102+
console.log(
103+
chalk.gray(
104+
"Add skills to .dex/skills/ to share with your team.",
105+
),
106+
);
98107

99108
// Show setup hint if API key is not configured
100109
if (!config.apiKey && !process.env.ANTHROPIC_API_KEY) {

src/skills/__tests__/executor-acp.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ function makeSkill(handler: (ctx: SkillContext) => Promise<void>): LoadedSkill {
3232
},
3333
handler,
3434
path: "/test",
35-
builtIn: false,
35+
source: "user",
3636
};
3737
}
3838

src/skills/__tests__/executor.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function makeTestSkill(
2727
},
2828
handler,
2929
path: "/test",
30-
builtIn: false,
30+
source: "user",
3131
};
3232
}
3333

src/skills/__tests__/loader.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { loadBuiltInSkills, loadUserSkills } from "../loader.js";
2+
import { loadBuiltInSkills, loadUserSkills, loadProjectSkills } from "../loader.js";
33
import { SkillRegistry } from "../registry.js";
44
import { createLogger } from "../../core/logger.js";
55
import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises";
@@ -42,7 +42,7 @@ describe("loadBuiltInSkills", () => {
4242

4343
expect(registry.has("test-skill")).toBe(true);
4444
expect(registry.has("ts")).toBe(true);
45-
expect(registry.get("test-skill").builtIn).toBe(false);
45+
expect(registry.get("test-skill").source).toBe("user");
4646
} finally {
4747
await rm(tmpDir, { recursive: true });
4848
}
@@ -83,7 +83,7 @@ describe("loadUserSkills", () => {
8383
await loadUserSkills(registry, [tmpDir], logger);
8484

8585
expect(registry.has("my-skill")).toBe(true);
86-
expect(registry.get("my-skill").builtIn).toBe(false);
86+
expect(registry.get("my-skill").source).toBe("user");
8787
} finally {
8888
await rm(tmpDir, { recursive: true });
8989
}
@@ -149,3 +149,40 @@ describe("loadUserSkills", () => {
149149
}
150150
});
151151
});
152+
153+
describe("loadProjectSkills", () => {
154+
it("should skip when .dex/skills/ does not exist", async () => {
155+
const registry = new SkillRegistry();
156+
await loadProjectSkills(registry, "/tmp/nonexistent-project-xxx", logger);
157+
expect(registry.list()).toHaveLength(0);
158+
});
159+
160+
it("should load a valid project skill", async () => {
161+
const tmpDir = await mkdtemp(join(tmpdir(), "dex-project-"));
162+
const skillDir = join(tmpDir, ".dex", "skills", "team-skill");
163+
try {
164+
await mkdir(skillDir, { recursive: true });
165+
await writeFile(
166+
join(skillDir, "manifest.json"),
167+
JSON.stringify({
168+
name: "team-skill",
169+
version: "0.1.0",
170+
description: "A project team skill",
171+
inputs: {},
172+
}),
173+
);
174+
await writeFile(
175+
join(skillDir, "handler.ts"),
176+
"export default async function() {}",
177+
);
178+
179+
const registry = new SkillRegistry();
180+
await loadProjectSkills(registry, tmpDir, logger);
181+
182+
expect(registry.has("team-skill")).toBe(true);
183+
expect(registry.get("team-skill").source).toBe("project");
184+
} finally {
185+
await rm(tmpDir, { recursive: true });
186+
}
187+
});
188+
});

src/skills/__tests__/registry.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function makeSkill(
1717
},
1818
handler: async () => {},
1919
path: `/skills/${name}`,
20-
builtIn: true,
20+
source: "built-in",
2121
};
2222
}
2323

src/skills/loader.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ function getBuiltInPath(logger: Logger): string | null {
3939

4040
async function loadSkillFromDir(
4141
dir: string,
42-
builtIn: boolean,
42+
source: "built-in" | "user" | "project",
4343
logger: Logger,
4444
): Promise<LoadedSkill | null> {
4545
const manifestPath = join(dir, "manifest.json");
@@ -71,7 +71,7 @@ async function loadSkillFromDir(
7171
return null;
7272
}
7373

74-
return { manifest, handler, path: dir, builtIn };
74+
return { manifest, handler, path: dir, source };
7575
} catch (err) {
7676
logger.warn(
7777
`Failed to load skill from ${dir}: ${err instanceof Error ? err.message : err}`,
@@ -92,7 +92,7 @@ export async function loadBuiltInSkills(
9292
if (!entry.isDirectory()) continue;
9393
const skill = await loadSkillFromDir(
9494
join(builtInPath, entry.name),
95-
true,
95+
"built-in",
9696
logger,
9797
);
9898
if (skill) {
@@ -115,7 +115,7 @@ export async function loadUserSkills(
115115
if (!entry.isDirectory()) continue;
116116
const skill = await loadSkillFromDir(
117117
join(dir, entry.name),
118-
false,
118+
"user",
119119
logger,
120120
);
121121
if (skill) {
@@ -125,3 +125,26 @@ export async function loadUserSkills(
125125
}
126126
}
127127
}
128+
129+
export async function loadProjectSkills(
130+
registry: SkillRegistry,
131+
cwd: string,
132+
logger: Logger,
133+
): Promise<void> {
134+
const projectSkillsDir = join(cwd, ".dex", "skills");
135+
if (!existsSync(projectSkillsDir)) return;
136+
137+
const entries = await readdir(projectSkillsDir, { withFileTypes: true });
138+
for (const entry of entries) {
139+
if (!entry.isDirectory()) continue;
140+
const skill = await loadSkillFromDir(
141+
join(projectSkillsDir, entry.name),
142+
"project",
143+
logger,
144+
);
145+
if (skill) {
146+
registry.register(skill);
147+
logger.debug(`Loaded project skill: ${skill.manifest.name}`);
148+
}
149+
}
150+
}

src/skills/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,5 +88,5 @@ export interface LoadedSkill {
8888
manifest: SkillManifest;
8989
handler: SkillHandler;
9090
path: string;
91-
builtIn: boolean;
91+
source: "built-in" | "user" | "project";
9292
}

0 commit comments

Comments
 (0)