@crewhaus/target-claude-plugin 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Track H (§59) — `target-claude-plugin`. Emits an Anthropic-compatible
3
+ * Claude Code plugin directory from any CrewHaus IR variant.
4
+ *
5
+ * Source: claude-plugins-official (Anthropic's reference repo at
6
+ * github.com/anthropics/claude-code-plugins). Anthropic's reference
7
+ * plugin format is intentionally minimal:
8
+ *
9
+ * plugin-name/
10
+ * ├── .claude-plugin/
11
+ * │ └── plugin.json # required: name, description, author
12
+ * ├── .mcp.json # optional, MCP server config
13
+ * ├── skills/<name>/SKILL.md
14
+ * ├── agents/<name>.md # optional sub-agent definitions
15
+ * ├── commands/<name>.md # optional legacy slash entries
16
+ * └── README.md # documentation
17
+ *
18
+ * The emitter is shape-aware:
19
+ *
20
+ * - `cli` / `channel` / `managed` / `pipeline` / `research` / `batch` /
21
+ * `voice` / `browser` / `eval` / `onchain` / `onchain-game` →
22
+ * produce a single SKILL.md derived from agent.instructions, plus
23
+ * sub-agent .md files for each declared sub-agent.
24
+ * - `workflow` → one SKILL.md per step.
25
+ * - `graph` → one SKILL.md per node, plus a top-level SKILL.md naming
26
+ * the entry node.
27
+ * - `crew` → one agent .md per role; the entry role becomes the
28
+ * primary SKILL.md.
29
+ *
30
+ * This is a strict-emission package: no I/O, returns a `Bundle` of
31
+ * files for the caller (the CLI) to write to disk. Pure functions.
32
+ *
33
+ * See Track H of the §55–§59 batch in factory/CHANGELOG.md.
34
+ */
35
+ import { CrewhausError } from "@crewhaus/errors";
36
+ import type { IrNode } from "@crewhaus/ir";
37
+ export declare class TargetClaudePluginError extends CrewhausError {
38
+ readonly name = "TargetClaudePluginError";
39
+ constructor(message: string, cause?: unknown);
40
+ }
41
+ export type PluginFile = {
42
+ readonly path: string;
43
+ readonly content: string;
44
+ };
45
+ export type PluginBundle = {
46
+ readonly files: ReadonlyArray<PluginFile>;
47
+ };
48
+ export type EmitClaudePluginOptions = {
49
+ /** Plugin author (required by Anthropic's minimal schema). */
50
+ readonly author: {
51
+ readonly name: string;
52
+ readonly email?: string;
53
+ };
54
+ /**
55
+ * Optional human-readable description; defaults to the IR's name + target.
56
+ * Anthropic recommends 1-2 sentences describing trigger conditions.
57
+ */
58
+ readonly description?: string;
59
+ };
60
+ /**
61
+ * Main entry. Returns a `PluginBundle` of files; the caller writes
62
+ * them to disk under the chosen plugin directory.
63
+ */
64
+ export declare function emitClaudePlugin(ir: IrNode, opts: EmitClaudePluginOptions): PluginBundle;
package/dist/index.js ADDED
@@ -0,0 +1,282 @@
1
+ /**
2
+ * Track H (§59) — `target-claude-plugin`. Emits an Anthropic-compatible
3
+ * Claude Code plugin directory from any CrewHaus IR variant.
4
+ *
5
+ * Source: claude-plugins-official (Anthropic's reference repo at
6
+ * github.com/anthropics/claude-code-plugins). Anthropic's reference
7
+ * plugin format is intentionally minimal:
8
+ *
9
+ * plugin-name/
10
+ * ├── .claude-plugin/
11
+ * │ └── plugin.json # required: name, description, author
12
+ * ├── .mcp.json # optional, MCP server config
13
+ * ├── skills/<name>/SKILL.md
14
+ * ├── agents/<name>.md # optional sub-agent definitions
15
+ * ├── commands/<name>.md # optional legacy slash entries
16
+ * └── README.md # documentation
17
+ *
18
+ * The emitter is shape-aware:
19
+ *
20
+ * - `cli` / `channel` / `managed` / `pipeline` / `research` / `batch` /
21
+ * `voice` / `browser` / `eval` / `onchain` / `onchain-game` →
22
+ * produce a single SKILL.md derived from agent.instructions, plus
23
+ * sub-agent .md files for each declared sub-agent.
24
+ * - `workflow` → one SKILL.md per step.
25
+ * - `graph` → one SKILL.md per node, plus a top-level SKILL.md naming
26
+ * the entry node.
27
+ * - `crew` → one agent .md per role; the entry role becomes the
28
+ * primary SKILL.md.
29
+ *
30
+ * This is a strict-emission package: no I/O, returns a `Bundle` of
31
+ * files for the caller (the CLI) to write to disk. Pure functions.
32
+ *
33
+ * See Track H of the §55–§59 batch in factory/CHANGELOG.md.
34
+ */
35
+ import { CrewhausError } from "@crewhaus/errors";
36
+ export class TargetClaudePluginError extends CrewhausError {
37
+ name = "TargetClaudePluginError";
38
+ constructor(message, cause) {
39
+ super("compiler", message, cause);
40
+ }
41
+ }
42
+ /**
43
+ * Main entry. Returns a `PluginBundle` of files; the caller writes
44
+ * them to disk under the chosen plugin directory.
45
+ */
46
+ export function emitClaudePlugin(ir, opts) {
47
+ const description = opts.description ?? defaultDescription(ir);
48
+ const pluginJson = renderPluginJson(ir.name, description, opts.author);
49
+ const files = [
50
+ { path: ".claude-plugin/plugin.json", content: pluginJson },
51
+ { path: "README.md", content: renderReadme(ir, description) },
52
+ ];
53
+ // MCP servers — only emitted for variants that carry them in IR.
54
+ const mcp = renderMcpJson(ir);
55
+ if (mcp !== undefined) {
56
+ files.push({ path: ".mcp.json", content: mcp });
57
+ }
58
+ // Per-variant skill + agent emission.
59
+ switch (ir.target) {
60
+ case "cli":
61
+ files.push(...emitCliShape(ir));
62
+ break;
63
+ case "workflow":
64
+ files.push(...emitWorkflowShape(ir));
65
+ break;
66
+ case "channel":
67
+ files.push(...emitChannelShape(ir));
68
+ break;
69
+ case "graph":
70
+ files.push(...emitGraphShape(ir));
71
+ break;
72
+ case "crew":
73
+ files.push(...emitCrewShape(ir));
74
+ break;
75
+ case "managed":
76
+ files.push(...emitGenericAgentShape(ir, ir.target));
77
+ break;
78
+ case "pipeline":
79
+ files.push(...emitGenericAgentShape(ir, ir.target));
80
+ break;
81
+ case "research":
82
+ files.push(...emitGenericAgentShape(ir, ir.target));
83
+ break;
84
+ case "eval":
85
+ files.push(...emitEvalShape(ir));
86
+ break;
87
+ case "batch":
88
+ case "voice":
89
+ case "browser":
90
+ case "onchain":
91
+ case "onchain-game":
92
+ files.push(...emitGenericAgentShape(ir, ir.target));
93
+ break;
94
+ default:
95
+ throw new TargetClaudePluginError(`unsupported IR target for claude-plugin emission: ${ir.target}`);
96
+ }
97
+ return { files };
98
+ }
99
+ function defaultDescription(ir) {
100
+ return `${ir.name} — CrewHaus ${ir.target} agent compiled to Claude Code plugin format.`;
101
+ }
102
+ function renderPluginJson(name, description, author) {
103
+ const obj = {
104
+ name,
105
+ description,
106
+ author: {
107
+ name: author.name,
108
+ ...(author.email !== undefined ? { email: author.email } : {}),
109
+ },
110
+ };
111
+ return `${JSON.stringify(obj, null, 2)}\n`;
112
+ }
113
+ function renderReadme(ir, description) {
114
+ return [
115
+ `# ${ir.name}`,
116
+ "",
117
+ description,
118
+ "",
119
+ "## Origin",
120
+ "",
121
+ `Generated by CrewHaus \`target-claude-plugin\` from a \`target: ${ir.target}\` spec.`,
122
+ "Format reference: [claude-plugins-official](https://github.com/anthropics/claude-code-plugins).",
123
+ "",
124
+ "## Install",
125
+ "",
126
+ "Drop this directory under `~/.claude/plugins/` or your project's `.claude/plugins/`.",
127
+ "",
128
+ ].join("\n");
129
+ }
130
+ function renderMcpJson(ir) {
131
+ // Only emit when the IR variant has mcp_servers and it's non-empty.
132
+ const v = ir;
133
+ if (v.mcp_servers === undefined)
134
+ return undefined;
135
+ const keys = Object.keys(v.mcp_servers);
136
+ if (keys.length === 0)
137
+ return undefined;
138
+ return `${JSON.stringify(v.mcp_servers, null, 2)}\n`;
139
+ }
140
+ /**
141
+ * Render a SKILL.md frontmatter block. Anthropic's minimal schema:
142
+ * `name` + `description` required. Optional `argument-hint` makes
143
+ * the skill user-invokable as a slash command.
144
+ */
145
+ function renderSkill(opts) {
146
+ const frontmatter = ["---", `name: ${opts.name}`, `description: ${opts.description}`];
147
+ if (opts.argumentHint !== undefined) {
148
+ frontmatter.push(`argument-hint: ${opts.argumentHint}`);
149
+ }
150
+ frontmatter.push("---");
151
+ return `${frontmatter.join("\n")}\n\n${opts.body}\n`;
152
+ }
153
+ function emitCliShape(ir) {
154
+ const files = [
155
+ {
156
+ path: `skills/${ir.name}/SKILL.md`,
157
+ content: renderSkill({
158
+ name: ir.name,
159
+ description: firstSentence(ir.agent.instructions) || defaultDescription(ir),
160
+ body: ir.agent.instructions,
161
+ }),
162
+ },
163
+ ];
164
+ for (const sa of ir.subAgents ?? []) {
165
+ files.push({
166
+ path: `agents/${sa.name}.md`,
167
+ content: renderAgent(sa.name, sa.description, sa.instructions),
168
+ });
169
+ }
170
+ return files;
171
+ }
172
+ function emitWorkflowShape(ir) {
173
+ const files = [];
174
+ for (const step of ir.steps) {
175
+ files.push({
176
+ path: `skills/${ir.name}-${step.name}/SKILL.md`,
177
+ content: renderSkill({
178
+ name: `${ir.name}-${step.name}`,
179
+ description: `Step ${step.name} of workflow ${ir.name}`,
180
+ body: step.instructions,
181
+ }),
182
+ });
183
+ }
184
+ return files;
185
+ }
186
+ function emitChannelShape(ir) {
187
+ const files = emitCliShape({
188
+ ...ir,
189
+ target: "cli",
190
+ });
191
+ // Channel daemons aren't natively claude-plugin-shaped; we emit the
192
+ // skill content but flag the channel context in the README.
193
+ files.push({
194
+ path: "CLAUDE_PLUGIN_NOTES.md",
195
+ content: [
196
+ "# Channel daemon notes",
197
+ "",
198
+ "This plugin was emitted from a `target: channel` spec. The channel " +
199
+ "daemon (Slack/Telegram/Discord/etc.) lifecycle is NOT part of the " +
200
+ "Claude Code plugin runtime — re-deploy the channel separately and " +
201
+ "use this plugin's skills inside Claude Code for design-time work.",
202
+ "",
203
+ ].join("\n"),
204
+ });
205
+ return files;
206
+ }
207
+ function emitGraphShape(ir) {
208
+ const files = [
209
+ {
210
+ path: `skills/${ir.name}/SKILL.md`,
211
+ content: renderSkill({
212
+ name: ir.name,
213
+ description: `Stateful graph; entry node = "${ir.entry}".`,
214
+ body: `Graph nodes: ${ir.nodes.map((n) => n.name).join(", ")}\nEdges: ${ir.edges
215
+ .map((e) => `${e.from}→${e.to}`)
216
+ .join(", ")}`,
217
+ }),
218
+ },
219
+ ];
220
+ for (const node of ir.nodes) {
221
+ files.push({
222
+ path: `skills/${ir.name}-${node.name}/SKILL.md`,
223
+ content: renderSkill({
224
+ name: `${ir.name}-${node.name}`,
225
+ description: `Graph node "${node.name}" (entry=${node.name === ir.entry})`,
226
+ body: node.instructions,
227
+ }),
228
+ });
229
+ }
230
+ return files;
231
+ }
232
+ function emitCrewShape(ir) {
233
+ const files = [];
234
+ for (const role of ir.roles) {
235
+ if (role.name === ir.entry) {
236
+ files.push({
237
+ path: `skills/${ir.name}/SKILL.md`,
238
+ content: renderSkill({
239
+ name: ir.name,
240
+ description: `Entry role "${role.name}" of crew ${ir.name}`,
241
+ body: role.instructions,
242
+ }),
243
+ });
244
+ }
245
+ files.push({
246
+ path: `agents/${role.name}.md`,
247
+ content: renderAgent(role.name, `Role: ${role.name}`, role.instructions),
248
+ });
249
+ }
250
+ return files;
251
+ }
252
+ function emitEvalShape(ir) {
253
+ return [
254
+ {
255
+ path: `skills/${ir.name}/SKILL.md`,
256
+ content: renderSkill({
257
+ name: ir.name,
258
+ description: `Eval harness over dataset ${ir.dataset.name} (${ir.dataset.split})`,
259
+ body: ir.agent.instructions,
260
+ }),
261
+ },
262
+ ];
263
+ }
264
+ function emitGenericAgentShape(ir, target) {
265
+ return [
266
+ {
267
+ path: `skills/${ir.name}/SKILL.md`,
268
+ content: renderSkill({
269
+ name: ir.name,
270
+ description: `${target} agent — see body for instructions.`,
271
+ body: ir.agent.instructions,
272
+ }),
273
+ },
274
+ ];
275
+ }
276
+ function renderAgent(name, description, instructions) {
277
+ return ["---", `name: ${name}`, `description: ${description}`, "---", "", instructions, ""].join("\n");
278
+ }
279
+ function firstSentence(text) {
280
+ const m = text.match(/^[^.\n]+[.!?]/);
281
+ return m !== null ? m[0] : (text.split("\n")[0] ?? "");
282
+ }
package/package.json CHANGED
@@ -1,19 +1,22 @@
1
1
  {
2
2
  "name": "@crewhaus/target-claude-plugin",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "type": "module",
5
5
  "description": "Track H / §59 — emits an Anthropic-compatible Claude Code plugin directory from any CrewHaus IR variant. Source: claude-plugins-official (Anthropic).",
6
- "main": "src/index.ts",
7
- "types": "src/index.ts",
6
+ "main": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
8
  "exports": {
9
- ".": "./src/index.ts"
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ }
10
13
  },
11
14
  "scripts": {
12
15
  "test": "bun test src"
13
16
  },
14
17
  "dependencies": {
15
- "@crewhaus/errors": "0.1.4",
16
- "@crewhaus/ir": "0.1.4"
18
+ "@crewhaus/errors": "0.1.5",
19
+ "@crewhaus/ir": "0.1.5"
17
20
  },
18
21
  "license": "Apache-2.0",
19
22
  "author": {
@@ -33,5 +36,5 @@
33
36
  "publishConfig": {
34
37
  "access": "public"
35
38
  },
36
- "files": ["src", "README.md", "LICENSE", "NOTICE"]
39
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"]
37
40
  }
package/src/index.test.ts DELETED
@@ -1,328 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import type {
3
- IrChannelV0,
4
- IrCrewV0,
5
- IrEvalV0,
6
- IrGraphV0,
7
- IrManagedV0,
8
- IrNode,
9
- IrV0,
10
- IrWorkflowV0,
11
- } from "@crewhaus/ir";
12
- import { TargetClaudePluginError, emitClaudePlugin } from "./index";
13
-
14
- const baseCli: IrV0 = {
15
- version: 0,
16
- name: "hello-plugin",
17
- target: "cli",
18
- agent: { model: "claude-sonnet-4-6", instructions: "Be helpful." },
19
- tools: [],
20
- toolConfigs: {},
21
- mcp_servers: {},
22
- permissions: { rules: [] },
23
- subAgents: [],
24
- compaction: {},
25
- };
26
-
27
- const baseWorkflow: IrWorkflowV0 = {
28
- version: 0,
29
- name: "summarize-then-translate",
30
- target: "workflow",
31
- steps: [
32
- {
33
- name: "summarize",
34
- instructions: "Summarize the input.",
35
- model: "m",
36
- tools: [],
37
- toolConfigs: {},
38
- },
39
- {
40
- name: "translate",
41
- instructions: "Translate the summary to French.",
42
- model: "m",
43
- tools: [],
44
- toolConfigs: {},
45
- },
46
- ],
47
- mcp_servers: {},
48
- permissions: { rules: [] },
49
- compaction: {},
50
- };
51
-
52
- const baseGraph: IrGraphV0 = {
53
- version: 0,
54
- name: "plan-then-execute",
55
- target: "graph",
56
- entry: "planner",
57
- nodes: [
58
- { name: "planner", instructions: "Make a plan", model: "m", tools: [], toolConfigs: {} },
59
- { name: "executor", instructions: "Execute", model: "m", tools: [], toolConfigs: {} },
60
- ],
61
- edges: [{ from: "planner", to: "executor" }],
62
- permissions: { rules: [] },
63
- compaction: {},
64
- };
65
-
66
- const baseCrew: IrCrewV0 = {
67
- version: 0,
68
- name: "research-crew",
69
- target: "crew",
70
- entry: "researcher",
71
- roles: [
72
- {
73
- name: "researcher",
74
- model: "m",
75
- instructions: "Do research.",
76
- tools: [],
77
- toolConfigs: {},
78
- subAgents: [],
79
- },
80
- {
81
- name: "writer",
82
- model: "m",
83
- instructions: "Write the report.",
84
- tools: [],
85
- toolConfigs: {},
86
- subAgents: [],
87
- },
88
- ],
89
- mcp_servers: {},
90
- permissions: { rules: [] },
91
- compaction: {},
92
- };
93
-
94
- describe("emitClaudePlugin — universal files", () => {
95
- test("always emits plugin.json and README.md", () => {
96
- const b = emitClaudePlugin(baseCli, { author: { name: "Test" } });
97
- const paths = b.files.map((f) => f.path);
98
- expect(paths).toContain(".claude-plugin/plugin.json");
99
- expect(paths).toContain("README.md");
100
- });
101
-
102
- test("plugin.json has the minimal Anthropic schema", () => {
103
- const b = emitClaudePlugin(baseCli, {
104
- author: { name: "Test Author", email: "x@y.z" },
105
- description: "test description",
106
- });
107
- const file = b.files.find((f) => f.path === ".claude-plugin/plugin.json");
108
- expect(file).toBeDefined();
109
- const parsed = JSON.parse(file?.content ?? "{}");
110
- expect(parsed.name).toBe("hello-plugin");
111
- expect(parsed.description).toBe("test description");
112
- expect(parsed.author.name).toBe("Test Author");
113
- expect(parsed.author.email).toBe("x@y.z");
114
- // No extra fields beyond what Anthropic requires.
115
- expect(Object.keys(parsed).sort()).toEqual(["author", "description", "name"]);
116
- });
117
-
118
- test("omits .mcp.json when mcp_servers is empty", () => {
119
- const b = emitClaudePlugin(baseCli, { author: { name: "x" } });
120
- expect(b.files.find((f) => f.path === ".mcp.json")).toBeUndefined();
121
- });
122
-
123
- test("emits .mcp.json when mcp_servers is populated", () => {
124
- const ir: IrV0 = {
125
- ...baseCli,
126
- mcp_servers: {
127
- fs: { transport: "stdio", command: "npx", args: ["-y", "fs"] },
128
- },
129
- };
130
- const b = emitClaudePlugin(ir, { author: { name: "x" } });
131
- const mcp = b.files.find((f) => f.path === ".mcp.json");
132
- expect(mcp).toBeDefined();
133
- expect(JSON.parse(mcp?.content ?? "{}").fs.transport).toBe("stdio");
134
- });
135
- });
136
-
137
- describe("emitClaudePlugin — per-shape emission", () => {
138
- test("cli emits one SKILL.md and one agent per sub-agent", () => {
139
- const ir: IrV0 = {
140
- ...baseCli,
141
- subAgents: [
142
- {
143
- name: "reviewer",
144
- description: "Reviews code",
145
- instructions: "Find bugs",
146
- tools: [],
147
- permissions: "inherit",
148
- inheritBypass: false,
149
- },
150
- ],
151
- };
152
- const b = emitClaudePlugin(ir, { author: { name: "x" } });
153
- expect(b.files.some((f) => f.path === "skills/hello-plugin/SKILL.md")).toBe(true);
154
- expect(b.files.some((f) => f.path === "agents/reviewer.md")).toBe(true);
155
- });
156
-
157
- test("workflow emits one SKILL.md per step", () => {
158
- const b = emitClaudePlugin(baseWorkflow, { author: { name: "x" } });
159
- const skillPaths = b.files.map((f) => f.path).filter((p) => p.startsWith("skills/"));
160
- expect(skillPaths).toContain("skills/summarize-then-translate-summarize/SKILL.md");
161
- expect(skillPaths).toContain("skills/summarize-then-translate-translate/SKILL.md");
162
- });
163
-
164
- test("graph emits one top-level SKILL.md plus one per node", () => {
165
- const b = emitClaudePlugin(baseGraph, { author: { name: "x" } });
166
- const skillPaths = b.files.map((f) => f.path).filter((p) => p.startsWith("skills/"));
167
- expect(skillPaths).toContain("skills/plan-then-execute/SKILL.md");
168
- expect(skillPaths).toContain("skills/plan-then-execute-planner/SKILL.md");
169
- expect(skillPaths).toContain("skills/plan-then-execute-executor/SKILL.md");
170
- });
171
-
172
- test("crew emits SKILL.md for entry role and one agent per role", () => {
173
- const b = emitClaudePlugin(baseCrew, { author: { name: "x" } });
174
- expect(b.files.some((f) => f.path === "skills/research-crew/SKILL.md")).toBe(true);
175
- expect(b.files.some((f) => f.path === "agents/researcher.md")).toBe(true);
176
- expect(b.files.some((f) => f.path === "agents/writer.md")).toBe(true);
177
- });
178
- });
179
-
180
- describe("SKILL.md frontmatter", () => {
181
- test("uses minimal name + description frontmatter", () => {
182
- const b = emitClaudePlugin(baseCli, { author: { name: "x" } });
183
- const skill = b.files.find((f) => f.path === "skills/hello-plugin/SKILL.md");
184
- expect(skill?.content).toMatch(/^---\nname: hello-plugin\ndescription: /);
185
- expect(skill?.content).toContain("Be helpful.");
186
- });
187
- });
188
-
189
- describe("emitClaudePlugin — channel shape", () => {
190
- const baseChannel: IrChannelV0 = {
191
- version: 0,
192
- name: "slackbot",
193
- target: "channel",
194
- agent: { model: "claude-sonnet-4-6", instructions: "Greet users warmly. Then help them." },
195
- tools: [],
196
- toolConfigs: {},
197
- channels: {
198
- slack: {
199
- botToken: { kind: "literal", value: "xoxb-fake" },
200
- signingSecret: { kind: "env", name: "SLACK_SIGNING_SECRET" },
201
- },
202
- },
203
- routing: { sessionKey: "thread" },
204
- mcp_servers: {},
205
- permissions: { rules: [] },
206
- subAgents: [],
207
- compaction: {},
208
- };
209
-
210
- test("emits the CLI-shaped SKILL.md from agent.instructions", () => {
211
- const b = emitClaudePlugin(baseChannel, { author: { name: "x" } });
212
- const skill = b.files.find((f) => f.path === "skills/slackbot/SKILL.md");
213
- expect(skill).toBeDefined();
214
- // description is the first sentence of the instructions.
215
- expect(skill?.content).toContain("Greet users warmly.");
216
- expect(skill?.content).toContain("Then help them.");
217
- });
218
-
219
- test("appends a CLAUDE_PLUGIN_NOTES.md flagging the channel daemon context", () => {
220
- const b = emitClaudePlugin(baseChannel, { author: { name: "x" } });
221
- const notes = b.files.find((f) => f.path === "CLAUDE_PLUGIN_NOTES.md");
222
- expect(notes).toBeDefined();
223
- expect(notes?.content).toContain("# Channel daemon notes");
224
- expect(notes?.content).toContain("target: channel");
225
- expect(notes?.content).toContain("lifecycle is NOT part of the");
226
- });
227
-
228
- test("forwards channel sub-agents into agents/<name>.md", () => {
229
- const withSub: IrChannelV0 = {
230
- ...baseChannel,
231
- subAgents: [
232
- {
233
- name: "triage",
234
- description: "Triage incoming messages",
235
- instructions: "Sort by urgency",
236
- tools: [],
237
- permissions: "inherit",
238
- inheritBypass: false,
239
- },
240
- ],
241
- };
242
- const b = emitClaudePlugin(withSub, { author: { name: "x" } });
243
- const agent = b.files.find((f) => f.path === "agents/triage.md");
244
- expect(agent).toBeDefined();
245
- expect(agent?.content).toContain("name: triage");
246
- expect(agent?.content).toContain("Sort by urgency");
247
- });
248
- });
249
-
250
- describe("emitClaudePlugin — eval shape", () => {
251
- const baseEval: IrEvalV0 = {
252
- version: 0,
253
- name: "qa-eval",
254
- target: "eval",
255
- agent: { model: "claude-sonnet-4-6", instructions: "Answer concisely.", tools: [] },
256
- dataset: { name: "qa-bench", version: "v1", split: "dev" },
257
- graders: [{ name: "exact_match" }],
258
- concurrency: 4,
259
- };
260
-
261
- test("emits a single SKILL.md naming the dataset + split in its description", () => {
262
- const b = emitClaudePlugin(baseEval, { author: { name: "x" } });
263
- const skill = b.files.find((f) => f.path === "skills/qa-eval/SKILL.md");
264
- expect(skill).toBeDefined();
265
- expect(skill?.content).toContain("Eval harness over dataset qa-bench (dev)");
266
- expect(skill?.content).toContain("Answer concisely.");
267
- // eval shape emits exactly one skill (no per-grader files).
268
- const skillPaths = b.files.map((f) => f.path).filter((p) => p.startsWith("skills/"));
269
- expect(skillPaths).toEqual(["skills/qa-eval/SKILL.md"]);
270
- });
271
- });
272
-
273
- describe("emitClaudePlugin — generic agent shapes", () => {
274
- const managed: IrManagedV0 = {
275
- version: 0,
276
- name: "saas-bot",
277
- target: "managed",
278
- agent: { model: "claude-sonnet-4-6", instructions: "Serve every tenant." },
279
- tenants: [],
280
- permissions: { rules: [] },
281
- compaction: {},
282
- };
283
-
284
- test("managed emits one SKILL.md with a `<target> agent` description", () => {
285
- const b = emitClaudePlugin(managed, { author: { name: "x" } });
286
- const skill = b.files.find((f) => f.path === "skills/saas-bot/SKILL.md");
287
- expect(skill).toBeDefined();
288
- expect(skill?.content).toContain("managed agent — see body for instructions.");
289
- expect(skill?.content).toContain("Serve every tenant.");
290
- });
291
-
292
- // pipeline / research route through the same generic helper but with a
293
- // dedicated switch arm; batch / voice / browser / onchain / onchain-game
294
- // share a single arm. Exercise every arm so each `case` is covered.
295
- test.each([
296
- "pipeline",
297
- "research",
298
- "batch",
299
- "voice",
300
- "browser",
301
- "onchain",
302
- "onchain-game",
303
- ] as const)("%s target emits a generic SKILL.md", (target) => {
304
- const ir = {
305
- version: 0,
306
- name: `${target}-agent`,
307
- target,
308
- agent: { model: "m", instructions: `Run the ${target}.` },
309
- permissions: { rules: [] },
310
- compaction: {},
311
- } as unknown as IrNode;
312
- const b = emitClaudePlugin(ir, { author: { name: "x" } });
313
- const skill = b.files.find((f) => f.path === `skills/${target}-agent/SKILL.md`);
314
- expect(skill).toBeDefined();
315
- expect(skill?.content).toContain(`${target} agent — see body for instructions.`);
316
- expect(skill?.content).toContain(`Run the ${target}.`);
317
- });
318
- });
319
-
320
- describe("emitClaudePlugin — error handling", () => {
321
- test("throws on unsupported target shape", () => {
322
- expect(() =>
323
- emitClaudePlugin({ target: "unknown-shape" } as never, {
324
- author: { name: "x" },
325
- }),
326
- ).toThrow(TargetClaudePluginError);
327
- });
328
- });
package/src/index.ts DELETED
@@ -1,348 +0,0 @@
1
- /**
2
- * Track H (§59) — `target-claude-plugin`. Emits an Anthropic-compatible
3
- * Claude Code plugin directory from any CrewHaus IR variant.
4
- *
5
- * Source: claude-plugins-official (Anthropic's reference repo at
6
- * github.com/anthropics/claude-code-plugins). Anthropic's reference
7
- * plugin format is intentionally minimal:
8
- *
9
- * plugin-name/
10
- * ├── .claude-plugin/
11
- * │ └── plugin.json # required: name, description, author
12
- * ├── .mcp.json # optional, MCP server config
13
- * ├── skills/<name>/SKILL.md
14
- * ├── agents/<name>.md # optional sub-agent definitions
15
- * ├── commands/<name>.md # optional legacy slash entries
16
- * └── README.md # documentation
17
- *
18
- * The emitter is shape-aware:
19
- *
20
- * - `cli` / `channel` / `managed` / `pipeline` / `research` / `batch` /
21
- * `voice` / `browser` / `eval` / `onchain` / `onchain-game` →
22
- * produce a single SKILL.md derived from agent.instructions, plus
23
- * sub-agent .md files for each declared sub-agent.
24
- * - `workflow` → one SKILL.md per step.
25
- * - `graph` → one SKILL.md per node, plus a top-level SKILL.md naming
26
- * the entry node.
27
- * - `crew` → one agent .md per role; the entry role becomes the
28
- * primary SKILL.md.
29
- *
30
- * This is a strict-emission package: no I/O, returns a `Bundle` of
31
- * files for the caller (the CLI) to write to disk. Pure functions.
32
- *
33
- * See Track H of the §55–§59 batch in factory/CHANGELOG.md.
34
- */
35
- import { CrewhausError } from "@crewhaus/errors";
36
- import type {
37
- IrChannelV0,
38
- IrCrewV0,
39
- IrEvalV0,
40
- IrGraphV0,
41
- IrManagedV0,
42
- IrNode,
43
- IrPipelineV0,
44
- IrResearchV0,
45
- IrV0,
46
- IrWorkflowV0,
47
- } from "@crewhaus/ir";
48
-
49
- export class TargetClaudePluginError extends CrewhausError {
50
- override readonly name = "TargetClaudePluginError";
51
- constructor(message: string, cause?: unknown) {
52
- super("compiler", message, cause);
53
- }
54
- }
55
-
56
- export type PluginFile = {
57
- readonly path: string;
58
- readonly content: string;
59
- };
60
-
61
- export type PluginBundle = {
62
- readonly files: ReadonlyArray<PluginFile>;
63
- };
64
-
65
- export type EmitClaudePluginOptions = {
66
- /** Plugin author (required by Anthropic's minimal schema). */
67
- readonly author: {
68
- readonly name: string;
69
- readonly email?: string;
70
- };
71
- /**
72
- * Optional human-readable description; defaults to the IR's name + target.
73
- * Anthropic recommends 1-2 sentences describing trigger conditions.
74
- */
75
- readonly description?: string;
76
- };
77
-
78
- /**
79
- * Main entry. Returns a `PluginBundle` of files; the caller writes
80
- * them to disk under the chosen plugin directory.
81
- */
82
- export function emitClaudePlugin(ir: IrNode, opts: EmitClaudePluginOptions): PluginBundle {
83
- const description = opts.description ?? defaultDescription(ir);
84
- const pluginJson = renderPluginJson(ir.name, description, opts.author);
85
- const files: PluginFile[] = [
86
- { path: ".claude-plugin/plugin.json", content: pluginJson },
87
- { path: "README.md", content: renderReadme(ir, description) },
88
- ];
89
-
90
- // MCP servers — only emitted for variants that carry them in IR.
91
- const mcp = renderMcpJson(ir);
92
- if (mcp !== undefined) {
93
- files.push({ path: ".mcp.json", content: mcp });
94
- }
95
-
96
- // Per-variant skill + agent emission.
97
- switch (ir.target) {
98
- case "cli":
99
- files.push(...emitCliShape(ir as IrV0));
100
- break;
101
- case "workflow":
102
- files.push(...emitWorkflowShape(ir as IrWorkflowV0));
103
- break;
104
- case "channel":
105
- files.push(...emitChannelShape(ir as IrChannelV0));
106
- break;
107
- case "graph":
108
- files.push(...emitGraphShape(ir as IrGraphV0));
109
- break;
110
- case "crew":
111
- files.push(...emitCrewShape(ir as IrCrewV0));
112
- break;
113
- case "managed":
114
- files.push(...emitGenericAgentShape(ir as IrManagedV0, ir.target));
115
- break;
116
- case "pipeline":
117
- files.push(...emitGenericAgentShape(ir as IrPipelineV0, ir.target));
118
- break;
119
- case "research":
120
- files.push(...emitGenericAgentShape(ir as IrResearchV0, ir.target));
121
- break;
122
- case "eval":
123
- files.push(...emitEvalShape(ir as IrEvalV0));
124
- break;
125
- case "batch":
126
- case "voice":
127
- case "browser":
128
- case "onchain":
129
- case "onchain-game":
130
- files.push(...emitGenericAgentShape(ir, ir.target));
131
- break;
132
- default:
133
- throw new TargetClaudePluginError(
134
- `unsupported IR target for claude-plugin emission: ${(ir as { target: string }).target}`,
135
- );
136
- }
137
- return { files };
138
- }
139
-
140
- function defaultDescription(ir: IrNode): string {
141
- return `${ir.name} — CrewHaus ${ir.target} agent compiled to Claude Code plugin format.`;
142
- }
143
-
144
- function renderPluginJson(
145
- name: string,
146
- description: string,
147
- author: EmitClaudePluginOptions["author"],
148
- ): string {
149
- const obj = {
150
- name,
151
- description,
152
- author: {
153
- name: author.name,
154
- ...(author.email !== undefined ? { email: author.email } : {}),
155
- },
156
- };
157
- return `${JSON.stringify(obj, null, 2)}\n`;
158
- }
159
-
160
- function renderReadme(ir: IrNode, description: string): string {
161
- return [
162
- `# ${ir.name}`,
163
- "",
164
- description,
165
- "",
166
- "## Origin",
167
- "",
168
- `Generated by CrewHaus \`target-claude-plugin\` from a \`target: ${ir.target}\` spec.`,
169
- "Format reference: [claude-plugins-official](https://github.com/anthropics/claude-code-plugins).",
170
- "",
171
- "## Install",
172
- "",
173
- "Drop this directory under `~/.claude/plugins/` or your project's `.claude/plugins/`.",
174
- "",
175
- ].join("\n");
176
- }
177
-
178
- function renderMcpJson(ir: IrNode): string | undefined {
179
- // Only emit when the IR variant has mcp_servers and it's non-empty.
180
- const v = ir as { mcp_servers?: Record<string, unknown> };
181
- if (v.mcp_servers === undefined) return undefined;
182
- const keys = Object.keys(v.mcp_servers);
183
- if (keys.length === 0) return undefined;
184
- return `${JSON.stringify(v.mcp_servers, null, 2)}\n`;
185
- }
186
-
187
- /**
188
- * Render a SKILL.md frontmatter block. Anthropic's minimal schema:
189
- * `name` + `description` required. Optional `argument-hint` makes
190
- * the skill user-invokable as a slash command.
191
- */
192
- function renderSkill(opts: {
193
- readonly name: string;
194
- readonly description: string;
195
- readonly body: string;
196
- readonly argumentHint?: string;
197
- }): string {
198
- const frontmatter: string[] = ["---", `name: ${opts.name}`, `description: ${opts.description}`];
199
- if (opts.argumentHint !== undefined) {
200
- frontmatter.push(`argument-hint: ${opts.argumentHint}`);
201
- }
202
- frontmatter.push("---");
203
- return `${frontmatter.join("\n")}\n\n${opts.body}\n`;
204
- }
205
-
206
- function emitCliShape(ir: IrV0): PluginFile[] {
207
- const files: PluginFile[] = [
208
- {
209
- path: `skills/${ir.name}/SKILL.md`,
210
- content: renderSkill({
211
- name: ir.name,
212
- description: firstSentence(ir.agent.instructions) || defaultDescription(ir),
213
- body: ir.agent.instructions,
214
- }),
215
- },
216
- ];
217
- for (const sa of ir.subAgents ?? []) {
218
- files.push({
219
- path: `agents/${sa.name}.md`,
220
- content: renderAgent(sa.name, sa.description, sa.instructions),
221
- });
222
- }
223
- return files;
224
- }
225
-
226
- function emitWorkflowShape(ir: IrWorkflowV0): PluginFile[] {
227
- const files: PluginFile[] = [];
228
- for (const step of ir.steps) {
229
- files.push({
230
- path: `skills/${ir.name}-${step.name}/SKILL.md`,
231
- content: renderSkill({
232
- name: `${ir.name}-${step.name}`,
233
- description: `Step ${step.name} of workflow ${ir.name}`,
234
- body: step.instructions,
235
- }),
236
- });
237
- }
238
- return files;
239
- }
240
-
241
- function emitChannelShape(ir: IrChannelV0): PluginFile[] {
242
- const files = emitCliShape({
243
- ...(ir as unknown as IrV0),
244
- target: "cli",
245
- } as IrV0);
246
- // Channel daemons aren't natively claude-plugin-shaped; we emit the
247
- // skill content but flag the channel context in the README.
248
- files.push({
249
- path: "CLAUDE_PLUGIN_NOTES.md",
250
- content: [
251
- "# Channel daemon notes",
252
- "",
253
- "This plugin was emitted from a `target: channel` spec. The channel " +
254
- "daemon (Slack/Telegram/Discord/etc.) lifecycle is NOT part of the " +
255
- "Claude Code plugin runtime — re-deploy the channel separately and " +
256
- "use this plugin's skills inside Claude Code for design-time work.",
257
- "",
258
- ].join("\n"),
259
- });
260
- return files;
261
- }
262
-
263
- function emitGraphShape(ir: IrGraphV0): PluginFile[] {
264
- const files: PluginFile[] = [
265
- {
266
- path: `skills/${ir.name}/SKILL.md`,
267
- content: renderSkill({
268
- name: ir.name,
269
- description: `Stateful graph; entry node = "${ir.entry}".`,
270
- body: `Graph nodes: ${ir.nodes.map((n) => n.name).join(", ")}\nEdges: ${ir.edges
271
- .map((e) => `${e.from}→${e.to}`)
272
- .join(", ")}`,
273
- }),
274
- },
275
- ];
276
- for (const node of ir.nodes) {
277
- files.push({
278
- path: `skills/${ir.name}-${node.name}/SKILL.md`,
279
- content: renderSkill({
280
- name: `${ir.name}-${node.name}`,
281
- description: `Graph node "${node.name}" (entry=${node.name === ir.entry})`,
282
- body: node.instructions,
283
- }),
284
- });
285
- }
286
- return files;
287
- }
288
-
289
- function emitCrewShape(ir: IrCrewV0): PluginFile[] {
290
- const files: PluginFile[] = [];
291
- for (const role of ir.roles) {
292
- if (role.name === ir.entry) {
293
- files.push({
294
- path: `skills/${ir.name}/SKILL.md`,
295
- content: renderSkill({
296
- name: ir.name,
297
- description: `Entry role "${role.name}" of crew ${ir.name}`,
298
- body: role.instructions,
299
- }),
300
- });
301
- }
302
- files.push({
303
- path: `agents/${role.name}.md`,
304
- content: renderAgent(role.name, `Role: ${role.name}`, role.instructions),
305
- });
306
- }
307
- return files;
308
- }
309
-
310
- function emitEvalShape(ir: IrEvalV0): PluginFile[] {
311
- return [
312
- {
313
- path: `skills/${ir.name}/SKILL.md`,
314
- content: renderSkill({
315
- name: ir.name,
316
- description: `Eval harness over dataset ${ir.dataset.name} (${ir.dataset.split})`,
317
- body: ir.agent.instructions,
318
- }),
319
- },
320
- ];
321
- }
322
-
323
- function emitGenericAgentShape(
324
- ir: { name: string; target: string; agent: { instructions: string } },
325
- target: string,
326
- ): PluginFile[] {
327
- return [
328
- {
329
- path: `skills/${ir.name}/SKILL.md`,
330
- content: renderSkill({
331
- name: ir.name,
332
- description: `${target} agent — see body for instructions.`,
333
- body: ir.agent.instructions,
334
- }),
335
- },
336
- ];
337
- }
338
-
339
- function renderAgent(name: string, description: string, instructions: string): string {
340
- return ["---", `name: ${name}`, `description: ${description}`, "---", "", instructions, ""].join(
341
- "\n",
342
- );
343
- }
344
-
345
- function firstSentence(text: string): string {
346
- const m = text.match(/^[^.\n]+[.!?]/);
347
- return m !== null ? m[0] : (text.split("\n")[0] ?? "");
348
- }