Skip to content

Commit 3a1d504

Browse files
committed
feat(cli): add skill gateway to install and sync skills across agents (#1442)
1 parent 35dba11 commit 3a1d504

3 files changed

Lines changed: 1014 additions & 0 deletions

File tree

apps/cli/src/main.ts

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ import {
167167
validateCliServerConnectionProfileName,
168168
type CliServerConnectionStore,
169169
} from "./server-profile";
170+
import { addSkill, listSkills, removeSkill, syncSkills, toggleSkill } from "./skills-gateway";
170171
import {
171172
buildResumeContentTemplate,
172173
buildDescribeToolCode,
@@ -2209,6 +2210,129 @@ const toolsCommand = Command.make("tools").pipe(
22092210
Command.withDescription("Discover available tools and integrations"),
22102211
);
22112212

2213+
// ---------------------------------------------------------------------------
2214+
// Skills Gateway — Central skill store & multi-agent sync
2215+
// ---------------------------------------------------------------------------
2216+
2217+
const skillsGlobalOption = Options.boolean("global")
2218+
.pipe(Options.withDefault(false))
2219+
.pipe(
2220+
Options.withDescription(
2221+
"Manage user-level skills (~/.executor/skills) instead of workspace skills (.executor/skills)",
2222+
),
2223+
);
2224+
2225+
const skillsListCommand = Command.make(
2226+
"list",
2227+
{
2228+
global: skillsGlobalOption,
2229+
},
2230+
({ global }) =>
2231+
Effect.gen(function* () {
2232+
const skills = yield* listSkills({ global });
2233+
if (skills.length === 0) {
2234+
console.log("No skills installed.");
2235+
console.log("");
2236+
console.log("Install a skill from a local folder or Git repo:");
2237+
console.log(` ${cliPrefix} skills add <source>`);
2238+
return;
2239+
}
2240+
console.log(`Installed skills (${global ? "global" : "workspace"}):`);
2241+
console.log("");
2242+
for (const skill of skills) {
2243+
const status = skill.enabled ? "enabled" : "disabled";
2244+
const targets =
2245+
skill.syncedTargets.length > 0 ? ` (synced to: ${skill.syncedTargets.join(", ")})` : "";
2246+
console.log(` - ${skill.name} [${status}]${targets}`);
2247+
if (skill.description) {
2248+
console.log(` ${skill.description}`);
2249+
}
2250+
console.log(` Source: ${skill.source} (${skill.sourceType})`);
2251+
}
2252+
}),
2253+
).pipe(Command.withDescription("List installed skills"));
2254+
2255+
const skillsAddCommand = Command.make(
2256+
"add",
2257+
{
2258+
source: Args.string("source"),
2259+
name: Options.string("name").pipe(
2260+
Options.optional,
2261+
Options.withDescription("Custom name for the skill"),
2262+
),
2263+
global: skillsGlobalOption,
2264+
},
2265+
({ source, name, global }) =>
2266+
Effect.gen(function* () {
2267+
const customName = Option.getOrUndefined(name);
2268+
console.log(`Installing skill from ${source}...`);
2269+
const result = yield* addSkill({ source, name: customName, global });
2270+
console.log(`Installed skill '${result.name}' successfully.`);
2271+
if (result.syncedTargets.length > 0) {
2272+
console.log(`Synced to agents: ${result.syncedTargets.join(", ")}`);
2273+
}
2274+
}),
2275+
).pipe(Command.withDescription("Install a skill and sync it to connected agents"));
2276+
2277+
const skillsRemoveCommand = Command.make(
2278+
"remove",
2279+
{
2280+
name: Args.string("name"),
2281+
global: skillsGlobalOption,
2282+
},
2283+
({ name, global }) =>
2284+
Effect.gen(function* () {
2285+
yield* removeSkill({ name, global });
2286+
console.log(`Removed skill '${name}'.`);
2287+
}),
2288+
).pipe(Command.withDescription("Remove an installed skill"));
2289+
2290+
const skillsToggleCommand = Command.make(
2291+
"toggle",
2292+
{
2293+
name: Args.string("name"),
2294+
action: Args.choice("action", ["enable", "disable"] as const),
2295+
global: skillsGlobalOption,
2296+
},
2297+
({ name, action, global }) =>
2298+
Effect.gen(function* () {
2299+
const enabled = action === "enable";
2300+
yield* toggleSkill({ name, enabled, global });
2301+
console.log(`Skill '${name}' is now ${action}d.`);
2302+
}),
2303+
).pipe(Command.withDescription("Enable or disable a skill to toggle agent loading"));
2304+
2305+
const skillsSyncCommand = Command.make(
2306+
"sync",
2307+
{
2308+
global: skillsGlobalOption,
2309+
},
2310+
({ global }) =>
2311+
Effect.gen(function* () {
2312+
console.log("Syncing skills to connected agents...");
2313+
const synced = yield* syncSkills({ global });
2314+
if (synced.length === 0) {
2315+
console.log("No enabled skills or agent target directories found to sync.");
2316+
return;
2317+
}
2318+
for (const record of synced) {
2319+
console.log(` - Synced ${record.skillName} -> ${record.target} (${record.destination})`);
2320+
}
2321+
console.log(`Sync complete (${synced.length} target records synced).`);
2322+
}),
2323+
).pipe(Command.withDescription("Sync installed skills to detected agent directories"));
2324+
2325+
const skillsCommand = Command.make("skills").pipe(
2326+
Command.withSubcommands([
2327+
skillsListCommand,
2328+
skillsAddCommand,
2329+
skillsRemoveCommand,
2330+
skillsToggleCommand,
2331+
skillsSyncCommand,
2332+
] as const),
2333+
Command.withDescription("Manage agent skills and sync them across AI coding assistants"),
2334+
);
2335+
22122336
const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
22132337
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
22142338

@@ -3299,6 +3423,7 @@ const root = Command.make("executor").pipe(
32993423
callCommand,
33003424
resumeCommand,
33013425
toolsCommand,
3426+
skillsCommand,
33023427
installCommand,
33033428
loginCommand,
33043429
logoutCommand,
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { BunServices } from "@effect/platform-bun";
3+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
4+
import { tmpdir } from "node:os";
5+
import { join } from "node:path";
6+
import { Effect } from "effect";
7+
8+
import {
9+
addSkill,
10+
detectAgentTargets,
11+
listSkills,
12+
loadSkillsLock,
13+
parseSkillMetadata,
14+
removeSkill,
15+
toggleSkill,
16+
} from "./skills-gateway";
17+
18+
const withTmpDir = <A, E, R>(
19+
body: (dir: string) => Effect.Effect<A, E, R>,
20+
): Effect.Effect<A, E, R> =>
21+
Effect.acquireUseRelease(
22+
Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-skill-test-"))),
23+
body,
24+
(dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })),
25+
);
26+
27+
describe("parseSkillMetadata", () => {
28+
it("parses YAML frontmatter name and description", () => {
29+
const raw = `---
30+
name: "my-custom-skill"
31+
description: "A skill for custom refactorings"
32+
---
33+
34+
# My Custom Skill
35+
Instructions go here.
36+
`;
37+
const meta = parseSkillMetadata(raw);
38+
expect(meta.name).toBe("my-custom-skill");
39+
expect(meta.description).toBe("A skill for custom refactorings");
40+
expect(meta.body).toContain("# My Custom Skill");
41+
});
42+
43+
it("handles markdown without frontmatter", () => {
44+
const raw = `# Plain Skill\nJust markdown body.`;
45+
const meta = parseSkillMetadata(raw);
46+
expect(meta.name).toBeUndefined();
47+
expect(meta.description).toBeUndefined();
48+
expect(meta.body).toBe(raw);
49+
});
50+
});
51+
52+
describe("skills-gateway operations", () => {
53+
it.effect(
54+
"installs a local directory skill, syncs to detected agents, and updates lockfile",
55+
() =>
56+
withTmpDir((cwd) =>
57+
Effect.gen(function* () {
58+
// Setup source skill
59+
const sourceSkillDir = join(cwd, "my-source-skill");
60+
mkdirSync(sourceSkillDir, { recursive: true });
61+
writeFileSync(
62+
join(sourceSkillDir, "SKILL.md"),
63+
`---\nname: "test-skill"\ndescription: "Test skill description"\n---\n# Test Skill Body\n`,
64+
);
65+
writeFileSync(join(sourceSkillDir, "helper.sh"), `echo "helper script"`);
66+
67+
// Setup simulated agent directories in workspace: Claude and Gemini/Agents
68+
mkdirSync(join(cwd, ".claude"), { recursive: true });
69+
mkdirSync(join(cwd, ".agents"), { recursive: true });
70+
71+
// Add skill
72+
const addResult = yield* addSkill({
73+
source: sourceSkillDir,
74+
cwd,
75+
});
76+
77+
expect(addResult.name).toBe("test-skill");
78+
expect(addResult.description).toBe("Test skill description");
79+
expect(addResult.syncedTargets).toContain("claude");
80+
expect(addResult.syncedTargets).toContain("gemini");
81+
82+
// Verify central store has skill
83+
const lock = yield* loadSkillsLock({ cwd });
84+
expect(lock.skills["test-skill"]).toBeDefined();
85+
expect(lock.skills["test-skill"]?.enabled).toBe(true);
86+
87+
// Verify target folders received the skill and helper scripts
88+
const list = yield* listSkills({ cwd });
89+
expect(list).toHaveLength(1);
90+
expect(list[0]?.name).toBe("test-skill");
91+
expect(list[0]?.enabled).toBe(true);
92+
93+
// Toggle disable
94+
yield* toggleSkill({ name: "test-skill", enabled: false, cwd });
95+
const listDisabled = yield* listSkills({ cwd });
96+
expect(listDisabled[0]?.enabled).toBe(false);
97+
98+
// Toggle re-enable
99+
yield* toggleSkill({ name: "test-skill", enabled: true, cwd });
100+
const listReenabled = yield* listSkills({ cwd });
101+
expect(listReenabled[0]?.enabled).toBe(true);
102+
103+
// Remove skill
104+
yield* removeSkill({ name: "test-skill", cwd });
105+
const listAfterRemove = yield* listSkills({ cwd });
106+
expect(listAfterRemove).toHaveLength(0);
107+
}),
108+
).pipe(Effect.provide(BunServices.layer)),
109+
);
110+
111+
it.effect("detects multiple agent directory formats in workspace", () =>
112+
withTmpDir((cwd) =>
113+
Effect.gen(function* () {
114+
mkdirSync(join(cwd, ".claude"), { recursive: true });
115+
mkdirSync(join(cwd, ".codex"), { recursive: true });
116+
mkdirSync(join(cwd, ".cursor"), { recursive: true });
117+
118+
const targets = yield* detectAgentTargets({ cwd });
119+
const names = targets.map((t) => t.target);
120+
expect(names).toContain("claude");
121+
expect(names).toContain("codex");
122+
expect(names).toContain("cursor");
123+
expect(names).not.toContain("windsurf");
124+
}),
125+
).pipe(Effect.provide(BunServices.layer)),
126+
);
127+
});

0 commit comments

Comments
 (0)