@tankpkg/cli 0.11.0 → 0.13.1

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-UndcFUFx.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 { execSync, spawn } from "node:child_process";
15
+ import crypto$1 from "node:crypto";
14
16
  import { create, extract } from "tar";
17
+ import { createInterface } from "node:readline";
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");
@@ -326,7 +599,1518 @@ async function auditCommand(options) {
326
599
  });
327
600
  }
328
601
  }
329
- displayTable(results);
602
+ displayTable(results);
603
+ }
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 deepMerge(a, b) {
2048
+ const result = { ...a };
2049
+ for (const [key, val] of Object.entries(b)) {
2050
+ const existing = result[key];
2051
+ if (val !== null && typeof val === "object" && !Array.isArray(val) && existing !== null && typeof existing === "object" && !Array.isArray(existing)) result[key] = deepMerge(existing, val);
2052
+ else result[key] = val;
2053
+ }
2054
+ return result;
2055
+ }
2056
+ function writeFiles(targetDir, compiled) {
2057
+ for (const f of compiled.files) {
2058
+ const fullPath = path.join(targetDir, f.path);
2059
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
2060
+ if (f.path.endsWith(".json") && fs.existsSync(fullPath)) try {
2061
+ const merged = deepMerge(JSON.parse(fs.readFileSync(fullPath, "utf-8")), JSON.parse(f.content));
2062
+ fs.writeFileSync(fullPath, JSON.stringify(merged, null, 2));
2063
+ continue;
2064
+ } catch {}
2065
+ fs.writeFileSync(fullPath, f.content);
2066
+ }
2067
+ return compiled.files.length;
2068
+ }
2069
+ function listPlatforms() {
2070
+ logger.info("Available platforms:\n");
2071
+ for (const [id, adapter] of Object.entries(ADAPTERS)) {
2072
+ const caps = Object.entries(adapter.capabilities).filter(([, v]) => v !== "none").map(([k]) => k);
2073
+ logger.info(` ${id.padEnd(14)} ${caps.join(", ")}`);
2074
+ }
2075
+ }
2076
+ async function buildCommand(opts) {
2077
+ if (opts.listPlatforms) {
2078
+ listPlatforms();
2079
+ return;
2080
+ }
2081
+ const spinner = ora("Building...").start();
2082
+ try {
2083
+ const skillDir = path.resolve(opts.skill);
2084
+ if (!fs.existsSync(skillDir)) throw new Error(`Skill directory not found: ${skillDir}`);
2085
+ const pkg = loadManifest(skillDir);
2086
+ if (!pkg.atoms || pkg.atoms.length === 0) {
2087
+ spinner.warn(`${pkg.name} has no atoms — nothing to build`);
2088
+ return;
2089
+ }
2090
+ const platformId = opts.platform ?? detectPlatform();
2091
+ if (!platformId) throw new Error("Could not detect platform. Use --platform to specify one of: " + Object.keys(ADAPTERS).join(", "));
2092
+ const adapter = ADAPTERS[platformId];
2093
+ if (!adapter) throw new Error(`Unknown platform "${platformId}". Available: ${Object.keys(ADAPTERS).join(", ")}`);
2094
+ const targetDir = opts.out ?? opts.target ?? process.cwd();
2095
+ spinner.text = `Compiling ${pkg.name} for ${adapter.name}...`;
2096
+ const compiled = compilePackage(pkg, adapter, { sourceDir: skillDir });
2097
+ if (opts.dryRun) {
2098
+ spinner.succeed(`[dry-run] Would write ${compiled.files.length} files for ${adapter.name}`);
2099
+ for (const f of compiled.files) logger.info(` ${f.path}`);
2100
+ } else {
2101
+ const count = writeFiles(targetDir, compiled);
2102
+ spinner.succeed(`Built ${count} files for ${adapter.name}`);
2103
+ for (const f of compiled.files) logger.info(` ${f.path}`);
2104
+ }
2105
+ for (const w of compiled.warnings) {
2106
+ const icon = w.level === "skipped" ? "⏭️ " : "⚠️ ";
2107
+ logger.warn(`${icon}[${w.atomKind}] ${w.message}`);
2108
+ }
2109
+ if (compiled.skipped.length > 0) logger.warn(`${compiled.skipped.length} atom(s) skipped — ${adapter.name} does not support: ${compiled.skipped.join(", ")}`);
2110
+ } catch (err) {
2111
+ spinner.fail("Build failed");
2112
+ throw err;
2113
+ }
330
2114
  }
331
2115
  //#endregion
332
2116
  //#region src/lib/agents.ts
@@ -798,17 +2582,17 @@ async function infoCommand(options) {
798
2582
  }
799
2583
  //#endregion
800
2584
  //#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.-]+)?$/;
2585
+ const NAME_PATTERN$1 = /^(@[a-z0-9-]+\/)?[a-z0-9][a-z0-9-]*$/;
2586
+ const SEMVER_PATTERN$1 = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
803
2587
  const MAX_NAME_LENGTH = 214;
804
2588
  function validateName(value) {
805
2589
  if (!value) return "Name must not be empty";
806
2590
  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)";
2591
+ if (!NAME_PATTERN$1.test(value)) return "Name must be lowercase, alphanumeric + hyphens, optionally scoped (@org/name)";
808
2592
  return true;
809
2593
  }
810
2594
  function validateVersion(value) {
811
- if (!SEMVER_PATTERN.test(value)) return "Version must be valid semver (e.g. 1.0.0)";
2595
+ if (!SEMVER_PATTERN$1.test(value)) return "Version must be valid semver (e.g. 1.0.0)";
812
2596
  return true;
813
2597
  }
814
2598
  async function initCommand(options = {}) {
@@ -2606,6 +4390,61 @@ const $ZodUnion = /* @__PURE__ */ $constructor("$ZodUnion", (inst, def) => {
2606
4390
  });
2607
4391
  };
2608
4392
  });
4393
+ const $ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("$ZodDiscriminatedUnion", (inst, def) => {
4394
+ def.inclusive = false;
4395
+ $ZodUnion.init(inst, def);
4396
+ const _super = inst._zod.parse;
4397
+ defineLazy(inst._zod, "propValues", () => {
4398
+ const propValues = {};
4399
+ for (const option of def.options) {
4400
+ const pv = option._zod.propValues;
4401
+ if (!pv || Object.keys(pv).length === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(option)}"`);
4402
+ for (const [k, v] of Object.entries(pv)) {
4403
+ if (!propValues[k]) propValues[k] = /* @__PURE__ */ new Set();
4404
+ for (const val of v) propValues[k].add(val);
4405
+ }
4406
+ }
4407
+ return propValues;
4408
+ });
4409
+ const disc = cached(() => {
4410
+ const opts = def.options;
4411
+ const map = /* @__PURE__ */ new Map();
4412
+ for (const o of opts) {
4413
+ const values = o._zod.propValues?.[def.discriminator];
4414
+ if (!values || values.size === 0) throw new Error(`Invalid discriminated union option at index "${def.options.indexOf(o)}"`);
4415
+ for (const v of values) {
4416
+ if (map.has(v)) throw new Error(`Duplicate discriminator value "${String(v)}"`);
4417
+ map.set(v, o);
4418
+ }
4419
+ }
4420
+ return map;
4421
+ });
4422
+ inst._zod.parse = (payload, ctx) => {
4423
+ const input = payload.value;
4424
+ if (!isObject(input)) {
4425
+ payload.issues.push({
4426
+ code: "invalid_type",
4427
+ expected: "object",
4428
+ input,
4429
+ inst
4430
+ });
4431
+ return payload;
4432
+ }
4433
+ const opt = disc.value.get(input?.[def.discriminator]);
4434
+ if (opt) return opt._zod.run(payload, ctx);
4435
+ if (def.unionFallback) return _super(payload, ctx);
4436
+ payload.issues.push({
4437
+ code: "invalid_union",
4438
+ errors: [],
4439
+ note: "No matching discriminator",
4440
+ discriminator: def.discriminator,
4441
+ input,
4442
+ path: [def.discriminator],
4443
+ inst
4444
+ });
4445
+ return payload;
4446
+ };
4447
+ });
2609
4448
  const $ZodIntersection = /* @__PURE__ */ $constructor("$ZodIntersection", (inst, def) => {
2610
4449
  $ZodType.init(inst, def);
2611
4450
  inst._zod.parse = (payload, ctx) => {
@@ -4554,6 +6393,18 @@ function union(options, params) {
4554
6393
  ...normalizeParams(params)
4555
6394
  });
4556
6395
  }
6396
+ const ZodDiscriminatedUnion = /* @__PURE__ */ $constructor("ZodDiscriminatedUnion", (inst, def) => {
6397
+ ZodUnion.init(inst, def);
6398
+ $ZodDiscriminatedUnion.init(inst, def);
6399
+ });
6400
+ function discriminatedUnion(discriminator, options, params) {
6401
+ return new ZodDiscriminatedUnion({
6402
+ type: "union",
6403
+ options,
6404
+ discriminator,
6405
+ ...normalizeParams(params)
6406
+ });
6407
+ }
4557
6408
  const ZodIntersection = /* @__PURE__ */ $constructor("ZodIntersection", (inst, def) => {
4558
6409
  $ZodIntersection.init(inst, def);
4559
6410
  ZodType.init(inst, def);
@@ -4801,6 +6652,159 @@ function superRefine(fn) {
4801
6652
  process.env.TANK_REGISTRY_URL;
4802
6653
  const MANIFEST_FILENAME = "tank.json";
4803
6654
  const LEGACY_MANIFEST_FILENAME = "skills.json";
6655
+ const supportLevelSchema = _enum([
6656
+ "full",
6657
+ "degraded",
6658
+ "none"
6659
+ ]);
6660
+ const adapterCapabilitiesSchema = object({
6661
+ instruction: supportLevelSchema,
6662
+ hook: supportLevelSchema,
6663
+ tool: supportLevelSchema,
6664
+ agent: supportLevelSchema,
6665
+ rule: supportLevelSchema,
6666
+ resource: supportLevelSchema,
6667
+ prompt: supportLevelSchema
6668
+ }).strict();
6669
+ const compilationWarningSchema = object({
6670
+ level: _enum(["degraded", "skipped"]),
6671
+ atomKind: string(),
6672
+ message: string()
6673
+ }).strict();
6674
+ object({
6675
+ files: array(object({
6676
+ path: string().min(1),
6677
+ content: string()
6678
+ }).strict()),
6679
+ warnings: array(compilationWarningSchema)
6680
+ }).strict();
6681
+ object({
6682
+ name: string().min(1, "Adapter name must not be empty"),
6683
+ supportedRange: string().min(1, "Supported range must not be empty"),
6684
+ capabilities: adapterCapabilitiesSchema
6685
+ }).strict();
6686
+ _enum([
6687
+ "instruction",
6688
+ "hook",
6689
+ "tool",
6690
+ "agent",
6691
+ "rule",
6692
+ "resource",
6693
+ "prompt"
6694
+ ]);
6695
+ const extensionBagSchema = record(string(), unknown()).optional();
6696
+ const modelTierSchema = _enum([
6697
+ "fast",
6698
+ "balanced",
6699
+ "powerful",
6700
+ "custom"
6701
+ ]);
6702
+ modelTierSchema.options;
6703
+ const canonicalToolNameSchema = _enum([
6704
+ "bash",
6705
+ "read",
6706
+ "write",
6707
+ "edit",
6708
+ "grep",
6709
+ "glob",
6710
+ "lsp",
6711
+ "mcp",
6712
+ "browser",
6713
+ "fetch",
6714
+ "git",
6715
+ "task",
6716
+ "notebook"
6717
+ ]);
6718
+ canonicalToolNameSchema.options;
6719
+ const agentIRSchema = object({
6720
+ kind: literal("agent"),
6721
+ name: string().min(1, "Agent name must not be empty"),
6722
+ role: string().min(1, "Agent role must not be empty"),
6723
+ tools: array(canonicalToolNameSchema.or(string().min(1))).optional(),
6724
+ model: modelTierSchema.or(string().min(1)).optional(),
6725
+ readonly: boolean().optional(),
6726
+ extensions: extensionBagSchema
6727
+ }).strict();
6728
+ const hookEventSchema = _enum([
6729
+ "pre-tool-use",
6730
+ "post-tool-use",
6731
+ "pre-file-read",
6732
+ "post-file-read",
6733
+ "pre-file-write",
6734
+ "post-file-write",
6735
+ "file-edited",
6736
+ "file-watcher-updated",
6737
+ "pre-command",
6738
+ "post-command",
6739
+ "pre-mcp-tool-use",
6740
+ "post-mcp-tool-use",
6741
+ "session-created",
6742
+ "session-updated",
6743
+ "session-idle",
6744
+ "session-error",
6745
+ "session-deleted",
6746
+ "pre-stop",
6747
+ "task-start",
6748
+ "task-resume",
6749
+ "task-complete",
6750
+ "task-cancel",
6751
+ "pre-user-prompt",
6752
+ "post-response",
6753
+ "message-updated",
6754
+ "message-removed",
6755
+ "system-prompt-transform",
6756
+ "pre-context-compact",
6757
+ "post-context-compact",
6758
+ "permission-asked",
6759
+ "permission-replied",
6760
+ "lsp-diagnostics",
6761
+ "lsp-updated",
6762
+ "subagent-start",
6763
+ "subagent-complete",
6764
+ "subagent-tool-use",
6765
+ "shell-env",
6766
+ "todo-updated",
6767
+ "installation-updated"
6768
+ ]);
6769
+ hookEventSchema.options;
6770
+ const hookActionIRSchema = object({
6771
+ action: _enum([
6772
+ "block",
6773
+ "allow",
6774
+ "rewrite",
6775
+ "injectContext"
6776
+ ]),
6777
+ match: string().optional(),
6778
+ reason: string().optional(),
6779
+ value: string().optional()
6780
+ }).strict();
6781
+ const hookHandlerIRSchema = discriminatedUnion("type", [object({
6782
+ type: literal("dsl"),
6783
+ actions: array(hookActionIRSchema).min(1, "DSL handler must have at least one action")
6784
+ }).strict(), object({
6785
+ type: literal("js"),
6786
+ entry: string().min(1, "JS handler entry path must not be empty")
6787
+ }).strict()]);
6788
+ const hookIRSchema = object({
6789
+ kind: literal("hook"),
6790
+ name: string().optional(),
6791
+ event: hookEventSchema,
6792
+ match: canonicalToolNameSchema.or(string().min(1)).optional(),
6793
+ handler: hookHandlerIRSchema,
6794
+ scope: _enum(["project", "global"]).optional(),
6795
+ extensions: extensionBagSchema
6796
+ }).strict();
6797
+ const instructionIRSchema = object({
6798
+ kind: literal("instruction"),
6799
+ content: string().min(1, "Content path must not be empty"),
6800
+ scope: _enum([
6801
+ "project",
6802
+ "global",
6803
+ "directory"
6804
+ ]).optional(),
6805
+ globs: array(string()).optional(),
6806
+ extensions: extensionBagSchema
6807
+ }).strict();
4804
6808
  const networkPermissionsSchema = object({ outbound: array(string()).optional() }).strict();
4805
6809
  const filesystemPermissionsSchema = object({
4806
6810
  read: array(string()).optional(),
@@ -4839,7 +6843,77 @@ _enum([
4839
6843
  "org.member.remove",
4840
6844
  "org.delete"
4841
6845
  ]);
6846
+ const promptIRSchema = object({
6847
+ kind: literal("prompt"),
6848
+ name: string().min(1, "Prompt name must not be empty"),
6849
+ description: string().optional(),
6850
+ template: string().min(1, "Prompt template path must not be empty"),
6851
+ arguments: array(object({
6852
+ name: string(),
6853
+ description: string().optional(),
6854
+ required: boolean().optional()
6855
+ }).strict()).optional(),
6856
+ extensions: extensionBagSchema
6857
+ }).strict();
6858
+ const resourceIRSchema = object({
6859
+ kind: literal("resource"),
6860
+ name: string().optional(),
6861
+ uri: string().min(1, "Resource URI must not be empty"),
6862
+ description: string().optional(),
6863
+ mimeType: string().optional(),
6864
+ extensions: extensionBagSchema
6865
+ }).strict();
6866
+ const ruleIRSchema = object({
6867
+ kind: literal("rule"),
6868
+ name: string().optional(),
6869
+ event: hookEventSchema,
6870
+ match: canonicalToolNameSchema.or(string().min(1)).optional(),
6871
+ policy: _enum([
6872
+ "block",
6873
+ "allow",
6874
+ "warn"
6875
+ ]),
6876
+ reason: string().optional(),
6877
+ extensions: extensionBagSchema
6878
+ }).strict();
6879
+ const mcpServerConfigSchema = object({
6880
+ command: string().min(1).optional(),
6881
+ args: array(string()).optional(),
6882
+ env: record(string(), string()).optional(),
6883
+ runtime: string().min(1).optional(),
6884
+ entry: string().min(1).optional()
6885
+ }).strict().refine((data) => data.command || data.runtime && data.entry, "MCP config must have either \"command\" or both \"runtime\" and \"entry\"");
6886
+ const toolIRSchema = object({
6887
+ kind: literal("tool"),
6888
+ name: string().min(1, "Tool name must not be empty"),
6889
+ description: string().optional(),
6890
+ mcp: mcpServerConfigSchema.optional(),
6891
+ extensions: extensionBagSchema
6892
+ }).strict();
6893
+ const NAME_PATTERN = /^@[a-z0-9-]+\/[a-z0-9][a-z0-9-]*$/;
6894
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
6895
+ const atomIRSchema = discriminatedUnion("kind", [
6896
+ instructionIRSchema,
6897
+ hookIRSchema,
6898
+ toolIRSchema,
6899
+ agentIRSchema,
6900
+ ruleIRSchema,
6901
+ resourceIRSchema,
6902
+ promptIRSchema
6903
+ ]);
4842
6904
  object({
6905
+ 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"),
6906
+ version: string().regex(SEMVER_PATTERN, "Version must be valid semver"),
6907
+ description: string().max(500).optional(),
6908
+ atoms: array(atomIRSchema),
6909
+ includes: array(string()).optional(),
6910
+ skills: record(string(), string()).optional(),
6911
+ permissions: permissionsSchema.optional(),
6912
+ repository: string().url("Repository must be a valid URL").optional(),
6913
+ visibility: _enum(["public", "private"]).optional(),
6914
+ audit: object({ min_score: number().min(0).max(10) }).strict().optional()
6915
+ }).strict();
6916
+ const baseManifestFields = {
4843
6917
  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
6918
  version: string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
4845
6919
  description: string().max(500, `Description must be 500 characters or fewer`).optional(),
@@ -4848,7 +6922,29 @@ object({
4848
6922
  repository: string().url("Repository must be a valid URL").optional(),
4849
6923
  visibility: _enum(["public", "private"]).optional(),
4850
6924
  audit: object({ min_score: number().min(0).max(10) }).strict().optional()
6925
+ };
6926
+ object(baseManifestFields).strict();
6927
+ object({
6928
+ ...baseManifestFields,
6929
+ atoms: array(record(string(), unknown())).optional(),
6930
+ includes: array(string()).optional()
4851
6931
  }).strict();
6932
+ const SKILL_SOURCES = [
6933
+ "registry",
6934
+ "github",
6935
+ "clawhub",
6936
+ "skills_sh",
6937
+ "agentskills_il",
6938
+ "npm",
6939
+ "local"
6940
+ ];
6941
+ const SCAN_VERDICTS = [
6942
+ "pass",
6943
+ "pass_with_notes",
6944
+ "flagged",
6945
+ "fail",
6946
+ "error"
6947
+ ];
4852
6948
  const lockedSkillV1Schema = object({
4853
6949
  resolved: string().url(),
4854
6950
  integrity: string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -4864,7 +6960,10 @@ const lockedSkillSchema = object({
4864
6960
  integrity: string().regex(/^sha512-/, "Integrity must start with sha512-"),
4865
6961
  permissions: permissionsSchema,
4866
6962
  audit_score: number().min(0).max(10).nullable(),
4867
- dependencies: record(string(), string()).optional()
6963
+ dependencies: record(string(), string()).optional(),
6964
+ source: _enum(SKILL_SOURCES).optional(),
6965
+ scan_verdict: _enum(SCAN_VERDICTS).optional(),
6966
+ scanned_at: string().optional()
4868
6967
  });
4869
6968
  object({
4870
6969
  lockfileVersion: union([literal(1), literal(2)]),
@@ -6529,115 +8628,636 @@ async function extractSafely(tarball, destDir) {
6529
8628
  } else throw err;
6530
8629
  }
6531
8630
  }
6532
- } finally {
6533
- fs.rmSync(tmpDir, {
8631
+ } finally {
8632
+ fs.rmSync(tmpDir, {
8633
+ recursive: true,
8634
+ force: true
8635
+ });
8636
+ }
8637
+ }
8638
+ function getExtractDir$1(projectDir, skillName) {
8639
+ if (skillName.startsWith("@")) {
8640
+ const [scope, name] = skillName.split("/");
8641
+ return path.join(projectDir, ".tank", "skills", scope, name);
8642
+ }
8643
+ return path.join(projectDir, ".tank", "skills", skillName);
8644
+ }
8645
+ function getGlobalExtractDir(homedir, skillName) {
8646
+ const globalDir = path.join(homedir, ".tank", "skills");
8647
+ if (skillName.startsWith("@")) {
8648
+ const [scope, name] = skillName.split("/");
8649
+ return path.join(globalDir, scope, name);
8650
+ }
8651
+ return path.join(globalDir, skillName);
8652
+ }
8653
+ function parseLockKey$2(key) {
8654
+ const lastAt = key.lastIndexOf("@");
8655
+ if (lastAt <= 0) throw new Error(`Invalid lockfile key: ${key}`);
8656
+ return key.slice(0, lastAt);
8657
+ }
8658
+ function parseVersionFromLockKey(key) {
8659
+ const lastAt = key.lastIndexOf("@");
8660
+ if (lastAt <= 0 || lastAt === key.length - 1) throw new Error(`Invalid lockfile key: ${key}`);
8661
+ return key.slice(lastAt + 1);
8662
+ }
8663
+ function getResolvedNodesInOrder(nodes, installOrder) {
8664
+ const orderedNodes = [];
8665
+ for (const key of installOrder) {
8666
+ const skillName = parseLockKey$2(key);
8667
+ const node = nodes.get(skillName);
8668
+ if (!node) throw new Error(`Internal error: missing resolved node for ${key}`);
8669
+ orderedNodes.push(node);
8670
+ }
8671
+ return orderedNodes;
8672
+ }
8673
+ //#endregion
8674
+ //#region src/lib/frontmatter.ts
8675
+ function hasFrontmatter(content) {
8676
+ return /^---\s*\n/.test(content);
8677
+ }
8678
+ function stripScope(skillName) {
8679
+ const match = skillName.match(/^@[^/]+\/(.+)$/);
8680
+ if (!match) return skillName;
8681
+ return match[1] ?? skillName;
8682
+ }
8683
+ function extractDescriptionFromMarkdown(content) {
8684
+ const lines = content.split(/\r?\n/);
8685
+ const firstLine = lines.find((line) => line.trim().length > 0);
8686
+ if (firstLine && /^#\s+/.test(firstLine)) return firstLine.replace(/^#\s+/, "").trim();
8687
+ let seenHeading = false;
8688
+ let paragraphLines = [];
8689
+ for (const line of lines) {
8690
+ const trimmed = line.trim();
8691
+ if (/^#{1,6}\s+/.test(trimmed)) {
8692
+ seenHeading = true;
8693
+ paragraphLines = [];
8694
+ continue;
8695
+ }
8696
+ if (!seenHeading) continue;
8697
+ if (trimmed.length === 0) {
8698
+ if (paragraphLines.length > 0) break;
8699
+ continue;
8700
+ }
8701
+ paragraphLines.push(trimmed);
8702
+ }
8703
+ if (paragraphLines.length > 0) {
8704
+ const paragraph = paragraphLines.join(" ").trim();
8705
+ const match = paragraph.match(/^(.+?[.!?])(\s|$)/);
8706
+ return (match ? match[1] : paragraph).trim();
8707
+ }
8708
+ return "An AI agent skill";
8709
+ }
8710
+ function generateFrontmatter(name, description) {
8711
+ return `---\nname: ${name}\ndescription: |\n${description.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}\n---\n\n`;
8712
+ }
8713
+ function prepareAgentSkillDir(options) {
8714
+ const { skillName, extractDir, agentSkillsBaseDir, description } = options;
8715
+ const symlinkName = getSymlinkName(skillName);
8716
+ const targetDir = path.resolve(agentSkillsBaseDir, symlinkName);
8717
+ fs.mkdirSync(targetDir, { recursive: true });
8718
+ const sourceSkillPath = path.join(extractDir, "SKILL.md");
8719
+ const targetSkillPath = path.join(targetDir, "SKILL.md");
8720
+ const baseName = stripScope(skillName);
8721
+ if (!fs.existsSync(sourceSkillPath)) {
8722
+ const minimal = generateFrontmatter(baseName, description ?? "An AI agent skill");
8723
+ fs.writeFileSync(targetSkillPath, minimal, "utf-8");
8724
+ } else {
8725
+ const content = fs.readFileSync(sourceSkillPath, "utf-8");
8726
+ if (hasFrontmatter(content)) fs.writeFileSync(targetSkillPath, content, "utf-8");
8727
+ else {
8728
+ const frontmatter = generateFrontmatter(baseName, description ?? extractDescriptionFromMarkdown(content));
8729
+ fs.writeFileSync(targetSkillPath, `${frontmatter}${content}`, "utf-8");
8730
+ }
8731
+ }
8732
+ const entries = fs.readdirSync(extractDir, { withFileTypes: true });
8733
+ for (const entry of entries) {
8734
+ if (entry.name === "SKILL.md") continue;
8735
+ const sourcePath = path.join(extractDir, entry.name);
8736
+ const targetPath = path.join(targetDir, entry.name);
8737
+ fs.cpSync(sourcePath, targetPath, { recursive: true });
8738
+ }
8739
+ return targetDir;
8740
+ }
8741
+ //#endregion
8742
+ //#region src/lib/scan-gate.ts
8743
+ /**
8744
+ * Security scan gate for `tank install <url>`.
8745
+ * Calls the public scan API and enforces verdicts.
8746
+ */
8747
+ function verdictColor$1(verdict) {
8748
+ switch (verdict) {
8749
+ case "pass": return chalk.green;
8750
+ case "pass_with_notes": return chalk.yellow;
8751
+ case "flagged": return chalk.hex("#FF8C00");
8752
+ case "fail": return chalk.red;
8753
+ case "error": return chalk.red;
8754
+ default: return chalk.white;
8755
+ }
8756
+ }
8757
+ function severityColor$1(severity) {
8758
+ switch (severity) {
8759
+ case "critical": return chalk.red;
8760
+ case "high": return chalk.hex("#FF8C00");
8761
+ case "medium": return chalk.yellow;
8762
+ case "low": return chalk.green;
8763
+ case "info": return chalk.blue;
8764
+ default: return chalk.white;
8765
+ }
8766
+ }
8767
+ function scoreColor$2(score) {
8768
+ if (score >= 7) return chalk.green;
8769
+ if (score >= 4) return chalk.yellow;
8770
+ return chalk.red;
8771
+ }
8772
+ async function promptUser(question) {
8773
+ const rl = createInterface({
8774
+ input: process.stdin,
8775
+ output: process.stdout
8776
+ });
8777
+ return new Promise((resolve) => {
8778
+ rl.question(question, (answer) => {
8779
+ rl.close();
8780
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
8781
+ });
8782
+ });
8783
+ }
8784
+ async function scanUrl(url, options) {
8785
+ const config = getConfig();
8786
+ const registryUrl = options?.registryUrl ?? config.registry;
8787
+ const token = options?.token ?? config.token;
8788
+ let res;
8789
+ try {
8790
+ const headers = {
8791
+ "Content-Type": "application/json",
8792
+ "User-Agent": USER_AGENT
8793
+ };
8794
+ if (token) headers.Authorization = `Bearer ${token}`;
8795
+ res = await fetch(`${registryUrl}/api/v1/scan`, {
8796
+ method: "POST",
8797
+ headers,
8798
+ body: JSON.stringify({ url }),
8799
+ signal: AbortSignal.timeout(65e3)
8800
+ });
8801
+ } catch (err) {
8802
+ return {
8803
+ success: false,
8804
+ verdict: "error",
8805
+ auditScore: null,
8806
+ findings: [],
8807
+ durationMs: null,
8808
+ error: `Network error: ${err instanceof Error ? err.message : String(err)}`
8809
+ };
8810
+ }
8811
+ if (!res.ok) {
8812
+ if (res.status === 429) return {
8813
+ success: false,
8814
+ verdict: "error",
8815
+ auditScore: null,
8816
+ findings: [],
8817
+ durationMs: null,
8818
+ 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."}`
8819
+ };
8820
+ if (res.status === 504) return {
8821
+ success: false,
8822
+ verdict: "error",
8823
+ auditScore: null,
8824
+ findings: [],
8825
+ durationMs: null,
8826
+ error: "Scan timed out (504). The skill may be too large or the scanner is overloaded."
8827
+ };
8828
+ return {
8829
+ success: false,
8830
+ verdict: "error",
8831
+ auditScore: null,
8832
+ findings: [],
8833
+ durationMs: null,
8834
+ error: (await res.json().catch(() => null))?.error ?? `HTTP ${res.status}: ${res.statusText}`
8835
+ };
8836
+ }
8837
+ const data = await res.json();
8838
+ const findings = data.findings.map((f) => ({
8839
+ severity: f.severity,
8840
+ type: f.type,
8841
+ description: f.description,
8842
+ ...f.location ? { location: f.location } : {}
8843
+ }));
8844
+ return {
8845
+ success: true,
8846
+ verdict: data.verdict,
8847
+ auditScore: data.audit_score ?? null,
8848
+ findings,
8849
+ durationMs: data.duration_ms ?? null
8850
+ };
8851
+ }
8852
+ function displayScanResults(result) {
8853
+ const verdictLabel = verdictColor$1(result.verdict)(result.verdict.toUpperCase());
8854
+ console.log("");
8855
+ console.log(chalk.bold("Security Scan Results"));
8856
+ console.log("");
8857
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
8858
+ if (result.auditScore !== null) {
8859
+ const scoreLabel = scoreColor$2(result.auditScore)(result.auditScore.toFixed(1));
8860
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
8861
+ }
8862
+ if (result.durationMs !== null) console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.durationMs / 1e3).toFixed(1)}s`);
8863
+ if (result.error) console.log(`${chalk.dim("Error:".padEnd(14))}${chalk.red(result.error)}`);
8864
+ if (result.findings.length > 0) {
8865
+ console.log("");
8866
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
8867
+ const bySeverity = {
8868
+ critical: [],
8869
+ high: [],
8870
+ medium: [],
8871
+ low: [],
8872
+ info: []
8873
+ };
8874
+ for (const f of result.findings) bySeverity[f.severity].push(f);
8875
+ for (const severity of [
8876
+ "critical",
8877
+ "high",
8878
+ "medium",
8879
+ "low",
8880
+ "info"
8881
+ ]) {
8882
+ const group = bySeverity[severity];
8883
+ if (group.length === 0) continue;
8884
+ console.log("");
8885
+ const label = severityColor$1(severity)(`${severity.toUpperCase()} (${group.length})`);
8886
+ console.log(` ${label}`);
8887
+ for (const f of group) {
8888
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
8889
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
8890
+ }
8891
+ }
8892
+ } else if (result.success) {
8893
+ console.log("");
8894
+ console.log(chalk.green("No findings. The skill looks secure."));
8895
+ }
8896
+ console.log("");
8897
+ }
8898
+ async function enforceVerdict(result, options) {
8899
+ switch (result.verdict) {
8900
+ case "pass":
8901
+ case "pass_with_notes": return { allowed: true };
8902
+ case "flagged": {
8903
+ if (options?.yes) return { allowed: true };
8904
+ const count = result.findings.length;
8905
+ if (await promptUser(chalk.yellow(`⚠ Security scan flagged ${count} issue${count === 1 ? "" : "s"}. Install anyway? (y/N) `))) return { allowed: true };
8906
+ return {
8907
+ allowed: false,
8908
+ reason: "User declined after security warnings"
8909
+ };
8910
+ }
8911
+ case "fail": return {
8912
+ allowed: false,
8913
+ reason: "Security scan failed with critical findings"
8914
+ };
8915
+ case "error": return {
8916
+ allowed: false,
8917
+ reason: `Security scan error: ${result.error ?? "unknown"}`
8918
+ };
8919
+ default: return {
8920
+ allowed: false,
8921
+ reason: `Unknown verdict: ${result.verdict}`
8922
+ };
8923
+ }
8924
+ }
8925
+ //#endregion
8926
+ //#region src/lib/url-fetcher.ts
8927
+ /**
8928
+ * Fetch skills from URLs for `tank install <url>`.
8929
+ * Routes GitHub (git clone), ClawHub (zip), skills.sh, and generic tarballs
8930
+ * to temp directories with cleanup-on-failure semantics.
8931
+ */
8932
+ const HOST_MAP = [
8933
+ [/github\.com/i, "github"],
8934
+ [/clawhub\.ai/i, "clawhub"],
8935
+ [/skills\.sh/i, "skills_sh"],
8936
+ [/agentskills\.co\.il/i, "agentskills_il"],
8937
+ [/registry\.npmjs\.org/i, "npm"]
8938
+ ];
8939
+ function detectSourceType(url) {
8940
+ for (const [pattern, sourceType] of HOST_MAP) if (pattern.test(url)) return sourceType;
8941
+ return "unknown";
8942
+ }
8943
+ /** Returns true if the input looks like a URL rather than a package name. */
8944
+ function isUrl(input) {
8945
+ if (input.startsWith("http://") || input.startsWith("https://")) return true;
8946
+ for (const [pattern] of HOST_MAP) if (pattern.test(input)) return true;
8947
+ return false;
8948
+ }
8949
+ /** Best-effort skill name extraction from a URL. */
8950
+ function inferSkillName(url) {
8951
+ try {
8952
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
8953
+ switch (detectSourceType(url)) {
8954
+ case "github": {
8955
+ if (segments.length < 2) return null;
8956
+ const treeIdx = segments.indexOf("tree");
8957
+ if (treeIdx !== -1 && segments.length > treeIdx + 2) return segments[segments.length - 1] ?? null;
8958
+ return segments[1] ?? null;
8959
+ }
8960
+ case "clawhub": return segments[1] ?? null;
8961
+ case "skills_sh": return segments[2] ?? segments[1] ?? null;
8962
+ case "agentskills_il": return segments[1] ?? null;
8963
+ case "npm": return segments[segments.length - 1] ?? null;
8964
+ default: return segments[segments.length - 1] ?? null;
8965
+ }
8966
+ } catch {
8967
+ return null;
8968
+ }
8969
+ }
8970
+ async function createTempDir() {
8971
+ return mkdtemp(join(tmpdir(), "tank-fetch-"));
8972
+ }
8973
+ async function cleanupDir(dir) {
8974
+ try {
8975
+ await rm(dir, {
6534
8976
  recursive: true,
6535
8977
  force: true
6536
8978
  });
6537
- }
8979
+ } catch {}
6538
8980
  }
6539
- function getExtractDir$1(projectDir, skillName) {
6540
- if (skillName.startsWith("@")) {
6541
- const [scope, name] = skillName.split("/");
6542
- return path.join(projectDir, ".tank", "skills", scope, name);
8981
+ function ensureGitInstalled() {
8982
+ try {
8983
+ execSync("git --version", { stdio: "ignore" });
8984
+ } catch {
8985
+ throw new Error("Git is not installed. Install git and try again.");
6543
8986
  }
6544
- return path.join(projectDir, ".tank", "skills", skillName);
6545
8987
  }
6546
- function getGlobalExtractDir(homedir, skillName) {
6547
- const globalDir = path.join(homedir, ".tank", "skills");
6548
- if (skillName.startsWith("@")) {
6549
- const [scope, name] = skillName.split("/");
6550
- return path.join(globalDir, scope, name);
8988
+ function gitCloneShallow(repoUrl, dest) {
8989
+ try {
8990
+ execSync(`git clone --depth 1 ${repoUrl} ${dest}`, {
8991
+ stdio: "pipe",
8992
+ timeout: 6e4
8993
+ });
8994
+ } catch (err) {
8995
+ const msg = err instanceof Error ? err.message : String(err);
8996
+ if (msg.includes("Repository not found") || msg.includes("not found")) throw new Error(`Repository not found: ${repoUrl}`);
8997
+ if (msg.includes("timed out") || msg.includes("ETIMEDOUT")) throw new Error(`Network timeout cloning ${repoUrl}`);
8998
+ throw new Error(`Git clone failed: ${msg}`);
6551
8999
  }
6552
- return path.join(globalDir, skillName);
6553
- }
6554
- function parseLockKey$2(key) {
6555
- const lastAt = key.lastIndexOf("@");
6556
- if (lastAt <= 0) throw new Error(`Invalid lockfile key: ${key}`);
6557
- return key.slice(0, lastAt);
6558
9000
  }
6559
- function parseVersionFromLockKey(key) {
6560
- const lastAt = key.lastIndexOf("@");
6561
- if (lastAt <= 0 || lastAt === key.length - 1) throw new Error(`Invalid lockfile key: ${key}`);
6562
- return key.slice(lastAt + 1);
9001
+ function gitRevParseHead(dir) {
9002
+ try {
9003
+ return execSync("git rev-parse HEAD", {
9004
+ cwd: dir,
9005
+ stdio: "pipe"
9006
+ }).toString().trim();
9007
+ } catch {
9008
+ return null;
9009
+ }
6563
9010
  }
6564
- function getResolvedNodesInOrder(nodes, installOrder) {
6565
- const orderedNodes = [];
6566
- for (const key of installOrder) {
6567
- const skillName = parseLockKey$2(key);
6568
- const node = nodes.get(skillName);
6569
- if (!node) throw new Error(`Internal error: missing resolved node for ${key}`);
6570
- orderedNodes.push(node);
9011
+ async function downloadFile(url, dest) {
9012
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
9013
+ if (!res.ok) {
9014
+ if (res.status === 404) throw new Error(`Not found: ${url}`);
9015
+ throw new Error(`HTTP ${res.status} downloading ${url}`);
6571
9016
  }
6572
- return orderedNodes;
9017
+ if (!res.body) throw new Error(`Empty response body from ${url}`);
9018
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
6573
9019
  }
6574
- //#endregion
6575
- //#region src/lib/frontmatter.ts
6576
- function hasFrontmatter(content) {
6577
- return /^---\s*\n/.test(content);
9020
+ async function extractZip(zipPath, dest) {
9021
+ try {
9022
+ execSync(`unzip -o -q "${zipPath}" -d "${dest}"`, {
9023
+ stdio: "pipe",
9024
+ timeout: 3e4
9025
+ });
9026
+ } catch (err) {
9027
+ const msg = err instanceof Error ? err.message : String(err);
9028
+ throw new Error(`Zip extraction failed: ${msg}`);
9029
+ }
6578
9030
  }
6579
- function stripScope(skillName) {
6580
- const match = skillName.match(/^@[^/]+\/(.+)$/);
6581
- if (!match) return skillName;
6582
- return match[1] ?? skillName;
9031
+ async function extractTarball(tarPath, dest) {
9032
+ try {
9033
+ execSync(`tar xzf "${tarPath}" -C "${dest}"`, {
9034
+ stdio: "pipe",
9035
+ timeout: 3e4
9036
+ });
9037
+ } catch (err) {
9038
+ const msg = err instanceof Error ? err.message : String(err);
9039
+ throw new Error(`Tarball extraction failed: ${msg}`);
9040
+ }
6583
9041
  }
6584
- function extractDescriptionFromMarkdown(content) {
6585
- const lines = content.split(/\r?\n/);
6586
- const firstLine = lines.find((line) => line.trim().length > 0);
6587
- if (firstLine && /^#\s+/.test(firstLine)) return firstLine.replace(/^#\s+/, "").trim();
6588
- let seenHeading = false;
6589
- let paragraphLines = [];
6590
- for (const line of lines) {
6591
- const trimmed = line.trim();
6592
- if (/^#{1,6}\s+/.test(trimmed)) {
6593
- seenHeading = true;
6594
- paragraphLines = [];
6595
- continue;
9042
+ function parseGitHubUrl(url) {
9043
+ try {
9044
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
9045
+ if (segments.length < 2) return null;
9046
+ const owner = segments[0];
9047
+ const repo = segments[1];
9048
+ let branch = null;
9049
+ let subpath = null;
9050
+ if (segments[2] === "tree" && segments.length > 3) {
9051
+ branch = segments[3];
9052
+ if (segments.length > 4) subpath = segments.slice(4).join("/");
6596
9053
  }
6597
- if (!seenHeading) continue;
6598
- if (trimmed.length === 0) {
6599
- if (paragraphLines.length > 0) break;
6600
- continue;
9054
+ return {
9055
+ owner,
9056
+ repo,
9057
+ branch,
9058
+ subpath
9059
+ };
9060
+ } catch {
9061
+ return null;
9062
+ }
9063
+ }
9064
+ async function fetchFromGitHub(url, tempDir) {
9065
+ ensureGitInstalled();
9066
+ const parts = parseGitHubUrl(url);
9067
+ if (!parts) throw new Error(`Invalid GitHub URL: ${url}`);
9068
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
9069
+ const cloneDest = join(tempDir, parts.repo);
9070
+ logger.info(`Cloning ${parts.owner}/${parts.repo}...`);
9071
+ gitCloneShallow(cloneUrl, cloneDest);
9072
+ if (parts.branch) try {
9073
+ execSync(`git checkout ${parts.branch}`, {
9074
+ cwd: cloneDest,
9075
+ stdio: "pipe",
9076
+ timeout: 1e4
9077
+ });
9078
+ } catch {}
9079
+ const commitSha = gitRevParseHead(cloneDest);
9080
+ let localPath = cloneDest;
9081
+ if (parts.subpath) {
9082
+ const subDir = join(cloneDest, parts.subpath);
9083
+ try {
9084
+ if ((await stat(subDir)).isDirectory()) localPath = subDir;
9085
+ } catch {
9086
+ throw new Error(`Subpath not found in repo: ${parts.subpath}`);
6601
9087
  }
6602
- paragraphLines.push(trimmed);
6603
9088
  }
6604
- if (paragraphLines.length > 0) {
6605
- const paragraph = paragraphLines.join(" ").trim();
6606
- const match = paragraph.match(/^(.+?[.!?])(\s|$)/);
6607
- return (match ? match[1] : paragraph).trim();
9089
+ return {
9090
+ localPath,
9091
+ sourceType: "github",
9092
+ sourceUrl: url,
9093
+ commitSha,
9094
+ inferredName: parts.subpath ? parts.subpath.split("/").pop() ?? parts.repo : parts.repo,
9095
+ cleanup: () => cleanupDir(tempDir)
9096
+ };
9097
+ }
9098
+ function parseClawHubUrl(url) {
9099
+ try {
9100
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
9101
+ if (segments.length < 2) return null;
9102
+ return {
9103
+ owner: segments[0],
9104
+ skillName: segments[1]
9105
+ };
9106
+ } catch {
9107
+ return null;
6608
9108
  }
6609
- return "An AI agent skill";
6610
9109
  }
6611
- function generateFrontmatter(name, description) {
6612
- return `---\nname: ${name}\ndescription: |\n${description.split(/\r?\n/).map((line) => ` ${line}`).join("\n")}\n---\n\n`;
9110
+ async function fetchFromClawHub(url, tempDir) {
9111
+ const parts = parseClawHubUrl(url);
9112
+ if (!parts) throw new Error(`Invalid ClawHub URL: ${url}`);
9113
+ logger.info(`Fetching ${parts.owner}/${parts.skillName} from ClawHub...`);
9114
+ const pageUrl = url.startsWith("http") ? url : `https://${url}`;
9115
+ const pageRes = await fetch(pageUrl, { signal: AbortSignal.timeout(3e4) });
9116
+ if (!pageRes.ok) {
9117
+ if (pageRes.status === 404) throw new Error(`Skill not found on ClawHub: ${parts.skillName}`);
9118
+ throw new Error(`HTTP ${pageRes.status} fetching ClawHub page`);
9119
+ }
9120
+ const html = await pageRes.text();
9121
+ const downloadUrlMatch = html.match(/https?:\/\/[^\s"']+\.convex\.cloud[^\s"']*download[^\s"']*/i) ?? html.match(/https?:\/\/[^\s"']+\.zip/i);
9122
+ if (!downloadUrlMatch) throw new Error("Could not find download URL on ClawHub page. The skill may not have a downloadable archive.");
9123
+ const zipPath = join(tempDir, `${parts.skillName}.zip`);
9124
+ await downloadFile(downloadUrlMatch[0], zipPath);
9125
+ const extractDir = join(tempDir, parts.skillName);
9126
+ await mkdir(extractDir, { recursive: true });
9127
+ await extractZip(zipPath, extractDir);
9128
+ return {
9129
+ localPath: extractDir,
9130
+ sourceType: "clawhub",
9131
+ sourceUrl: url,
9132
+ commitSha: null,
9133
+ inferredName: parts.skillName,
9134
+ cleanup: () => cleanupDir(tempDir)
9135
+ };
6613
9136
  }
6614
- function prepareAgentSkillDir(options) {
6615
- const { skillName, extractDir, agentSkillsBaseDir, description } = options;
6616
- const symlinkName = getSymlinkName(skillName);
6617
- const targetDir = path.resolve(agentSkillsBaseDir, symlinkName);
6618
- fs.mkdirSync(targetDir, { recursive: true });
6619
- const sourceSkillPath = path.join(extractDir, "SKILL.md");
6620
- const targetSkillPath = path.join(targetDir, "SKILL.md");
6621
- const baseName = stripScope(skillName);
6622
- if (!fs.existsSync(sourceSkillPath)) {
6623
- const minimal = generateFrontmatter(baseName, description ?? "An AI agent skill");
6624
- fs.writeFileSync(targetSkillPath, minimal, "utf-8");
6625
- } else {
6626
- const content = fs.readFileSync(sourceSkillPath, "utf-8");
6627
- if (hasFrontmatter(content)) fs.writeFileSync(targetSkillPath, content, "utf-8");
6628
- else {
6629
- const frontmatter = generateFrontmatter(baseName, description ?? extractDescriptionFromMarkdown(content));
6630
- fs.writeFileSync(targetSkillPath, `${frontmatter}${content}`, "utf-8");
9137
+ function parseSkillsShUrl(url) {
9138
+ try {
9139
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
9140
+ if (segments.length < 2) return null;
9141
+ return {
9142
+ owner: segments[0],
9143
+ repo: segments[1],
9144
+ skillName: segments[2] ?? null
9145
+ };
9146
+ } catch {
9147
+ return null;
9148
+ }
9149
+ }
9150
+ async function fetchFromSkillsSh(url, tempDir) {
9151
+ ensureGitInstalled();
9152
+ const parts = parseSkillsShUrl(url);
9153
+ if (!parts) throw new Error(`Invalid skills.sh URL: ${url}`);
9154
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
9155
+ const cloneDest = join(tempDir, parts.repo);
9156
+ logger.info(`Cloning ${parts.owner}/${parts.repo} (via skills.sh)...`);
9157
+ gitCloneShallow(cloneUrl, cloneDest);
9158
+ const commitSha = gitRevParseHead(cloneDest);
9159
+ let localPath = cloneDest;
9160
+ const inferredName = parts.skillName ?? parts.repo;
9161
+ if (parts.skillName) {
9162
+ const candidates = [
9163
+ join(cloneDest, "skills", parts.skillName),
9164
+ join(cloneDest, "src", "skills", parts.skillName),
9165
+ join(cloneDest, parts.skillName)
9166
+ ];
9167
+ let found = false;
9168
+ for (const candidate of candidates) try {
9169
+ if ((await stat(candidate)).isDirectory()) {
9170
+ localPath = candidate;
9171
+ found = true;
9172
+ break;
9173
+ }
9174
+ } catch {}
9175
+ if (!found) throw new Error(`Skill "${parts.skillName}" not found in ${parts.repo}. Searched: skills/${parts.skillName}, src/skills/${parts.skillName}, ${parts.skillName}`);
9176
+ }
9177
+ return {
9178
+ localPath,
9179
+ sourceType: "skills_sh",
9180
+ sourceUrl: url,
9181
+ commitSha,
9182
+ inferredName,
9183
+ cleanup: () => cleanupDir(tempDir)
9184
+ };
9185
+ }
9186
+ async function fetchFromGenericUrl(url, tempDir) {
9187
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
9188
+ logger.info(`Downloading from ${fullUrl}...`);
9189
+ const isTarball = /\.(tar\.gz|tgz)(\?|$)/i.test(fullUrl);
9190
+ const isZip = /\.zip(\?|$)/i.test(fullUrl);
9191
+ if (!isTarball && !isZip) {
9192
+ const archivePath = join(tempDir, "skill.tar.gz");
9193
+ await downloadFile(fullUrl, archivePath);
9194
+ const extractDir = join(tempDir, "skill");
9195
+ await mkdir(extractDir, { recursive: true });
9196
+ try {
9197
+ await extractTarball(archivePath, extractDir);
9198
+ } catch {
9199
+ try {
9200
+ await extractZip(archivePath, extractDir);
9201
+ } catch {
9202
+ throw new Error(`Failed to extract archive from ${fullUrl}. Expected .tar.gz or .zip format.`);
9203
+ }
6631
9204
  }
9205
+ return {
9206
+ localPath: extractDir,
9207
+ sourceType: detectSourceType(url),
9208
+ sourceUrl: url,
9209
+ commitSha: null,
9210
+ inferredName: inferSkillName(url),
9211
+ cleanup: () => cleanupDir(tempDir)
9212
+ };
6632
9213
  }
6633
- const entries = fs.readdirSync(extractDir, { withFileTypes: true });
6634
- for (const entry of entries) {
6635
- if (entry.name === "SKILL.md") continue;
6636
- const sourcePath = path.join(extractDir, entry.name);
6637
- const targetPath = path.join(targetDir, entry.name);
6638
- fs.cpSync(sourcePath, targetPath, { recursive: true });
9214
+ const archivePath = join(tempDir, `skill.${isTarball ? "tar.gz" : "zip"}`);
9215
+ await downloadFile(fullUrl, archivePath);
9216
+ const extractDir = join(tempDir, "skill");
9217
+ await mkdir(extractDir, { recursive: true });
9218
+ if (isTarball) await extractTarball(archivePath, extractDir);
9219
+ else await extractZip(archivePath, extractDir);
9220
+ return {
9221
+ localPath: extractDir,
9222
+ sourceType: detectSourceType(url),
9223
+ sourceUrl: url,
9224
+ commitSha: null,
9225
+ inferredName: inferSkillName(url),
9226
+ cleanup: () => cleanupDir(tempDir)
9227
+ };
9228
+ }
9229
+ /** Fetch a skill from a URL to a local temp directory. */
9230
+ async function fetchFromUrl(url) {
9231
+ const sourceType = detectSourceType(url);
9232
+ let tempDir = null;
9233
+ try {
9234
+ tempDir = await createTempDir();
9235
+ let result;
9236
+ switch (sourceType) {
9237
+ case "github":
9238
+ result = await fetchFromGitHub(url, tempDir);
9239
+ break;
9240
+ case "clawhub":
9241
+ result = await fetchFromClawHub(url, tempDir);
9242
+ break;
9243
+ case "skills_sh":
9244
+ result = await fetchFromSkillsSh(url, tempDir);
9245
+ break;
9246
+ default:
9247
+ result = await fetchFromGenericUrl(url, tempDir);
9248
+ break;
9249
+ }
9250
+ return {
9251
+ success: true,
9252
+ ...result
9253
+ };
9254
+ } catch (err) {
9255
+ if (tempDir) await cleanupDir(tempDir);
9256
+ return {
9257
+ success: false,
9258
+ error: err instanceof Error ? err.message : String(err)
9259
+ };
6639
9260
  }
6640
- return targetDir;
6641
9261
  }
6642
9262
  //#endregion
6643
9263
  //#region src/commands/install.ts
@@ -6762,7 +9382,22 @@ async function runLegacyFallback(options) {
6762
9382
  }
6763
9383
  }
6764
9384
  }
6765
- function linkInstalledRoots(options) {
9385
+ function installToolDependencies(extractDir, skillName) {
9386
+ const packageJsonPath = path.join(extractDir, "package.json");
9387
+ if (!fs.existsSync(packageJsonPath)) return;
9388
+ try {
9389
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"));
9390
+ if (!(pkg.dependencies && Object.keys(pkg.dependencies).length > 0 || pkg.peerDependencies && Object.keys(pkg.peerDependencies).length > 0)) return;
9391
+ execSync("npm install --production --ignore-scripts --no-audit --no-fund", {
9392
+ cwd: extractDir,
9393
+ stdio: "pipe",
9394
+ timeout: 6e4
9395
+ });
9396
+ } catch {
9397
+ logger.warn(`Dependency install skipped for ${skillName} (non-fatal)`);
9398
+ }
9399
+ }
9400
+ async function linkInstalledRoots(options) {
6766
9401
  const { rootSkillNames, resolvedNodeByName, extractDirForSkill, directory, global, resolvedHome, homedir } = options;
6767
9402
  const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
6768
9403
  const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
@@ -6787,7 +9422,34 @@ function linkInstalledRoots(options) {
6787
9422
  if (rootSkillNames.length === 1) logger.warn("Agent linking skipped (non-fatal)");
6788
9423
  else logger.warn(`Agent linking skipped for ${skillName} (non-fatal)`);
6789
9424
  }
6790
- if (detectInstalledAgents(homedir).length === 0) logger.warn("No agents detected for linking");
9425
+ const detectedAgents = detectInstalledAgents(homedir);
9426
+ if (detectedAgents.length === 0) logger.warn("No agents detected for linking");
9427
+ const agentToPlatform = {
9428
+ claude: "claude-code",
9429
+ opencode: "opencode",
9430
+ cursor: "cursor",
9431
+ codex: "claude-code",
9432
+ openclaw: "opencode"
9433
+ };
9434
+ const platforms = new Set(detectedAgents.map((a) => agentToPlatform[a.id]).filter(Boolean));
9435
+ for (const skillName of rootSkillNames) {
9436
+ if (!resolvedNodeByName.get(skillName)) continue;
9437
+ const skillDir = extractDirForSkill(skillName);
9438
+ const manifestPath = path.join(skillDir, "tank.json");
9439
+ if (!fs.existsSync(manifestPath)) continue;
9440
+ try {
9441
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
9442
+ if (!manifest.atoms || !Array.isArray(manifest.atoms) || manifest.atoms.length === 0) continue;
9443
+ const { buildCommand: runBuild } = await Promise.resolve().then(() => build_exports);
9444
+ for (const platform of platforms) await runBuild({
9445
+ skill: skillDir,
9446
+ target: directory,
9447
+ platform
9448
+ });
9449
+ } catch {
9450
+ logger.warn(`Auto-build skipped for ${skillName} (non-fatal)`);
9451
+ }
9452
+ }
6791
9453
  }
6792
9454
  async function executeInstallPipeline(options) {
6793
9455
  const { directory, configDir, global, homedir, resolvedHome, lock, lockPath, resolvedNodes, nodesToInstall, rootSkillNames, projectPermissions, auditMinScore, spinner } = options;
@@ -6805,6 +9467,8 @@ async function executeInstallPipeline(options) {
6805
9467
  fs.mkdirSync(extractDir, { recursive: true });
6806
9468
  await extractSafely(payload.buffer, extractDir);
6807
9469
  verifyExtractedDependencies(extractDir, node);
9470
+ spinner.text = `Installing dependencies for ${node.name}...`;
9471
+ installToolDependencies(extractDir, node.name);
6808
9472
  }
6809
9473
  lock.lockfileVersion = 2;
6810
9474
  const updatedLock = writeLockfileWithResolvedGraph(lock, resolvedNodes, downloaded);
@@ -6819,7 +9483,7 @@ async function executeInstallPipeline(options) {
6819
9483
  global,
6820
9484
  homedir
6821
9485
  });
6822
- linkInstalledRoots({
9486
+ await linkInstalledRoots({
6823
9487
  rootSkillNames,
6824
9488
  resolvedNodeByName,
6825
9489
  extractDirForSkill,
@@ -7051,6 +9715,149 @@ async function installAll(options) {
7051
9715
  function buildIntegrity(buffer) {
7052
9716
  return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
7053
9717
  }
9718
+ /** Map url-fetcher source types to lockfile SkillSource values. */
9719
+ function mapSourceType(urlSourceType) {
9720
+ switch (urlSourceType) {
9721
+ case "github": return "github";
9722
+ case "clawhub": return "clawhub";
9723
+ case "skills_sh": return "skills_sh";
9724
+ case "agentskills_il": return "agentskills_il";
9725
+ case "npm": return "npm";
9726
+ default: return "local";
9727
+ }
9728
+ }
9729
+ /** Compute SHA-512 integrity hash over all files in a directory (sorted by path). */
9730
+ function computeDirectoryIntegrity(dir) {
9731
+ const files = [];
9732
+ function walkDir(current) {
9733
+ const entries = fs.readdirSync(current, { withFileTypes: true });
9734
+ for (const entry of entries) {
9735
+ if (entry.name === ".git") continue;
9736
+ const fullPath = path.join(current, entry.name);
9737
+ if (entry.isDirectory()) walkDir(fullPath);
9738
+ else if (entry.isFile()) files.push(fullPath);
9739
+ }
9740
+ }
9741
+ walkDir(dir);
9742
+ files.sort();
9743
+ const hash = crypto$1.createHash("sha512");
9744
+ for (const file of files) hash.update(fs.readFileSync(file));
9745
+ return `sha512-${hash.digest("base64")}`;
9746
+ }
9747
+ /** Read a manifest (tank.json or skills.json) from a directory, returning null if missing/invalid. */
9748
+ function readManifestFromDir(dir) {
9749
+ for (const filename of ["tank.json", "skills.json"]) {
9750
+ const manifestPath = path.join(dir, filename);
9751
+ if (fs.existsSync(manifestPath)) try {
9752
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
9753
+ } catch {
9754
+ return null;
9755
+ }
9756
+ }
9757
+ return null;
9758
+ }
9759
+ async function installFromUrl(url, options) {
9760
+ const { global = false, yes = false } = options;
9761
+ const resolvedHome = os.homedir();
9762
+ const directory = process.cwd();
9763
+ const spinner = ora(`Fetching from URL...`).start();
9764
+ let fetchResult;
9765
+ try {
9766
+ const output = await fetchFromUrl(url);
9767
+ if (!output.success) {
9768
+ spinner.fail("Fetch failed");
9769
+ logger.error(output.error);
9770
+ process.exit(1);
9771
+ }
9772
+ fetchResult = output;
9773
+ spinner.text = "Scanning for security issues...";
9774
+ } catch (err) {
9775
+ spinner.fail("Fetch failed");
9776
+ const msg = err instanceof Error ? err.message : String(err);
9777
+ logger.error(msg);
9778
+ process.exit(1);
9779
+ }
9780
+ try {
9781
+ const scanResult = await scanUrl(url);
9782
+ displayScanResults(scanResult);
9783
+ const enforcement = await enforceVerdict(scanResult, { yes });
9784
+ if (!enforcement.allowed) {
9785
+ spinner.fail(enforcement.reason ?? "Install blocked by security scan");
9786
+ await fetchResult.cleanup();
9787
+ process.exit(1);
9788
+ }
9789
+ const skillMdPath = path.join(fetchResult.localPath, "SKILL.md");
9790
+ if (!fs.existsSync(skillMdPath)) throw new Error("No SKILL.md found. This doesn't appear to be a valid skill.");
9791
+ const existingManifest = readManifestFromDir(fetchResult.localPath);
9792
+ const skillName = existingManifest?.name ?? fetchResult.inferredName ?? path.basename(fetchResult.localPath);
9793
+ const skillVersion = existingManifest?.version ?? "0.0.0";
9794
+ const skillDescription = existingManifest?.description ?? "";
9795
+ if (!existingManifest) {
9796
+ const generatedManifest = {
9797
+ name: skillName,
9798
+ version: skillVersion,
9799
+ description: skillDescription
9800
+ };
9801
+ fs.writeFileSync(path.join(fetchResult.localPath, "tank.json"), `${JSON.stringify(generatedManifest, null, 2)}\n`);
9802
+ logger.info("Generated tank.json (no manifest found in source)");
9803
+ }
9804
+ spinner.text = `Installing ${skillName}...`;
9805
+ const installDir = global ? path.join(resolvedHome, ".tank", "skills", skillName) : path.join(directory, ".tank", "skills", skillName);
9806
+ if (fs.existsSync(installDir)) fs.rmSync(installDir, {
9807
+ recursive: true,
9808
+ force: true
9809
+ });
9810
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
9811
+ fs.cpSync(fetchResult.localPath, installDir, { recursive: true });
9812
+ const integrity = computeDirectoryIntegrity(installDir);
9813
+ const resolvedLock = resolveLockfilePath(global ? path.join(resolvedHome, ".tank") : directory);
9814
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
9815
+ const lock = readLockOrFresh(lockPath);
9816
+ const lockKey = `${skillName}@${skillVersion}`;
9817
+ const skillPermissions = existingManifest?.permissions ?? {};
9818
+ lock.skills[lockKey] = {
9819
+ resolved: url.startsWith("http") ? url : `https://${url}`,
9820
+ integrity,
9821
+ permissions: skillPermissions,
9822
+ audit_score: scanResult.auditScore ?? null,
9823
+ source: mapSourceType(fetchResult.sourceType),
9824
+ scan_verdict: scanResult.verdict,
9825
+ scanned_at: (/* @__PURE__ */ new Date()).toISOString()
9826
+ };
9827
+ lock.lockfileVersion = 2;
9828
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
9829
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
9830
+ const linkedAgents = [];
9831
+ try {
9832
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
9833
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
9834
+ const linkResult = linkSkillToAgents({
9835
+ skillName,
9836
+ sourceDir: prepareAgentSkillDir({
9837
+ skillName,
9838
+ extractDir: installDir,
9839
+ agentSkillsBaseDir,
9840
+ description: skillDescription
9841
+ }),
9842
+ linksDir,
9843
+ source: global ? "global" : "local"
9844
+ });
9845
+ linkedAgents.push(...linkResult.linked);
9846
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
9847
+ } catch {
9848
+ logger.warn("Agent linking skipped (non-fatal)");
9849
+ }
9850
+ if (detectInstalledAgents().length === 0) logger.warn("No agents detected for linking");
9851
+ await fetchResult.cleanup();
9852
+ spinner.succeed(`Installed ${skillName} from ${fetchResult.sourceType}`);
9853
+ if (linkedAgents.length > 0) logger.info(`Linked to ${linkedAgents.join(", ")}`);
9854
+ logger.info(`Locked (${integrity.slice(0, 20)}..., scanned ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]})`);
9855
+ } catch (err) {
9856
+ await fetchResult.cleanup();
9857
+ spinner.fail("Install failed");
9858
+ throw err;
9859
+ }
9860
+ }
7054
9861
  //#endregion
7055
9862
  //#region src/commands/link.ts
7056
9863
  async function linkCommand(options = {}) {
@@ -7421,8 +10228,7 @@ const IGNORE_FILES = [".tankignore", ".gitignore"];
7421
10228
  * Pack a skill directory into a .tgz tarball with integrity hashing.
7422
10229
  *
7423
10230
  * Validates:
7424
- * - skills.json exists and is valid
7425
- * - SKILL.md exists
10231
+ * - tank.json (or skills.json) exists and is valid
7426
10232
  * - No symlinks or hardlinks
7427
10233
  * - No path traversal (.. components)
7428
10234
  * - No absolute paths
@@ -7452,18 +10258,23 @@ async function pack(directory) {
7452
10258
  } catch {
7453
10259
  throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
7454
10260
  }
7455
- const validation = skillsJsonSchema.safeParse(parsed);
10261
+ const validation = publishManifestSchema.safeParse(parsed);
7456
10262
  if (!validation.success) {
7457
10263
  const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
7458
10264
  throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
7459
10265
  }
10266
+ let readmeContent = "";
7460
10267
  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 {
10268
+ const readmeMdPath = path.join(absDir, "README.md");
10269
+ if (fs.existsSync(skillMdPath)) try {
7464
10270
  readmeContent = fs.readFileSync(skillMdPath, "utf-8");
7465
10271
  } catch {
7466
- throw new Error("Failed to read SKILL.md");
10272
+ readmeContent = "";
10273
+ }
10274
+ else if (fs.existsSync(readmeMdPath)) try {
10275
+ readmeContent = fs.readFileSync(readmeMdPath, "utf-8");
10276
+ } catch {
10277
+ readmeContent = "";
7467
10278
  }
7468
10279
  const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
7469
10280
  if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
@@ -7565,9 +10376,12 @@ function collectFiles(baseDir, currentDir, ig) {
7565
10376
  if (relativePath.split(path.sep).includes("..")) throw new Error(`Path traversal detected: "${relativePath}" contains ".." component`);
7566
10377
  if (path.isAbsolute(relativePath)) throw new Error(`Absolute path detected: "${relativePath}"`);
7567
10378
  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;
10379
+ const pathForIgnore = lstatResult.isDirectory() || lstatResult.isSymbolicLink() && fs.statSync(fullPath).isDirectory() ? `${relativePath}/` : relativePath;
7570
10380
  if (ig.ignores(pathForIgnore)) continue;
10381
+ if (lstatResult.isSymbolicLink()) {
10382
+ console.warn(`⚠ Skipping symlink: ${relativePath}`);
10383
+ continue;
10384
+ }
7571
10385
  if (lstatResult.isDirectory()) {
7572
10386
  const subFiles = collectFiles(baseDir, fullPath, ig);
7573
10387
  files.push(...subFiles);
@@ -8638,6 +11452,21 @@ program.command("init").description("Create a new tank.json in the current direc
8638
11452
  process.exit(1);
8639
11453
  }
8640
11454
  });
11455
+ 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) => {
11456
+ try {
11457
+ await buildCommand({
11458
+ skill,
11459
+ platform: opts.platform,
11460
+ out: opts.out,
11461
+ dryRun: opts.dryRun,
11462
+ listPlatforms: opts.listPlatforms
11463
+ });
11464
+ } catch (err) {
11465
+ const msg = err instanceof Error ? err.message : String(err);
11466
+ console.error(`Build failed: ${msg}`);
11467
+ process.exit(1);
11468
+ }
11469
+ });
8641
11470
  program.command("login").description("Authenticate with the Tank registry via browser").action(async () => {
8642
11471
  try {
8643
11472
  await loginCommand();
@@ -8680,9 +11509,13 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
8680
11509
  process.exit(1);
8681
11510
  }
8682
11511
  });
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) => {
11512
+ 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
11513
  try {
8685
- if (name) await installCommand({
11514
+ if (name && isUrl(name)) await installFromUrl(name, {
11515
+ global: opts.global,
11516
+ yes: opts.yes
11517
+ });
11518
+ else if (name) await installCommand({
8686
11519
  name,
8687
11520
  versionRange,
8688
11521
  global: opts.global