@tankpkg/cli 0.11.0 → 0.13.0

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.
package/dist/bin/tank.js CHANGED
@@ -1,26 +1,198 @@
1
1
  #!/usr/bin/env node
2
- import { a as VERSION, c as getConfigDir, i as USER_AGENT, n as flushLogs, o as logger, s as getConfig, t as authFlowLog, u as setConfig } from "../debug-logger-BA8I3PcR.js";
2
+ import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-DpL2B_iY.js";
3
+ import { t as logger } from "../logger-BhULz3Uz.js";
3
4
  import { createRequire } from "node:module";
4
5
  import { Command } from "commander";
5
6
  import chalk from "chalk";
6
- import fs from "node:fs";
7
- import os from "node:os";
8
- import path from "node:path";
7
+ import fs, { createWriteStream } from "node:fs";
8
+ import os, { tmpdir } from "node:os";
9
+ import path, { join } from "node:path";
9
10
  import { z } from "zod";
10
- import { confirm, input } from "@inquirer/prompts";
11
- import crypto$1 from "node:crypto";
12
11
  import semver from "semver";
13
12
  import ora from "ora";
13
+ import { confirm, input } from "@inquirer/prompts";
14
+ import crypto$1 from "node:crypto";
14
15
  import { create, extract } from "tar";
16
+ import { createInterface } from "node:readline";
17
+ import { execSync, spawn } from "node:child_process";
18
+ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
19
+ import { Readable } from "node:stream";
20
+ import { pipeline } from "node:stream/promises";
15
21
  import open from "open";
16
22
  import ignore from "ignore";
17
- import { spawn } from "node:child_process";
18
23
  import { fileURLToPath } from "node:url";
24
+ //#region \0rolldown/runtime.js
25
+ var __defProp$1 = Object.defineProperty;
26
+ var __exportAll = (all, no_symbols) => {
27
+ let target = {};
28
+ for (var name in all) __defProp$1(target, name, {
29
+ get: all[name],
30
+ enumerable: true
31
+ });
32
+ if (!no_symbols) __defProp$1(target, Symbol.toStringTag, { value: "Module" });
33
+ return target;
34
+ };
19
35
  process.env.TANK_REGISTRY_URL;
20
36
  const MANIFEST_FILENAME$1 = "tank.json";
21
37
  const LEGACY_MANIFEST_FILENAME$1 = "skills.json";
22
38
  const LOCKFILE_FILENAME = "tank.lock";
23
39
  const LEGACY_LOCKFILE_FILENAME = "skills.lock";
40
+ const supportLevelSchema$1 = z.enum([
41
+ "full",
42
+ "degraded",
43
+ "none"
44
+ ]);
45
+ const adapterCapabilitiesSchema$1 = z.object({
46
+ instruction: supportLevelSchema$1,
47
+ hook: supportLevelSchema$1,
48
+ tool: supportLevelSchema$1,
49
+ agent: supportLevelSchema$1,
50
+ rule: supportLevelSchema$1,
51
+ resource: supportLevelSchema$1,
52
+ prompt: supportLevelSchema$1
53
+ }).strict();
54
+ const compilationWarningSchema$1 = z.object({
55
+ level: z.enum(["degraded", "skipped"]),
56
+ atomKind: z.string(),
57
+ message: z.string()
58
+ }).strict();
59
+ const fileWriteSchema = z.object({
60
+ path: z.string().min(1),
61
+ content: z.string()
62
+ }).strict();
63
+ z.object({
64
+ files: z.array(fileWriteSchema),
65
+ warnings: z.array(compilationWarningSchema$1)
66
+ }).strict();
67
+ z.object({
68
+ name: z.string().min(1, "Adapter name must not be empty"),
69
+ supportedRange: z.string().min(1, "Supported range must not be empty"),
70
+ capabilities: adapterCapabilitiesSchema$1
71
+ }).strict();
72
+ z.enum([
73
+ "instruction",
74
+ "hook",
75
+ "tool",
76
+ "agent",
77
+ "rule",
78
+ "resource",
79
+ "prompt"
80
+ ]);
81
+ const extensionBagSchema$1 = z.record(z.string(), z.unknown()).optional();
82
+ const modelTierSchema$1 = z.enum([
83
+ "fast",
84
+ "balanced",
85
+ "powerful",
86
+ "custom"
87
+ ]);
88
+ modelTierSchema$1.options;
89
+ const canonicalToolNameSchema$1 = z.enum([
90
+ "bash",
91
+ "read",
92
+ "write",
93
+ "edit",
94
+ "grep",
95
+ "glob",
96
+ "lsp",
97
+ "mcp",
98
+ "browser",
99
+ "fetch",
100
+ "git",
101
+ "task",
102
+ "notebook"
103
+ ]);
104
+ canonicalToolNameSchema$1.options;
105
+ const agentIRSchema$1 = z.object({
106
+ kind: z.literal("agent"),
107
+ name: z.string().min(1, "Agent name must not be empty"),
108
+ role: z.string().min(1, "Agent role must not be empty"),
109
+ tools: z.array(canonicalToolNameSchema$1.or(z.string().min(1))).optional(),
110
+ model: modelTierSchema$1.or(z.string().min(1)).optional(),
111
+ readonly: z.boolean().optional(),
112
+ extensions: extensionBagSchema$1
113
+ }).strict();
114
+ const hookEventSchema$1 = z.enum([
115
+ "pre-tool-use",
116
+ "post-tool-use",
117
+ "pre-file-read",
118
+ "post-file-read",
119
+ "pre-file-write",
120
+ "post-file-write",
121
+ "file-edited",
122
+ "file-watcher-updated",
123
+ "pre-command",
124
+ "post-command",
125
+ "pre-mcp-tool-use",
126
+ "post-mcp-tool-use",
127
+ "session-created",
128
+ "session-updated",
129
+ "session-idle",
130
+ "session-error",
131
+ "session-deleted",
132
+ "pre-stop",
133
+ "task-start",
134
+ "task-resume",
135
+ "task-complete",
136
+ "task-cancel",
137
+ "pre-user-prompt",
138
+ "post-response",
139
+ "message-updated",
140
+ "message-removed",
141
+ "system-prompt-transform",
142
+ "pre-context-compact",
143
+ "post-context-compact",
144
+ "permission-asked",
145
+ "permission-replied",
146
+ "lsp-diagnostics",
147
+ "lsp-updated",
148
+ "subagent-start",
149
+ "subagent-complete",
150
+ "subagent-tool-use",
151
+ "shell-env",
152
+ "todo-updated",
153
+ "installation-updated"
154
+ ]);
155
+ hookEventSchema$1.options;
156
+ const hookActionIRSchema$1 = z.object({
157
+ action: z.enum([
158
+ "block",
159
+ "allow",
160
+ "rewrite",
161
+ "injectContext"
162
+ ]),
163
+ match: z.string().optional(),
164
+ reason: z.string().optional(),
165
+ value: z.string().optional()
166
+ }).strict();
167
+ const hookDslHandlerSchema = z.object({
168
+ type: z.literal("dsl"),
169
+ actions: z.array(hookActionIRSchema$1).min(1, "DSL handler must have at least one action")
170
+ }).strict();
171
+ const hookJsHandlerSchema = z.object({
172
+ type: z.literal("js"),
173
+ entry: z.string().min(1, "JS handler entry path must not be empty")
174
+ }).strict();
175
+ const hookHandlerIRSchema$1 = z.discriminatedUnion("type", [hookDslHandlerSchema, hookJsHandlerSchema]);
176
+ const hookIRSchema$1 = z.object({
177
+ kind: z.literal("hook"),
178
+ name: z.string().optional(),
179
+ event: hookEventSchema$1,
180
+ match: canonicalToolNameSchema$1.or(z.string().min(1)).optional(),
181
+ handler: hookHandlerIRSchema$1,
182
+ scope: z.enum(["project", "global"]).optional(),
183
+ extensions: extensionBagSchema$1
184
+ }).strict();
185
+ const instructionIRSchema$1 = z.object({
186
+ kind: z.literal("instruction"),
187
+ content: z.string().min(1, "Content path must not be empty"),
188
+ scope: z.enum([
189
+ "project",
190
+ "global",
191
+ "directory"
192
+ ]).optional(),
193
+ globs: z.array(z.string()).optional(),
194
+ extensions: extensionBagSchema$1
195
+ }).strict();
24
196
  const networkPermissionsSchema$1 = z.object({ outbound: z.array(z.string()).optional() }).strict();
25
197
  const filesystemPermissionsSchema$1 = z.object({
26
198
  read: z.array(z.string()).optional(),
@@ -59,7 +231,77 @@ z.enum([
59
231
  "org.member.remove",
60
232
  "org.delete"
61
233
  ]);
62
- const skillsJsonSchema = z.object({
234
+ const promptIRSchema$1 = z.object({
235
+ kind: z.literal("prompt"),
236
+ name: z.string().min(1, "Prompt name must not be empty"),
237
+ description: z.string().optional(),
238
+ template: z.string().min(1, "Prompt template path must not be empty"),
239
+ arguments: z.array(z.object({
240
+ name: z.string(),
241
+ description: z.string().optional(),
242
+ required: z.boolean().optional()
243
+ }).strict()).optional(),
244
+ extensions: extensionBagSchema$1
245
+ }).strict();
246
+ const resourceIRSchema$1 = z.object({
247
+ kind: z.literal("resource"),
248
+ name: z.string().optional(),
249
+ uri: z.string().min(1, "Resource URI must not be empty"),
250
+ description: z.string().optional(),
251
+ mimeType: z.string().optional(),
252
+ extensions: extensionBagSchema$1
253
+ }).strict();
254
+ const ruleIRSchema$1 = z.object({
255
+ kind: z.literal("rule"),
256
+ name: z.string().optional(),
257
+ event: hookEventSchema$1,
258
+ match: canonicalToolNameSchema$1.or(z.string().min(1)).optional(),
259
+ policy: z.enum([
260
+ "block",
261
+ "allow",
262
+ "warn"
263
+ ]),
264
+ reason: z.string().optional(),
265
+ extensions: extensionBagSchema$1
266
+ }).strict();
267
+ const mcpServerConfigSchema$1 = z.object({
268
+ command: z.string().min(1).optional(),
269
+ args: z.array(z.string()).optional(),
270
+ env: z.record(z.string(), z.string()).optional(),
271
+ runtime: z.string().min(1).optional(),
272
+ entry: z.string().min(1).optional()
273
+ }).strict().refine((data) => data.command || data.runtime && data.entry, "MCP config must have either \"command\" or both \"runtime\" and \"entry\"");
274
+ const toolIRSchema$1 = z.object({
275
+ kind: z.literal("tool"),
276
+ name: z.string().min(1, "Tool name must not be empty"),
277
+ description: z.string().optional(),
278
+ mcp: mcpServerConfigSchema$1.optional(),
279
+ extensions: extensionBagSchema$1
280
+ }).strict();
281
+ const NAME_PATTERN$2 = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
282
+ const SEMVER_PATTERN$2 = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
283
+ const atomIRSchema$1 = z.discriminatedUnion("kind", [
284
+ instructionIRSchema$1,
285
+ hookIRSchema$1,
286
+ toolIRSchema$1,
287
+ agentIRSchema$1,
288
+ ruleIRSchema$1,
289
+ resourceIRSchema$1,
290
+ promptIRSchema$1
291
+ ]);
292
+ const packageIRSchema = z.object({
293
+ name: z.string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(NAME_PATTERN$2, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
294
+ version: z.string().regex(SEMVER_PATTERN$2, "Version must be valid semver"),
295
+ description: z.string().max(500).optional(),
296
+ atoms: z.array(atomIRSchema$1),
297
+ includes: z.array(z.string()).optional(),
298
+ skills: z.record(z.string(), z.string()).optional(),
299
+ permissions: permissionsSchema$1.optional(),
300
+ repository: z.string().url("Repository must be a valid URL").optional(),
301
+ visibility: z.enum(["public", "private"]).optional(),
302
+ audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional()
303
+ }).strict();
304
+ const baseManifestFields$1 = {
63
305
  name: z.string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(/^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
64
306
  version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
65
307
  description: z.string().max(500, `Description must be 500 characters or fewer`).optional(),
@@ -68,7 +310,35 @@ const skillsJsonSchema = z.object({
68
310
  repository: z.string().url("Repository must be a valid URL").optional(),
69
311
  visibility: z.enum(["public", "private"]).optional(),
70
312
  audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional()
313
+ };
314
+ /** Legacy skills.json schema — strict, no atoms. Used for backward-compatible consumers. */
315
+ const skillsJsonSchema = z.object(baseManifestFields$1).strict();
316
+ /**
317
+ * Publish manifest schema — accepts both legacy skills.json AND atom-enriched tank.json.
318
+ * The `atoms` and `includes` fields are passed through as opaque JSON arrays,
319
+ * validated only at surface level. Full atom IR validation happens at build time.
320
+ */
321
+ const publishManifestSchema = z.object({
322
+ ...baseManifestFields$1,
323
+ atoms: z.array(z.record(z.string(), z.unknown())).optional(),
324
+ includes: z.array(z.string()).optional()
71
325
  }).strict();
326
+ const SKILL_SOURCES$1 = [
327
+ "registry",
328
+ "github",
329
+ "clawhub",
330
+ "skills_sh",
331
+ "agentskills_il",
332
+ "npm",
333
+ "local"
334
+ ];
335
+ const SCAN_VERDICTS$1 = [
336
+ "pass",
337
+ "pass_with_notes",
338
+ "flagged",
339
+ "fail",
340
+ "error"
341
+ ];
72
342
  const lockedSkillV1Schema$1 = z.object({
73
343
  resolved: z.string().url(),
74
344
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -84,7 +354,10 @@ const lockedSkillSchema$1 = z.object({
84
354
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
85
355
  permissions: permissionsSchema$1,
86
356
  audit_score: z.number().min(0).max(10).nullable(),
87
- dependencies: z.record(z.string(), z.string()).optional()
357
+ dependencies: z.record(z.string(), z.string()).optional(),
358
+ source: z.enum(SKILL_SOURCES$1).optional(),
359
+ scan_verdict: z.enum(SCAN_VERDICTS$1).optional(),
360
+ scanned_at: z.string().optional()
88
361
  });
89
362
  z.object({
90
363
  lockfileVersion: z.union([z.literal(1), z.literal(2)]),
@@ -189,7 +462,7 @@ function readLockfile$1(directory) {
189
462
  }
190
463
  //#endregion
191
464
  //#region src/commands/audit.ts
192
- function scoreColor$2(score) {
465
+ function scoreColor$3(score) {
193
466
  if (score >= 7) return chalk.green;
194
467
  if (score >= 4) return chalk.yellow;
195
468
  return chalk.red;
@@ -197,7 +470,7 @@ function scoreColor$2(score) {
197
470
  function formatScore(result) {
198
471
  if (result.error) return chalk.dim("error");
199
472
  if (result.score == null || result.status !== "completed") return chalk.dim("pending");
200
- return scoreColor$2(result.score)(result.score.toFixed(1));
473
+ return scoreColor$3(result.score)(result.score.toFixed(1));
201
474
  }
202
475
  function formatStatus(result) {
203
476
  if (result.error) return chalk.dim("error");
@@ -329,6 +602,1503 @@ async function auditCommand(options) {
329
602
  displayTable(results);
330
603
  }
331
604
  //#endregion
605
+ //#region ../adapters/dist/index.mjs
606
+ function emitInstruction$5(atom) {
607
+ const globs = atom.globs?.length ? atom.globs : void 0;
608
+ if (globs) {
609
+ const frontmatter = `---\nglobs: ${JSON.stringify(globs)}\n---\n`;
610
+ return {
611
+ files: [{
612
+ path: `.claude/rules/${slugify$5(atom.content)}.md`,
613
+ content: `${frontmatter}\n{file:${atom.content}}`
614
+ }],
615
+ warnings: []
616
+ };
617
+ }
618
+ return {
619
+ files: [{
620
+ path: `.claude/rules/${slugify$5(atom.content)}.md`,
621
+ content: `{file:${atom.content}}`
622
+ }],
623
+ warnings: []
624
+ };
625
+ }
626
+ function emitHook$5(atom) {
627
+ const ccEvent = {
628
+ "pre-tool-use": "PreToolUse",
629
+ "post-tool-use": "PostToolUse",
630
+ "pre-stop": "Stop",
631
+ "session-created": "SessionStart",
632
+ "session-idle": "Notification",
633
+ "task-start": "SessionStart",
634
+ "task-complete": "TaskCompleted",
635
+ "task-cancel": "SessionEnd",
636
+ "pre-user-prompt": "UserPromptSubmit",
637
+ "pre-context-compact": "PreCompact",
638
+ "post-context-compact": "PostCompact",
639
+ "post-response": "Notification",
640
+ "subagent-start": "SubagentStart",
641
+ "subagent-complete": "SubagentStop",
642
+ "permission-asked": "PermissionRequest",
643
+ "permission-replied": "PermissionDenied",
644
+ "file-edited": "FileChanged",
645
+ "pre-file-read": "PreToolUse",
646
+ "post-file-read": "PostToolUse",
647
+ "pre-file-write": "PreToolUse",
648
+ "post-file-write": "PostToolUse",
649
+ "pre-command": "PreToolUse",
650
+ "post-command": "PostToolUse",
651
+ "pre-mcp-tool-use": "PreToolUse",
652
+ "post-mcp-tool-use": "PostToolUse",
653
+ "system-prompt-transform": "InstructionsLoaded",
654
+ "message-updated": "Notification",
655
+ "lsp-diagnostics": "Notification"
656
+ }[atom.event] ?? "Notification";
657
+ const name = atom.name ?? `hook-${atom.event}`;
658
+ const matcher = atom.match ?? void 0;
659
+ const hookEntry = { type: "command" };
660
+ if (atom.handler.type === "js") hookEntry.command = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}.mjs"`;
661
+ else {
662
+ const actions = atom.handler.actions;
663
+ const script = buildDslShellScript(name, actions);
664
+ hookEntry.command = `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}.sh"`;
665
+ const files = [{
666
+ path: `.claude/hooks/${name}.sh`,
667
+ content: script
668
+ }];
669
+ const settingsFragment = buildSettingsFragment(ccEvent, matcher, hookEntry);
670
+ files.push({
671
+ path: `.claude/settings.json`,
672
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
673
+ });
674
+ return {
675
+ files,
676
+ warnings: []
677
+ };
678
+ }
679
+ const jsWrapper = buildJsWrapper(name, atom);
680
+ const settingsFragment = buildSettingsFragment(ccEvent, matcher, hookEntry);
681
+ return {
682
+ files: [{
683
+ path: `.claude/hooks/${name}.mjs`,
684
+ content: jsWrapper
685
+ }, {
686
+ path: `.claude/settings.json`,
687
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
688
+ }],
689
+ warnings: []
690
+ };
691
+ }
692
+ function emitAgent$5(atom) {
693
+ const tools = atom.tools ?? [];
694
+ const toolsSection = tools.length ? `\n## Tools\n\n${tools.map((t) => `- ${t}`).join("\n")}` : "";
695
+ const readonlySection = atom.readonly ? "\n\n## Permissions\n\nThis agent is read-only. Do not modify files." : "";
696
+ const md = `# ${atom.name}\n\n${atom.role}${toolsSection}${readonlySection}\n`;
697
+ return {
698
+ files: [{
699
+ path: `.claude/agents/${atom.name}.md`,
700
+ content: md
701
+ }],
702
+ warnings: []
703
+ };
704
+ }
705
+ function emitTool$5(atom) {
706
+ if (!atom.mcp) return {
707
+ files: [],
708
+ warnings: [{
709
+ level: "skipped",
710
+ atomKind: "tool",
711
+ message: `Tool "${atom.name}" has no MCP config`
712
+ }]
713
+ };
714
+ const mcpConfig = { mcpServers: { [atom.name]: {
715
+ command: atom.mcp.command,
716
+ args: atom.mcp.args ?? [],
717
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
718
+ } } };
719
+ return {
720
+ files: [{
721
+ path: ".mcp.json",
722
+ content: JSON.stringify(mcpConfig, null, 2)
723
+ }],
724
+ warnings: []
725
+ };
726
+ }
727
+ function emitRule$5(atom) {
728
+ const ccEvent = atom.event === "pre-tool-use" ? "PreToolUse" : atom.event === "pre-stop" ? "Stop" : "PreToolUse";
729
+ if (atom.policy === "block") {
730
+ const denyPattern = atom.match ? `Bash(${atom.match}*)` : void 0;
731
+ if (denyPattern) return {
732
+ files: [{
733
+ path: ".claude/settings.json",
734
+ content: JSON.stringify({ permissions: { deny: [denyPattern] } }, null, 2)
735
+ }],
736
+ warnings: []
737
+ };
738
+ }
739
+ const hookEntry = {
740
+ type: "command",
741
+ command: `echo '${atom.reason ?? "Rule triggered"}' >&2 && exit ${atom.policy === "block" ? "2" : "0"}`
742
+ };
743
+ const settingsFragment = buildSettingsFragment(ccEvent, atom.match ?? void 0, hookEntry);
744
+ return {
745
+ files: [{
746
+ path: ".claude/settings.json",
747
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
748
+ }],
749
+ warnings: []
750
+ };
751
+ }
752
+ function emitResource$5(atom) {
753
+ return {
754
+ files: [],
755
+ warnings: [{
756
+ level: "degraded",
757
+ atomKind: "resource",
758
+ message: `Claude Code uses CLAUDE.md @import for resources — "${atom.name ?? atom.uri}" registered as instruction`
759
+ }]
760
+ };
761
+ }
762
+ function emitPrompt$5(atom) {
763
+ const md = atom.description ? `${atom.description}\n\n{file:${atom.template}}` : `{file:${atom.template}}`;
764
+ return {
765
+ files: [{
766
+ path: `.claude/commands/${atom.name}.md`,
767
+ content: md
768
+ }],
769
+ warnings: []
770
+ };
771
+ }
772
+ function slugify$5(s) {
773
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
774
+ }
775
+ function buildSettingsFragment(event, matcher, hookEntry) {
776
+ return { [event]: [{
777
+ ...matcher ? { matcher } : {},
778
+ hooks: [hookEntry]
779
+ }] };
780
+ }
781
+ function buildDslShellScript(name, actions) {
782
+ return `#!/usr/bin/env bash
783
+ set -euo pipefail
784
+ INPUT=$(cat)
785
+ ${actions.map((a) => {
786
+ if (a.action === "block" && a.match) return `if echo "$INPUT" | grep -q '${a.match}'; then
787
+ echo '{"decision":"block","reason":"${a.reason ?? `Blocked: ${a.match}`}"}'
788
+ exit 2
789
+ fi`;
790
+ return null;
791
+ }).filter(Boolean).join("\n")}
792
+ exit 0
793
+ `;
794
+ }
795
+ function buildJsWrapper(name, _atom) {
796
+ return `import { readFileSync } from "node:fs";
797
+ import { execSync } from "node:child_process";
798
+
799
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));
800
+
801
+ const CODE_EXTS = new Set([".ts",".tsx",".js",".jsx",".py",".go",".rs",".java",".rb",".c",".cpp",".h",".cs",".swift",".kt",".sh"]);
802
+ const EXCLUDED = [".opencode/",".cursor/",".claude/",".windsurf/",".clinerules/",".roo/","node_modules/",".git/"];
803
+
804
+ function getChangedCodeFiles() {
805
+ try {
806
+ const status = execSync("git status --porcelain -uall 2>/dev/null", { encoding: "utf-8" }).trim();
807
+ if (!status) return [];
808
+ const files = status.split("\\n").map(l => l.slice(3).trim()).filter(Boolean);
809
+ return files.filter(f => {
810
+ if (EXCLUDED.some(e => f.startsWith(e))) return false;
811
+ const ext = f.slice(f.lastIndexOf("."));
812
+ return CODE_EXTS.has(ext);
813
+ });
814
+ } catch { return []; }
815
+ }
816
+
817
+ const codeFiles = getChangedCodeFiles();
818
+ if (codeFiles.length === 0) process.exit(0);
819
+
820
+ const reason = "Quality gate: " + codeFiles.length + " code file(s) modified (" + codeFiles.join(", ") + "). Review for critical/high issues before completing.";
821
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
822
+ process.exit(0);
823
+ `;
824
+ }
825
+ const claudeCodeAdapter = {
826
+ name: "claude-code",
827
+ supportedRange: ">=1.0.0",
828
+ capabilities: {
829
+ instruction: "full",
830
+ hook: "full",
831
+ tool: "full",
832
+ agent: "full",
833
+ rule: "full",
834
+ resource: "degraded",
835
+ prompt: "full"
836
+ },
837
+ compileAtom(atom) {
838
+ const a = atom;
839
+ switch (a.kind) {
840
+ case "instruction": return emitInstruction$5(a);
841
+ case "hook": return emitHook$5(a);
842
+ case "agent": return emitAgent$5(a);
843
+ case "tool": return emitTool$5(a);
844
+ case "rule": return emitRule$5(a);
845
+ case "resource": return emitResource$5(a);
846
+ case "prompt": return emitPrompt$5(a);
847
+ default: return {
848
+ files: [],
849
+ warnings: [{
850
+ level: "skipped",
851
+ atomKind: "unknown",
852
+ message: "Unknown atom kind"
853
+ }]
854
+ };
855
+ }
856
+ }
857
+ };
858
+ function emitInstruction$4(atom) {
859
+ return {
860
+ files: [{
861
+ path: `.clinerules/${slugify$4(atom.content)}.md`,
862
+ content: `{file:${atom.content}}`
863
+ }],
864
+ warnings: []
865
+ };
866
+ }
867
+ function emitHook$4(atom) {
868
+ if (!{
869
+ "pre-tool-use": "PreToolUse",
870
+ "post-tool-use": "PostToolUse",
871
+ "task-start": "TaskStart",
872
+ "task-resume": "TaskResume",
873
+ "task-complete": "TaskComplete",
874
+ "task-cancel": "TaskCancel",
875
+ "pre-user-prompt": "UserPromptSubmit",
876
+ "pre-context-compact": "PreCompact",
877
+ "pre-stop": "TaskComplete"
878
+ }[atom.event]) return {
879
+ files: [],
880
+ warnings: [{
881
+ level: "degraded",
882
+ atomKind: "hook",
883
+ message: `Cline does not support event "${atom.event}" — skipped`
884
+ }]
885
+ };
886
+ const name = atom.name ?? `hook-${atom.event}`;
887
+ if (atom.handler.type === "js") {
888
+ const wrapper = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nconst input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));\nconst handler = await import("./${name}.handler.ts");\nconst result = await handler.default(input);\nif (result) process.stdout.write(JSON.stringify(result));\n`;
889
+ return {
890
+ files: [{
891
+ path: `.clinerules/hooks/${name}.mjs`,
892
+ content: wrapper
893
+ }],
894
+ warnings: []
895
+ };
896
+ }
897
+ const script = buildDslScript$2(atom.handler.actions);
898
+ return {
899
+ files: [{
900
+ path: `.clinerules/hooks/${name}.sh`,
901
+ content: script
902
+ }],
903
+ warnings: []
904
+ };
905
+ }
906
+ function emitAgent$4(atom) {
907
+ return {
908
+ files: [],
909
+ warnings: [{
910
+ level: "degraded",
911
+ atomKind: "agent",
912
+ message: `Cline only has Plan/Act modes — agent "${atom.name}" compiled as instruction`
913
+ }]
914
+ };
915
+ }
916
+ function emitTool$4(atom) {
917
+ if (!atom.mcp) return {
918
+ files: [],
919
+ warnings: [{
920
+ level: "skipped",
921
+ atomKind: "tool",
922
+ message: `Tool "${atom.name}" has no MCP config`
923
+ }]
924
+ };
925
+ const config = { mcpServers: { [atom.name]: {
926
+ command: atom.mcp.command,
927
+ args: atom.mcp.args ?? [],
928
+ disabled: false,
929
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
930
+ } } };
931
+ return {
932
+ files: [{
933
+ path: ".vscode/cline_mcp_settings.json",
934
+ content: JSON.stringify(config, null, 2)
935
+ }],
936
+ warnings: []
937
+ };
938
+ }
939
+ function emitRule$4(atom) {
940
+ const content = `## Rule: ${atom.name ?? atom.event}\n\n- Policy: ${atom.policy}\n- Reason: ${atom.reason ?? "No reason specified"}\n`;
941
+ return {
942
+ files: [{
943
+ path: `.clinerules/rule-${slugify$4(atom.name ?? atom.event)}.md`,
944
+ content
945
+ }],
946
+ warnings: [{
947
+ level: "degraded",
948
+ atomKind: "rule",
949
+ message: "Cline rules are soft guidance — use hooks for enforcement"
950
+ }]
951
+ };
952
+ }
953
+ function emitResource$4(atom) {
954
+ return {
955
+ files: [],
956
+ warnings: [{
957
+ level: "degraded",
958
+ atomKind: "resource",
959
+ message: `Cline MCP resources supported — "${atom.name ?? atom.uri}" requires MCP server registration`
960
+ }]
961
+ };
962
+ }
963
+ function emitPrompt$4(atom) {
964
+ return {
965
+ files: [{
966
+ path: `.clinerules/skills/${atom.name}/SKILL.md`,
967
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
968
+ }],
969
+ warnings: []
970
+ };
971
+ }
972
+ function slugify$4(s) {
973
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
974
+ }
975
+ function buildDslScript$2(actions) {
976
+ return `#!/usr/bin/env bash\nset -euo pipefail\nINPUT=$(cat)\n${actions.filter((a) => a.action === "block" && a.match).map((a) => `if echo "$INPUT" | grep -q '${a.match}'; then\n echo '{"cancel":true,"reason":"${a.reason ?? a.match}"}'\n exit 0\nfi`).join("\n")}\nexit 0\n`;
977
+ }
978
+ const clineAdapter = {
979
+ name: "cline",
980
+ supportedRange: ">=3.0.0",
981
+ capabilities: {
982
+ instruction: "full",
983
+ hook: "full",
984
+ tool: "full",
985
+ agent: "degraded",
986
+ rule: "degraded",
987
+ resource: "degraded",
988
+ prompt: "full"
989
+ },
990
+ compileAtom(atom) {
991
+ const a = atom;
992
+ switch (a.kind) {
993
+ case "instruction": return emitInstruction$4(a);
994
+ case "hook": return emitHook$4(a);
995
+ case "agent": return emitAgent$4(a);
996
+ case "tool": return emitTool$4(a);
997
+ case "rule": return emitRule$4(a);
998
+ case "resource": return emitResource$4(a);
999
+ case "prompt": return emitPrompt$4(a);
1000
+ default: return {
1001
+ files: [],
1002
+ warnings: [{
1003
+ level: "skipped",
1004
+ atomKind: "unknown",
1005
+ message: "Unknown atom kind"
1006
+ }]
1007
+ };
1008
+ }
1009
+ }
1010
+ };
1011
+ function emitInstruction$3(atom) {
1012
+ const globs = atom.globs?.length ? atom.globs.join(", ") : void 0;
1013
+ const alwaysApply = !globs && atom.scope !== "directory";
1014
+ const frontmatter = [
1015
+ "---",
1016
+ `description: Tank-generated instruction`,
1017
+ globs ? `globs: ${globs}` : null,
1018
+ `alwaysApply: ${alwaysApply}`,
1019
+ "---"
1020
+ ].filter(Boolean).join("\n");
1021
+ return {
1022
+ files: [{
1023
+ path: `.cursor/rules/${slugify$3(atom.content)}.mdc`,
1024
+ content: `${frontmatter}\n\n{file:${atom.content}}`
1025
+ }],
1026
+ warnings: []
1027
+ };
1028
+ }
1029
+ function emitHook$3(atom) {
1030
+ const cursorEvent = {
1031
+ "pre-tool-use": "beforeToolCall",
1032
+ "post-tool-use": "afterToolCall",
1033
+ "pre-file-write": "beforeFileEdit",
1034
+ "post-file-write": "afterFileEdit",
1035
+ "pre-command": "beforeCommand",
1036
+ "post-command": "afterCommand",
1037
+ "pre-stop": "afterResponse",
1038
+ "post-response": "afterResponse",
1039
+ "pre-file-read": "beforeTabFileRead",
1040
+ "pre-mcp-tool-use": "beforeMcpToolCall",
1041
+ "post-mcp-tool-use": "afterMcpToolCall"
1042
+ }[atom.event];
1043
+ if (!cursorEvent) return {
1044
+ files: [],
1045
+ warnings: [{
1046
+ level: "degraded",
1047
+ atomKind: "hook",
1048
+ message: `Cursor does not have a direct equivalent for event "${atom.event}" — skipped`
1049
+ }]
1050
+ };
1051
+ const name = atom.name ?? `hook-${atom.event}`;
1052
+ const hookConfig = {};
1053
+ if (atom.handler.type === "js") hookConfig[cursorEvent] = [{
1054
+ type: "command",
1055
+ command: `node "$PROJECT_DIR/.cursor/hooks/${name}.mjs"`
1056
+ }];
1057
+ else {
1058
+ const script = buildDslScript$1(atom.handler.actions);
1059
+ hookConfig[cursorEvent] = [{
1060
+ type: "command",
1061
+ command: `bash "$PROJECT_DIR/.cursor/hooks/${name}.sh"`
1062
+ }];
1063
+ return {
1064
+ files: [{
1065
+ path: `.cursor/hooks/${name}.sh`,
1066
+ content: script
1067
+ }, {
1068
+ path: ".cursor/hooks.json",
1069
+ content: JSON.stringify({ hooks: hookConfig }, null, 2)
1070
+ }],
1071
+ warnings: []
1072
+ };
1073
+ }
1074
+ const jsWrapper = `import { readFileSync } from "node:fs";\nconst input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));\nconst handler = await import("./${name}.handler.ts");\nawait handler.default(input);\n`;
1075
+ return {
1076
+ files: [{
1077
+ path: `.cursor/hooks/${name}.mjs`,
1078
+ content: jsWrapper
1079
+ }, {
1080
+ path: ".cursor/hooks.json",
1081
+ content: JSON.stringify({ hooks: hookConfig }, null, 2)
1082
+ }],
1083
+ warnings: []
1084
+ };
1085
+ }
1086
+ function emitAgent$3(atom) {
1087
+ const tools = atom.tools ?? [];
1088
+ const readonlyNote = atom.readonly ? "\n\nThis agent is read-only. Do not modify files." : "";
1089
+ const md = `# ${atom.name}\n\n${atom.role}\n\nTools: ${tools.join(", ")}${readonlyNote}\n`;
1090
+ return {
1091
+ files: [{
1092
+ path: `.cursor/agents/${atom.name}.md`,
1093
+ content: md
1094
+ }],
1095
+ warnings: []
1096
+ };
1097
+ }
1098
+ function emitTool$3(atom) {
1099
+ if (!atom.mcp) return {
1100
+ files: [],
1101
+ warnings: [{
1102
+ level: "skipped",
1103
+ atomKind: "tool",
1104
+ message: `Tool "${atom.name}" has no MCP config`
1105
+ }]
1106
+ };
1107
+ const config = { mcpServers: { [atom.name]: {
1108
+ command: atom.mcp.command,
1109
+ args: atom.mcp.args ?? [],
1110
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1111
+ } } };
1112
+ return {
1113
+ files: [{
1114
+ path: ".cursor/mcp.json",
1115
+ content: JSON.stringify(config, null, 2)
1116
+ }],
1117
+ warnings: []
1118
+ };
1119
+ }
1120
+ function emitRule$3(atom) {
1121
+ const content = `# Rule: ${atom.name ?? atom.event}\n\n**Policy:** ${atom.policy}\n**Event:** ${atom.event}\n${atom.match ? `**Match:** ${atom.match}\n` : ""}${atom.reason ? `**Reason:** ${atom.reason}\n` : ""}`;
1122
+ return {
1123
+ files: [{
1124
+ path: `.cursor/rules/rule-${slugify$3(atom.name ?? atom.event)}.mdc`,
1125
+ content: `---\nalwaysApply: true\n---\n\n${content}`
1126
+ }],
1127
+ warnings: [{
1128
+ level: "degraded",
1129
+ atomKind: "rule",
1130
+ message: "Cursor rules are soft guidance — use hooks for hard enforcement"
1131
+ }]
1132
+ };
1133
+ }
1134
+ function emitResource$3(atom) {
1135
+ return {
1136
+ files: [],
1137
+ warnings: [{
1138
+ level: "degraded",
1139
+ atomKind: "resource",
1140
+ message: `Cursor uses @Docs for resources — "${atom.name ?? atom.uri}" not directly registrable`
1141
+ }]
1142
+ };
1143
+ }
1144
+ function emitPrompt$3(atom) {
1145
+ return {
1146
+ files: [{
1147
+ path: `.cursor/skills/${atom.name}/SKILL.md`,
1148
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
1149
+ }],
1150
+ warnings: []
1151
+ };
1152
+ }
1153
+ function slugify$3(s) {
1154
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1155
+ }
1156
+ function buildDslScript$1(actions) {
1157
+ return `#!/usr/bin/env bash\nset -euo pipefail\nINPUT=$(cat)\n${actions.filter((a) => a.action === "block" && a.match).map((a) => `if echo "$INPUT" | grep -q '${a.match}'; then\n echo "Blocked: ${a.reason ?? a.match}" >&2\n exit 2\nfi`).join("\n")}\nexit 0\n`;
1158
+ }
1159
+ const cursorAdapter = {
1160
+ name: "cursor",
1161
+ supportedRange: ">=0.40.0",
1162
+ capabilities: {
1163
+ instruction: "full",
1164
+ hook: "full",
1165
+ tool: "full",
1166
+ agent: "full",
1167
+ rule: "degraded",
1168
+ resource: "degraded",
1169
+ prompt: "full"
1170
+ },
1171
+ compileAtom(atom) {
1172
+ const a = atom;
1173
+ switch (a.kind) {
1174
+ case "instruction": return emitInstruction$3(a);
1175
+ case "hook": return emitHook$3(a);
1176
+ case "agent": return emitAgent$3(a);
1177
+ case "tool": return emitTool$3(a);
1178
+ case "rule": return emitRule$3(a);
1179
+ case "resource": return emitResource$3(a);
1180
+ case "prompt": return emitPrompt$3(a);
1181
+ default: return {
1182
+ files: [],
1183
+ warnings: [{
1184
+ level: "skipped",
1185
+ atomKind: "unknown",
1186
+ message: "Unknown atom kind"
1187
+ }]
1188
+ };
1189
+ }
1190
+ }
1191
+ };
1192
+ function emitInstruction$2(atom) {
1193
+ return {
1194
+ files: [{
1195
+ path: `.opencode/instructions/${slugify$2(atom.content)}.md`,
1196
+ content: `{file:${atom.content}}`
1197
+ }],
1198
+ warnings: []
1199
+ };
1200
+ }
1201
+ function emitHook$2(atom) {
1202
+ const TRIGGER_HOOKS = {
1203
+ "pre-tool-use": "tool.execute.before",
1204
+ "post-tool-use": "tool.execute.after",
1205
+ "pre-file-read": "tool.execute.before",
1206
+ "post-file-read": "tool.execute.after",
1207
+ "pre-file-write": "tool.execute.before",
1208
+ "post-file-write": "tool.execute.after",
1209
+ "pre-command": "tool.execute.before",
1210
+ "post-command": "tool.execute.after",
1211
+ "pre-context-compact": "experimental.session.compacting",
1212
+ "system-prompt-transform": "experimental.chat.system.transform"
1213
+ };
1214
+ const EVENT_MAP = {
1215
+ "pre-stop": "session.idle",
1216
+ "session-created": "session.created",
1217
+ "session-idle": "session.idle",
1218
+ "session-error": "session.error",
1219
+ "file-edited": "file.edited",
1220
+ "file-watcher-updated": "file.watcher.updated",
1221
+ "task-start": "session.created",
1222
+ "task-complete": "session.idle",
1223
+ "todo-updated": "todo.updated",
1224
+ "permission-asked": "permission.asked",
1225
+ "permission-replied": "permission.replied",
1226
+ "post-response": "session.idle",
1227
+ "pre-user-prompt": "message.updated",
1228
+ "message-updated": "message.updated",
1229
+ "lsp-diagnostics": "lsp.client.diagnostics",
1230
+ "lsp-updated": "lsp.updated",
1231
+ "subagent-start": "session.created",
1232
+ "subagent-complete": "session.idle",
1233
+ "installation-updated": "installation.updated",
1234
+ "shell-env": "shell.env",
1235
+ "pre-mcp-tool-use": "tool.execute.before",
1236
+ "post-mcp-tool-use": "tool.execute.after"
1237
+ };
1238
+ const name = atom.name ?? `hook-${atom.event}`;
1239
+ const triggerHook = TRIGGER_HOOKS[atom.event];
1240
+ if (triggerHook) {
1241
+ const pluginContent = atom.handler.type === "js" ? buildJsTriggerPlugin(name, triggerHook, atom) : buildDslTriggerPlugin(name, triggerHook, atom);
1242
+ return {
1243
+ files: [{
1244
+ path: `.opencode/plugins/${name}.ts`,
1245
+ content: pluginContent
1246
+ }],
1247
+ warnings: []
1248
+ };
1249
+ }
1250
+ const busEvent = EVENT_MAP[atom.event] ?? atom.event.replace(/-/g, ".");
1251
+ const pluginContent = atom.handler.type === "js" ? buildJsEventPlugin(name, busEvent, atom) : buildDslEventPlugin(name, busEvent, atom);
1252
+ return {
1253
+ files: [{
1254
+ path: `.opencode/plugins/${name}.ts`,
1255
+ content: pluginContent
1256
+ }],
1257
+ warnings: []
1258
+ };
1259
+ }
1260
+ function emitAgent$2(atom) {
1261
+ const READ_ONLY_TOOLS = new Set([
1262
+ "read",
1263
+ "grep",
1264
+ "glob",
1265
+ "lsp",
1266
+ "fetch",
1267
+ "mcp"
1268
+ ]);
1269
+ const permissions = {};
1270
+ for (const tool of atom.tools ?? []) permissions[tool] = atom.readonly ? READ_ONLY_TOOLS.has(tool) : true;
1271
+ const md = [
1272
+ `---`,
1273
+ `description: "${atom.role}"`,
1274
+ `mode: subagent`,
1275
+ atom.model && ![
1276
+ "fast",
1277
+ "balanced",
1278
+ "powerful",
1279
+ "custom"
1280
+ ].includes(atom.model) ? `model: ${atom.model}` : null,
1281
+ `permissions:`,
1282
+ ...Object.entries(permissions).map(([k, v]) => ` ${k}: ${v}`),
1283
+ atom.readonly ? ` write: false\n edit: false\n bash: false` : null,
1284
+ `---`,
1285
+ "",
1286
+ atom.role
1287
+ ].filter(Boolean).join("\n");
1288
+ return {
1289
+ files: [{
1290
+ path: `.opencode/agent/${atom.name}.md`,
1291
+ content: md
1292
+ }],
1293
+ warnings: []
1294
+ };
1295
+ }
1296
+ function emitTool$2(atom) {
1297
+ if (!atom.mcp) return {
1298
+ files: [],
1299
+ warnings: [{
1300
+ level: "skipped",
1301
+ atomKind: "tool",
1302
+ message: `Tool "${atom.name}" has no MCP config — cannot register in OpenCode`
1303
+ }]
1304
+ };
1305
+ const config = { [atom.name]: {
1306
+ type: "local",
1307
+ command: [atom.mcp.command, ...atom.mcp.args ?? []],
1308
+ ...atom.mcp.env ? { environment: atom.mcp.env } : {}
1309
+ } };
1310
+ return {
1311
+ files: [{
1312
+ path: `.opencode/mcp/${atom.name}.json`,
1313
+ content: JSON.stringify(config, null, 2)
1314
+ }],
1315
+ warnings: []
1316
+ };
1317
+ }
1318
+ function emitRule$2(atom) {
1319
+ const name = atom.name ?? `rule-${atom.event}`;
1320
+ const pluginContent = buildRulePlugin(name, atom);
1321
+ return {
1322
+ files: [{
1323
+ path: `.opencode/plugins/${name}.ts`,
1324
+ content: pluginContent
1325
+ }],
1326
+ warnings: []
1327
+ };
1328
+ }
1329
+ function emitResource$2(atom) {
1330
+ return {
1331
+ files: [],
1332
+ warnings: [{
1333
+ level: "degraded",
1334
+ atomKind: "resource",
1335
+ message: `OpenCode MCP resources are experimental — resource "${atom.name ?? atom.uri}" registered as instruction reference`
1336
+ }]
1337
+ };
1338
+ }
1339
+ function emitPrompt$2(atom) {
1340
+ const frontmatter = [
1341
+ "---",
1342
+ `description: "${atom.description ?? atom.name}"`,
1343
+ "---",
1344
+ "",
1345
+ `{file:${atom.template}}`
1346
+ ].join("\n");
1347
+ return {
1348
+ files: [{
1349
+ path: `.opencode/commands/${atom.name}.md`,
1350
+ content: frontmatter
1351
+ }],
1352
+ warnings: []
1353
+ };
1354
+ }
1355
+ function slugify$2(s) {
1356
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1357
+ }
1358
+ function buildJsTriggerPlugin(name, hook, atom) {
1359
+ const matchFilter = atom.match ? `\n if (input.tool !== "${atom.match}") return;` : "";
1360
+ const handlerRelPath = `./handlers/${name}.handler`;
1361
+ return `import type { Plugin } from "@opencode-ai/plugin";
1362
+
1363
+ export const ${pascalCase(name)}: Plugin = async ({ client }) => {
1364
+ return {
1365
+ "${hook}": async (input, output) => {${matchFilter}
1366
+ const handler = await import("${handlerRelPath}");
1367
+ await handler.default(input, output, client);
1368
+ },
1369
+ };
1370
+ };
1371
+ `;
1372
+ }
1373
+ function buildDslTriggerPlugin(name, hook, atom) {
1374
+ if (atom.handler.type !== "dsl") return "";
1375
+ const checks = atom.handler.actions.map((a) => {
1376
+ if (a.action === "block" && a.match) return ` if (JSON.stringify(output.args ?? input).includes("${a.match}")) {
1377
+ throw new Error("${a.reason ?? `Blocked: ${a.match}`}");
1378
+ }`;
1379
+ if (a.action === "injectContext" && a.value) return ` output.system?.push?.("${a.value}");`;
1380
+ return null;
1381
+ }).filter(Boolean).join("\n");
1382
+ return `import type { Plugin } from "@opencode-ai/plugin";
1383
+
1384
+ export const ${pascalCase(name)}: Plugin = async () => {
1385
+ return {
1386
+ "${hook}": async (input, output) => {
1387
+ ${checks}
1388
+ },
1389
+ };
1390
+ };
1391
+ `;
1392
+ }
1393
+ function buildJsEventPlugin(name, busEvent, _atom) {
1394
+ const handlerRelPath = `./handlers/${name}.handler`;
1395
+ return `import type { Plugin } from "@opencode-ai/plugin";
1396
+
1397
+ export const ${pascalCase(name)}: Plugin = async ({ client, $ }) => {
1398
+ let _lastFingerprint = "";
1399
+ let _running = false;
1400
+ return {
1401
+ event: ({ event }) => {
1402
+ const e = event;
1403
+ if (e.type !== "${busEvent}") return;
1404
+ if (_running) return;
1405
+ const sid = e.properties?.sessionID ?? "";
1406
+ if (!sid) return;
1407
+ _running = true;
1408
+ $\`git status --porcelain -uall 2>/dev/null\`.text().then((stat) => {
1409
+ const fp = stat.trim();
1410
+ if (!fp || fp === _lastFingerprint) {
1411
+ _running = false;
1412
+ return;
1413
+ }
1414
+ _lastFingerprint = fp;
1415
+ return import("${handlerRelPath}").then((handler) => {
1416
+ return handler.default(e, { client, $ });
1417
+ });
1418
+ }).catch((err) => console.error("[${name}] ERROR:", err)).finally(() => { _running = false; });
1419
+ },
1420
+ };
1421
+ };
1422
+ `;
1423
+ }
1424
+ function buildDslEventPlugin(name, busEvent, atom) {
1425
+ if (atom.handler.type !== "dsl") return "";
1426
+ const checks = atom.handler.actions.map((a) => {
1427
+ if (a.action === "block" && a.match) return ` console.error("[${name}] Blocked: ${a.reason ?? a.match}");`;
1428
+ return null;
1429
+ }).filter(Boolean).join("\n");
1430
+ return `import type { Plugin } from "@opencode-ai/plugin";
1431
+
1432
+ export const ${pascalCase(name)}: Plugin = async () => {
1433
+ return {
1434
+ event: ({ event }) => {
1435
+ if (event.type !== "${busEvent}") return;
1436
+ ${checks}
1437
+ },
1438
+ };
1439
+ };
1440
+ `;
1441
+ }
1442
+ function buildRulePlugin(name, atom) {
1443
+ const triggerHook = {
1444
+ "pre-tool-use": "tool.execute.before",
1445
+ "post-tool-use": "tool.execute.after"
1446
+ }[atom.event];
1447
+ const matchFilter = atom.match ? `\n if (input.tool !== "${atom.match}") return;` : "";
1448
+ if (triggerHook) {
1449
+ if (atom.policy === "block") return `import type { Plugin } from "@opencode-ai/plugin";
1450
+
1451
+ export const ${pascalCase(name)}: Plugin = async () => {
1452
+ return {
1453
+ "${triggerHook}": async (input, output) => {${matchFilter}
1454
+ throw new Error("${atom.reason ?? "Blocked by rule"}");
1455
+ },
1456
+ };
1457
+ };
1458
+ `;
1459
+ return `import type { Plugin } from "@opencode-ai/plugin";
1460
+
1461
+ export const ${pascalCase(name)}: Plugin = async () => {
1462
+ return {
1463
+ "${triggerHook}": async (input, output) => {${matchFilter}
1464
+ console.warn("[${name}] ${atom.reason ?? "Rule triggered"}");
1465
+ },
1466
+ };
1467
+ };
1468
+ `;
1469
+ }
1470
+ return `import type { Plugin } from "@opencode-ai/plugin";
1471
+
1472
+ export const ${pascalCase(name)}: Plugin = async () => {
1473
+ return {
1474
+ event: ({ event }) => {
1475
+ if (event.type !== "${atom.event.replace(/-/g, ".")}") return;
1476
+ console.${atom.policy === "block" ? "error" : "warn"}("[${name}] ${atom.reason ?? "Rule triggered"}");
1477
+ },
1478
+ };
1479
+ };
1480
+ `;
1481
+ }
1482
+ function pascalCase(s) {
1483
+ return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
1484
+ }
1485
+ const opencodeAdapter = {
1486
+ name: "opencode",
1487
+ supportedRange: ">=0.1.0",
1488
+ capabilities: {
1489
+ instruction: "full",
1490
+ hook: "full",
1491
+ tool: "full",
1492
+ agent: "full",
1493
+ rule: "full",
1494
+ resource: "degraded",
1495
+ prompt: "full"
1496
+ },
1497
+ compileAtom(atom) {
1498
+ const a = atom;
1499
+ switch (a.kind) {
1500
+ case "instruction": return emitInstruction$2(a);
1501
+ case "hook": return emitHook$2(a);
1502
+ case "agent": return emitAgent$2(a);
1503
+ case "tool": return emitTool$2(a);
1504
+ case "rule": return emitRule$2(a);
1505
+ case "resource": return emitResource$2(a);
1506
+ case "prompt": return emitPrompt$2(a);
1507
+ default: return {
1508
+ files: [],
1509
+ warnings: [{
1510
+ level: "skipped",
1511
+ atomKind: "unknown",
1512
+ message: "Unknown atom kind"
1513
+ }]
1514
+ };
1515
+ }
1516
+ }
1517
+ };
1518
+ function emitInstruction$1(atom) {
1519
+ return {
1520
+ files: [{
1521
+ path: `.roo/rules/${slugify$1(atom.content)}.md`,
1522
+ content: `{file:${atom.content}}`
1523
+ }],
1524
+ warnings: []
1525
+ };
1526
+ }
1527
+ function emitHook$1(_atom) {
1528
+ return {
1529
+ files: [],
1530
+ warnings: [{
1531
+ level: "skipped",
1532
+ atomKind: "hook",
1533
+ message: "Roo Code does not support hooks"
1534
+ }]
1535
+ };
1536
+ }
1537
+ function emitAgent$1(atom) {
1538
+ const toolGroups = [];
1539
+ for (const tool of atom.tools ?? []) {
1540
+ if ([
1541
+ "read",
1542
+ "grep",
1543
+ "glob"
1544
+ ].includes(tool)) toolGroups.push("read");
1545
+ if (["write", "edit"].includes(tool)) toolGroups.push("edit");
1546
+ if (["bash"].includes(tool)) toolGroups.push("command");
1547
+ if (["browser"].includes(tool)) toolGroups.push("browser");
1548
+ if (["mcp"].includes(tool)) toolGroups.push("mcp");
1549
+ }
1550
+ const groups = [...new Set(toolGroups)].map((g) => {
1551
+ if (g === "edit" && atom.readonly) return null;
1552
+ if (g === "command" && atom.readonly) return null;
1553
+ return g;
1554
+ }).filter(Boolean);
1555
+ const mode = {
1556
+ slug: atom.name,
1557
+ name: atom.name.charAt(0).toUpperCase() + atom.name.slice(1),
1558
+ roleDefinition: atom.role,
1559
+ groups: groups.map((g) => [g, {}]),
1560
+ customInstructions: atom.readonly ? "This mode is read-only. Do not modify any files." : void 0
1561
+ };
1562
+ return {
1563
+ files: [{
1564
+ path: `.roomodes`,
1565
+ content: JSON.stringify({ customModes: [mode] }, null, 2)
1566
+ }],
1567
+ warnings: []
1568
+ };
1569
+ }
1570
+ function emitTool$1(atom) {
1571
+ if (!atom.mcp) return {
1572
+ files: [],
1573
+ warnings: [{
1574
+ level: "skipped",
1575
+ atomKind: "tool",
1576
+ message: `Tool "${atom.name}" has no MCP config`
1577
+ }]
1578
+ };
1579
+ const config = { mcpServers: { [atom.name]: {
1580
+ command: atom.mcp.command,
1581
+ args: atom.mcp.args ?? [],
1582
+ disabled: false,
1583
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1584
+ } } };
1585
+ return {
1586
+ files: [{
1587
+ path: ".vscode/mcp.json",
1588
+ content: JSON.stringify(config, null, 2)
1589
+ }],
1590
+ warnings: []
1591
+ };
1592
+ }
1593
+ function emitRule$1(atom) {
1594
+ const content = `## Rule: ${atom.name ?? atom.event}\n\n- Policy: ${atom.policy}\n- Reason: ${atom.reason ?? "No reason specified"}\n`;
1595
+ return {
1596
+ files: [{
1597
+ path: `.roo/rules/rule-${slugify$1(atom.name ?? atom.event)}.md`,
1598
+ content
1599
+ }],
1600
+ warnings: [{
1601
+ level: "degraded",
1602
+ atomKind: "rule",
1603
+ message: "Roo Code rules are soft guidance only"
1604
+ }]
1605
+ };
1606
+ }
1607
+ function emitResource$1(atom) {
1608
+ return {
1609
+ files: [],
1610
+ warnings: [{
1611
+ level: "degraded",
1612
+ atomKind: "resource",
1613
+ message: `Roo Code MCP resources are mode-scoped — "${atom.name ?? atom.uri}" requires manual MCP setup`
1614
+ }]
1615
+ };
1616
+ }
1617
+ function emitPrompt$1(atom) {
1618
+ return {
1619
+ files: [],
1620
+ warnings: [{
1621
+ level: "degraded",
1622
+ atomKind: "prompt",
1623
+ message: `Roo Code does not support custom slash commands — prompt "${atom.name}" skipped`
1624
+ }]
1625
+ };
1626
+ }
1627
+ function slugify$1(s) {
1628
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1629
+ }
1630
+ const rooCodeAdapter = {
1631
+ name: "roo-code",
1632
+ supportedRange: ">=3.0.0",
1633
+ capabilities: {
1634
+ instruction: "full",
1635
+ hook: "none",
1636
+ tool: "full",
1637
+ agent: "full",
1638
+ rule: "degraded",
1639
+ resource: "degraded",
1640
+ prompt: "degraded"
1641
+ },
1642
+ compileAtom(atom) {
1643
+ const a = atom;
1644
+ switch (a.kind) {
1645
+ case "instruction": return emitInstruction$1(a);
1646
+ case "hook": return emitHook$1(a);
1647
+ case "agent": return emitAgent$1(a);
1648
+ case "tool": return emitTool$1(a);
1649
+ case "rule": return emitRule$1(a);
1650
+ case "resource": return emitResource$1(a);
1651
+ case "prompt": return emitPrompt$1(a);
1652
+ default: return {
1653
+ files: [],
1654
+ warnings: [{
1655
+ level: "skipped",
1656
+ atomKind: "unknown",
1657
+ message: "Unknown atom kind"
1658
+ }]
1659
+ };
1660
+ }
1661
+ }
1662
+ };
1663
+ function emitInstruction(atom) {
1664
+ return {
1665
+ files: [{
1666
+ path: `.windsurf/rules/${slugify$6(atom.content)}.md`,
1667
+ content: `{file:${atom.content}}`
1668
+ }],
1669
+ warnings: []
1670
+ };
1671
+ }
1672
+ function emitHook(atom) {
1673
+ const wsEvent = {
1674
+ "pre-tool-use": "pre_mcp_tool_use",
1675
+ "post-tool-use": "post_mcp_tool_use",
1676
+ "pre-file-read": "pre_read_code",
1677
+ "post-file-read": "post_read_code",
1678
+ "pre-file-write": "pre_write_code",
1679
+ "post-file-write": "post_write_code",
1680
+ "pre-command": "pre_run_command",
1681
+ "post-command": "post_run_command",
1682
+ "pre-user-prompt": "pre_user_prompt",
1683
+ "post-response": "post_cascade_response",
1684
+ "pre-stop": "post_cascade_response",
1685
+ "pre-mcp-tool-use": "pre_mcp_tool_use",
1686
+ "post-mcp-tool-use": "post_mcp_tool_use"
1687
+ }[atom.event];
1688
+ if (!wsEvent) return {
1689
+ files: [],
1690
+ warnings: [{
1691
+ level: "degraded",
1692
+ atomKind: "hook",
1693
+ message: `Windsurf does not support event "${atom.event}" — skipped`
1694
+ }]
1695
+ };
1696
+ const name = atom.name ?? `hook-${atom.event}`;
1697
+ if (atom.handler.type === "js") {
1698
+ const wrapper = `#!/usr/bin/env node\nimport { readFileSync } from "node:fs";\nconst input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));\nconst handler = await import("./${name}.handler.ts");\nawait handler.default(input);\n`;
1699
+ const hookConfig = { hooks: { [wsEvent]: [{
1700
+ command: `node "$WORKSPACE_DIR/.windsurf/hooks/${name}.mjs"`,
1701
+ show_output: true
1702
+ }] } };
1703
+ return {
1704
+ files: [{
1705
+ path: `.windsurf/hooks/${name}.mjs`,
1706
+ content: wrapper
1707
+ }, {
1708
+ path: ".windsurf/hooks.json",
1709
+ content: JSON.stringify(hookConfig, null, 2)
1710
+ }],
1711
+ warnings: []
1712
+ };
1713
+ }
1714
+ const script = buildDslScript(atom.handler.actions);
1715
+ const hookConfig = { hooks: { [wsEvent]: [{
1716
+ command: `bash "$WORKSPACE_DIR/.windsurf/hooks/${name}.sh"`,
1717
+ show_output: true
1718
+ }] } };
1719
+ return {
1720
+ files: [{
1721
+ path: `.windsurf/hooks/${name}.sh`,
1722
+ content: script
1723
+ }, {
1724
+ path: ".windsurf/hooks.json",
1725
+ content: JSON.stringify(hookConfig, null, 2)
1726
+ }],
1727
+ warnings: []
1728
+ };
1729
+ }
1730
+ function emitAgent(atom) {
1731
+ return {
1732
+ files: [],
1733
+ warnings: [{
1734
+ level: "degraded",
1735
+ atomKind: "agent",
1736
+ message: `Windsurf has 3 fixed modes (Code/Plan/Ask) — agent "${atom.name}" compiled as instruction rule`
1737
+ }]
1738
+ };
1739
+ }
1740
+ function emitTool(atom) {
1741
+ if (!atom.mcp) return {
1742
+ files: [],
1743
+ warnings: [{
1744
+ level: "skipped",
1745
+ atomKind: "tool",
1746
+ message: `Tool "${atom.name}" has no MCP config`
1747
+ }]
1748
+ };
1749
+ const config = { mcpServers: { [atom.name]: {
1750
+ command: atom.mcp.command,
1751
+ args: atom.mcp.args ?? [],
1752
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1753
+ } } };
1754
+ return {
1755
+ files: [{
1756
+ path: ".windsurf/mcp.json",
1757
+ content: JSON.stringify(config, null, 2)
1758
+ }],
1759
+ warnings: []
1760
+ };
1761
+ }
1762
+ function emitRule(atom) {
1763
+ const content = `## Rule: ${atom.name ?? atom.event}\n\n- Policy: ${atom.policy}\n- Event: ${atom.event}\n${atom.match ? `- Match: ${atom.match}\n` : ""}- Reason: ${atom.reason ?? "No reason specified"}\n`;
1764
+ return {
1765
+ files: [{
1766
+ path: `.windsurf/rules/rule-${slugify$6(atom.name ?? atom.event)}.md`,
1767
+ content
1768
+ }],
1769
+ warnings: [{
1770
+ level: "degraded",
1771
+ atomKind: "rule",
1772
+ message: "Windsurf rules are soft guidance — use hooks for enforcement"
1773
+ }]
1774
+ };
1775
+ }
1776
+ function emitResource(atom) {
1777
+ return {
1778
+ files: [],
1779
+ warnings: [{
1780
+ level: "degraded",
1781
+ atomKind: "resource",
1782
+ message: `Windsurf uses RAG indexing — resource "${atom.name ?? atom.uri}" not directly registrable`
1783
+ }]
1784
+ };
1785
+ }
1786
+ function emitPrompt(atom) {
1787
+ return {
1788
+ files: [{
1789
+ path: `.windsurf/skills/${atom.name}/SKILL.md`,
1790
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
1791
+ }],
1792
+ warnings: []
1793
+ };
1794
+ }
1795
+ function slugify$6(s) {
1796
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1797
+ }
1798
+ function buildDslScript(actions) {
1799
+ return `#!/usr/bin/env bash\nset -euo pipefail\nINPUT=$(cat)\n${actions.filter((a) => a.action === "block" && a.match).map((a) => `if echo "$INPUT" | grep -q '${a.match}'; then\n echo "Blocked: ${a.reason ?? a.match}" >&2\n exit 2\nfi`).join("\n")}\nexit 0\n`;
1800
+ }
1801
+ const windsurfAdapter = {
1802
+ name: "windsurf",
1803
+ supportedRange: ">=1.0.0",
1804
+ capabilities: {
1805
+ instruction: "full",
1806
+ hook: "full",
1807
+ tool: "full",
1808
+ agent: "degraded",
1809
+ rule: "degraded",
1810
+ resource: "degraded",
1811
+ prompt: "full"
1812
+ },
1813
+ compileAtom(atom) {
1814
+ const a = atom;
1815
+ switch (a.kind) {
1816
+ case "instruction": return emitInstruction(a);
1817
+ case "hook": return emitHook(a);
1818
+ case "agent": return emitAgent(a);
1819
+ case "tool": return emitTool(a);
1820
+ case "rule": return emitRule(a);
1821
+ case "resource": return emitResource(a);
1822
+ case "prompt": return emitPrompt(a);
1823
+ default: return {
1824
+ files: [],
1825
+ warnings: [{
1826
+ level: "skipped",
1827
+ atomKind: "unknown",
1828
+ message: "Unknown atom kind"
1829
+ }]
1830
+ };
1831
+ }
1832
+ }
1833
+ };
1834
+ const HANDLER_DIRS = {
1835
+ opencode: ".opencode/plugins/handlers",
1836
+ "claude-code": ".claude/hooks",
1837
+ cursor: ".cursor/hooks",
1838
+ windsurf: ".windsurf/hooks",
1839
+ cline: ".clinerules/hooks",
1840
+ "roo-code": ".roo/hooks"
1841
+ };
1842
+ function resolveSourceFiles(atoms, sourceDir, adapterName) {
1843
+ const files = [];
1844
+ const handlerDir = HANDLER_DIRS[adapterName] ?? `.${adapterName}/hooks`;
1845
+ for (const atom of atoms) {
1846
+ if (atom.kind === "hook" && atom.handler.type === "js") {
1847
+ const srcPath = path.resolve(sourceDir, atom.handler.entry);
1848
+ if (fs.existsSync(srcPath)) {
1849
+ const name = "name" in atom && atom.name ? atom.name : `hook-${atom.event}`;
1850
+ files.push({
1851
+ path: `${handlerDir}/${name}.handler.ts`,
1852
+ content: fs.readFileSync(srcPath, "utf-8")
1853
+ });
1854
+ }
1855
+ }
1856
+ if (atom.kind === "instruction") {
1857
+ const srcPath = path.resolve(sourceDir, atom.content);
1858
+ if (fs.existsSync(srcPath)) files.push({
1859
+ path: `__resolved__/${atom.content}`,
1860
+ content: fs.readFileSync(srcPath, "utf-8")
1861
+ });
1862
+ }
1863
+ if (atom.kind === "prompt" && "template" in atom) {
1864
+ const srcPath = path.resolve(sourceDir, atom.template);
1865
+ if (fs.existsSync(srcPath)) files.push({
1866
+ path: `__resolved__/${atom.template}`,
1867
+ content: fs.readFileSync(srcPath, "utf-8")
1868
+ });
1869
+ }
1870
+ }
1871
+ return files;
1872
+ }
1873
+ function inlineFileReferences(files, resolvedFiles) {
1874
+ return files.map((f) => {
1875
+ let content = f.content;
1876
+ for (const [refPath, refContent] of resolvedFiles) {
1877
+ content = content.replace(`{file:${refPath}}`, refContent);
1878
+ content = content.replace(`{file:./${refPath}}`, refContent);
1879
+ }
1880
+ return {
1881
+ path: f.path,
1882
+ content
1883
+ };
1884
+ });
1885
+ }
1886
+ function deepMergeJson(a, b) {
1887
+ try {
1888
+ const merged = mergeObjects(JSON.parse(a), JSON.parse(b));
1889
+ return JSON.stringify(merged, null, 2);
1890
+ } catch {
1891
+ return b;
1892
+ }
1893
+ }
1894
+ function mergeObjects(a, b) {
1895
+ if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
1896
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
1897
+ const result = { ...a };
1898
+ for (const [key, val] of Object.entries(b)) if (key in result) result[key] = mergeObjects(result[key], val);
1899
+ else result[key] = val;
1900
+ return result;
1901
+ }
1902
+ return b;
1903
+ }
1904
+ function mergeFilesByPath(files) {
1905
+ const byPath = /* @__PURE__ */ new Map();
1906
+ for (const f of files) {
1907
+ const existing = byPath.get(f.path);
1908
+ if (!existing) {
1909
+ byPath.set(f.path, f);
1910
+ continue;
1911
+ }
1912
+ if (f.path.endsWith(".json") || f.path === ".roomodes") byPath.set(f.path, {
1913
+ path: f.path,
1914
+ content: deepMergeJson(existing.content, f.content)
1915
+ });
1916
+ else if (f.path.endsWith(".md") || f.path.endsWith(".mdc")) byPath.set(f.path, {
1917
+ path: f.path,
1918
+ content: `${existing.content}\n\n${f.content}`
1919
+ });
1920
+ else byPath.set(f.path, f);
1921
+ }
1922
+ return [...byPath.values()];
1923
+ }
1924
+ function collectAtoms(pkg, resolver, visited) {
1925
+ const seen = visited ?? /* @__PURE__ */ new Set();
1926
+ if (seen.has(pkg.name)) return [];
1927
+ seen.add(pkg.name);
1928
+ const atoms = [];
1929
+ if (pkg.includes) for (const dep of pkg.includes) {
1930
+ const resolved = resolver?.resolve(dep);
1931
+ if (resolved) atoms.push(...collectAtoms(resolved, resolver, seen));
1932
+ }
1933
+ atoms.push(...pkg.atoms);
1934
+ return atoms;
1935
+ }
1936
+ function compilePackage(pkg, adapter, options) {
1937
+ const rawFiles = [];
1938
+ const allWarnings = [];
1939
+ const skipped = [];
1940
+ const resolvedContent = /* @__PURE__ */ new Map();
1941
+ const allAtomsForResolve = collectAtoms(pkg, options?.resolver);
1942
+ if (options?.sourceDir) {
1943
+ const sourceFiles = resolveSourceFiles(allAtomsForResolve, options.sourceDir, adapter.name);
1944
+ for (const sf of sourceFiles) if (sf.path.startsWith("__resolved__/")) resolvedContent.set(sf.path.replace("__resolved__/", ""), sf.content);
1945
+ else rawFiles.push(sf);
1946
+ }
1947
+ const allAtoms = collectAtoms(pkg, options?.resolver);
1948
+ if (options?.sourceDir) {
1949
+ for (const atom of allAtoms) if (atom.kind === "tool" && atom.mcp?.entry && atom.mcp?.runtime) {
1950
+ const absoluteEntry = path.resolve(options.sourceDir, atom.mcp.entry);
1951
+ atom.mcp.command = atom.mcp.runtime;
1952
+ atom.mcp.args = [absoluteEntry, ...atom.mcp.args ?? []];
1953
+ }
1954
+ }
1955
+ for (const atom of allAtoms) {
1956
+ if (adapter.capabilities[atom.kind] === "none") {
1957
+ const label = "name" in atom && atom.name ? `${atom.kind}/${atom.name}` : atom.kind;
1958
+ allWarnings.push({
1959
+ level: "skipped",
1960
+ atomKind: atom.kind,
1961
+ message: `${adapter.name} does not support ${atom.kind} — skipped "${label}"`
1962
+ });
1963
+ skipped.push(atom.kind);
1964
+ continue;
1965
+ }
1966
+ const output = adapter.compileAtom(atom);
1967
+ rawFiles.push(...output.files);
1968
+ allWarnings.push(...output.warnings);
1969
+ }
1970
+ return {
1971
+ files: mergeFilesByPath(resolvedContent.size > 0 ? inlineFileReferences(rawFiles, resolvedContent) : rawFiles),
1972
+ warnings: allWarnings,
1973
+ skipped
1974
+ };
1975
+ }
1976
+ function normalizeDirectory(dir) {
1977
+ const tankJsonPath = path.join(dir, "tank.json");
1978
+ const skillsJsonPath = path.join(dir, "skills.json");
1979
+ const skillMdPath = path.join(dir, "SKILL.md");
1980
+ const manifestPath = fs.existsSync(tankJsonPath) ? tankJsonPath : fs.existsSync(skillsJsonPath) ? skillsJsonPath : null;
1981
+ if (!manifestPath) return {
1982
+ success: false,
1983
+ error: "No tank.json or skills.json found"
1984
+ };
1985
+ let manifest;
1986
+ try {
1987
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1988
+ } catch {
1989
+ return {
1990
+ success: false,
1991
+ error: `Failed to parse ${path.basename(manifestPath)}`
1992
+ };
1993
+ }
1994
+ const hasAtoms = "atoms" in manifest && Array.isArray(manifest.atoms);
1995
+ const hasSkillMd = fs.existsSync(skillMdPath);
1996
+ if (!hasAtoms && !hasSkillMd) return {
1997
+ success: false,
1998
+ error: "No atoms field in manifest and no SKILL.md found"
1999
+ };
2000
+ const atoms = hasAtoms ? manifest.atoms : [{
2001
+ kind: "instruction",
2002
+ content: "SKILL.md"
2003
+ }];
2004
+ const pkg = {
2005
+ ...manifest,
2006
+ atoms
2007
+ };
2008
+ const result = packageIRSchema.safeParse(pkg);
2009
+ if (!result.success) return {
2010
+ success: false,
2011
+ error: `Invalid manifest:\n${result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
2012
+ };
2013
+ return {
2014
+ success: true,
2015
+ data: result.data
2016
+ };
2017
+ }
2018
+ //#endregion
2019
+ //#region src/commands/build.ts
2020
+ var build_exports = /* @__PURE__ */ __exportAll({
2021
+ buildCommand: () => buildCommand,
2022
+ listPlatforms: () => listPlatforms
2023
+ });
2024
+ const ADAPTERS = {
2025
+ opencode: opencodeAdapter,
2026
+ "claude-code": claudeCodeAdapter,
2027
+ cursor: cursorAdapter,
2028
+ windsurf: windsurfAdapter,
2029
+ cline: clineAdapter,
2030
+ "roo-code": rooCodeAdapter
2031
+ };
2032
+ function detectPlatform() {
2033
+ const cwd = process.cwd();
2034
+ if (fs.existsSync(path.join(cwd, ".opencode")) || fs.existsSync(path.join(cwd, "opencode.json"))) return "opencode";
2035
+ if (fs.existsSync(path.join(cwd, ".cursor"))) return "cursor";
2036
+ if (fs.existsSync(path.join(cwd, ".claude"))) return "claude-code";
2037
+ if (fs.existsSync(path.join(cwd, ".windsurf")) || fs.existsSync(path.join(cwd, ".windsurfrules"))) return "windsurf";
2038
+ if (fs.existsSync(path.join(cwd, ".clinerules")) || fs.existsSync(path.join(cwd, ".cline"))) return "cline";
2039
+ if (fs.existsSync(path.join(cwd, ".roo")) || fs.existsSync(path.join(cwd, ".roomodes"))) return "roo-code";
2040
+ return null;
2041
+ }
2042
+ function loadManifest(skillDir) {
2043
+ const result = normalizeDirectory(skillDir);
2044
+ if (!result.success) throw new Error(result.error);
2045
+ return result.data;
2046
+ }
2047
+ function writeFiles(targetDir, compiled) {
2048
+ for (const f of compiled.files) {
2049
+ const fullPath = path.join(targetDir, f.path);
2050
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
2051
+ fs.writeFileSync(fullPath, f.content);
2052
+ }
2053
+ return compiled.files.length;
2054
+ }
2055
+ function listPlatforms() {
2056
+ logger.info("Available platforms:\n");
2057
+ for (const [id, adapter] of Object.entries(ADAPTERS)) {
2058
+ const caps = Object.entries(adapter.capabilities).filter(([, v]) => v !== "none").map(([k]) => k);
2059
+ logger.info(` ${id.padEnd(14)} ${caps.join(", ")}`);
2060
+ }
2061
+ }
2062
+ async function buildCommand(opts) {
2063
+ if (opts.listPlatforms) {
2064
+ listPlatforms();
2065
+ return;
2066
+ }
2067
+ const spinner = ora("Building...").start();
2068
+ try {
2069
+ const skillDir = path.resolve(opts.skill);
2070
+ if (!fs.existsSync(skillDir)) throw new Error(`Skill directory not found: ${skillDir}`);
2071
+ const pkg = loadManifest(skillDir);
2072
+ if (!pkg.atoms || pkg.atoms.length === 0) {
2073
+ spinner.warn(`${pkg.name} has no atoms — nothing to build`);
2074
+ return;
2075
+ }
2076
+ const platformId = opts.platform ?? detectPlatform();
2077
+ if (!platformId) throw new Error("Could not detect platform. Use --platform to specify one of: " + Object.keys(ADAPTERS).join(", "));
2078
+ const adapter = ADAPTERS[platformId];
2079
+ if (!adapter) throw new Error(`Unknown platform "${platformId}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
2080
+ const targetDir = opts.out ?? opts.target ?? process.cwd();
2081
+ spinner.text = `Compiling ${pkg.name} for ${adapter.name}...`;
2082
+ const compiled = compilePackage(pkg, adapter, { sourceDir: skillDir });
2083
+ if (opts.dryRun) {
2084
+ spinner.succeed(`[dry-run] Would write ${compiled.files.length} files for ${adapter.name}`);
2085
+ for (const f of compiled.files) logger.info(` ${f.path}`);
2086
+ } else {
2087
+ const count = writeFiles(targetDir, compiled);
2088
+ spinner.succeed(`Built ${count} files for ${adapter.name}`);
2089
+ for (const f of compiled.files) logger.info(` ${f.path}`);
2090
+ }
2091
+ for (const w of compiled.warnings) {
2092
+ const icon = w.level === "skipped" ? "⏭️ " : "⚠️ ";
2093
+ logger.warn(`${icon}[${w.atomKind}] ${w.message}`);
2094
+ }
2095
+ if (compiled.skipped.length > 0) logger.warn(`${compiled.skipped.length} atom(s) skipped — ${adapter.name} does not support: ${compiled.skipped.join(", ")}`);
2096
+ } catch (err) {
2097
+ spinner.fail("Build failed");
2098
+ throw err;
2099
+ }
2100
+ }
2101
+ //#endregion
332
2102
  //#region src/lib/agents.ts
333
2103
  const resolveHomedir = (homedir) => homedir ?? os.homedir();
334
2104
  const isWindows = process.platform === "win32";
@@ -798,17 +2568,17 @@ async function infoCommand(options) {
798
2568
  }
799
2569
  //#endregion
800
2570
  //#region src/commands/init.ts
801
- const NAME_PATTERN = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
802
- const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
2571
+ const NAME_PATTERN$1 = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
2572
+ const SEMVER_PATTERN$1 = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
803
2573
  const MAX_NAME_LENGTH = 214;
804
2574
  function validateName(value) {
805
2575
  if (!value) return "Name must not be empty";
806
2576
  if (value.length > MAX_NAME_LENGTH) return `Name must be ${MAX_NAME_LENGTH} characters or fewer`;
807
- if (!NAME_PATTERN.test(value)) return "Name must be lowercase, alphanumeric + hyphens, optionally scoped (@org/name)";
2577
+ if (!NAME_PATTERN$1.test(value)) return "Name must be lowercase, alphanumeric + hyphens, optionally scoped (@org/name)";
808
2578
  return true;
809
2579
  }
810
2580
  function validateVersion(value) {
811
- if (!SEMVER_PATTERN.test(value)) return "Version must be valid semver (e.g. 1.0.0)";
2581
+ if (!SEMVER_PATTERN$1.test(value)) return "Version must be valid semver (e.g. 1.0.0)";
812
2582
  return true;
813
2583
  }
814
2584
  async function initCommand(options = {}) {
@@ -2606,6 +4376,61 @@ const $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => {
2606
4376
  });
2607
4377
  };
2608
4378
  });
4379
+ const $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnion", (inst, def) => {
4380
+ def.inclusive = false;
4381
+ $ZodUnion.init(inst, def);
4382
+ const _super = inst._zod.parse;
4383
+ defineLazy(inst._zod, "propValues", () => {
4384
+ const propValues = {};
4385
+ for (const option of def.options) {
4386
+ const pv = option._zod.propValues;
4387
+ if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`);
4388
+ for (const [k, v] of Object.entries(pv)) {
4389
+ if (!propValues[k]) propValues[k] = /* @__PURE__ */ new Set();
4390
+ for (const val of v) propValues[k].add(val);
4391
+ }
4392
+ }
4393
+ return propValues;
4394
+ });
4395
+ const disc = cached(() => {
4396
+ const opts = def.options;
4397
+ const map = /* @__PURE__ */ new Map();
4398
+ for (const o of opts) {
4399
+ const values = o._zod.propValues?.[def.discriminator];
4400
+ if (!values || values.size === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(o)}"`);
4401
+ for (const v of values) {
4402
+ if (map.has(v)) throw new Error(`Duplicate discriminator value "${String(v)}"`);
4403
+ map.set(v, o);
4404
+ }
4405
+ }
4406
+ return map;
4407
+ });
4408
+ inst._zod.parse = (payload, ctx) => {
4409
+ const input = payload.value;
4410
+ if (!isObject(input)) {
4411
+ payload.issues.push({
4412
+ code: "invalid_type",
4413
+ expected: "object",
4414
+ input,
4415
+ inst
4416
+ });
4417
+ return payload;
4418
+ }
4419
+ const opt = disc.value.get(input?.[def.discriminator]);
4420
+ if (opt) return opt._zod.run(payload, ctx);
4421
+ if (def.unionFallback) return _super(payload, ctx);
4422
+ payload.issues.push({
4423
+ code: "invalid_union",
4424
+ errors: [],
4425
+ note: "No matching discriminator",
4426
+ discriminator: def.discriminator,
4427
+ input,
4428
+ path: [def.discriminator],
4429
+ inst
4430
+ });
4431
+ return payload;
4432
+ };
4433
+ });
2609
4434
  const $ZodIntersection = /* @__PURE__ */ $constructor("$ZodIntersection", (inst, def) => {
2610
4435
  $ZodType.init(inst, def);
2611
4436
  inst._zod.parse = (payload, ctx) => {
@@ -4554,6 +6379,18 @@ function union(options, params) {
4554
6379
  ...normalizeParams(params)
4555
6380
  });
4556
6381
  }
6382
+ const ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => {
6383
+ ZodUnion.init(inst, def);
6384
+ $ZodDiscriminatedUnion.init(inst, def);
6385
+ });
6386
+ function discriminatedUnion(discriminator, options, params) {
6387
+ return new ZodDiscriminatedUnion({
6388
+ type: "union",
6389
+ options,
6390
+ discriminator,
6391
+ ...normalizeParams(params)
6392
+ });
6393
+ }
4557
6394
  const ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => {
4558
6395
  $ZodIntersection.init(inst, def);
4559
6396
  ZodType.init(inst, def);
@@ -4801,6 +6638,159 @@ function superRefine(fn) {
4801
6638
  process.env.TANK_REGISTRY_URL;
4802
6639
  const MANIFEST_FILENAME = "tank.json";
4803
6640
  const LEGACY_MANIFEST_FILENAME = "skills.json";
6641
+ const supportLevelSchema = _enum([
6642
+ "full",
6643
+ "degraded",
6644
+ "none"
6645
+ ]);
6646
+ const adapterCapabilitiesSchema = object({
6647
+ instruction: supportLevelSchema,
6648
+ hook: supportLevelSchema,
6649
+ tool: supportLevelSchema,
6650
+ agent: supportLevelSchema,
6651
+ rule: supportLevelSchema,
6652
+ resource: supportLevelSchema,
6653
+ prompt: supportLevelSchema
6654
+ }).strict();
6655
+ const compilationWarningSchema = object({
6656
+ level: _enum(["degraded", "skipped"]),
6657
+ atomKind: string(),
6658
+ message: string()
6659
+ }).strict();
6660
+ object({
6661
+ files: array(object({
6662
+ path: string().min(1),
6663
+ content: string()
6664
+ }).strict()),
6665
+ warnings: array(compilationWarningSchema)
6666
+ }).strict();
6667
+ object({
6668
+ name: string().min(1, "Adapter name must not be empty"),
6669
+ supportedRange: string().min(1, "Supported range must not be empty"),
6670
+ capabilities: adapterCapabilitiesSchema
6671
+ }).strict();
6672
+ _enum([
6673
+ "instruction",
6674
+ "hook",
6675
+ "tool",
6676
+ "agent",
6677
+ "rule",
6678
+ "resource",
6679
+ "prompt"
6680
+ ]);
6681
+ const extensionBagSchema = record(string(), unknown()).optional();
6682
+ const modelTierSchema = _enum([
6683
+ "fast",
6684
+ "balanced",
6685
+ "powerful",
6686
+ "custom"
6687
+ ]);
6688
+ modelTierSchema.options;
6689
+ const canonicalToolNameSchema = _enum([
6690
+ "bash",
6691
+ "read",
6692
+ "write",
6693
+ "edit",
6694
+ "grep",
6695
+ "glob",
6696
+ "lsp",
6697
+ "mcp",
6698
+ "browser",
6699
+ "fetch",
6700
+ "git",
6701
+ "task",
6702
+ "notebook"
6703
+ ]);
6704
+ canonicalToolNameSchema.options;
6705
+ const agentIRSchema = object({
6706
+ kind: literal("agent"),
6707
+ name: string().min(1, "Agent name must not be empty"),
6708
+ role: string().min(1, "Agent role must not be empty"),
6709
+ tools: array(canonicalToolNameSchema.or(string().min(1))).optional(),
6710
+ model: modelTierSchema.or(string().min(1)).optional(),
6711
+ readonly: boolean().optional(),
6712
+ extensions: extensionBagSchema
6713
+ }).strict();
6714
+ const hookEventSchema = _enum([
6715
+ "pre-tool-use",
6716
+ "post-tool-use",
6717
+ "pre-file-read",
6718
+ "post-file-read",
6719
+ "pre-file-write",
6720
+ "post-file-write",
6721
+ "file-edited",
6722
+ "file-watcher-updated",
6723
+ "pre-command",
6724
+ "post-command",
6725
+ "pre-mcp-tool-use",
6726
+ "post-mcp-tool-use",
6727
+ "session-created",
6728
+ "session-updated",
6729
+ "session-idle",
6730
+ "session-error",
6731
+ "session-deleted",
6732
+ "pre-stop",
6733
+ "task-start",
6734
+ "task-resume",
6735
+ "task-complete",
6736
+ "task-cancel",
6737
+ "pre-user-prompt",
6738
+ "post-response",
6739
+ "message-updated",
6740
+ "message-removed",
6741
+ "system-prompt-transform",
6742
+ "pre-context-compact",
6743
+ "post-context-compact",
6744
+ "permission-asked",
6745
+ "permission-replied",
6746
+ "lsp-diagnostics",
6747
+ "lsp-updated",
6748
+ "subagent-start",
6749
+ "subagent-complete",
6750
+ "subagent-tool-use",
6751
+ "shell-env",
6752
+ "todo-updated",
6753
+ "installation-updated"
6754
+ ]);
6755
+ hookEventSchema.options;
6756
+ const hookActionIRSchema = object({
6757
+ action: _enum([
6758
+ "block",
6759
+ "allow",
6760
+ "rewrite",
6761
+ "injectContext"
6762
+ ]),
6763
+ match: string().optional(),
6764
+ reason: string().optional(),
6765
+ value: string().optional()
6766
+ }).strict();
6767
+ const hookHandlerIRSchema = discriminatedUnion("type", [object({
6768
+ type: literal("dsl"),
6769
+ actions: array(hookActionIRSchema).min(1, "DSL handler must have at least one action")
6770
+ }).strict(), object({
6771
+ type: literal("js"),
6772
+ entry: string().min(1, "JS handler entry path must not be empty")
6773
+ }).strict()]);
6774
+ const hookIRSchema = object({
6775
+ kind: literal("hook"),
6776
+ name: string().optional(),
6777
+ event: hookEventSchema,
6778
+ match: canonicalToolNameSchema.or(string().min(1)).optional(),
6779
+ handler: hookHandlerIRSchema,
6780
+ scope: _enum(["project", "global"]).optional(),
6781
+ extensions: extensionBagSchema
6782
+ }).strict();
6783
+ const instructionIRSchema = object({
6784
+ kind: literal("instruction"),
6785
+ content: string().min(1, "Content path must not be empty"),
6786
+ scope: _enum([
6787
+ "project",
6788
+ "global",
6789
+ "directory"
6790
+ ]).optional(),
6791
+ globs: array(string()).optional(),
6792
+ extensions: extensionBagSchema
6793
+ }).strict();
4804
6794
  const networkPermissionsSchema = object({ outbound: array(string()).optional() }).strict();
4805
6795
  const filesystemPermissionsSchema = object({
4806
6796
  read: array(string()).optional(),
@@ -4839,7 +6829,77 @@ _enum([
4839
6829
  "org.member.remove",
4840
6830
  "org.delete"
4841
6831
  ]);
6832
+ const promptIRSchema = object({
6833
+ kind: literal("prompt"),
6834
+ name: string().min(1, "Prompt name must not be empty"),
6835
+ description: string().optional(),
6836
+ template: string().min(1, "Prompt template path must not be empty"),
6837
+ arguments: array(object({
6838
+ name: string(),
6839
+ description: string().optional(),
6840
+ required: boolean().optional()
6841
+ }).strict()).optional(),
6842
+ extensions: extensionBagSchema
6843
+ }).strict();
6844
+ const resourceIRSchema = object({
6845
+ kind: literal("resource"),
6846
+ name: string().optional(),
6847
+ uri: string().min(1, "Resource URI must not be empty"),
6848
+ description: string().optional(),
6849
+ mimeType: string().optional(),
6850
+ extensions: extensionBagSchema
6851
+ }).strict();
6852
+ const ruleIRSchema = object({
6853
+ kind: literal("rule"),
6854
+ name: string().optional(),
6855
+ event: hookEventSchema,
6856
+ match: canonicalToolNameSchema.or(string().min(1)).optional(),
6857
+ policy: _enum([
6858
+ "block",
6859
+ "allow",
6860
+ "warn"
6861
+ ]),
6862
+ reason: string().optional(),
6863
+ extensions: extensionBagSchema
6864
+ }).strict();
6865
+ const mcpServerConfigSchema = object({
6866
+ command: string().min(1).optional(),
6867
+ args: array(string()).optional(),
6868
+ env: record(string(), string()).optional(),
6869
+ runtime: string().min(1).optional(),
6870
+ entry: string().min(1).optional()
6871
+ }).strict().refine((data) => data.command || data.runtime && data.entry, "MCP config must have either \"command\" or both \"runtime\" and \"entry\"");
6872
+ const toolIRSchema = object({
6873
+ kind: literal("tool"),
6874
+ name: string().min(1, "Tool name must not be empty"),
6875
+ description: string().optional(),
6876
+ mcp: mcpServerConfigSchema.optional(),
6877
+ extensions: extensionBagSchema
6878
+ }).strict();
6879
+ const NAME_PATTERN = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
6880
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
6881
+ const atomIRSchema = discriminatedUnion("kind", [
6882
+ instructionIRSchema,
6883
+ hookIRSchema,
6884
+ toolIRSchema,
6885
+ agentIRSchema,
6886
+ ruleIRSchema,
6887
+ resourceIRSchema,
6888
+ promptIRSchema
6889
+ ]);
4842
6890
  object({
6891
+ name: string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(NAME_PATTERN, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
6892
+ version: string().regex(SEMVER_PATTERN, "Version must be valid semver"),
6893
+ description: string().max(500).optional(),
6894
+ atoms: array(atomIRSchema),
6895
+ includes: array(string()).optional(),
6896
+ skills: record(string(), string()).optional(),
6897
+ permissions: permissionsSchema.optional(),
6898
+ repository: string().url("Repository must be a valid URL").optional(),
6899
+ visibility: _enum(["public", "private"]).optional(),
6900
+ audit: object({ min_score: number().min(0).max(10) }).strict().optional()
6901
+ }).strict();
6902
+ const baseManifestFields = {
4843
6903
  name: string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(/^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
4844
6904
  version: string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
4845
6905
  description: string().max(500, `Description must be 500 characters or fewer`).optional(),
@@ -4848,7 +6908,29 @@ object({
4848
6908
  repository: string().url("Repository must be a valid URL").optional(),
4849
6909
  visibility: _enum(["public", "private"]).optional(),
4850
6910
  audit: object({ min_score: number().min(0).max(10) }).strict().optional()
6911
+ };
6912
+ object(baseManifestFields).strict();
6913
+ object({
6914
+ ...baseManifestFields,
6915
+ atoms: array(record(string(), unknown())).optional(),
6916
+ includes: array(string()).optional()
4851
6917
  }).strict();
6918
+ const SKILL_SOURCES = [
6919
+ "registry",
6920
+ "github",
6921
+ "clawhub",
6922
+ "skills_sh",
6923
+ "agentskills_il",
6924
+ "npm",
6925
+ "local"
6926
+ ];
6927
+ const SCAN_VERDICTS = [
6928
+ "pass",
6929
+ "pass_with_notes",
6930
+ "flagged",
6931
+ "fail",
6932
+ "error"
6933
+ ];
4852
6934
  const lockedSkillV1Schema = object({
4853
6935
  resolved: string().url(),
4854
6936
  integrity: string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -4864,7 +6946,10 @@ const lockedSkillSchema = object({
4864
6946
  integrity: string().regex(/^sha512-/, "Integrity must start with sha512-"),
4865
6947
  permissions: permissionsSchema,
4866
6948
  audit_score: number().min(0).max(10).nullable(),
4867
- dependencies: record(string(), string()).optional()
6949
+ dependencies: record(string(), string()).optional(),
6950
+ source: _enum(SKILL_SOURCES).optional(),
6951
+ scan_verdict: _enum(SCAN_VERDICTS).optional(),
6952
+ scanned_at: string().optional()
4868
6953
  });
4869
6954
  object({
4870
6955
  lockfileVersion: union([literal(1), literal(2)]),
@@ -6640,6 +8725,527 @@ function prepareAgentSkillDir(options) {
6640
8725
  return targetDir;
6641
8726
  }
6642
8727
  //#endregion
8728
+ //#region src/lib/scan-gate.ts
8729
+ /**
8730
+ * Security scan gate for `tank install <url>`.
8731
+ * Calls the public scan API and enforces verdicts.
8732
+ */
8733
+ function verdictColor$1(verdict) {
8734
+ switch (verdict) {
8735
+ case "pass": return chalk.green;
8736
+ case "pass_with_notes": return chalk.yellow;
8737
+ case "flagged": return chalk.hex("#FF8C00");
8738
+ case "fail": return chalk.red;
8739
+ case "error": return chalk.red;
8740
+ default: return chalk.white;
8741
+ }
8742
+ }
8743
+ function severityColor$1(severity) {
8744
+ switch (severity) {
8745
+ case "critical": return chalk.red;
8746
+ case "high": return chalk.hex("#FF8C00");
8747
+ case "medium": return chalk.yellow;
8748
+ case "low": return chalk.green;
8749
+ case "info": return chalk.blue;
8750
+ default: return chalk.white;
8751
+ }
8752
+ }
8753
+ function scoreColor$2(score) {
8754
+ if (score >= 7) return chalk.green;
8755
+ if (score >= 4) return chalk.yellow;
8756
+ return chalk.red;
8757
+ }
8758
+ async function promptUser(question) {
8759
+ const rl = createInterface({
8760
+ input: process.stdin,
8761
+ output: process.stdout
8762
+ });
8763
+ return new Promise((resolve) => {
8764
+ rl.question(question, (answer) => {
8765
+ rl.close();
8766
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
8767
+ });
8768
+ });
8769
+ }
8770
+ async function scanUrl(url, options) {
8771
+ const config = getConfig();
8772
+ const registryUrl = options?.registryUrl ?? config.registry;
8773
+ const token = options?.token ?? config.token;
8774
+ let res;
8775
+ try {
8776
+ const headers = {
8777
+ "Content-Type": "application/json",
8778
+ "User-Agent": USER_AGENT
8779
+ };
8780
+ if (token) headers.Authorization = `Bearer ${token}`;
8781
+ res = await fetch(`${registryUrl}/api/v1/scan`, {
8782
+ method: "POST",
8783
+ headers,
8784
+ body: JSON.stringify({ url }),
8785
+ signal: AbortSignal.timeout(65e3)
8786
+ });
8787
+ } catch (err) {
8788
+ return {
8789
+ success: false,
8790
+ verdict: "error",
8791
+ auditScore: null,
8792
+ findings: [],
8793
+ durationMs: null,
8794
+ error: `Network error: ${err instanceof Error ? err.message : String(err)}`
8795
+ };
8796
+ }
8797
+ if (!res.ok) {
8798
+ if (res.status === 429) return {
8799
+ success: false,
8800
+ verdict: "error",
8801
+ auditScore: null,
8802
+ findings: [],
8803
+ durationMs: null,
8804
+ error: `Rate limited (429): ${token ? "Authenticated rate limit reached (20/hr). Try again later." : "Anonymous rate limit reached (3/hr). Run `tank login` for higher limits."}`
8805
+ };
8806
+ if (res.status === 504) return {
8807
+ success: false,
8808
+ verdict: "error",
8809
+ auditScore: null,
8810
+ findings: [],
8811
+ durationMs: null,
8812
+ error: "Scan timed out (504). The skill may be too large or the scanner is overloaded."
8813
+ };
8814
+ return {
8815
+ success: false,
8816
+ verdict: "error",
8817
+ auditScore: null,
8818
+ findings: [],
8819
+ durationMs: null,
8820
+ error: (await res.json().catch(() => null))?.error ?? `HTTP ${res.status}: ${res.statusText}`
8821
+ };
8822
+ }
8823
+ const data = await res.json();
8824
+ const findings = data.findings.map((f) => ({
8825
+ severity: f.severity,
8826
+ type: f.type,
8827
+ description: f.description,
8828
+ ...f.location ? { location: f.location } : {}
8829
+ }));
8830
+ return {
8831
+ success: true,
8832
+ verdict: data.verdict,
8833
+ auditScore: data.audit_score ?? null,
8834
+ findings,
8835
+ durationMs: data.duration_ms ?? null
8836
+ };
8837
+ }
8838
+ function displayScanResults(result) {
8839
+ const verdictLabel = verdictColor$1(result.verdict)(result.verdict.toUpperCase());
8840
+ console.log("");
8841
+ console.log(chalk.bold("Security Scan Results"));
8842
+ console.log("");
8843
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
8844
+ if (result.auditScore !== null) {
8845
+ const scoreLabel = scoreColor$2(result.auditScore)(result.auditScore.toFixed(1));
8846
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
8847
+ }
8848
+ if (result.durationMs !== null) console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.durationMs / 1e3).toFixed(1)}s`);
8849
+ if (result.error) console.log(`${chalk.dim("Error:".padEnd(14))}${chalk.red(result.error)}`);
8850
+ if (result.findings.length > 0) {
8851
+ console.log("");
8852
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
8853
+ const bySeverity = {
8854
+ critical: [],
8855
+ high: [],
8856
+ medium: [],
8857
+ low: [],
8858
+ info: []
8859
+ };
8860
+ for (const f of result.findings) bySeverity[f.severity].push(f);
8861
+ for (const severity of [
8862
+ "critical",
8863
+ "high",
8864
+ "medium",
8865
+ "low",
8866
+ "info"
8867
+ ]) {
8868
+ const group = bySeverity[severity];
8869
+ if (group.length === 0) continue;
8870
+ console.log("");
8871
+ const label = severityColor$1(severity)(`${severity.toUpperCase()} (${group.length})`);
8872
+ console.log(` ${label}`);
8873
+ for (const f of group) {
8874
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
8875
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
8876
+ }
8877
+ }
8878
+ } else if (result.success) {
8879
+ console.log("");
8880
+ console.log(chalk.green("No findings. The skill looks secure."));
8881
+ }
8882
+ console.log("");
8883
+ }
8884
+ async function enforceVerdict(result, options) {
8885
+ switch (result.verdict) {
8886
+ case "pass":
8887
+ case "pass_with_notes": return { allowed: true };
8888
+ case "flagged": {
8889
+ if (options?.yes) return { allowed: true };
8890
+ const count = result.findings.length;
8891
+ if (await promptUser(chalk.yellow(`⚠ Security scan flagged ${count} issue${count === 1 ? "" : "s"}. Install anyway? (y/N) `))) return { allowed: true };
8892
+ return {
8893
+ allowed: false,
8894
+ reason: "User declined after security warnings"
8895
+ };
8896
+ }
8897
+ case "fail": return {
8898
+ allowed: false,
8899
+ reason: "Security scan failed with critical findings"
8900
+ };
8901
+ case "error": return {
8902
+ allowed: false,
8903
+ reason: `Security scan error: ${result.error ?? "unknown"}`
8904
+ };
8905
+ default: return {
8906
+ allowed: false,
8907
+ reason: `Unknown verdict: ${result.verdict}`
8908
+ };
8909
+ }
8910
+ }
8911
+ //#endregion
8912
+ //#region src/lib/url-fetcher.ts
8913
+ /**
8914
+ * Fetch skills from URLs for `tank install <url>`.
8915
+ * Routes GitHub (git clone), ClawHub (zip), skills.sh, and generic tarballs
8916
+ * to temp directories with cleanup-on-failure semantics.
8917
+ */
8918
+ const HOST_MAP = [
8919
+ [/github\.com/i, "github"],
8920
+ [/clawhub\.ai/i, "clawhub"],
8921
+ [/skills\.sh/i, "skills_sh"],
8922
+ [/agentskills\.co\.il/i, "agentskills_il"],
8923
+ [/registry\.npmjs\.org/i, "npm"]
8924
+ ];
8925
+ function detectSourceType(url) {
8926
+ for (const [pattern, sourceType] of HOST_MAP) if (pattern.test(url)) return sourceType;
8927
+ return "unknown";
8928
+ }
8929
+ /** Returns true if the input looks like a URL rather than a package name. */
8930
+ function isUrl(input) {
8931
+ if (input.startsWith("http://") || input.startsWith("https://")) return true;
8932
+ for (const [pattern] of HOST_MAP) if (pattern.test(input)) return true;
8933
+ return false;
8934
+ }
8935
+ /** Best-effort skill name extraction from a URL. */
8936
+ function inferSkillName(url) {
8937
+ try {
8938
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
8939
+ switch (detectSourceType(url)) {
8940
+ case "github": {
8941
+ if (segments.length < 2) return null;
8942
+ const treeIdx = segments.indexOf("tree");
8943
+ if (treeIdx !== -1 && segments.length > treeIdx + 2) return segments[segments.length - 1] ?? null;
8944
+ return segments[1] ?? null;
8945
+ }
8946
+ case "clawhub": return segments[1] ?? null;
8947
+ case "skills_sh": return segments[2] ?? segments[1] ?? null;
8948
+ case "agentskills_il": return segments[1] ?? null;
8949
+ case "npm": return segments[segments.length - 1] ?? null;
8950
+ default: return segments[segments.length - 1] ?? null;
8951
+ }
8952
+ } catch {
8953
+ return null;
8954
+ }
8955
+ }
8956
+ async function createTempDir() {
8957
+ return mkdtemp(join(tmpdir(), "tank-fetch-"));
8958
+ }
8959
+ async function cleanupDir(dir) {
8960
+ try {
8961
+ await rm(dir, {
8962
+ recursive: true,
8963
+ force: true
8964
+ });
8965
+ } catch {}
8966
+ }
8967
+ function ensureGitInstalled() {
8968
+ try {
8969
+ execSync("git --version", { stdio: "ignore" });
8970
+ } catch {
8971
+ throw new Error("Git is not installed. Install git and try again.");
8972
+ }
8973
+ }
8974
+ function gitCloneShallow(repoUrl, dest) {
8975
+ try {
8976
+ execSync(`git clone --depth 1 ${repoUrl} ${dest}`, {
8977
+ stdio: "pipe",
8978
+ timeout: 6e4
8979
+ });
8980
+ } catch (err) {
8981
+ const msg = err instanceof Error ? err.message : String(err);
8982
+ if (msg.includes("Repository not found") || msg.includes("not found")) throw new Error(`Repository not found: ${repoUrl}`);
8983
+ if (msg.includes("timed out") || msg.includes("ETIMEDOUT")) throw new Error(`Network timeout cloning ${repoUrl}`);
8984
+ throw new Error(`Git clone failed: ${msg}`);
8985
+ }
8986
+ }
8987
+ function gitRevParseHead(dir) {
8988
+ try {
8989
+ return execSync("git rev-parse HEAD", {
8990
+ cwd: dir,
8991
+ stdio: "pipe"
8992
+ }).toString().trim();
8993
+ } catch {
8994
+ return null;
8995
+ }
8996
+ }
8997
+ async function downloadFile(url, dest) {
8998
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
8999
+ if (!res.ok) {
9000
+ if (res.status === 404) throw new Error(`Not found: ${url}`);
9001
+ throw new Error(`HTTP ${res.status} downloading ${url}`);
9002
+ }
9003
+ if (!res.body) throw new Error(`Empty response body from ${url}`);
9004
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
9005
+ }
9006
+ async function extractZip(zipPath, dest) {
9007
+ try {
9008
+ execSync(`unzip -o -q "${zipPath}" -d "${dest}"`, {
9009
+ stdio: "pipe",
9010
+ timeout: 3e4
9011
+ });
9012
+ } catch (err) {
9013
+ const msg = err instanceof Error ? err.message : String(err);
9014
+ throw new Error(`Zip extraction failed: ${msg}`);
9015
+ }
9016
+ }
9017
+ async function extractTarball(tarPath, dest) {
9018
+ try {
9019
+ execSync(`tar xzf "${tarPath}" -C "${dest}"`, {
9020
+ stdio: "pipe",
9021
+ timeout: 3e4
9022
+ });
9023
+ } catch (err) {
9024
+ const msg = err instanceof Error ? err.message : String(err);
9025
+ throw new Error(`Tarball extraction failed: ${msg}`);
9026
+ }
9027
+ }
9028
+ function parseGitHubUrl(url) {
9029
+ try {
9030
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
9031
+ if (segments.length < 2) return null;
9032
+ const owner = segments[0];
9033
+ const repo = segments[1];
9034
+ let branch = null;
9035
+ let subpath = null;
9036
+ if (segments[2] === "tree" && segments.length > 3) {
9037
+ branch = segments[3];
9038
+ if (segments.length > 4) subpath = segments.slice(4).join("/");
9039
+ }
9040
+ return {
9041
+ owner,
9042
+ repo,
9043
+ branch,
9044
+ subpath
9045
+ };
9046
+ } catch {
9047
+ return null;
9048
+ }
9049
+ }
9050
+ async function fetchFromGitHub(url, tempDir) {
9051
+ ensureGitInstalled();
9052
+ const parts = parseGitHubUrl(url);
9053
+ if (!parts) throw new Error(`Invalid GitHub URL: ${url}`);
9054
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
9055
+ const cloneDest = join(tempDir, parts.repo);
9056
+ logger.info(`Cloning ${parts.owner}/${parts.repo}...`);
9057
+ gitCloneShallow(cloneUrl, cloneDest);
9058
+ if (parts.branch) try {
9059
+ execSync(`git checkout ${parts.branch}`, {
9060
+ cwd: cloneDest,
9061
+ stdio: "pipe",
9062
+ timeout: 1e4
9063
+ });
9064
+ } catch {}
9065
+ const commitSha = gitRevParseHead(cloneDest);
9066
+ let localPath = cloneDest;
9067
+ if (parts.subpath) {
9068
+ const subDir = join(cloneDest, parts.subpath);
9069
+ try {
9070
+ if ((await stat(subDir)).isDirectory()) localPath = subDir;
9071
+ } catch {
9072
+ throw new Error(`Subpath not found in repo: ${parts.subpath}`);
9073
+ }
9074
+ }
9075
+ return {
9076
+ localPath,
9077
+ sourceType: "github",
9078
+ sourceUrl: url,
9079
+ commitSha,
9080
+ inferredName: parts.subpath ? parts.subpath.split("/").pop() ?? parts.repo : parts.repo,
9081
+ cleanup: () => cleanupDir(tempDir)
9082
+ };
9083
+ }
9084
+ function parseClawHubUrl(url) {
9085
+ try {
9086
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
9087
+ if (segments.length < 2) return null;
9088
+ return {
9089
+ owner: segments[0],
9090
+ skillName: segments[1]
9091
+ };
9092
+ } catch {
9093
+ return null;
9094
+ }
9095
+ }
9096
+ async function fetchFromClawHub(url, tempDir) {
9097
+ const parts = parseClawHubUrl(url);
9098
+ if (!parts) throw new Error(`Invalid ClawHub URL: ${url}`);
9099
+ logger.info(`Fetching ${parts.owner}/${parts.skillName} from ClawHub...`);
9100
+ const pageUrl = url.startsWith("http") ? url : `https://${url}`;
9101
+ const pageRes = await fetch(pageUrl, { signal: AbortSignal.timeout(3e4) });
9102
+ if (!pageRes.ok) {
9103
+ if (pageRes.status === 404) throw new Error(`Skill not found on ClawHub: ${parts.skillName}`);
9104
+ throw new Error(`HTTP ${pageRes.status} fetching ClawHub page`);
9105
+ }
9106
+ const html = await pageRes.text();
9107
+ const downloadUrlMatch = html.match(/https?:\/\/[^\s"']+\.convex\.cloud[^\s"']*download[^\s"']*/i) ?? html.match(/https?:\/\/[^\s"']+\.zip/i);
9108
+ if (!downloadUrlMatch) throw new Error("Could not find download URL on ClawHub page. The skill may not have a downloadable archive.");
9109
+ const zipPath = join(tempDir, `${parts.skillName}.zip`);
9110
+ await downloadFile(downloadUrlMatch[0], zipPath);
9111
+ const extractDir = join(tempDir, parts.skillName);
9112
+ await mkdir(extractDir, { recursive: true });
9113
+ await extractZip(zipPath, extractDir);
9114
+ return {
9115
+ localPath: extractDir,
9116
+ sourceType: "clawhub",
9117
+ sourceUrl: url,
9118
+ commitSha: null,
9119
+ inferredName: parts.skillName,
9120
+ cleanup: () => cleanupDir(tempDir)
9121
+ };
9122
+ }
9123
+ function parseSkillsShUrl(url) {
9124
+ try {
9125
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
9126
+ if (segments.length < 2) return null;
9127
+ return {
9128
+ owner: segments[0],
9129
+ repo: segments[1],
9130
+ skillName: segments[2] ?? null
9131
+ };
9132
+ } catch {
9133
+ return null;
9134
+ }
9135
+ }
9136
+ async function fetchFromSkillsSh(url, tempDir) {
9137
+ ensureGitInstalled();
9138
+ const parts = parseSkillsShUrl(url);
9139
+ if (!parts) throw new Error(`Invalid skills.sh URL: ${url}`);
9140
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
9141
+ const cloneDest = join(tempDir, parts.repo);
9142
+ logger.info(`Cloning ${parts.owner}/${parts.repo} (via skills.sh)...`);
9143
+ gitCloneShallow(cloneUrl, cloneDest);
9144
+ const commitSha = gitRevParseHead(cloneDest);
9145
+ let localPath = cloneDest;
9146
+ const inferredName = parts.skillName ?? parts.repo;
9147
+ if (parts.skillName) {
9148
+ const candidates = [
9149
+ join(cloneDest, "skills", parts.skillName),
9150
+ join(cloneDest, "src", "skills", parts.skillName),
9151
+ join(cloneDest, parts.skillName)
9152
+ ];
9153
+ let found = false;
9154
+ for (const candidate of candidates) try {
9155
+ if ((await stat(candidate)).isDirectory()) {
9156
+ localPath = candidate;
9157
+ found = true;
9158
+ break;
9159
+ }
9160
+ } catch {}
9161
+ if (!found) throw new Error(`Skill "${parts.skillName}" not found in ${parts.repo}. Searched: skills/${parts.skillName}, src/skills/${parts.skillName}, ${parts.skillName}`);
9162
+ }
9163
+ return {
9164
+ localPath,
9165
+ sourceType: "skills_sh",
9166
+ sourceUrl: url,
9167
+ commitSha,
9168
+ inferredName,
9169
+ cleanup: () => cleanupDir(tempDir)
9170
+ };
9171
+ }
9172
+ async function fetchFromGenericUrl(url, tempDir) {
9173
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
9174
+ logger.info(`Downloading from ${fullUrl}...`);
9175
+ const isTarball = /\.(tar\.gz|tgz)(\?|$)/i.test(fullUrl);
9176
+ const isZip = /\.zip(\?|$)/i.test(fullUrl);
9177
+ if (!isTarball && !isZip) {
9178
+ const archivePath = join(tempDir, "skill.tar.gz");
9179
+ await downloadFile(fullUrl, archivePath);
9180
+ const extractDir = join(tempDir, "skill");
9181
+ await mkdir(extractDir, { recursive: true });
9182
+ try {
9183
+ await extractTarball(archivePath, extractDir);
9184
+ } catch {
9185
+ try {
9186
+ await extractZip(archivePath, extractDir);
9187
+ } catch {
9188
+ throw new Error(`Failed to extract archive from ${fullUrl}. Expected .tar.gz or .zip format.`);
9189
+ }
9190
+ }
9191
+ return {
9192
+ localPath: extractDir,
9193
+ sourceType: detectSourceType(url),
9194
+ sourceUrl: url,
9195
+ commitSha: null,
9196
+ inferredName: inferSkillName(url),
9197
+ cleanup: () => cleanupDir(tempDir)
9198
+ };
9199
+ }
9200
+ const archivePath = join(tempDir, `skill.${isTarball ? "tar.gz" : "zip"}`);
9201
+ await downloadFile(fullUrl, archivePath);
9202
+ const extractDir = join(tempDir, "skill");
9203
+ await mkdir(extractDir, { recursive: true });
9204
+ if (isTarball) await extractTarball(archivePath, extractDir);
9205
+ else await extractZip(archivePath, extractDir);
9206
+ return {
9207
+ localPath: extractDir,
9208
+ sourceType: detectSourceType(url),
9209
+ sourceUrl: url,
9210
+ commitSha: null,
9211
+ inferredName: inferSkillName(url),
9212
+ cleanup: () => cleanupDir(tempDir)
9213
+ };
9214
+ }
9215
+ /** Fetch a skill from a URL to a local temp directory. */
9216
+ async function fetchFromUrl(url) {
9217
+ const sourceType = detectSourceType(url);
9218
+ let tempDir = null;
9219
+ try {
9220
+ tempDir = await createTempDir();
9221
+ let result;
9222
+ switch (sourceType) {
9223
+ case "github":
9224
+ result = await fetchFromGitHub(url, tempDir);
9225
+ break;
9226
+ case "clawhub":
9227
+ result = await fetchFromClawHub(url, tempDir);
9228
+ break;
9229
+ case "skills_sh":
9230
+ result = await fetchFromSkillsSh(url, tempDir);
9231
+ break;
9232
+ default:
9233
+ result = await fetchFromGenericUrl(url, tempDir);
9234
+ break;
9235
+ }
9236
+ return {
9237
+ success: true,
9238
+ ...result
9239
+ };
9240
+ } catch (err) {
9241
+ if (tempDir) await cleanupDir(tempDir);
9242
+ return {
9243
+ success: false,
9244
+ error: err instanceof Error ? err.message : String(err)
9245
+ };
9246
+ }
9247
+ }
9248
+ //#endregion
6643
9249
  //#region src/commands/install.ts
6644
9250
  function createRegistryFetcher(registry, headers) {
6645
9251
  const versionsCache = /* @__PURE__ */ new Map();
@@ -6762,7 +9368,7 @@ async function runLegacyFallback(options) {
6762
9368
  }
6763
9369
  }
6764
9370
  }
6765
- function linkInstalledRoots(options) {
9371
+ async function linkInstalledRoots(options) {
6766
9372
  const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, global, resolvedHome, homedir } = options;
6767
9373
  const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
6768
9374
  const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
@@ -6788,6 +9394,23 @@ function linkInstalledRoots(options) {
6788
9394
  else logger.warn(`Agent linking skipped for ${skillName} (non-fatal)`);
6789
9395
  }
6790
9396
  if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
9397
+ for (const skillName of rootSkillNames) {
9398
+ if (!resolvedNodeByName.get(skillName)) continue;
9399
+ const skillDir = extractDirForSkill(skillName);
9400
+ const manifestPath = path.join(skillDir, "tank.json");
9401
+ if (!fs.existsSync(manifestPath)) continue;
9402
+ try {
9403
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
9404
+ if (!manifest.atoms || !Array.isArray(manifest.atoms) || manifest.atoms.length === 0) continue;
9405
+ const { buildCommand: runBuild } = await Promise.resolve().then(() => build_exports);
9406
+ await runBuild({
9407
+ skill: skillDir,
9408
+ target: directory
9409
+ });
9410
+ } catch {
9411
+ logger.warn(`Auto-build skipped for ${skillName} (non-fatal)`);
9412
+ }
9413
+ }
6791
9414
  }
6792
9415
  async function executeInstallPipeline(options) {
6793
9416
  const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner } = options;
@@ -6819,7 +9442,7 @@ async function executeInstallPipeline(options) {
6819
9442
  global,
6820
9443
  homedir
6821
9444
  });
6822
- linkInstalledRoots({
9445
+ await linkInstalledRoots({
6823
9446
  rootSkillNames,
6824
9447
  resolvedNodeByName,
6825
9448
  extractDirForSkill,
@@ -7051,6 +9674,149 @@ async function installAll(options) {
7051
9674
  function buildIntegrity(buffer) {
7052
9675
  return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
7053
9676
  }
9677
+ /** Map url-fetcher source types to lockfile SkillSource values. */
9678
+ function mapSourceType(urlSourceType) {
9679
+ switch (urlSourceType) {
9680
+ case "github": return "github";
9681
+ case "clawhub": return "clawhub";
9682
+ case "skills_sh": return "skills_sh";
9683
+ case "agentskills_il": return "agentskills_il";
9684
+ case "npm": return "npm";
9685
+ default: return "local";
9686
+ }
9687
+ }
9688
+ /** Compute SHA-512 integrity hash over all files in a directory (sorted by path). */
9689
+ function computeDirectoryIntegrity(dir) {
9690
+ const files = [];
9691
+ function walkDir(current) {
9692
+ const entries = fs.readdirSync(current, { withFileTypes: true });
9693
+ for (const entry of entries) {
9694
+ if (entry.name === ".git") continue;
9695
+ const fullPath = path.join(current, entry.name);
9696
+ if (entry.isDirectory()) walkDir(fullPath);
9697
+ else if (entry.isFile()) files.push(fullPath);
9698
+ }
9699
+ }
9700
+ walkDir(dir);
9701
+ files.sort();
9702
+ const hash = crypto$1.createHash("sha512");
9703
+ for (const file of files) hash.update(fs.readFileSync(file));
9704
+ return `sha512-${hash.digest("base64")}`;
9705
+ }
9706
+ /** Read a manifest (tank.json or skills.json) from a directory, returning null if missing/invalid. */
9707
+ function readManifestFromDir(dir) {
9708
+ for (const filename of ["tank.json", "skills.json"]) {
9709
+ const manifestPath = path.join(dir, filename);
9710
+ if (fs.existsSync(manifestPath)) try {
9711
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
9712
+ } catch {
9713
+ return null;
9714
+ }
9715
+ }
9716
+ return null;
9717
+ }
9718
+ async function installFromUrl(url, options) {
9719
+ const { global = false, yes = false } = options;
9720
+ const resolvedHome = os.homedir();
9721
+ const directory = process.cwd();
9722
+ const spinner = ora(`Fetching from URL...`).start();
9723
+ let fetchResult;
9724
+ try {
9725
+ const output = await fetchFromUrl(url);
9726
+ if (!output.success) {
9727
+ spinner.fail("Fetch failed");
9728
+ logger.error(output.error);
9729
+ process.exit(1);
9730
+ }
9731
+ fetchResult = output;
9732
+ spinner.text = "Scanning for security issues...";
9733
+ } catch (err) {
9734
+ spinner.fail("Fetch failed");
9735
+ const msg = err instanceof Error ? err.message : String(err);
9736
+ logger.error(msg);
9737
+ process.exit(1);
9738
+ }
9739
+ try {
9740
+ const scanResult = await scanUrl(url);
9741
+ displayScanResults(scanResult);
9742
+ const enforcement = await enforceVerdict(scanResult, { yes });
9743
+ if (!enforcement.allowed) {
9744
+ spinner.fail(enforcement.reason ?? "Install blocked by security scan");
9745
+ await fetchResult.cleanup();
9746
+ process.exit(1);
9747
+ }
9748
+ const skillMdPath = path.join(fetchResult.localPath, "SKILL.md");
9749
+ if (!fs.existsSync(skillMdPath)) throw new Error("No SKILL.md found. This doesn't appear to be a valid skill.");
9750
+ const existingManifest = readManifestFromDir(fetchResult.localPath);
9751
+ const skillName = existingManifest?.name ?? fetchResult.inferredName ?? path.basename(fetchResult.localPath);
9752
+ const skillVersion = existingManifest?.version ?? "0.0.0";
9753
+ const skillDescription = existingManifest?.description ?? "";
9754
+ if (!existingManifest) {
9755
+ const generatedManifest = {
9756
+ name: skillName,
9757
+ version: skillVersion,
9758
+ description: skillDescription
9759
+ };
9760
+ fs.writeFileSync(path.join(fetchResult.localPath, "tank.json"), `${JSON.stringify(generatedManifest, null, 2)}\n`);
9761
+ logger.info("Generated tank.json (no manifest found in source)");
9762
+ }
9763
+ spinner.text = `Installing ${skillName}...`;
9764
+ const installDir = global ? path.join(resolvedHome, ".tank", "skills", skillName) : path.join(directory, ".tank", "skills", skillName);
9765
+ if (fs.existsSync(installDir)) fs.rmSync(installDir, {
9766
+ recursive: true,
9767
+ force: true
9768
+ });
9769
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
9770
+ fs.cpSync(fetchResult.localPath, installDir, { recursive: true });
9771
+ const integrity = computeDirectoryIntegrity(installDir);
9772
+ const resolvedLock = resolveLockfilePath(global ? path.join(resolvedHome, ".tank") : directory);
9773
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
9774
+ const lock = readLockOrFresh(lockPath);
9775
+ const lockKey = `${skillName}@${skillVersion}`;
9776
+ const skillPermissions = existingManifest?.permissions ?? {};
9777
+ lock.skills[lockKey] = {
9778
+ resolved: url.startsWith("http") ? url : `https://${url}`,
9779
+ integrity,
9780
+ permissions: skillPermissions,
9781
+ audit_score: scanResult.auditScore ?? null,
9782
+ source: mapSourceType(fetchResult.sourceType),
9783
+ scan_verdict: scanResult.verdict,
9784
+ scanned_at: (/* @__PURE__ */ new Date()).toISOString()
9785
+ };
9786
+ lock.lockfileVersion = 2;
9787
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
9788
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
9789
+ const linkedAgents = [];
9790
+ try {
9791
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
9792
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
9793
+ const linkResult = linkSkillToAgents({
9794
+ skillName,
9795
+ sourceDir: prepareAgentSkillDir({
9796
+ skillName,
9797
+ extractDir: installDir,
9798
+ agentSkillsBaseDir,
9799
+ description: skillDescription
9800
+ }),
9801
+ linksDir,
9802
+ source: global ? "global" : "local"
9803
+ });
9804
+ linkedAgents.push(...linkResult.linked);
9805
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
9806
+ } catch {
9807
+ logger.warn("Agent linking skipped (non-fatal)");
9808
+ }
9809
+ if (detectInstalledAgents().length === 0) logger.warn("No agents detected for linking");
9810
+ await fetchResult.cleanup();
9811
+ spinner.succeed(`Installed ${skillName} from ${fetchResult.sourceType}`);
9812
+ if (linkedAgents.length > 0) logger.info(`Linked to ${linkedAgents.join(", ")}`);
9813
+ logger.info(`Locked (${integrity.slice(0, 20)}..., scanned ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]})`);
9814
+ } catch (err) {
9815
+ await fetchResult.cleanup();
9816
+ spinner.fail("Install failed");
9817
+ throw err;
9818
+ }
9819
+ }
7054
9820
  //#endregion
7055
9821
  //#region src/commands/link.ts
7056
9822
  async function linkCommand(options = {}) {
@@ -7421,8 +10187,7 @@ const IGNORE_FILES = [".tankignore", ".gitignore"];
7421
10187
  * Pack a skill directory into a .tgz tarball with integrity hashing.
7422
10188
  *
7423
10189
  * Validates:
7424
- * - skills.json exists and is valid
7425
- * - SKILL.md exists
10190
+ * - tank.json (or skills.json) exists and is valid
7426
10191
  * - No symlinks or hardlinks
7427
10192
  * - No path traversal (.. components)
7428
10193
  * - No absolute paths
@@ -7452,18 +10217,23 @@ async function pack(directory) {
7452
10217
  } catch {
7453
10218
  throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
7454
10219
  }
7455
- const validation = skillsJsonSchema.safeParse(parsed);
10220
+ const validation = publishManifestSchema.safeParse(parsed);
7456
10221
  if (!validation.success) {
7457
10222
  const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
7458
10223
  throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
7459
10224
  }
10225
+ let readmeContent = "";
7460
10226
  const skillMdPath = path.join(absDir, "SKILL.md");
7461
- if (!fs.existsSync(skillMdPath)) throw new Error("Missing required file: SKILL.md");
7462
- let readmeContent;
7463
- try {
10227
+ const readmeMdPath = path.join(absDir, "README.md");
10228
+ if (fs.existsSync(skillMdPath)) try {
7464
10229
  readmeContent = fs.readFileSync(skillMdPath, "utf-8");
7465
10230
  } catch {
7466
- throw new Error("Failed to read SKILL.md");
10231
+ readmeContent = "";
10232
+ }
10233
+ else if (fs.existsSync(readmeMdPath)) try {
10234
+ readmeContent = fs.readFileSync(readmeMdPath, "utf-8");
10235
+ } catch {
10236
+ readmeContent = "";
7467
10237
  }
7468
10238
  const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
7469
10239
  if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
@@ -7565,9 +10335,12 @@ function collectFiles(baseDir, currentDir, ig) {
7565
10335
  if (relativePath.split(path.sep).includes("..")) throw new Error(`Path traversal detected: "${relativePath}" contains ".." component`);
7566
10336
  if (path.isAbsolute(relativePath)) throw new Error(`Absolute path detected: "${relativePath}"`);
7567
10337
  const lstatResult = fs.lstatSync(fullPath);
7568
- if (lstatResult.isSymbolicLink()) throw new Error(`Symlink detected: "${relativePath}" symlinks are not allowed in skill packages`);
7569
- const pathForIgnore = lstatResult.isDirectory() ? `${relativePath}/` : relativePath;
10338
+ const pathForIgnore = lstatResult.isDirectory() || lstatResult.isSymbolicLink() && fs.statSync(fullPath).isDirectory() ? `${relativePath}/` : relativePath;
7570
10339
  if (ig.ignores(pathForIgnore)) continue;
10340
+ if (lstatResult.isSymbolicLink()) {
10341
+ console.warn(`⚠ Skipping symlink: ${relativePath}`);
10342
+ continue;
10343
+ }
7571
10344
  if (lstatResult.isDirectory()) {
7572
10345
  const subFiles = collectFiles(baseDir, fullPath, ig);
7573
10346
  files.push(...subFiles);
@@ -8638,6 +11411,21 @@ program.command("init").description("Create a new tank.json in the current direc
8638
11411
  process.exit(1);
8639
11412
  }
8640
11413
  });
11414
+ program.command("build <skill>").description("Compile a skill's atoms for the detected (or specified) platform").option("-p, --platform <platform>", "Target platform (opencode, claude-code, cursor, windsurf, cline, roo-code)").option("-o, --out <dir>", "Output directory (default: current directory)").option("--dry-run", "Preview files without writing").option("--list-platforms", "List available platforms and exit").action(async (skill, opts) => {
11415
+ try {
11416
+ await buildCommand({
11417
+ skill,
11418
+ platform: opts.platform,
11419
+ out: opts.out,
11420
+ dryRun: opts.dryRun,
11421
+ listPlatforms: opts.listPlatforms
11422
+ });
11423
+ } catch (err) {
11424
+ const msg = err instanceof Error ? err.message : String(err);
11425
+ console.error(`Build failed: ${msg}`);
11426
+ process.exit(1);
11427
+ }
11428
+ });
8641
11429
  program.command("login").description("Authenticate with the Tank registry via browser").action(async () => {
8642
11430
  try {
8643
11431
  await loginCommand();
@@ -8680,9 +11468,13 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
8680
11468
  process.exit(1);
8681
11469
  }
8682
11470
  });
8683
- program.command("install").alias("i").description("Install a skill from the Tank registry, or all skills from lockfile").argument("[name]", "Skill name (e.g., @org/skill-name). Omit to install from lockfile.").argument("[version-range]", "Semver range (default: *)", "*").option("-g, --global", "Install skill globally (available to all projects)").action(async (name, versionRange, opts) => {
11471
+ program.command("install").alias("i").description("Install a skill from the Tank registry, a URL, or all skills from lockfile").argument("[name]", "Skill name or URL (e.g., @org/skill-name or https://github.com/owner/repo). Omit to install from lockfile.").argument("[version-range]", "Semver range (default: *)", "*").option("-g, --global", "Install skill globally (available to all projects)").option("-y, --yes", "Auto-accept flagged scan verdicts").action(async (name, versionRange, opts) => {
8684
11472
  try {
8685
- if (name) await installCommand({
11473
+ if (name && isUrl(name)) await installFromUrl(name, {
11474
+ global: opts.global,
11475
+ yes: opts.yes
11476
+ });
11477
+ else if (name) await installCommand({
8686
11478
  name,
8687
11479
  versionRange,
8688
11480
  global: opts.global