Skip to content
Closed
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
125 changes: 125 additions & 0 deletions apps/cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ import {
validateCliServerConnectionProfileName,
type CliServerConnectionStore,
} from "./server-profile";
import { addSkill, listSkills, removeSkill, syncSkills, toggleSkill } from "./skills-gateway";
import {
buildResumeContentTemplate,
buildDescribeToolCode,
Expand Down Expand Up @@ -2209,6 +2210,129 @@ const toolsCommand = Command.make("tools").pipe(
Command.withDescription("Discover available tools and integrations"),
);

// ---------------------------------------------------------------------------
// Skills Gateway — Central skill store & multi-agent sync
// ---------------------------------------------------------------------------

const skillsGlobalOption = Options.boolean("global")
.pipe(Options.withDefault(false))
.pipe(
Options.withDescription(
"Manage user-level skills (~/.executor/skills) instead of workspace skills (.executor/skills)",
),
);

const skillsListCommand = Command.make(
"list",
{
global: skillsGlobalOption,
},
({ global }) =>
Effect.gen(function* () {
const skills = yield* listSkills({ global });
if (skills.length === 0) {
console.log("No skills installed.");
console.log("");
console.log("Install a skill from a local folder or Git repo:");
console.log(` ${cliPrefix} skills add <source>`);
return;
}
console.log(`Installed skills (${global ? "global" : "workspace"}):`);
console.log("");
for (const skill of skills) {
const status = skill.enabled ? "enabled" : "disabled";
const targets =
skill.syncedTargets.length > 0 ? ` (synced to: ${skill.syncedTargets.join(", ")})` : "";
console.log(` - ${skill.name} [${status}]${targets}`);
if (skill.description) {
console.log(` ${skill.description}`);
}
console.log(` Source: ${skill.source} (${skill.sourceType})`);
}
}),
).pipe(Command.withDescription("List installed skills"));

const skillsAddCommand = Command.make(
"add",
{
source: Args.string("source"),
name: Options.string("name").pipe(
Options.optional,
Options.withDescription("Custom name for the skill"),
),
global: skillsGlobalOption,
},
({ source, name, global }) =>
Effect.gen(function* () {
const customName = Option.getOrUndefined(name);
console.log(`Installing skill from ${source}...`);
const result = yield* addSkill({ source, name: customName, global });
console.log(`Installed skill '${result.name}' successfully.`);
if (result.syncedTargets.length > 0) {
console.log(`Synced to agents: ${result.syncedTargets.join(", ")}`);
}
}),
).pipe(Command.withDescription("Install a skill and sync it to connected agents"));

const skillsRemoveCommand = Command.make(
"remove",
{
name: Args.string("name"),
global: skillsGlobalOption,
},
({ name, global }) =>
Effect.gen(function* () {
yield* removeSkill({ name, global });
console.log(`Removed skill '${name}'.`);
}),
).pipe(Command.withDescription("Remove an installed skill"));

const skillsToggleCommand = Command.make(
"toggle",
{
name: Args.string("name"),
action: Args.choice("action", ["enable", "disable"] as const),
global: skillsGlobalOption,
},
({ name, action, global }) =>
Effect.gen(function* () {
const enabled = action === "enable";
yield* toggleSkill({ name, enabled, global });
console.log(`Skill '${name}' is now ${action}d.`);
}),
).pipe(Command.withDescription("Enable or disable a skill to toggle agent loading"));

const skillsSyncCommand = Command.make(
"sync",
{
global: skillsGlobalOption,
},
({ global }) =>
Effect.gen(function* () {
console.log("Syncing skills to connected agents...");
const synced = yield* syncSkills({ global });
if (synced.length === 0) {
console.log("No enabled skills or agent target directories found to sync.");
return;
}
for (const record of synced) {
console.log(` - Synced ${record.skillName} -> ${record.target} (${record.destination})`);
}
console.log(`Sync complete (${synced.length} target records synced).`);
}),
).pipe(Command.withDescription("Sync installed skills to detected agent directories"));

const skillsCommand = Command.make("skills").pipe(
Command.withSubcommands([
skillsListCommand,
skillsAddCommand,
skillsRemoveCommand,
skillsToggleCommand,
skillsSyncCommand,
] as const),
Command.withDescription("Manage agent skills and sync them across AI coding assistants"),
);

const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
const ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;

Expand Down Expand Up @@ -3299,6 +3423,7 @@ const root = Command.make("executor").pipe(
callCommand,
resumeCommand,
toolsCommand,
skillsCommand,
installCommand,
loginCommand,
logoutCommand,
Expand Down
127 changes: 127 additions & 0 deletions apps/cli/src/skills-gateway.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { describe, expect, it } from "@effect/vitest";
import { BunServices } from "@effect/platform-bun";
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Effect } from "effect";

import {
addSkill,
detectAgentTargets,
listSkills,
loadSkillsLock,
parseSkillMetadata,
removeSkill,
toggleSkill,
} from "./skills-gateway";

const withTmpDir = <A, E, R>(
body: (dir: string) => Effect.Effect<A, E, R>,
): Effect.Effect<A, E, R> =>
Effect.acquireUseRelease(
Effect.sync(() => mkdtempSync(join(tmpdir(), "exec-skill-test-"))),
body,
(dir) => Effect.sync(() => rmSync(dir, { recursive: true, force: true })),
);

describe("parseSkillMetadata", () => {
it("parses YAML frontmatter name and description", () => {
const raw = `---
name: "my-custom-skill"
description: "A skill for custom refactorings"
---

# My Custom Skill
Instructions go here.
`;
const meta = parseSkillMetadata(raw);
expect(meta.name).toBe("my-custom-skill");
expect(meta.description).toBe("A skill for custom refactorings");
expect(meta.body).toContain("# My Custom Skill");
});

it("handles markdown without frontmatter", () => {
const raw = `# Plain Skill\nJust markdown body.`;
const meta = parseSkillMetadata(raw);
expect(meta.name).toBeUndefined();
expect(meta.description).toBeUndefined();
expect(meta.body).toBe(raw);
});
});

describe("skills-gateway operations", () => {
it.effect(
"installs a local directory skill, syncs to detected agents, and updates lockfile",
() =>
withTmpDir((cwd) =>
Effect.gen(function* () {
// Setup source skill
const sourceSkillDir = join(cwd, "my-source-skill");
mkdirSync(sourceSkillDir, { recursive: true });
writeFileSync(
join(sourceSkillDir, "SKILL.md"),
`---\nname: "test-skill"\ndescription: "Test skill description"\n---\n# Test Skill Body\n`,
);
writeFileSync(join(sourceSkillDir, "helper.sh"), `echo "helper script"`);

// Setup simulated agent directories in workspace: Claude and Gemini/Agents
mkdirSync(join(cwd, ".claude"), { recursive: true });
mkdirSync(join(cwd, ".agents"), { recursive: true });

// Add skill
const addResult = yield* addSkill({
source: sourceSkillDir,
cwd,
});

expect(addResult.name).toBe("test-skill");
expect(addResult.description).toBe("Test skill description");
expect(addResult.syncedTargets).toContain("claude");
expect(addResult.syncedTargets).toContain("gemini");

// Verify central store has skill
const lock = yield* loadSkillsLock({ cwd });
expect(lock.skills["test-skill"]).toBeDefined();
expect(lock.skills["test-skill"]?.enabled).toBe(true);

// Verify target folders received the skill and helper scripts
const list = yield* listSkills({ cwd });
expect(list).toHaveLength(1);
expect(list[0]?.name).toBe("test-skill");
expect(list[0]?.enabled).toBe(true);

// Toggle disable
yield* toggleSkill({ name: "test-skill", enabled: false, cwd });
const listDisabled = yield* listSkills({ cwd });
expect(listDisabled[0]?.enabled).toBe(false);

// Toggle re-enable
yield* toggleSkill({ name: "test-skill", enabled: true, cwd });
const listReenabled = yield* listSkills({ cwd });
expect(listReenabled[0]?.enabled).toBe(true);

// Remove skill
yield* removeSkill({ name: "test-skill", cwd });
const listAfterRemove = yield* listSkills({ cwd });
expect(listAfterRemove).toHaveLength(0);
}),
).pipe(Effect.provide(BunServices.layer)),
);

it.effect("detects multiple agent directory formats in workspace", () =>
withTmpDir((cwd) =>
Effect.gen(function* () {
mkdirSync(join(cwd, ".claude"), { recursive: true });
mkdirSync(join(cwd, ".codex"), { recursive: true });
mkdirSync(join(cwd, ".cursor"), { recursive: true });

const targets = yield* detectAgentTargets({ cwd });
const names = targets.map((t) => t.target);
expect(names).toContain("claude");
expect(names).toContain("codex");
expect(names).toContain("cursor");
expect(names).not.toContain("windsurf");
}),
).pipe(Effect.provide(BunServices.layer)),
);
});
Loading
Loading