@tankpkg/cli 0.0.0-nightly.20260415.9bc2c8a → 0.0.0-nightly.20260416.2d9a5bf

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,22 +1,25 @@
1
1
  #!/usr/bin/env node
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-CbsZyznz.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-DD91p4Iq.js";
3
3
  import { t as logger } from "../logger-BhULz3Uz.js";
4
4
  import { Command } from "commander";
5
5
  import chalk from "chalk";
6
- import fs from "node:fs";
7
- import os from "node:os";
8
- import path from "node:path";
6
+ import fs, { createWriteStream } from "node:fs";
7
+ import os, { tmpdir } from "node:os";
8
+ import path, { join } from "node:path";
9
9
  import { z } from "zod";
10
- import { claudeCodeAdapter, clineAdapter, compilePackage, cursorAdapter, normalizeDirectory, opencodeAdapter, rooCodeAdapter, windsurfAdapter } from "@internals/adapters";
10
+ import semver from "semver";
11
11
  import ora from "ora";
12
12
  import { confirm, input } from "@inquirer/prompts";
13
13
  import crypto$1 from "node:crypto";
14
- import semver from "semver";
15
14
  import { buildSkillKey, checkPermissionBudget, downloadAllParallel, extractSafely, getExtractDir as getExtractDir$1, getGlobalExtractDir, getResolvedNodesInOrder, parseLockKey as parseLockKey$2, parseVersionFromLockKey, readExtractedDependencies, resolveDependencyTree, verifyExtractedDependencies, writeLockfileWithResolvedGraph } from "@tankpkg/sdk";
15
+ import { createInterface } from "node:readline";
16
+ import { execSync, spawn } from "node:child_process";
17
+ import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
18
+ import { Readable } from "node:stream";
19
+ import { pipeline } from "node:stream/promises";
16
20
  import open from "open";
17
21
  import ignore from "ignore";
18
22
  import { create } from "tar";
19
- import { spawn } from "node:child_process";
20
23
  import { fileURLToPath } from "node:url";
21
24
  //#region \0rolldown/runtime.js
22
25
  var __defProp = Object.defineProperty;
@@ -262,10 +265,12 @@ const ruleIRSchema = z.object({
262
265
  extensions: extensionBagSchema
263
266
  }).strict();
264
267
  const mcpServerConfigSchema = z.object({
265
- command: z.string().min(1),
268
+ command: z.string().min(1).optional(),
266
269
  args: z.array(z.string()).optional(),
267
- env: z.record(z.string(), z.string()).optional()
268
- }).strict();
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\"");
269
274
  const toolIRSchema = z.object({
270
275
  kind: z.literal("tool"),
271
276
  name: z.string().min(1, "Tool name must not be empty"),
@@ -284,7 +289,7 @@ const atomIRSchema = z.discriminatedUnion("kind", [
284
289
  resourceIRSchema,
285
290
  promptIRSchema
286
291
  ]);
287
- z.object({
292
+ const packageIRSchema = z.object({
288
293
  name: z.string().min(1, "Name must not be empty").max(214, `Name must be 214 characters or fewer`).regex(NAME_PATTERN$1, "Name must be scoped (@org/name), lowercase alphanumeric and hyphens"),
289
294
  version: z.string().regex(SEMVER_PATTERN$1, "Version must be valid semver"),
290
295
  description: z.string().max(500).optional(),
@@ -308,11 +313,32 @@ const baseManifestFields = {
308
313
  };
309
314
  /** Legacy skills.json schema — strict, no atoms. Used for backward-compatible consumers. */
310
315
  const skillsJsonSchema = z.object(baseManifestFields).strict();
311
- z.object({
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({
312
322
  ...baseManifestFields,
313
323
  atoms: z.array(z.record(z.string(), z.unknown())).optional(),
314
324
  includes: z.array(z.string()).optional()
315
325
  }).strict();
326
+ const SKILL_SOURCES = [
327
+ "registry",
328
+ "github",
329
+ "clawhub",
330
+ "skills_sh",
331
+ "agentskills_il",
332
+ "npm",
333
+ "local"
334
+ ];
335
+ const SCAN_VERDICTS = [
336
+ "pass",
337
+ "pass_with_notes",
338
+ "flagged",
339
+ "fail",
340
+ "error"
341
+ ];
316
342
  const lockedSkillV1Schema = z.object({
317
343
  resolved: z.string().url(),
318
344
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -328,7 +354,10 @@ const lockedSkillSchema = z.object({
328
354
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
329
355
  permissions: permissionsSchema,
330
356
  audit_score: z.number().min(0).max(10).nullable(),
331
- dependencies: z.record(z.string(), z.string()).optional()
357
+ dependencies: z.record(z.string(), z.string()).optional(),
358
+ source: z.enum(SKILL_SOURCES).optional(),
359
+ scan_verdict: z.enum(SCAN_VERDICTS).optional(),
360
+ scanned_at: z.string().optional()
332
361
  });
333
362
  z.object({
334
363
  lockfileVersion: z.union([z.literal(1), z.literal(2)]),
@@ -433,7 +462,7 @@ function readLockfile$1(directory) {
433
462
  }
434
463
  //#endregion
435
464
  //#region src/commands/audit.ts
436
- function scoreColor$2(score) {
465
+ function scoreColor$3(score) {
437
466
  if (score >= 7) return chalk.green;
438
467
  if (score >= 4) return chalk.yellow;
439
468
  return chalk.red;
@@ -441,7 +470,7 @@ function scoreColor$2(score) {
441
470
  function formatScore(result) {
442
471
  if (result.error) return chalk.dim("error");
443
472
  if (result.score == null || result.status !== "completed") return chalk.dim("pending");
444
- return scoreColor$2(result.score)(result.score.toFixed(1));
473
+ return scoreColor$3(result.score)(result.score.toFixed(1));
445
474
  }
446
475
  function formatStatus(result) {
447
476
  if (result.error) return chalk.dim("error");
@@ -570,7 +599,1421 @@ async function auditCommand(options) {
570
599
  });
571
600
  }
572
601
  }
573
- 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(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(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(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
+ };
574
2017
  }
575
2018
  //#endregion
576
2019
  //#region src/commands/build.ts
@@ -1327,6 +2770,527 @@ function prepareAgentSkillDir(options) {
1327
2770
  return targetDir;
1328
2771
  }
1329
2772
  //#endregion
2773
+ //#region src/lib/scan-gate.ts
2774
+ /**
2775
+ * Security scan gate for `tank install <url>`.
2776
+ * Calls the public scan API and enforces verdicts.
2777
+ */
2778
+ function verdictColor$1(verdict) {
2779
+ switch (verdict) {
2780
+ case "pass": return chalk.green;
2781
+ case "pass_with_notes": return chalk.yellow;
2782
+ case "flagged": return chalk.hex("#FF8C00");
2783
+ case "fail": return chalk.red;
2784
+ case "error": return chalk.red;
2785
+ default: return chalk.white;
2786
+ }
2787
+ }
2788
+ function severityColor$1(severity) {
2789
+ switch (severity) {
2790
+ case "critical": return chalk.red;
2791
+ case "high": return chalk.hex("#FF8C00");
2792
+ case "medium": return chalk.yellow;
2793
+ case "low": return chalk.green;
2794
+ case "info": return chalk.blue;
2795
+ default: return chalk.white;
2796
+ }
2797
+ }
2798
+ function scoreColor$2(score) {
2799
+ if (score >= 7) return chalk.green;
2800
+ if (score >= 4) return chalk.yellow;
2801
+ return chalk.red;
2802
+ }
2803
+ async function promptUser(question) {
2804
+ const rl = createInterface({
2805
+ input: process.stdin,
2806
+ output: process.stdout
2807
+ });
2808
+ return new Promise((resolve) => {
2809
+ rl.question(question, (answer) => {
2810
+ rl.close();
2811
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
2812
+ });
2813
+ });
2814
+ }
2815
+ async function scanUrl(url, options) {
2816
+ const config = getConfig();
2817
+ const registryUrl = options?.registryUrl ?? config.registry;
2818
+ const token = options?.token ?? config.token;
2819
+ let res;
2820
+ try {
2821
+ const headers = {
2822
+ "Content-Type": "application/json",
2823
+ "User-Agent": USER_AGENT
2824
+ };
2825
+ if (token) headers.Authorization = `Bearer ${token}`;
2826
+ res = await fetch(`${registryUrl}/api/v1/scan`, {
2827
+ method: "POST",
2828
+ headers,
2829
+ body: JSON.stringify({ url }),
2830
+ signal: AbortSignal.timeout(65e3)
2831
+ });
2832
+ } catch (err) {
2833
+ return {
2834
+ success: false,
2835
+ verdict: "error",
2836
+ auditScore: null,
2837
+ findings: [],
2838
+ durationMs: null,
2839
+ error: `Network error: ${err instanceof Error ? err.message : String(err)}`
2840
+ };
2841
+ }
2842
+ if (!res.ok) {
2843
+ if (res.status === 429) return {
2844
+ success: false,
2845
+ verdict: "error",
2846
+ auditScore: null,
2847
+ findings: [],
2848
+ durationMs: null,
2849
+ 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."}`
2850
+ };
2851
+ if (res.status === 504) return {
2852
+ success: false,
2853
+ verdict: "error",
2854
+ auditScore: null,
2855
+ findings: [],
2856
+ durationMs: null,
2857
+ error: "Scan timed out (504). The skill may be too large or the scanner is overloaded."
2858
+ };
2859
+ return {
2860
+ success: false,
2861
+ verdict: "error",
2862
+ auditScore: null,
2863
+ findings: [],
2864
+ durationMs: null,
2865
+ error: (await res.json().catch(() => null))?.error ?? `HTTP ${res.status}: ${res.statusText}`
2866
+ };
2867
+ }
2868
+ const data = await res.json();
2869
+ const findings = data.findings.map((f) => ({
2870
+ severity: f.severity,
2871
+ type: f.type,
2872
+ description: f.description,
2873
+ ...f.location ? { location: f.location } : {}
2874
+ }));
2875
+ return {
2876
+ success: true,
2877
+ verdict: data.verdict,
2878
+ auditScore: data.audit_score ?? null,
2879
+ findings,
2880
+ durationMs: data.duration_ms ?? null
2881
+ };
2882
+ }
2883
+ function displayScanResults(result) {
2884
+ const verdictLabel = verdictColor$1(result.verdict)(result.verdict.toUpperCase());
2885
+ console.log("");
2886
+ console.log(chalk.bold("Security Scan Results"));
2887
+ console.log("");
2888
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
2889
+ if (result.auditScore !== null) {
2890
+ const scoreLabel = scoreColor$2(result.auditScore)(result.auditScore.toFixed(1));
2891
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
2892
+ }
2893
+ if (result.durationMs !== null) console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.durationMs / 1e3).toFixed(1)}s`);
2894
+ if (result.error) console.log(`${chalk.dim("Error:".padEnd(14))}${chalk.red(result.error)}`);
2895
+ if (result.findings.length > 0) {
2896
+ console.log("");
2897
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
2898
+ const bySeverity = {
2899
+ critical: [],
2900
+ high: [],
2901
+ medium: [],
2902
+ low: [],
2903
+ info: []
2904
+ };
2905
+ for (const f of result.findings) bySeverity[f.severity].push(f);
2906
+ for (const severity of [
2907
+ "critical",
2908
+ "high",
2909
+ "medium",
2910
+ "low",
2911
+ "info"
2912
+ ]) {
2913
+ const group = bySeverity[severity];
2914
+ if (group.length === 0) continue;
2915
+ console.log("");
2916
+ const label = severityColor$1(severity)(`${severity.toUpperCase()} (${group.length})`);
2917
+ console.log(` ${label}`);
2918
+ for (const f of group) {
2919
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
2920
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
2921
+ }
2922
+ }
2923
+ } else if (result.success) {
2924
+ console.log("");
2925
+ console.log(chalk.green("No findings. The skill looks secure."));
2926
+ }
2927
+ console.log("");
2928
+ }
2929
+ async function enforceVerdict(result, options) {
2930
+ switch (result.verdict) {
2931
+ case "pass":
2932
+ case "pass_with_notes": return { allowed: true };
2933
+ case "flagged": {
2934
+ if (options?.yes) return { allowed: true };
2935
+ const count = result.findings.length;
2936
+ if (await promptUser(chalk.yellow(`⚠ Security scan flagged ${count} issue${count === 1 ? "" : "s"}. Install anyway? (y/N) `))) return { allowed: true };
2937
+ return {
2938
+ allowed: false,
2939
+ reason: "User declined after security warnings"
2940
+ };
2941
+ }
2942
+ case "fail": return {
2943
+ allowed: false,
2944
+ reason: "Security scan failed with critical findings"
2945
+ };
2946
+ case "error": return {
2947
+ allowed: false,
2948
+ reason: `Security scan error: ${result.error ?? "unknown"}`
2949
+ };
2950
+ default: return {
2951
+ allowed: false,
2952
+ reason: `Unknown verdict: ${result.verdict}`
2953
+ };
2954
+ }
2955
+ }
2956
+ //#endregion
2957
+ //#region src/lib/url-fetcher.ts
2958
+ /**
2959
+ * Fetch skills from URLs for `tank install <url>`.
2960
+ * Routes GitHub (git clone), ClawHub (zip), skills.sh, and generic tarballs
2961
+ * to temp directories with cleanup-on-failure semantics.
2962
+ */
2963
+ const HOST_MAP = [
2964
+ [/github\.com/i, "github"],
2965
+ [/clawhub\.ai/i, "clawhub"],
2966
+ [/skills\.sh/i, "skills_sh"],
2967
+ [/agentskills\.co\.il/i, "agentskills_il"],
2968
+ [/registry\.npmjs\.org/i, "npm"]
2969
+ ];
2970
+ function detectSourceType(url) {
2971
+ for (const [pattern, sourceType] of HOST_MAP) if (pattern.test(url)) return sourceType;
2972
+ return "unknown";
2973
+ }
2974
+ /** Returns true if the input looks like a URL rather than a package name. */
2975
+ function isUrl(input) {
2976
+ if (input.startsWith("http://") || input.startsWith("https://")) return true;
2977
+ for (const [pattern] of HOST_MAP) if (pattern.test(input)) return true;
2978
+ return false;
2979
+ }
2980
+ /** Best-effort skill name extraction from a URL. */
2981
+ function inferSkillName(url) {
2982
+ try {
2983
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
2984
+ switch (detectSourceType(url)) {
2985
+ case "github": {
2986
+ if (segments.length < 2) return null;
2987
+ const treeIdx = segments.indexOf("tree");
2988
+ if (treeIdx !== -1 && segments.length > treeIdx + 2) return segments[segments.length - 1] ?? null;
2989
+ return segments[1] ?? null;
2990
+ }
2991
+ case "clawhub": return segments[1] ?? null;
2992
+ case "skills_sh": return segments[2] ?? segments[1] ?? null;
2993
+ case "agentskills_il": return segments[1] ?? null;
2994
+ case "npm": return segments[segments.length - 1] ?? null;
2995
+ default: return segments[segments.length - 1] ?? null;
2996
+ }
2997
+ } catch {
2998
+ return null;
2999
+ }
3000
+ }
3001
+ async function createTempDir() {
3002
+ return mkdtemp(join(tmpdir(), "tank-fetch-"));
3003
+ }
3004
+ async function cleanupDir(dir) {
3005
+ try {
3006
+ await rm(dir, {
3007
+ recursive: true,
3008
+ force: true
3009
+ });
3010
+ } catch {}
3011
+ }
3012
+ function ensureGitInstalled() {
3013
+ try {
3014
+ execSync("git --version", { stdio: "ignore" });
3015
+ } catch {
3016
+ throw new Error("Git is not installed. Install git and try again.");
3017
+ }
3018
+ }
3019
+ function gitCloneShallow(repoUrl, dest) {
3020
+ try {
3021
+ execSync(`git clone --depth 1 ${repoUrl} ${dest}`, {
3022
+ stdio: "pipe",
3023
+ timeout: 6e4
3024
+ });
3025
+ } catch (err) {
3026
+ const msg = err instanceof Error ? err.message : String(err);
3027
+ if (msg.includes("Repository not found") || msg.includes("not found")) throw new Error(`Repository not found: ${repoUrl}`);
3028
+ if (msg.includes("timed out") || msg.includes("ETIMEDOUT")) throw new Error(`Network timeout cloning ${repoUrl}`);
3029
+ throw new Error(`Git clone failed: ${msg}`);
3030
+ }
3031
+ }
3032
+ function gitRevParseHead(dir) {
3033
+ try {
3034
+ return execSync("git rev-parse HEAD", {
3035
+ cwd: dir,
3036
+ stdio: "pipe"
3037
+ }).toString().trim();
3038
+ } catch {
3039
+ return null;
3040
+ }
3041
+ }
3042
+ async function downloadFile(url, dest) {
3043
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
3044
+ if (!res.ok) {
3045
+ if (res.status === 404) throw new Error(`Not found: ${url}`);
3046
+ throw new Error(`HTTP ${res.status} downloading ${url}`);
3047
+ }
3048
+ if (!res.body) throw new Error(`Empty response body from ${url}`);
3049
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
3050
+ }
3051
+ async function extractZip(zipPath, dest) {
3052
+ try {
3053
+ execSync(`unzip -o -q "${zipPath}" -d "${dest}"`, {
3054
+ stdio: "pipe",
3055
+ timeout: 3e4
3056
+ });
3057
+ } catch (err) {
3058
+ const msg = err instanceof Error ? err.message : String(err);
3059
+ throw new Error(`Zip extraction failed: ${msg}`);
3060
+ }
3061
+ }
3062
+ async function extractTarball(tarPath, dest) {
3063
+ try {
3064
+ execSync(`tar xzf "${tarPath}" -C "${dest}"`, {
3065
+ stdio: "pipe",
3066
+ timeout: 3e4
3067
+ });
3068
+ } catch (err) {
3069
+ const msg = err instanceof Error ? err.message : String(err);
3070
+ throw new Error(`Tarball extraction failed: ${msg}`);
3071
+ }
3072
+ }
3073
+ function parseGitHubUrl(url) {
3074
+ try {
3075
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
3076
+ if (segments.length < 2) return null;
3077
+ const owner = segments[0];
3078
+ const repo = segments[1];
3079
+ let branch = null;
3080
+ let subpath = null;
3081
+ if (segments[2] === "tree" && segments.length > 3) {
3082
+ branch = segments[3];
3083
+ if (segments.length > 4) subpath = segments.slice(4).join("/");
3084
+ }
3085
+ return {
3086
+ owner,
3087
+ repo,
3088
+ branch,
3089
+ subpath
3090
+ };
3091
+ } catch {
3092
+ return null;
3093
+ }
3094
+ }
3095
+ async function fetchFromGitHub(url, tempDir) {
3096
+ ensureGitInstalled();
3097
+ const parts = parseGitHubUrl(url);
3098
+ if (!parts) throw new Error(`Invalid GitHub URL: ${url}`);
3099
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
3100
+ const cloneDest = join(tempDir, parts.repo);
3101
+ logger.info(`Cloning ${parts.owner}/${parts.repo}...`);
3102
+ gitCloneShallow(cloneUrl, cloneDest);
3103
+ if (parts.branch) try {
3104
+ execSync(`git checkout ${parts.branch}`, {
3105
+ cwd: cloneDest,
3106
+ stdio: "pipe",
3107
+ timeout: 1e4
3108
+ });
3109
+ } catch {}
3110
+ const commitSha = gitRevParseHead(cloneDest);
3111
+ let localPath = cloneDest;
3112
+ if (parts.subpath) {
3113
+ const subDir = join(cloneDest, parts.subpath);
3114
+ try {
3115
+ if ((await stat(subDir)).isDirectory()) localPath = subDir;
3116
+ } catch {
3117
+ throw new Error(`Subpath not found in repo: ${parts.subpath}`);
3118
+ }
3119
+ }
3120
+ return {
3121
+ localPath,
3122
+ sourceType: "github",
3123
+ sourceUrl: url,
3124
+ commitSha,
3125
+ inferredName: parts.subpath ? parts.subpath.split("/").pop() ?? parts.repo : parts.repo,
3126
+ cleanup: () => cleanupDir(tempDir)
3127
+ };
3128
+ }
3129
+ function parseClawHubUrl(url) {
3130
+ try {
3131
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
3132
+ if (segments.length < 2) return null;
3133
+ return {
3134
+ owner: segments[0],
3135
+ skillName: segments[1]
3136
+ };
3137
+ } catch {
3138
+ return null;
3139
+ }
3140
+ }
3141
+ async function fetchFromClawHub(url, tempDir) {
3142
+ const parts = parseClawHubUrl(url);
3143
+ if (!parts) throw new Error(`Invalid ClawHub URL: ${url}`);
3144
+ logger.info(`Fetching ${parts.owner}/${parts.skillName} from ClawHub...`);
3145
+ const pageUrl = url.startsWith("http") ? url : `https://${url}`;
3146
+ const pageRes = await fetch(pageUrl, { signal: AbortSignal.timeout(3e4) });
3147
+ if (!pageRes.ok) {
3148
+ if (pageRes.status === 404) throw new Error(`Skill not found on ClawHub: ${parts.skillName}`);
3149
+ throw new Error(`HTTP ${pageRes.status} fetching ClawHub page`);
3150
+ }
3151
+ const html = await pageRes.text();
3152
+ const downloadUrlMatch = html.match(/https?:\/\/[^\s"']+\.convex\.cloud[^\s"']*download[^\s"']*/i) ?? html.match(/https?:\/\/[^\s"']+\.zip/i);
3153
+ if (!downloadUrlMatch) throw new Error("Could not find download URL on ClawHub page. The skill may not have a downloadable archive.");
3154
+ const zipPath = join(tempDir, `${parts.skillName}.zip`);
3155
+ await downloadFile(downloadUrlMatch[0], zipPath);
3156
+ const extractDir = join(tempDir, parts.skillName);
3157
+ await mkdir(extractDir, { recursive: true });
3158
+ await extractZip(zipPath, extractDir);
3159
+ return {
3160
+ localPath: extractDir,
3161
+ sourceType: "clawhub",
3162
+ sourceUrl: url,
3163
+ commitSha: null,
3164
+ inferredName: parts.skillName,
3165
+ cleanup: () => cleanupDir(tempDir)
3166
+ };
3167
+ }
3168
+ function parseSkillsShUrl(url) {
3169
+ try {
3170
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
3171
+ if (segments.length < 2) return null;
3172
+ return {
3173
+ owner: segments[0],
3174
+ repo: segments[1],
3175
+ skillName: segments[2] ?? null
3176
+ };
3177
+ } catch {
3178
+ return null;
3179
+ }
3180
+ }
3181
+ async function fetchFromSkillsSh(url, tempDir) {
3182
+ ensureGitInstalled();
3183
+ const parts = parseSkillsShUrl(url);
3184
+ if (!parts) throw new Error(`Invalid skills.sh URL: ${url}`);
3185
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
3186
+ const cloneDest = join(tempDir, parts.repo);
3187
+ logger.info(`Cloning ${parts.owner}/${parts.repo} (via skills.sh)...`);
3188
+ gitCloneShallow(cloneUrl, cloneDest);
3189
+ const commitSha = gitRevParseHead(cloneDest);
3190
+ let localPath = cloneDest;
3191
+ const inferredName = parts.skillName ?? parts.repo;
3192
+ if (parts.skillName) {
3193
+ const candidates = [
3194
+ join(cloneDest, "skills", parts.skillName),
3195
+ join(cloneDest, "src", "skills", parts.skillName),
3196
+ join(cloneDest, parts.skillName)
3197
+ ];
3198
+ let found = false;
3199
+ for (const candidate of candidates) try {
3200
+ if ((await stat(candidate)).isDirectory()) {
3201
+ localPath = candidate;
3202
+ found = true;
3203
+ break;
3204
+ }
3205
+ } catch {}
3206
+ if (!found) throw new Error(`Skill "${parts.skillName}" not found in ${parts.repo}. Searched: skills/${parts.skillName}, src/skills/${parts.skillName}, ${parts.skillName}`);
3207
+ }
3208
+ return {
3209
+ localPath,
3210
+ sourceType: "skills_sh",
3211
+ sourceUrl: url,
3212
+ commitSha,
3213
+ inferredName,
3214
+ cleanup: () => cleanupDir(tempDir)
3215
+ };
3216
+ }
3217
+ async function fetchFromGenericUrl(url, tempDir) {
3218
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
3219
+ logger.info(`Downloading from ${fullUrl}...`);
3220
+ const isTarball = /\.(tar\.gz|tgz)(\?|$)/i.test(fullUrl);
3221
+ const isZip = /\.zip(\?|$)/i.test(fullUrl);
3222
+ if (!isTarball && !isZip) {
3223
+ const archivePath = join(tempDir, "skill.tar.gz");
3224
+ await downloadFile(fullUrl, archivePath);
3225
+ const extractDir = join(tempDir, "skill");
3226
+ await mkdir(extractDir, { recursive: true });
3227
+ try {
3228
+ await extractTarball(archivePath, extractDir);
3229
+ } catch {
3230
+ try {
3231
+ await extractZip(archivePath, extractDir);
3232
+ } catch {
3233
+ throw new Error(`Failed to extract archive from ${fullUrl}. Expected .tar.gz or .zip format.`);
3234
+ }
3235
+ }
3236
+ return {
3237
+ localPath: extractDir,
3238
+ sourceType: detectSourceType(url),
3239
+ sourceUrl: url,
3240
+ commitSha: null,
3241
+ inferredName: inferSkillName(url),
3242
+ cleanup: () => cleanupDir(tempDir)
3243
+ };
3244
+ }
3245
+ const archivePath = join(tempDir, `skill.${isTarball ? "tar.gz" : "zip"}`);
3246
+ await downloadFile(fullUrl, archivePath);
3247
+ const extractDir = join(tempDir, "skill");
3248
+ await mkdir(extractDir, { recursive: true });
3249
+ if (isTarball) await extractTarball(archivePath, extractDir);
3250
+ else await extractZip(archivePath, extractDir);
3251
+ return {
3252
+ localPath: extractDir,
3253
+ sourceType: detectSourceType(url),
3254
+ sourceUrl: url,
3255
+ commitSha: null,
3256
+ inferredName: inferSkillName(url),
3257
+ cleanup: () => cleanupDir(tempDir)
3258
+ };
3259
+ }
3260
+ /** Fetch a skill from a URL to a local temp directory. */
3261
+ async function fetchFromUrl(url) {
3262
+ const sourceType = detectSourceType(url);
3263
+ let tempDir = null;
3264
+ try {
3265
+ tempDir = await createTempDir();
3266
+ let result;
3267
+ switch (sourceType) {
3268
+ case "github":
3269
+ result = await fetchFromGitHub(url, tempDir);
3270
+ break;
3271
+ case "clawhub":
3272
+ result = await fetchFromClawHub(url, tempDir);
3273
+ break;
3274
+ case "skills_sh":
3275
+ result = await fetchFromSkillsSh(url, tempDir);
3276
+ break;
3277
+ default:
3278
+ result = await fetchFromGenericUrl(url, tempDir);
3279
+ break;
3280
+ }
3281
+ return {
3282
+ success: true,
3283
+ ...result
3284
+ };
3285
+ } catch (err) {
3286
+ if (tempDir) await cleanupDir(tempDir);
3287
+ return {
3288
+ success: false,
3289
+ error: err instanceof Error ? err.message : String(err)
3290
+ };
3291
+ }
3292
+ }
3293
+ //#endregion
1330
3294
  //#region src/commands/install.ts
1331
3295
  function createRegistryFetcher(registry, headers) {
1332
3296
  const versionsCache = /* @__PURE__ */ new Map();
@@ -1755,6 +3719,149 @@ async function installAll(options) {
1755
3719
  function buildIntegrity(buffer) {
1756
3720
  return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
1757
3721
  }
3722
+ /** Map url-fetcher source types to lockfile SkillSource values. */
3723
+ function mapSourceType(urlSourceType) {
3724
+ switch (urlSourceType) {
3725
+ case "github": return "github";
3726
+ case "clawhub": return "clawhub";
3727
+ case "skills_sh": return "skills_sh";
3728
+ case "agentskills_il": return "agentskills_il";
3729
+ case "npm": return "npm";
3730
+ default: return "local";
3731
+ }
3732
+ }
3733
+ /** Compute SHA-512 integrity hash over all files in a directory (sorted by path). */
3734
+ function computeDirectoryIntegrity(dir) {
3735
+ const files = [];
3736
+ function walkDir(current) {
3737
+ const entries = fs.readdirSync(current, { withFileTypes: true });
3738
+ for (const entry of entries) {
3739
+ if (entry.name === ".git") continue;
3740
+ const fullPath = path.join(current, entry.name);
3741
+ if (entry.isDirectory()) walkDir(fullPath);
3742
+ else if (entry.isFile()) files.push(fullPath);
3743
+ }
3744
+ }
3745
+ walkDir(dir);
3746
+ files.sort();
3747
+ const hash = crypto$1.createHash("sha512");
3748
+ for (const file of files) hash.update(fs.readFileSync(file));
3749
+ return `sha512-${hash.digest("base64")}`;
3750
+ }
3751
+ /** Read a manifest (tank.json or skills.json) from a directory, returning null if missing/invalid. */
3752
+ function readManifestFromDir(dir) {
3753
+ for (const filename of ["tank.json", "skills.json"]) {
3754
+ const manifestPath = path.join(dir, filename);
3755
+ if (fs.existsSync(manifestPath)) try {
3756
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
3757
+ } catch {
3758
+ return null;
3759
+ }
3760
+ }
3761
+ return null;
3762
+ }
3763
+ async function installFromUrl(url, options) {
3764
+ const { global = false, yes = false } = options;
3765
+ const resolvedHome = os.homedir();
3766
+ const directory = process.cwd();
3767
+ const spinner = ora(`Fetching from URL...`).start();
3768
+ let fetchResult;
3769
+ try {
3770
+ const output = await fetchFromUrl(url);
3771
+ if (!output.success) {
3772
+ spinner.fail("Fetch failed");
3773
+ logger.error(output.error);
3774
+ process.exit(1);
3775
+ }
3776
+ fetchResult = output;
3777
+ spinner.text = "Scanning for security issues...";
3778
+ } catch (err) {
3779
+ spinner.fail("Fetch failed");
3780
+ const msg = err instanceof Error ? err.message : String(err);
3781
+ logger.error(msg);
3782
+ process.exit(1);
3783
+ }
3784
+ try {
3785
+ const scanResult = await scanUrl(url);
3786
+ displayScanResults(scanResult);
3787
+ const enforcement = await enforceVerdict(scanResult, { yes });
3788
+ if (!enforcement.allowed) {
3789
+ spinner.fail(enforcement.reason ?? "Install blocked by security scan");
3790
+ await fetchResult.cleanup();
3791
+ process.exit(1);
3792
+ }
3793
+ const skillMdPath = path.join(fetchResult.localPath, "SKILL.md");
3794
+ if (!fs.existsSync(skillMdPath)) throw new Error("No SKILL.md found. This doesn't appear to be a valid skill.");
3795
+ const existingManifest = readManifestFromDir(fetchResult.localPath);
3796
+ const skillName = existingManifest?.name ?? fetchResult.inferredName ?? path.basename(fetchResult.localPath);
3797
+ const skillVersion = existingManifest?.version ?? "0.0.0";
3798
+ const skillDescription = existingManifest?.description ?? "";
3799
+ if (!existingManifest) {
3800
+ const generatedManifest = {
3801
+ name: skillName,
3802
+ version: skillVersion,
3803
+ description: skillDescription
3804
+ };
3805
+ fs.writeFileSync(path.join(fetchResult.localPath, "tank.json"), `${JSON.stringify(generatedManifest, null, 2)}\n`);
3806
+ logger.info("Generated tank.json (no manifest found in source)");
3807
+ }
3808
+ spinner.text = `Installing ${skillName}...`;
3809
+ const installDir = global ? path.join(resolvedHome, ".tank", "skills", skillName) : path.join(directory, ".tank", "skills", skillName);
3810
+ if (fs.existsSync(installDir)) fs.rmSync(installDir, {
3811
+ recursive: true,
3812
+ force: true
3813
+ });
3814
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
3815
+ fs.cpSync(fetchResult.localPath, installDir, { recursive: true });
3816
+ const integrity = computeDirectoryIntegrity(installDir);
3817
+ const resolvedLock = resolveLockfilePath(global ? path.join(resolvedHome, ".tank") : directory);
3818
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
3819
+ const lock = readLockOrFresh(lockPath);
3820
+ const lockKey = `${skillName}@${skillVersion}`;
3821
+ const skillPermissions = existingManifest?.permissions ?? {};
3822
+ lock.skills[lockKey] = {
3823
+ resolved: url.startsWith("http") ? url : `https://${url}`,
3824
+ integrity,
3825
+ permissions: skillPermissions,
3826
+ audit_score: scanResult.auditScore ?? null,
3827
+ source: mapSourceType(fetchResult.sourceType),
3828
+ scan_verdict: scanResult.verdict,
3829
+ scanned_at: (/* @__PURE__ */ new Date()).toISOString()
3830
+ };
3831
+ lock.lockfileVersion = 2;
3832
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
3833
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
3834
+ const linkedAgents = [];
3835
+ try {
3836
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
3837
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
3838
+ const linkResult = linkSkillToAgents({
3839
+ skillName,
3840
+ sourceDir: prepareAgentSkillDir({
3841
+ skillName,
3842
+ extractDir: installDir,
3843
+ agentSkillsBaseDir,
3844
+ description: skillDescription
3845
+ }),
3846
+ linksDir,
3847
+ source: global ? "global" : "local"
3848
+ });
3849
+ linkedAgents.push(...linkResult.linked);
3850
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
3851
+ } catch {
3852
+ logger.warn("Agent linking skipped (non-fatal)");
3853
+ }
3854
+ if (detectInstalledAgents().length === 0) logger.warn("No agents detected for linking");
3855
+ await fetchResult.cleanup();
3856
+ spinner.succeed(`Installed ${skillName} from ${fetchResult.sourceType}`);
3857
+ if (linkedAgents.length > 0) logger.info(`Linked to ${linkedAgents.join(", ")}`);
3858
+ logger.info(`Locked (${integrity.slice(0, 20)}..., scanned ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]})`);
3859
+ } catch (err) {
3860
+ await fetchResult.cleanup();
3861
+ spinner.fail("Install failed");
3862
+ throw err;
3863
+ }
3864
+ }
1758
3865
  //#endregion
1759
3866
  //#region src/commands/link.ts
1760
3867
  async function linkCommand(options = {}) {
@@ -2125,8 +4232,7 @@ const IGNORE_FILES = [".tankignore", ".gitignore"];
2125
4232
  * Pack a skill directory into a .tgz tarball with integrity hashing.
2126
4233
  *
2127
4234
  * Validates:
2128
- * - skills.json exists and is valid
2129
- * - SKILL.md exists
4235
+ * - tank.json (or skills.json) exists and is valid
2130
4236
  * - No symlinks or hardlinks
2131
4237
  * - No path traversal (.. components)
2132
4238
  * - No absolute paths
@@ -2156,18 +4262,23 @@ async function pack(directory) {
2156
4262
  } catch {
2157
4263
  throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
2158
4264
  }
2159
- const validation = skillsJsonSchema.safeParse(parsed);
4265
+ const validation = publishManifestSchema.safeParse(parsed);
2160
4266
  if (!validation.success) {
2161
4267
  const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
2162
4268
  throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
2163
4269
  }
4270
+ let readmeContent = "";
2164
4271
  const skillMdPath = path.join(absDir, "SKILL.md");
2165
- if (!fs.existsSync(skillMdPath)) throw new Error("Missing required file: SKILL.md");
2166
- let readmeContent;
2167
- try {
4272
+ const readmeMdPath = path.join(absDir, "README.md");
4273
+ if (fs.existsSync(skillMdPath)) try {
2168
4274
  readmeContent = fs.readFileSync(skillMdPath, "utf-8");
2169
4275
  } catch {
2170
- throw new Error("Failed to read SKILL.md");
4276
+ readmeContent = "";
4277
+ }
4278
+ else if (fs.existsSync(readmeMdPath)) try {
4279
+ readmeContent = fs.readFileSync(readmeMdPath, "utf-8");
4280
+ } catch {
4281
+ readmeContent = "";
2171
4282
  }
2172
4283
  const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
2173
4284
  if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
@@ -2269,9 +4380,12 @@ function collectFiles(baseDir, currentDir, ig) {
2269
4380
  if (relativePath.split(path.sep).includes("..")) throw new Error(`Path traversal detected: "${relativePath}" contains ".." component`);
2270
4381
  if (path.isAbsolute(relativePath)) throw new Error(`Absolute path detected: "${relativePath}"`);
2271
4382
  const lstatResult = fs.lstatSync(fullPath);
2272
- if (lstatResult.isSymbolicLink()) throw new Error(`Symlink detected: "${relativePath}" symlinks are not allowed in skill packages`);
2273
- const pathForIgnore = lstatResult.isDirectory() ? `${relativePath}/` : relativePath;
4383
+ const pathForIgnore = lstatResult.isDirectory() || lstatResult.isSymbolicLink() && fs.statSync(fullPath).isDirectory() ? `${relativePath}/` : relativePath;
2274
4384
  if (ig.ignores(pathForIgnore)) continue;
4385
+ if (lstatResult.isSymbolicLink()) {
4386
+ console.warn(`⚠ Skipping symlink: ${relativePath}`);
4387
+ continue;
4388
+ }
2275
4389
  if (lstatResult.isDirectory()) {
2276
4390
  const subFiles = collectFiles(baseDir, fullPath, ig);
2277
4391
  files.push(...subFiles);
@@ -3399,9 +5513,13 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
3399
5513
  process.exit(1);
3400
5514
  }
3401
5515
  });
3402
- 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) => {
5516
+ 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) => {
3403
5517
  try {
3404
- if (name) await installCommand({
5518
+ if (name && isUrl(name)) await installFromUrl(name, {
5519
+ global: opts.global,
5520
+ yes: opts.yes
5521
+ });
5522
+ else if (name) await installCommand({
3405
5523
  name,
3406
5524
  versionRange,
3407
5525
  global: opts.global