@tankpkg/cli 0.0.0-nightly.20260416.9bc2c8a → 0.0.0-nightly.20260416.c657a69

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-DMCCDELn.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-CsLqvzf2.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;
@@ -284,7 +287,7 @@ const atomIRSchema = z.discriminatedUnion("kind", [
284
287
  resourceIRSchema,
285
288
  promptIRSchema
286
289
  ]);
287
- z.object({
290
+ const packageIRSchema = z.object({
288
291
  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
292
  version: z.string().regex(SEMVER_PATTERN$1, "Version must be valid semver"),
290
293
  description: z.string().max(500).optional(),
@@ -308,11 +311,32 @@ const baseManifestFields = {
308
311
  };
309
312
  /** Legacy skills.json schema — strict, no atoms. Used for backward-compatible consumers. */
310
313
  const skillsJsonSchema = z.object(baseManifestFields).strict();
311
- z.object({
314
+ /**
315
+ * Publish manifest schema — accepts both legacy skills.json AND atom-enriched tank.json.
316
+ * The `atoms` and `includes` fields are passed through as opaque JSON arrays,
317
+ * validated only at surface level. Full atom IR validation happens at build time.
318
+ */
319
+ const publishManifestSchema = z.object({
312
320
  ...baseManifestFields,
313
321
  atoms: z.array(z.record(z.string(), z.unknown())).optional(),
314
322
  includes: z.array(z.string()).optional()
315
323
  }).strict();
324
+ const SKILL_SOURCES = [
325
+ "registry",
326
+ "github",
327
+ "clawhub",
328
+ "skills_sh",
329
+ "agentskills_il",
330
+ "npm",
331
+ "local"
332
+ ];
333
+ const SCAN_VERDICTS = [
334
+ "pass",
335
+ "pass_with_notes",
336
+ "flagged",
337
+ "fail",
338
+ "error"
339
+ ];
316
340
  const lockedSkillV1Schema = z.object({
317
341
  resolved: z.string().url(),
318
342
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
@@ -328,7 +352,10 @@ const lockedSkillSchema = z.object({
328
352
  integrity: z.string().regex(/^sha512-/, "Integrity must start with sha512-"),
329
353
  permissions: permissionsSchema,
330
354
  audit_score: z.number().min(0).max(10).nullable(),
331
- dependencies: z.record(z.string(), z.string()).optional()
355
+ dependencies: z.record(z.string(), z.string()).optional(),
356
+ source: z.enum(SKILL_SOURCES).optional(),
357
+ scan_verdict: z.enum(SCAN_VERDICTS).optional(),
358
+ scanned_at: z.string().optional()
332
359
  });
333
360
  z.object({
334
361
  lockfileVersion: z.union([z.literal(1), z.literal(2)]),
@@ -433,7 +460,7 @@ function readLockfile$1(directory) {
433
460
  }
434
461
  //#endregion
435
462
  //#region src/commands/audit.ts
436
- function scoreColor$2(score) {
463
+ function scoreColor$3(score) {
437
464
  if (score >= 7) return chalk.green;
438
465
  if (score >= 4) return chalk.yellow;
439
466
  return chalk.red;
@@ -441,7 +468,7 @@ function scoreColor$2(score) {
441
468
  function formatScore(result) {
442
469
  if (result.error) return chalk.dim("error");
443
470
  if (result.score == null || result.status !== "completed") return chalk.dim("pending");
444
- return scoreColor$2(result.score)(result.score.toFixed(1));
471
+ return scoreColor$3(result.score)(result.score.toFixed(1));
445
472
  }
446
473
  function formatStatus(result) {
447
474
  if (result.error) return chalk.dim("error");
@@ -570,7 +597,1414 @@ async function auditCommand(options) {
570
597
  });
571
598
  }
572
599
  }
573
- displayTable(results);
600
+ displayTable(results);
601
+ }
602
+ //#endregion
603
+ //#region ../adapters/dist/index.mjs
604
+ function emitInstruction$5(atom) {
605
+ const globs = atom.globs?.length ? atom.globs : void 0;
606
+ if (globs) {
607
+ const frontmatter = `---\nglobs: ${JSON.stringify(globs)}\n---\n`;
608
+ return {
609
+ files: [{
610
+ path: `.claude/rules/${slugify$5(atom.content)}.md`,
611
+ content: `${frontmatter}\n{file:${atom.content}}`
612
+ }],
613
+ warnings: []
614
+ };
615
+ }
616
+ return {
617
+ files: [{
618
+ path: `.claude/rules/${slugify$5(atom.content)}.md`,
619
+ content: `{file:${atom.content}}`
620
+ }],
621
+ warnings: []
622
+ };
623
+ }
624
+ function emitHook$5(atom) {
625
+ const ccEvent = {
626
+ "pre-tool-use": "PreToolUse",
627
+ "post-tool-use": "PostToolUse",
628
+ "pre-stop": "Stop",
629
+ "session-created": "SessionStart",
630
+ "session-idle": "Notification",
631
+ "task-start": "SessionStart",
632
+ "task-complete": "TaskCompleted",
633
+ "task-cancel": "SessionEnd",
634
+ "pre-user-prompt": "UserPromptSubmit",
635
+ "pre-context-compact": "PreCompact",
636
+ "post-context-compact": "PostCompact",
637
+ "post-response": "Notification",
638
+ "subagent-start": "SubagentStart",
639
+ "subagent-complete": "SubagentStop",
640
+ "permission-asked": "PermissionRequest",
641
+ "permission-replied": "PermissionDenied",
642
+ "file-edited": "FileChanged",
643
+ "pre-file-read": "PreToolUse",
644
+ "post-file-read": "PostToolUse",
645
+ "pre-file-write": "PreToolUse",
646
+ "post-file-write": "PostToolUse",
647
+ "pre-command": "PreToolUse",
648
+ "post-command": "PostToolUse",
649
+ "pre-mcp-tool-use": "PreToolUse",
650
+ "post-mcp-tool-use": "PostToolUse",
651
+ "system-prompt-transform": "InstructionsLoaded",
652
+ "message-updated": "Notification",
653
+ "lsp-diagnostics": "Notification"
654
+ }[atom.event] ?? "Notification";
655
+ const name = atom.name ?? `hook-${atom.event}`;
656
+ const matcher = atom.match ?? void 0;
657
+ const hookEntry = { type: "command" };
658
+ if (atom.handler.type === "js") hookEntry.command = `node "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}.mjs"`;
659
+ else {
660
+ const actions = atom.handler.actions;
661
+ const script = buildDslShellScript(name, actions);
662
+ hookEntry.command = `bash "$CLAUDE_PROJECT_DIR/.claude/hooks/${name}.sh"`;
663
+ const files = [{
664
+ path: `.claude/hooks/${name}.sh`,
665
+ content: script
666
+ }];
667
+ const settingsFragment = buildSettingsFragment(ccEvent, matcher, hookEntry);
668
+ files.push({
669
+ path: `.claude/settings.json`,
670
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
671
+ });
672
+ return {
673
+ files,
674
+ warnings: []
675
+ };
676
+ }
677
+ const jsWrapper = buildJsWrapper(name, atom);
678
+ const settingsFragment = buildSettingsFragment(ccEvent, matcher, hookEntry);
679
+ return {
680
+ files: [{
681
+ path: `.claude/hooks/${name}.mjs`,
682
+ content: jsWrapper
683
+ }, {
684
+ path: `.claude/settings.json`,
685
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
686
+ }],
687
+ warnings: []
688
+ };
689
+ }
690
+ function emitAgent$5(atom) {
691
+ const tools = atom.tools ?? [];
692
+ const toolsSection = tools.length ? `\n## Tools\n\n${tools.map((t) => `- ${t}`).join("\n")}` : "";
693
+ const readonlySection = atom.readonly ? "\n\n## Permissions\n\nThis agent is read-only. Do not modify files." : "";
694
+ const md = `# ${atom.name}\n\n${atom.role}${toolsSection}${readonlySection}\n`;
695
+ return {
696
+ files: [{
697
+ path: `.claude/agents/${atom.name}.md`,
698
+ content: md
699
+ }],
700
+ warnings: []
701
+ };
702
+ }
703
+ function emitTool$5(atom) {
704
+ if (!atom.mcp) return {
705
+ files: [],
706
+ warnings: [{
707
+ level: "skipped",
708
+ atomKind: "tool",
709
+ message: `Tool "${atom.name}" has no MCP config`
710
+ }]
711
+ };
712
+ const mcpConfig = { mcpServers: { [atom.name]: {
713
+ command: atom.mcp.command,
714
+ args: atom.mcp.args ?? [],
715
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
716
+ } } };
717
+ return {
718
+ files: [{
719
+ path: ".mcp.json",
720
+ content: JSON.stringify(mcpConfig, null, 2)
721
+ }],
722
+ warnings: []
723
+ };
724
+ }
725
+ function emitRule$5(atom) {
726
+ const ccEvent = atom.event === "pre-tool-use" ? "PreToolUse" : atom.event === "pre-stop" ? "Stop" : "PreToolUse";
727
+ if (atom.policy === "block") {
728
+ const denyPattern = atom.match ? `Bash(${atom.match}*)` : void 0;
729
+ if (denyPattern) return {
730
+ files: [{
731
+ path: ".claude/settings.json",
732
+ content: JSON.stringify({ permissions: { deny: [denyPattern] } }, null, 2)
733
+ }],
734
+ warnings: []
735
+ };
736
+ }
737
+ const hookEntry = {
738
+ type: "command",
739
+ command: `echo '${atom.reason ?? "Rule triggered"}' >&2 && exit ${atom.policy === "block" ? "2" : "0"}`
740
+ };
741
+ const settingsFragment = buildSettingsFragment(ccEvent, atom.match ?? void 0, hookEntry);
742
+ return {
743
+ files: [{
744
+ path: ".claude/settings.json",
745
+ content: JSON.stringify({ hooks: settingsFragment }, null, 2)
746
+ }],
747
+ warnings: []
748
+ };
749
+ }
750
+ function emitResource$5(atom) {
751
+ return {
752
+ files: [],
753
+ warnings: [{
754
+ level: "degraded",
755
+ atomKind: "resource",
756
+ message: `Claude Code uses CLAUDE.md @import for resources — "${atom.name ?? atom.uri}" registered as instruction`
757
+ }]
758
+ };
759
+ }
760
+ function emitPrompt$5(atom) {
761
+ const md = atom.description ? `${atom.description}\n\n{file:${atom.template}}` : `{file:${atom.template}}`;
762
+ return {
763
+ files: [{
764
+ path: `.claude/commands/${atom.name}.md`,
765
+ content: md
766
+ }],
767
+ warnings: []
768
+ };
769
+ }
770
+ function slugify$5(s) {
771
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
772
+ }
773
+ function buildSettingsFragment(event, matcher, hookEntry) {
774
+ return { [event]: [{
775
+ ...matcher ? { matcher } : {},
776
+ hooks: [hookEntry]
777
+ }] };
778
+ }
779
+ function buildDslShellScript(name, actions) {
780
+ return `#!/usr/bin/env bash
781
+ set -euo pipefail
782
+ INPUT=$(cat)
783
+ ${actions.map((a) => {
784
+ if (a.action === "block" && a.match) return `if echo "$INPUT" | grep -q '${a.match}'; then
785
+ echo '{"decision":"block","reason":"${a.reason ?? `Blocked: ${a.match}`}"}'
786
+ exit 2
787
+ fi`;
788
+ return null;
789
+ }).filter(Boolean).join("\n")}
790
+ exit 0
791
+ `;
792
+ }
793
+ function buildJsWrapper(name, _atom) {
794
+ return `import { readFileSync } from "node:fs";
795
+ import { execSync } from "node:child_process";
796
+
797
+ const input = JSON.parse(readFileSync("/dev/stdin", "utf-8"));
798
+
799
+ const CODE_EXTS = new Set([".ts",".tsx",".js",".jsx",".py",".go",".rs",".java",".rb",".c",".cpp",".h",".cs",".swift",".kt",".sh"]);
800
+ const EXCLUDED = [".opencode/",".cursor/",".claude/",".windsurf/",".clinerules/",".roo/","node_modules/",".git/"];
801
+
802
+ function getChangedCodeFiles() {
803
+ try {
804
+ const status = execSync("git status --porcelain -uall 2>/dev/null", { encoding: "utf-8" }).trim();
805
+ if (!status) return [];
806
+ const files = status.split("\\n").map(l => l.slice(3).trim()).filter(Boolean);
807
+ return files.filter(f => {
808
+ if (EXCLUDED.some(e => f.startsWith(e))) return false;
809
+ const ext = f.slice(f.lastIndexOf("."));
810
+ return CODE_EXTS.has(ext);
811
+ });
812
+ } catch { return []; }
813
+ }
814
+
815
+ const codeFiles = getChangedCodeFiles();
816
+ if (codeFiles.length === 0) process.exit(0);
817
+
818
+ const reason = "Quality gate: " + codeFiles.length + " code file(s) modified (" + codeFiles.join(", ") + "). Review for critical/high issues before completing.";
819
+ process.stdout.write(JSON.stringify({ decision: "block", reason }));
820
+ process.exit(0);
821
+ `;
822
+ }
823
+ const claudeCodeAdapter = {
824
+ name: "claude-code",
825
+ supportedRange: ">=1.0.0",
826
+ capabilities: {
827
+ instruction: "full",
828
+ hook: "full",
829
+ tool: "full",
830
+ agent: "full",
831
+ rule: "full",
832
+ resource: "degraded",
833
+ prompt: "full"
834
+ },
835
+ compileAtom(atom) {
836
+ const a = atom;
837
+ switch (a.kind) {
838
+ case "instruction": return emitInstruction$5(a);
839
+ case "hook": return emitHook$5(a);
840
+ case "agent": return emitAgent$5(a);
841
+ case "tool": return emitTool$5(a);
842
+ case "rule": return emitRule$5(a);
843
+ case "resource": return emitResource$5(a);
844
+ case "prompt": return emitPrompt$5(a);
845
+ default: return {
846
+ files: [],
847
+ warnings: [{
848
+ level: "skipped",
849
+ atomKind: "unknown",
850
+ message: "Unknown atom kind"
851
+ }]
852
+ };
853
+ }
854
+ }
855
+ };
856
+ function emitInstruction$4(atom) {
857
+ return {
858
+ files: [{
859
+ path: `.clinerules/${slugify$4(atom.content)}.md`,
860
+ content: `{file:${atom.content}}`
861
+ }],
862
+ warnings: []
863
+ };
864
+ }
865
+ function emitHook$4(atom) {
866
+ if (!{
867
+ "pre-tool-use": "PreToolUse",
868
+ "post-tool-use": "PostToolUse",
869
+ "task-start": "TaskStart",
870
+ "task-resume": "TaskResume",
871
+ "task-complete": "TaskComplete",
872
+ "task-cancel": "TaskCancel",
873
+ "pre-user-prompt": "UserPromptSubmit",
874
+ "pre-context-compact": "PreCompact",
875
+ "pre-stop": "TaskComplete"
876
+ }[atom.event]) return {
877
+ files: [],
878
+ warnings: [{
879
+ level: "degraded",
880
+ atomKind: "hook",
881
+ message: `Cline does not support event "${atom.event}" — skipped`
882
+ }]
883
+ };
884
+ const name = atom.name ?? `hook-${atom.event}`;
885
+ if (atom.handler.type === "js") {
886
+ 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`;
887
+ return {
888
+ files: [{
889
+ path: `.clinerules/hooks/${name}.mjs`,
890
+ content: wrapper
891
+ }],
892
+ warnings: []
893
+ };
894
+ }
895
+ const script = buildDslScript$2(atom.handler.actions);
896
+ return {
897
+ files: [{
898
+ path: `.clinerules/hooks/${name}.sh`,
899
+ content: script
900
+ }],
901
+ warnings: []
902
+ };
903
+ }
904
+ function emitAgent$4(atom) {
905
+ return {
906
+ files: [],
907
+ warnings: [{
908
+ level: "degraded",
909
+ atomKind: "agent",
910
+ message: `Cline only has Plan/Act modes — agent "${atom.name}" compiled as instruction`
911
+ }]
912
+ };
913
+ }
914
+ function emitTool$4(atom) {
915
+ if (!atom.mcp) return {
916
+ files: [],
917
+ warnings: [{
918
+ level: "skipped",
919
+ atomKind: "tool",
920
+ message: `Tool "${atom.name}" has no MCP config`
921
+ }]
922
+ };
923
+ const config = { mcpServers: { [atom.name]: {
924
+ command: atom.mcp.command,
925
+ args: atom.mcp.args ?? [],
926
+ disabled: false,
927
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
928
+ } } };
929
+ return {
930
+ files: [{
931
+ path: ".vscode/cline_mcp_settings.json",
932
+ content: JSON.stringify(config, null, 2)
933
+ }],
934
+ warnings: []
935
+ };
936
+ }
937
+ function emitRule$4(atom) {
938
+ const content = `## Rule: ${atom.name ?? atom.event}\n\n- Policy: ${atom.policy}\n- Reason: ${atom.reason ?? "No reason specified"}\n`;
939
+ return {
940
+ files: [{
941
+ path: `.clinerules/rule-${slugify$4(atom.name ?? atom.event)}.md`,
942
+ content
943
+ }],
944
+ warnings: [{
945
+ level: "degraded",
946
+ atomKind: "rule",
947
+ message: "Cline rules are soft guidance — use hooks for enforcement"
948
+ }]
949
+ };
950
+ }
951
+ function emitResource$4(atom) {
952
+ return {
953
+ files: [],
954
+ warnings: [{
955
+ level: "degraded",
956
+ atomKind: "resource",
957
+ message: `Cline MCP resources supported — "${atom.name ?? atom.uri}" requires MCP server registration`
958
+ }]
959
+ };
960
+ }
961
+ function emitPrompt$4(atom) {
962
+ return {
963
+ files: [{
964
+ path: `.clinerules/skills/${atom.name}/SKILL.md`,
965
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
966
+ }],
967
+ warnings: []
968
+ };
969
+ }
970
+ function slugify$4(s) {
971
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
972
+ }
973
+ function buildDslScript$2(actions) {
974
+ 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`;
975
+ }
976
+ const clineAdapter = {
977
+ name: "cline",
978
+ supportedRange: ">=3.0.0",
979
+ capabilities: {
980
+ instruction: "full",
981
+ hook: "full",
982
+ tool: "full",
983
+ agent: "degraded",
984
+ rule: "degraded",
985
+ resource: "degraded",
986
+ prompt: "full"
987
+ },
988
+ compileAtom(atom) {
989
+ const a = atom;
990
+ switch (a.kind) {
991
+ case "instruction": return emitInstruction$4(a);
992
+ case "hook": return emitHook$4(a);
993
+ case "agent": return emitAgent$4(a);
994
+ case "tool": return emitTool$4(a);
995
+ case "rule": return emitRule$4(a);
996
+ case "resource": return emitResource$4(a);
997
+ case "prompt": return emitPrompt$4(a);
998
+ default: return {
999
+ files: [],
1000
+ warnings: [{
1001
+ level: "skipped",
1002
+ atomKind: "unknown",
1003
+ message: "Unknown atom kind"
1004
+ }]
1005
+ };
1006
+ }
1007
+ }
1008
+ };
1009
+ function emitInstruction$3(atom) {
1010
+ const globs = atom.globs?.length ? atom.globs.join(", ") : void 0;
1011
+ const alwaysApply = !globs && atom.scope !== "directory";
1012
+ const frontmatter = [
1013
+ "---",
1014
+ `description: Tank-generated instruction`,
1015
+ globs ? `globs: ${globs}` : null,
1016
+ `alwaysApply: ${alwaysApply}`,
1017
+ "---"
1018
+ ].filter(Boolean).join("\n");
1019
+ return {
1020
+ files: [{
1021
+ path: `.cursor/rules/${slugify$3(atom.content)}.mdc`,
1022
+ content: `${frontmatter}\n\n{file:${atom.content}}`
1023
+ }],
1024
+ warnings: []
1025
+ };
1026
+ }
1027
+ function emitHook$3(atom) {
1028
+ const cursorEvent = {
1029
+ "pre-tool-use": "beforeToolCall",
1030
+ "post-tool-use": "afterToolCall",
1031
+ "pre-file-write": "beforeFileEdit",
1032
+ "post-file-write": "afterFileEdit",
1033
+ "pre-command": "beforeCommand",
1034
+ "post-command": "afterCommand",
1035
+ "pre-stop": "afterResponse",
1036
+ "post-response": "afterResponse",
1037
+ "pre-file-read": "beforeTabFileRead",
1038
+ "pre-mcp-tool-use": "beforeMcpToolCall",
1039
+ "post-mcp-tool-use": "afterMcpToolCall"
1040
+ }[atom.event];
1041
+ if (!cursorEvent) return {
1042
+ files: [],
1043
+ warnings: [{
1044
+ level: "degraded",
1045
+ atomKind: "hook",
1046
+ message: `Cursor does not have a direct equivalent for event "${atom.event}" — skipped`
1047
+ }]
1048
+ };
1049
+ const name = atom.name ?? `hook-${atom.event}`;
1050
+ const hookConfig = {};
1051
+ if (atom.handler.type === "js") hookConfig[cursorEvent] = [{
1052
+ type: "command",
1053
+ command: `node "$PROJECT_DIR/.cursor/hooks/${name}.mjs"`
1054
+ }];
1055
+ else {
1056
+ const script = buildDslScript$1(atom.handler.actions);
1057
+ hookConfig[cursorEvent] = [{
1058
+ type: "command",
1059
+ command: `bash "$PROJECT_DIR/.cursor/hooks/${name}.sh"`
1060
+ }];
1061
+ return {
1062
+ files: [{
1063
+ path: `.cursor/hooks/${name}.sh`,
1064
+ content: script
1065
+ }, {
1066
+ path: ".cursor/hooks.json",
1067
+ content: JSON.stringify({ hooks: hookConfig }, null, 2)
1068
+ }],
1069
+ warnings: []
1070
+ };
1071
+ }
1072
+ 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`;
1073
+ return {
1074
+ files: [{
1075
+ path: `.cursor/hooks/${name}.mjs`,
1076
+ content: jsWrapper
1077
+ }, {
1078
+ path: ".cursor/hooks.json",
1079
+ content: JSON.stringify({ hooks: hookConfig }, null, 2)
1080
+ }],
1081
+ warnings: []
1082
+ };
1083
+ }
1084
+ function emitAgent$3(atom) {
1085
+ const tools = atom.tools ?? [];
1086
+ const readonlyNote = atom.readonly ? "\n\nThis agent is read-only. Do not modify files." : "";
1087
+ const md = `# ${atom.name}\n\n${atom.role}\n\nTools: ${tools.join(", ")}${readonlyNote}\n`;
1088
+ return {
1089
+ files: [{
1090
+ path: `.cursor/agents/${atom.name}.md`,
1091
+ content: md
1092
+ }],
1093
+ warnings: []
1094
+ };
1095
+ }
1096
+ function emitTool$3(atom) {
1097
+ if (!atom.mcp) return {
1098
+ files: [],
1099
+ warnings: [{
1100
+ level: "skipped",
1101
+ atomKind: "tool",
1102
+ message: `Tool "${atom.name}" has no MCP config`
1103
+ }]
1104
+ };
1105
+ const config = { mcpServers: { [atom.name]: {
1106
+ command: atom.mcp.command,
1107
+ args: atom.mcp.args ?? [],
1108
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1109
+ } } };
1110
+ return {
1111
+ files: [{
1112
+ path: ".cursor/mcp.json",
1113
+ content: JSON.stringify(config, null, 2)
1114
+ }],
1115
+ warnings: []
1116
+ };
1117
+ }
1118
+ function emitRule$3(atom) {
1119
+ 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` : ""}`;
1120
+ return {
1121
+ files: [{
1122
+ path: `.cursor/rules/rule-${slugify$3(atom.name ?? atom.event)}.mdc`,
1123
+ content: `---\nalwaysApply: true\n---\n\n${content}`
1124
+ }],
1125
+ warnings: [{
1126
+ level: "degraded",
1127
+ atomKind: "rule",
1128
+ message: "Cursor rules are soft guidance — use hooks for hard enforcement"
1129
+ }]
1130
+ };
1131
+ }
1132
+ function emitResource$3(atom) {
1133
+ return {
1134
+ files: [],
1135
+ warnings: [{
1136
+ level: "degraded",
1137
+ atomKind: "resource",
1138
+ message: `Cursor uses @Docs for resources — "${atom.name ?? atom.uri}" not directly registrable`
1139
+ }]
1140
+ };
1141
+ }
1142
+ function emitPrompt$3(atom) {
1143
+ return {
1144
+ files: [{
1145
+ path: `.cursor/skills/${atom.name}/SKILL.md`,
1146
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
1147
+ }],
1148
+ warnings: []
1149
+ };
1150
+ }
1151
+ function slugify$3(s) {
1152
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1153
+ }
1154
+ function buildDslScript$1(actions) {
1155
+ 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`;
1156
+ }
1157
+ const cursorAdapter = {
1158
+ name: "cursor",
1159
+ supportedRange: ">=0.40.0",
1160
+ capabilities: {
1161
+ instruction: "full",
1162
+ hook: "full",
1163
+ tool: "full",
1164
+ agent: "full",
1165
+ rule: "degraded",
1166
+ resource: "degraded",
1167
+ prompt: "full"
1168
+ },
1169
+ compileAtom(atom) {
1170
+ const a = atom;
1171
+ switch (a.kind) {
1172
+ case "instruction": return emitInstruction$3(a);
1173
+ case "hook": return emitHook$3(a);
1174
+ case "agent": return emitAgent$3(a);
1175
+ case "tool": return emitTool$3(a);
1176
+ case "rule": return emitRule$3(a);
1177
+ case "resource": return emitResource$3(a);
1178
+ case "prompt": return emitPrompt$3(a);
1179
+ default: return {
1180
+ files: [],
1181
+ warnings: [{
1182
+ level: "skipped",
1183
+ atomKind: "unknown",
1184
+ message: "Unknown atom kind"
1185
+ }]
1186
+ };
1187
+ }
1188
+ }
1189
+ };
1190
+ function emitInstruction$2(atom) {
1191
+ return {
1192
+ files: [{
1193
+ path: `.opencode/instructions/${slugify$2(atom.content)}.md`,
1194
+ content: `{file:${atom.content}}`
1195
+ }],
1196
+ warnings: []
1197
+ };
1198
+ }
1199
+ function emitHook$2(atom) {
1200
+ const TRIGGER_HOOKS = {
1201
+ "pre-tool-use": "tool.execute.before",
1202
+ "post-tool-use": "tool.execute.after",
1203
+ "pre-file-read": "tool.execute.before",
1204
+ "post-file-read": "tool.execute.after",
1205
+ "pre-file-write": "tool.execute.before",
1206
+ "post-file-write": "tool.execute.after",
1207
+ "pre-command": "tool.execute.before",
1208
+ "post-command": "tool.execute.after",
1209
+ "pre-context-compact": "experimental.session.compacting",
1210
+ "system-prompt-transform": "experimental.chat.system.transform"
1211
+ };
1212
+ const EVENT_MAP = {
1213
+ "pre-stop": "session.idle",
1214
+ "session-created": "session.created",
1215
+ "session-idle": "session.idle",
1216
+ "session-error": "session.error",
1217
+ "file-edited": "file.edited",
1218
+ "file-watcher-updated": "file.watcher.updated",
1219
+ "task-start": "session.created",
1220
+ "task-complete": "session.idle",
1221
+ "todo-updated": "todo.updated",
1222
+ "permission-asked": "permission.asked",
1223
+ "permission-replied": "permission.replied",
1224
+ "post-response": "session.idle",
1225
+ "pre-user-prompt": "message.updated",
1226
+ "message-updated": "message.updated",
1227
+ "lsp-diagnostics": "lsp.client.diagnostics",
1228
+ "lsp-updated": "lsp.updated",
1229
+ "subagent-start": "session.created",
1230
+ "subagent-complete": "session.idle",
1231
+ "installation-updated": "installation.updated",
1232
+ "shell-env": "shell.env",
1233
+ "pre-mcp-tool-use": "tool.execute.before",
1234
+ "post-mcp-tool-use": "tool.execute.after"
1235
+ };
1236
+ const name = atom.name ?? `hook-${atom.event}`;
1237
+ const triggerHook = TRIGGER_HOOKS[atom.event];
1238
+ if (triggerHook) {
1239
+ const pluginContent = atom.handler.type === "js" ? buildJsTriggerPlugin(name, triggerHook, atom) : buildDslTriggerPlugin(name, triggerHook, atom);
1240
+ return {
1241
+ files: [{
1242
+ path: `.opencode/plugins/${name}.ts`,
1243
+ content: pluginContent
1244
+ }],
1245
+ warnings: []
1246
+ };
1247
+ }
1248
+ const busEvent = EVENT_MAP[atom.event] ?? atom.event.replace(/-/g, ".");
1249
+ const pluginContent = atom.handler.type === "js" ? buildJsEventPlugin(name, busEvent, atom) : buildDslEventPlugin(name, busEvent, atom);
1250
+ return {
1251
+ files: [{
1252
+ path: `.opencode/plugins/${name}.ts`,
1253
+ content: pluginContent
1254
+ }],
1255
+ warnings: []
1256
+ };
1257
+ }
1258
+ function emitAgent$2(atom) {
1259
+ const READ_ONLY_TOOLS = new Set([
1260
+ "read",
1261
+ "grep",
1262
+ "glob",
1263
+ "lsp",
1264
+ "fetch",
1265
+ "mcp"
1266
+ ]);
1267
+ const permissions = {};
1268
+ for (const tool of atom.tools ?? []) permissions[tool] = atom.readonly ? READ_ONLY_TOOLS.has(tool) : true;
1269
+ const md = [
1270
+ `---`,
1271
+ `description: "${atom.role}"`,
1272
+ `mode: subagent`,
1273
+ atom.model && ![
1274
+ "fast",
1275
+ "balanced",
1276
+ "powerful",
1277
+ "custom"
1278
+ ].includes(atom.model) ? `model: ${atom.model}` : null,
1279
+ `permissions:`,
1280
+ ...Object.entries(permissions).map(([k, v]) => ` ${k}: ${v}`),
1281
+ atom.readonly ? ` write: false\n edit: false\n bash: false` : null,
1282
+ `---`,
1283
+ "",
1284
+ atom.role
1285
+ ].filter(Boolean).join("\n");
1286
+ return {
1287
+ files: [{
1288
+ path: `.opencode/agent/${atom.name}.md`,
1289
+ content: md
1290
+ }],
1291
+ warnings: []
1292
+ };
1293
+ }
1294
+ function emitTool$2(atom) {
1295
+ if (!atom.mcp) return {
1296
+ files: [],
1297
+ warnings: [{
1298
+ level: "skipped",
1299
+ atomKind: "tool",
1300
+ message: `Tool "${atom.name}" has no MCP config — cannot register in OpenCode`
1301
+ }]
1302
+ };
1303
+ const config = { [atom.name]: {
1304
+ type: "local",
1305
+ command: [atom.mcp.command, ...atom.mcp.args ?? []],
1306
+ ...atom.mcp.env ? { environment: atom.mcp.env } : {}
1307
+ } };
1308
+ return {
1309
+ files: [{
1310
+ path: `.opencode/mcp/${atom.name}.json`,
1311
+ content: JSON.stringify(config, null, 2)
1312
+ }],
1313
+ warnings: []
1314
+ };
1315
+ }
1316
+ function emitRule$2(atom) {
1317
+ const name = atom.name ?? `rule-${atom.event}`;
1318
+ const pluginContent = buildRulePlugin(name, atom);
1319
+ return {
1320
+ files: [{
1321
+ path: `.opencode/plugins/${name}.ts`,
1322
+ content: pluginContent
1323
+ }],
1324
+ warnings: []
1325
+ };
1326
+ }
1327
+ function emitResource$2(atom) {
1328
+ return {
1329
+ files: [],
1330
+ warnings: [{
1331
+ level: "degraded",
1332
+ atomKind: "resource",
1333
+ message: `OpenCode MCP resources are experimental — resource "${atom.name ?? atom.uri}" registered as instruction reference`
1334
+ }]
1335
+ };
1336
+ }
1337
+ function emitPrompt$2(atom) {
1338
+ const frontmatter = [
1339
+ "---",
1340
+ `description: "${atom.description ?? atom.name}"`,
1341
+ "---",
1342
+ "",
1343
+ `{file:${atom.template}}`
1344
+ ].join("\n");
1345
+ return {
1346
+ files: [{
1347
+ path: `.opencode/commands/${atom.name}.md`,
1348
+ content: frontmatter
1349
+ }],
1350
+ warnings: []
1351
+ };
1352
+ }
1353
+ function slugify$2(s) {
1354
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1355
+ }
1356
+ function buildJsTriggerPlugin(name, hook, atom) {
1357
+ const matchFilter = atom.match ? `\n if (input.tool !== "${atom.match}") return;` : "";
1358
+ const handlerRelPath = `./handlers/${name}.handler`;
1359
+ return `import type { Plugin } from "@opencode-ai/plugin";
1360
+
1361
+ export const ${pascalCase(name)}: Plugin = async ({ client }) => {
1362
+ return {
1363
+ "${hook}": async (input, output) => {${matchFilter}
1364
+ const handler = await import("${handlerRelPath}");
1365
+ await handler.default(input, output, client);
1366
+ },
1367
+ };
1368
+ };
1369
+ `;
1370
+ }
1371
+ function buildDslTriggerPlugin(name, hook, atom) {
1372
+ if (atom.handler.type !== "dsl") return "";
1373
+ const checks = atom.handler.actions.map((a) => {
1374
+ if (a.action === "block" && a.match) return ` if (JSON.stringify(output.args ?? input).includes("${a.match}")) {
1375
+ throw new Error("${a.reason ?? `Blocked: ${a.match}`}");
1376
+ }`;
1377
+ if (a.action === "injectContext" && a.value) return ` output.system?.push?.("${a.value}");`;
1378
+ return null;
1379
+ }).filter(Boolean).join("\n");
1380
+ return `import type { Plugin } from "@opencode-ai/plugin";
1381
+
1382
+ export const ${pascalCase(name)}: Plugin = async () => {
1383
+ return {
1384
+ "${hook}": async (input, output) => {
1385
+ ${checks}
1386
+ },
1387
+ };
1388
+ };
1389
+ `;
1390
+ }
1391
+ function buildJsEventPlugin(name, busEvent, _atom) {
1392
+ const handlerRelPath = `./handlers/${name}.handler`;
1393
+ return `import type { Plugin } from "@opencode-ai/plugin";
1394
+
1395
+ export const ${pascalCase(name)}: Plugin = async ({ client, $ }) => {
1396
+ let _lastFingerprint = "";
1397
+ let _running = false;
1398
+ return {
1399
+ event: ({ event }) => {
1400
+ const e = event;
1401
+ if (e.type !== "${busEvent}") return;
1402
+ if (_running) return;
1403
+ const sid = e.properties?.sessionID ?? "";
1404
+ if (!sid) return;
1405
+ _running = true;
1406
+ $\`git status --porcelain -uall 2>/dev/null\`.text().then((stat) => {
1407
+ const fp = stat.trim();
1408
+ if (!fp || fp === _lastFingerprint) {
1409
+ _running = false;
1410
+ return;
1411
+ }
1412
+ _lastFingerprint = fp;
1413
+ return import("${handlerRelPath}").then((handler) => {
1414
+ return handler.default(e, { client, $ });
1415
+ });
1416
+ }).catch((err) => console.error("[${name}] ERROR:", err)).finally(() => { _running = false; });
1417
+ },
1418
+ };
1419
+ };
1420
+ `;
1421
+ }
1422
+ function buildDslEventPlugin(name, busEvent, atom) {
1423
+ if (atom.handler.type !== "dsl") return "";
1424
+ const checks = atom.handler.actions.map((a) => {
1425
+ if (a.action === "block" && a.match) return ` console.error("[${name}] Blocked: ${a.reason ?? a.match}");`;
1426
+ return null;
1427
+ }).filter(Boolean).join("\n");
1428
+ return `import type { Plugin } from "@opencode-ai/plugin";
1429
+
1430
+ export const ${pascalCase(name)}: Plugin = async () => {
1431
+ return {
1432
+ event: ({ event }) => {
1433
+ if (event.type !== "${busEvent}") return;
1434
+ ${checks}
1435
+ },
1436
+ };
1437
+ };
1438
+ `;
1439
+ }
1440
+ function buildRulePlugin(name, atom) {
1441
+ const triggerHook = {
1442
+ "pre-tool-use": "tool.execute.before",
1443
+ "post-tool-use": "tool.execute.after"
1444
+ }[atom.event];
1445
+ const matchFilter = atom.match ? `\n if (input.tool !== "${atom.match}") return;` : "";
1446
+ if (triggerHook) {
1447
+ if (atom.policy === "block") return `import type { Plugin } from "@opencode-ai/plugin";
1448
+
1449
+ export const ${pascalCase(name)}: Plugin = async () => {
1450
+ return {
1451
+ "${triggerHook}": async (input, output) => {${matchFilter}
1452
+ throw new Error("${atom.reason ?? "Blocked by rule"}");
1453
+ },
1454
+ };
1455
+ };
1456
+ `;
1457
+ return `import type { Plugin } from "@opencode-ai/plugin";
1458
+
1459
+ export const ${pascalCase(name)}: Plugin = async () => {
1460
+ return {
1461
+ "${triggerHook}": async (input, output) => {${matchFilter}
1462
+ console.warn("[${name}] ${atom.reason ?? "Rule triggered"}");
1463
+ },
1464
+ };
1465
+ };
1466
+ `;
1467
+ }
1468
+ return `import type { Plugin } from "@opencode-ai/plugin";
1469
+
1470
+ export const ${pascalCase(name)}: Plugin = async () => {
1471
+ return {
1472
+ event: ({ event }) => {
1473
+ if (event.type !== "${atom.event.replace(/-/g, ".")}") return;
1474
+ console.${atom.policy === "block" ? "error" : "warn"}("[${name}] ${atom.reason ?? "Rule triggered"}");
1475
+ },
1476
+ };
1477
+ };
1478
+ `;
1479
+ }
1480
+ function pascalCase(s) {
1481
+ return s.split(/[-_\s]+/).map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
1482
+ }
1483
+ const opencodeAdapter = {
1484
+ name: "opencode",
1485
+ supportedRange: ">=0.1.0",
1486
+ capabilities: {
1487
+ instruction: "full",
1488
+ hook: "full",
1489
+ tool: "full",
1490
+ agent: "full",
1491
+ rule: "full",
1492
+ resource: "degraded",
1493
+ prompt: "full"
1494
+ },
1495
+ compileAtom(atom) {
1496
+ const a = atom;
1497
+ switch (a.kind) {
1498
+ case "instruction": return emitInstruction$2(a);
1499
+ case "hook": return emitHook$2(a);
1500
+ case "agent": return emitAgent$2(a);
1501
+ case "tool": return emitTool$2(a);
1502
+ case "rule": return emitRule$2(a);
1503
+ case "resource": return emitResource$2(a);
1504
+ case "prompt": return emitPrompt$2(a);
1505
+ default: return {
1506
+ files: [],
1507
+ warnings: [{
1508
+ level: "skipped",
1509
+ atomKind: "unknown",
1510
+ message: "Unknown atom kind"
1511
+ }]
1512
+ };
1513
+ }
1514
+ }
1515
+ };
1516
+ function emitInstruction$1(atom) {
1517
+ return {
1518
+ files: [{
1519
+ path: `.roo/rules/${slugify$1(atom.content)}.md`,
1520
+ content: `{file:${atom.content}}`
1521
+ }],
1522
+ warnings: []
1523
+ };
1524
+ }
1525
+ function emitHook$1(_atom) {
1526
+ return {
1527
+ files: [],
1528
+ warnings: [{
1529
+ level: "skipped",
1530
+ atomKind: "hook",
1531
+ message: "Roo Code does not support hooks"
1532
+ }]
1533
+ };
1534
+ }
1535
+ function emitAgent$1(atom) {
1536
+ const toolGroups = [];
1537
+ for (const tool of atom.tools ?? []) {
1538
+ if ([
1539
+ "read",
1540
+ "grep",
1541
+ "glob"
1542
+ ].includes(tool)) toolGroups.push("read");
1543
+ if (["write", "edit"].includes(tool)) toolGroups.push("edit");
1544
+ if (["bash"].includes(tool)) toolGroups.push("command");
1545
+ if (["browser"].includes(tool)) toolGroups.push("browser");
1546
+ if (["mcp"].includes(tool)) toolGroups.push("mcp");
1547
+ }
1548
+ const groups = [...new Set(toolGroups)].map((g) => {
1549
+ if (g === "edit" && atom.readonly) return null;
1550
+ if (g === "command" && atom.readonly) return null;
1551
+ return g;
1552
+ }).filter(Boolean);
1553
+ const mode = {
1554
+ slug: atom.name,
1555
+ name: atom.name.charAt(0).toUpperCase() + atom.name.slice(1),
1556
+ roleDefinition: atom.role,
1557
+ groups: groups.map((g) => [g, {}]),
1558
+ customInstructions: atom.readonly ? "This mode is read-only. Do not modify any files." : void 0
1559
+ };
1560
+ return {
1561
+ files: [{
1562
+ path: `.roomodes`,
1563
+ content: JSON.stringify({ customModes: [mode] }, null, 2)
1564
+ }],
1565
+ warnings: []
1566
+ };
1567
+ }
1568
+ function emitTool$1(atom) {
1569
+ if (!atom.mcp) return {
1570
+ files: [],
1571
+ warnings: [{
1572
+ level: "skipped",
1573
+ atomKind: "tool",
1574
+ message: `Tool "${atom.name}" has no MCP config`
1575
+ }]
1576
+ };
1577
+ const config = { mcpServers: { [atom.name]: {
1578
+ command: atom.mcp.command,
1579
+ args: atom.mcp.args ?? [],
1580
+ disabled: false,
1581
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1582
+ } } };
1583
+ return {
1584
+ files: [{
1585
+ path: ".vscode/mcp.json",
1586
+ content: JSON.stringify(config, null, 2)
1587
+ }],
1588
+ warnings: []
1589
+ };
1590
+ }
1591
+ function emitRule$1(atom) {
1592
+ const content = `## Rule: ${atom.name ?? atom.event}\n\n- Policy: ${atom.policy}\n- Reason: ${atom.reason ?? "No reason specified"}\n`;
1593
+ return {
1594
+ files: [{
1595
+ path: `.roo/rules/rule-${slugify$1(atom.name ?? atom.event)}.md`,
1596
+ content
1597
+ }],
1598
+ warnings: [{
1599
+ level: "degraded",
1600
+ atomKind: "rule",
1601
+ message: "Roo Code rules are soft guidance only"
1602
+ }]
1603
+ };
1604
+ }
1605
+ function emitResource$1(atom) {
1606
+ return {
1607
+ files: [],
1608
+ warnings: [{
1609
+ level: "degraded",
1610
+ atomKind: "resource",
1611
+ message: `Roo Code MCP resources are mode-scoped — "${atom.name ?? atom.uri}" requires manual MCP setup`
1612
+ }]
1613
+ };
1614
+ }
1615
+ function emitPrompt$1(atom) {
1616
+ return {
1617
+ files: [],
1618
+ warnings: [{
1619
+ level: "degraded",
1620
+ atomKind: "prompt",
1621
+ message: `Roo Code does not support custom slash commands — prompt "${atom.name}" skipped`
1622
+ }]
1623
+ };
1624
+ }
1625
+ function slugify$1(s) {
1626
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1627
+ }
1628
+ const rooCodeAdapter = {
1629
+ name: "roo-code",
1630
+ supportedRange: ">=3.0.0",
1631
+ capabilities: {
1632
+ instruction: "full",
1633
+ hook: "none",
1634
+ tool: "full",
1635
+ agent: "full",
1636
+ rule: "degraded",
1637
+ resource: "degraded",
1638
+ prompt: "degraded"
1639
+ },
1640
+ compileAtom(atom) {
1641
+ const a = atom;
1642
+ switch (a.kind) {
1643
+ case "instruction": return emitInstruction$1(a);
1644
+ case "hook": return emitHook$1(a);
1645
+ case "agent": return emitAgent$1(a);
1646
+ case "tool": return emitTool$1(a);
1647
+ case "rule": return emitRule$1(a);
1648
+ case "resource": return emitResource$1(a);
1649
+ case "prompt": return emitPrompt$1(a);
1650
+ default: return {
1651
+ files: [],
1652
+ warnings: [{
1653
+ level: "skipped",
1654
+ atomKind: "unknown",
1655
+ message: "Unknown atom kind"
1656
+ }]
1657
+ };
1658
+ }
1659
+ }
1660
+ };
1661
+ function emitInstruction(atom) {
1662
+ return {
1663
+ files: [{
1664
+ path: `.windsurf/rules/${slugify(atom.content)}.md`,
1665
+ content: `{file:${atom.content}}`
1666
+ }],
1667
+ warnings: []
1668
+ };
1669
+ }
1670
+ function emitHook(atom) {
1671
+ const wsEvent = {
1672
+ "pre-tool-use": "pre_mcp_tool_use",
1673
+ "post-tool-use": "post_mcp_tool_use",
1674
+ "pre-file-read": "pre_read_code",
1675
+ "post-file-read": "post_read_code",
1676
+ "pre-file-write": "pre_write_code",
1677
+ "post-file-write": "post_write_code",
1678
+ "pre-command": "pre_run_command",
1679
+ "post-command": "post_run_command",
1680
+ "pre-user-prompt": "pre_user_prompt",
1681
+ "post-response": "post_cascade_response",
1682
+ "pre-stop": "post_cascade_response",
1683
+ "pre-mcp-tool-use": "pre_mcp_tool_use",
1684
+ "post-mcp-tool-use": "post_mcp_tool_use"
1685
+ }[atom.event];
1686
+ if (!wsEvent) return {
1687
+ files: [],
1688
+ warnings: [{
1689
+ level: "degraded",
1690
+ atomKind: "hook",
1691
+ message: `Windsurf does not support event "${atom.event}" — skipped`
1692
+ }]
1693
+ };
1694
+ const name = atom.name ?? `hook-${atom.event}`;
1695
+ if (atom.handler.type === "js") {
1696
+ 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`;
1697
+ const hookConfig = { hooks: { [wsEvent]: [{
1698
+ command: `node "$WORKSPACE_DIR/.windsurf/hooks/${name}.mjs"`,
1699
+ show_output: true
1700
+ }] } };
1701
+ return {
1702
+ files: [{
1703
+ path: `.windsurf/hooks/${name}.mjs`,
1704
+ content: wrapper
1705
+ }, {
1706
+ path: ".windsurf/hooks.json",
1707
+ content: JSON.stringify(hookConfig, null, 2)
1708
+ }],
1709
+ warnings: []
1710
+ };
1711
+ }
1712
+ const script = buildDslScript(atom.handler.actions);
1713
+ const hookConfig = { hooks: { [wsEvent]: [{
1714
+ command: `bash "$WORKSPACE_DIR/.windsurf/hooks/${name}.sh"`,
1715
+ show_output: true
1716
+ }] } };
1717
+ return {
1718
+ files: [{
1719
+ path: `.windsurf/hooks/${name}.sh`,
1720
+ content: script
1721
+ }, {
1722
+ path: ".windsurf/hooks.json",
1723
+ content: JSON.stringify(hookConfig, null, 2)
1724
+ }],
1725
+ warnings: []
1726
+ };
1727
+ }
1728
+ function emitAgent(atom) {
1729
+ return {
1730
+ files: [],
1731
+ warnings: [{
1732
+ level: "degraded",
1733
+ atomKind: "agent",
1734
+ message: `Windsurf has 3 fixed modes (Code/Plan/Ask) — agent "${atom.name}" compiled as instruction rule`
1735
+ }]
1736
+ };
1737
+ }
1738
+ function emitTool(atom) {
1739
+ if (!atom.mcp) return {
1740
+ files: [],
1741
+ warnings: [{
1742
+ level: "skipped",
1743
+ atomKind: "tool",
1744
+ message: `Tool "${atom.name}" has no MCP config`
1745
+ }]
1746
+ };
1747
+ const config = { mcpServers: { [atom.name]: {
1748
+ command: atom.mcp.command,
1749
+ args: atom.mcp.args ?? [],
1750
+ ...atom.mcp.env ? { env: atom.mcp.env } : {}
1751
+ } } };
1752
+ return {
1753
+ files: [{
1754
+ path: ".windsurf/mcp.json",
1755
+ content: JSON.stringify(config, null, 2)
1756
+ }],
1757
+ warnings: []
1758
+ };
1759
+ }
1760
+ function emitRule(atom) {
1761
+ 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`;
1762
+ return {
1763
+ files: [{
1764
+ path: `.windsurf/rules/rule-${slugify(atom.name ?? atom.event)}.md`,
1765
+ content
1766
+ }],
1767
+ warnings: [{
1768
+ level: "degraded",
1769
+ atomKind: "rule",
1770
+ message: "Windsurf rules are soft guidance — use hooks for enforcement"
1771
+ }]
1772
+ };
1773
+ }
1774
+ function emitResource(atom) {
1775
+ return {
1776
+ files: [],
1777
+ warnings: [{
1778
+ level: "degraded",
1779
+ atomKind: "resource",
1780
+ message: `Windsurf uses RAG indexing — resource "${atom.name ?? atom.uri}" not directly registrable`
1781
+ }]
1782
+ };
1783
+ }
1784
+ function emitPrompt(atom) {
1785
+ return {
1786
+ files: [{
1787
+ path: `.windsurf/skills/${atom.name}/SKILL.md`,
1788
+ content: `# ${atom.name}\n\n${atom.description ?? ""}\n\n{file:${atom.template}}`
1789
+ }],
1790
+ warnings: []
1791
+ };
1792
+ }
1793
+ function slugify(s) {
1794
+ return s.replace(/^\.\//, "").replace(/[/.]/g, "-").replace(/-+/g, "-").replace(/-$/, "");
1795
+ }
1796
+ function buildDslScript(actions) {
1797
+ 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`;
1798
+ }
1799
+ const windsurfAdapter = {
1800
+ name: "windsurf",
1801
+ supportedRange: ">=1.0.0",
1802
+ capabilities: {
1803
+ instruction: "full",
1804
+ hook: "full",
1805
+ tool: "full",
1806
+ agent: "degraded",
1807
+ rule: "degraded",
1808
+ resource: "degraded",
1809
+ prompt: "full"
1810
+ },
1811
+ compileAtom(atom) {
1812
+ const a = atom;
1813
+ switch (a.kind) {
1814
+ case "instruction": return emitInstruction(a);
1815
+ case "hook": return emitHook(a);
1816
+ case "agent": return emitAgent(a);
1817
+ case "tool": return emitTool(a);
1818
+ case "rule": return emitRule(a);
1819
+ case "resource": return emitResource(a);
1820
+ case "prompt": return emitPrompt(a);
1821
+ default: return {
1822
+ files: [],
1823
+ warnings: [{
1824
+ level: "skipped",
1825
+ atomKind: "unknown",
1826
+ message: "Unknown atom kind"
1827
+ }]
1828
+ };
1829
+ }
1830
+ }
1831
+ };
1832
+ const HANDLER_DIRS = {
1833
+ opencode: ".opencode/plugins/handlers",
1834
+ "claude-code": ".claude/hooks",
1835
+ cursor: ".cursor/hooks",
1836
+ windsurf: ".windsurf/hooks",
1837
+ cline: ".clinerules/hooks",
1838
+ "roo-code": ".roo/hooks"
1839
+ };
1840
+ function resolveSourceFiles(atoms, sourceDir, adapterName) {
1841
+ const files = [];
1842
+ const handlerDir = HANDLER_DIRS[adapterName] ?? `.${adapterName}/hooks`;
1843
+ for (const atom of atoms) {
1844
+ if (atom.kind === "hook" && atom.handler.type === "js") {
1845
+ const srcPath = path.resolve(sourceDir, atom.handler.entry);
1846
+ if (fs.existsSync(srcPath)) {
1847
+ const name = "name" in atom && atom.name ? atom.name : `hook-${atom.event}`;
1848
+ files.push({
1849
+ path: `${handlerDir}/${name}.handler.ts`,
1850
+ content: fs.readFileSync(srcPath, "utf-8")
1851
+ });
1852
+ }
1853
+ }
1854
+ if (atom.kind === "instruction") {
1855
+ const srcPath = path.resolve(sourceDir, atom.content);
1856
+ if (fs.existsSync(srcPath)) files.push({
1857
+ path: `__resolved__/${atom.content}`,
1858
+ content: fs.readFileSync(srcPath, "utf-8")
1859
+ });
1860
+ }
1861
+ if (atom.kind === "prompt" && "template" in atom) {
1862
+ const srcPath = path.resolve(sourceDir, atom.template);
1863
+ if (fs.existsSync(srcPath)) files.push({
1864
+ path: `__resolved__/${atom.template}`,
1865
+ content: fs.readFileSync(srcPath, "utf-8")
1866
+ });
1867
+ }
1868
+ }
1869
+ return files;
1870
+ }
1871
+ function inlineFileReferences(files, resolvedFiles) {
1872
+ return files.map((f) => {
1873
+ let content = f.content;
1874
+ for (const [refPath, refContent] of resolvedFiles) {
1875
+ content = content.replace(`{file:${refPath}}`, refContent);
1876
+ content = content.replace(`{file:./${refPath}}`, refContent);
1877
+ }
1878
+ return {
1879
+ path: f.path,
1880
+ content
1881
+ };
1882
+ });
1883
+ }
1884
+ function deepMergeJson(a, b) {
1885
+ try {
1886
+ const merged = mergeObjects(JSON.parse(a), JSON.parse(b));
1887
+ return JSON.stringify(merged, null, 2);
1888
+ } catch {
1889
+ return b;
1890
+ }
1891
+ }
1892
+ function mergeObjects(a, b) {
1893
+ if (Array.isArray(a) && Array.isArray(b)) return [...a, ...b];
1894
+ if (typeof a === "object" && a !== null && typeof b === "object" && b !== null) {
1895
+ const result = { ...a };
1896
+ for (const [key, val] of Object.entries(b)) if (key in result) result[key] = mergeObjects(result[key], val);
1897
+ else result[key] = val;
1898
+ return result;
1899
+ }
1900
+ return b;
1901
+ }
1902
+ function mergeFilesByPath(files) {
1903
+ const byPath = /* @__PURE__ */ new Map();
1904
+ for (const f of files) {
1905
+ const existing = byPath.get(f.path);
1906
+ if (!existing) {
1907
+ byPath.set(f.path, f);
1908
+ continue;
1909
+ }
1910
+ if (f.path.endsWith(".json") || f.path === ".roomodes") byPath.set(f.path, {
1911
+ path: f.path,
1912
+ content: deepMergeJson(existing.content, f.content)
1913
+ });
1914
+ else if (f.path.endsWith(".md") || f.path.endsWith(".mdc")) byPath.set(f.path, {
1915
+ path: f.path,
1916
+ content: `${existing.content}\n\n${f.content}`
1917
+ });
1918
+ else byPath.set(f.path, f);
1919
+ }
1920
+ return [...byPath.values()];
1921
+ }
1922
+ function collectAtoms(pkg, resolver, visited) {
1923
+ const seen = visited ?? /* @__PURE__ */ new Set();
1924
+ if (seen.has(pkg.name)) return [];
1925
+ seen.add(pkg.name);
1926
+ const atoms = [];
1927
+ if (pkg.includes) for (const dep of pkg.includes) {
1928
+ const resolved = resolver?.resolve(dep);
1929
+ if (resolved) atoms.push(...collectAtoms(resolved, resolver, seen));
1930
+ }
1931
+ atoms.push(...pkg.atoms);
1932
+ return atoms;
1933
+ }
1934
+ function compilePackage(pkg, adapter, options) {
1935
+ const rawFiles = [];
1936
+ const allWarnings = [];
1937
+ const skipped = [];
1938
+ const resolvedContent = /* @__PURE__ */ new Map();
1939
+ const allAtomsForResolve = collectAtoms(pkg, options?.resolver);
1940
+ if (options?.sourceDir) {
1941
+ const sourceFiles = resolveSourceFiles(allAtomsForResolve, options.sourceDir, adapter.name);
1942
+ for (const sf of sourceFiles) if (sf.path.startsWith("__resolved__/")) resolvedContent.set(sf.path.replace("__resolved__/", ""), sf.content);
1943
+ else rawFiles.push(sf);
1944
+ }
1945
+ const allAtoms = collectAtoms(pkg, options?.resolver);
1946
+ for (const atom of allAtoms) {
1947
+ if (adapter.capabilities[atom.kind] === "none") {
1948
+ const label = "name" in atom && atom.name ? `${atom.kind}/${atom.name}` : atom.kind;
1949
+ allWarnings.push({
1950
+ level: "skipped",
1951
+ atomKind: atom.kind,
1952
+ message: `${adapter.name} does not support ${atom.kind} — skipped "${label}"`
1953
+ });
1954
+ skipped.push(atom.kind);
1955
+ continue;
1956
+ }
1957
+ const output = adapter.compileAtom(atom);
1958
+ rawFiles.push(...output.files);
1959
+ allWarnings.push(...output.warnings);
1960
+ }
1961
+ return {
1962
+ files: mergeFilesByPath(resolvedContent.size > 0 ? inlineFileReferences(rawFiles, resolvedContent) : rawFiles),
1963
+ warnings: allWarnings,
1964
+ skipped
1965
+ };
1966
+ }
1967
+ function normalizeDirectory(dir) {
1968
+ const tankJsonPath = path.join(dir, "tank.json");
1969
+ const skillsJsonPath = path.join(dir, "skills.json");
1970
+ const skillMdPath = path.join(dir, "SKILL.md");
1971
+ const manifestPath = fs.existsSync(tankJsonPath) ? tankJsonPath : fs.existsSync(skillsJsonPath) ? skillsJsonPath : null;
1972
+ if (!manifestPath) return {
1973
+ success: false,
1974
+ error: "No tank.json or skills.json found"
1975
+ };
1976
+ let manifest;
1977
+ try {
1978
+ manifest = JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
1979
+ } catch {
1980
+ return {
1981
+ success: false,
1982
+ error: `Failed to parse ${path.basename(manifestPath)}`
1983
+ };
1984
+ }
1985
+ const hasAtoms = "atoms" in manifest && Array.isArray(manifest.atoms);
1986
+ const hasSkillMd = fs.existsSync(skillMdPath);
1987
+ if (!hasAtoms && !hasSkillMd) return {
1988
+ success: false,
1989
+ error: "No atoms field in manifest and no SKILL.md found"
1990
+ };
1991
+ const atoms = hasAtoms ? manifest.atoms : [{
1992
+ kind: "instruction",
1993
+ content: "SKILL.md"
1994
+ }];
1995
+ const pkg = {
1996
+ ...manifest,
1997
+ atoms
1998
+ };
1999
+ const result = packageIRSchema.safeParse(pkg);
2000
+ if (!result.success) return {
2001
+ success: false,
2002
+ error: `Invalid manifest:\n${result.error.issues.map((i) => ` ${i.path.join(".")}: ${i.message}`).join("\n")}`
2003
+ };
2004
+ return {
2005
+ success: true,
2006
+ data: result.data
2007
+ };
574
2008
  }
575
2009
  //#endregion
576
2010
  //#region src/commands/build.ts
@@ -1327,6 +2761,527 @@ function prepareAgentSkillDir(options) {
1327
2761
  return targetDir;
1328
2762
  }
1329
2763
  //#endregion
2764
+ //#region src/lib/scan-gate.ts
2765
+ /**
2766
+ * Security scan gate for `tank install <url>`.
2767
+ * Calls the public scan API and enforces verdicts.
2768
+ */
2769
+ function verdictColor$1(verdict) {
2770
+ switch (verdict) {
2771
+ case "pass": return chalk.green;
2772
+ case "pass_with_notes": return chalk.yellow;
2773
+ case "flagged": return chalk.hex("#FF8C00");
2774
+ case "fail": return chalk.red;
2775
+ case "error": return chalk.red;
2776
+ default: return chalk.white;
2777
+ }
2778
+ }
2779
+ function severityColor$1(severity) {
2780
+ switch (severity) {
2781
+ case "critical": return chalk.red;
2782
+ case "high": return chalk.hex("#FF8C00");
2783
+ case "medium": return chalk.yellow;
2784
+ case "low": return chalk.green;
2785
+ case "info": return chalk.blue;
2786
+ default: return chalk.white;
2787
+ }
2788
+ }
2789
+ function scoreColor$2(score) {
2790
+ if (score >= 7) return chalk.green;
2791
+ if (score >= 4) return chalk.yellow;
2792
+ return chalk.red;
2793
+ }
2794
+ async function promptUser(question) {
2795
+ const rl = createInterface({
2796
+ input: process.stdin,
2797
+ output: process.stdout
2798
+ });
2799
+ return new Promise((resolve) => {
2800
+ rl.question(question, (answer) => {
2801
+ rl.close();
2802
+ resolve(answer.toLowerCase() === "y" || answer.toLowerCase() === "yes");
2803
+ });
2804
+ });
2805
+ }
2806
+ async function scanUrl(url, options) {
2807
+ const config = getConfig();
2808
+ const registryUrl = options?.registryUrl ?? config.registry;
2809
+ const token = options?.token ?? config.token;
2810
+ let res;
2811
+ try {
2812
+ const headers = {
2813
+ "Content-Type": "application/json",
2814
+ "User-Agent": USER_AGENT
2815
+ };
2816
+ if (token) headers.Authorization = `Bearer ${token}`;
2817
+ res = await fetch(`${registryUrl}/api/v1/scan`, {
2818
+ method: "POST",
2819
+ headers,
2820
+ body: JSON.stringify({ url }),
2821
+ signal: AbortSignal.timeout(65e3)
2822
+ });
2823
+ } catch (err) {
2824
+ return {
2825
+ success: false,
2826
+ verdict: "error",
2827
+ auditScore: null,
2828
+ findings: [],
2829
+ durationMs: null,
2830
+ error: `Network error: ${err instanceof Error ? err.message : String(err)}`
2831
+ };
2832
+ }
2833
+ if (!res.ok) {
2834
+ if (res.status === 429) return {
2835
+ success: false,
2836
+ verdict: "error",
2837
+ auditScore: null,
2838
+ findings: [],
2839
+ durationMs: null,
2840
+ 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."}`
2841
+ };
2842
+ if (res.status === 504) return {
2843
+ success: false,
2844
+ verdict: "error",
2845
+ auditScore: null,
2846
+ findings: [],
2847
+ durationMs: null,
2848
+ error: "Scan timed out (504). The skill may be too large or the scanner is overloaded."
2849
+ };
2850
+ return {
2851
+ success: false,
2852
+ verdict: "error",
2853
+ auditScore: null,
2854
+ findings: [],
2855
+ durationMs: null,
2856
+ error: (await res.json().catch(() => null))?.error ?? `HTTP ${res.status}: ${res.statusText}`
2857
+ };
2858
+ }
2859
+ const data = await res.json();
2860
+ const findings = data.findings.map((f) => ({
2861
+ severity: f.severity,
2862
+ type: f.type,
2863
+ description: f.description,
2864
+ ...f.location ? { location: f.location } : {}
2865
+ }));
2866
+ return {
2867
+ success: true,
2868
+ verdict: data.verdict,
2869
+ auditScore: data.audit_score ?? null,
2870
+ findings,
2871
+ durationMs: data.duration_ms ?? null
2872
+ };
2873
+ }
2874
+ function displayScanResults(result) {
2875
+ const verdictLabel = verdictColor$1(result.verdict)(result.verdict.toUpperCase());
2876
+ console.log("");
2877
+ console.log(chalk.bold("Security Scan Results"));
2878
+ console.log("");
2879
+ console.log(`${chalk.dim("Verdict:".padEnd(14))}${verdictLabel}`);
2880
+ if (result.auditScore !== null) {
2881
+ const scoreLabel = scoreColor$2(result.auditScore)(result.auditScore.toFixed(1));
2882
+ console.log(`${chalk.dim("Score:".padEnd(14))}${scoreLabel}/10`);
2883
+ }
2884
+ if (result.durationMs !== null) console.log(`${chalk.dim("Duration:".padEnd(14))}${(result.durationMs / 1e3).toFixed(1)}s`);
2885
+ if (result.error) console.log(`${chalk.dim("Error:".padEnd(14))}${chalk.red(result.error)}`);
2886
+ if (result.findings.length > 0) {
2887
+ console.log("");
2888
+ console.log(chalk.bold(`Findings (${result.findings.length})`));
2889
+ const bySeverity = {
2890
+ critical: [],
2891
+ high: [],
2892
+ medium: [],
2893
+ low: [],
2894
+ info: []
2895
+ };
2896
+ for (const f of result.findings) bySeverity[f.severity].push(f);
2897
+ for (const severity of [
2898
+ "critical",
2899
+ "high",
2900
+ "medium",
2901
+ "low",
2902
+ "info"
2903
+ ]) {
2904
+ const group = bySeverity[severity];
2905
+ if (group.length === 0) continue;
2906
+ console.log("");
2907
+ const label = severityColor$1(severity)(`${severity.toUpperCase()} (${group.length})`);
2908
+ console.log(` ${label}`);
2909
+ for (const f of group) {
2910
+ console.log(` - ${chalk.bold(f.type)}: ${f.description}`);
2911
+ if (f.location) console.log(` ${chalk.dim("Location:")} ${f.location}`);
2912
+ }
2913
+ }
2914
+ } else if (result.success) {
2915
+ console.log("");
2916
+ console.log(chalk.green("No findings. The skill looks secure."));
2917
+ }
2918
+ console.log("");
2919
+ }
2920
+ async function enforceVerdict(result, options) {
2921
+ switch (result.verdict) {
2922
+ case "pass":
2923
+ case "pass_with_notes": return { allowed: true };
2924
+ case "flagged": {
2925
+ if (options?.yes) return { allowed: true };
2926
+ const count = result.findings.length;
2927
+ if (await promptUser(chalk.yellow(`⚠ Security scan flagged ${count} issue${count === 1 ? "" : "s"}. Install anyway? (y/N) `))) return { allowed: true };
2928
+ return {
2929
+ allowed: false,
2930
+ reason: "User declined after security warnings"
2931
+ };
2932
+ }
2933
+ case "fail": return {
2934
+ allowed: false,
2935
+ reason: "Security scan failed with critical findings"
2936
+ };
2937
+ case "error": return {
2938
+ allowed: false,
2939
+ reason: `Security scan error: ${result.error ?? "unknown"}`
2940
+ };
2941
+ default: return {
2942
+ allowed: false,
2943
+ reason: `Unknown verdict: ${result.verdict}`
2944
+ };
2945
+ }
2946
+ }
2947
+ //#endregion
2948
+ //#region src/lib/url-fetcher.ts
2949
+ /**
2950
+ * Fetch skills from URLs for `tank install <url>`.
2951
+ * Routes GitHub (git clone), ClawHub (zip), skills.sh, and generic tarballs
2952
+ * to temp directories with cleanup-on-failure semantics.
2953
+ */
2954
+ const HOST_MAP = [
2955
+ [/github\.com/i, "github"],
2956
+ [/clawhub\.ai/i, "clawhub"],
2957
+ [/skills\.sh/i, "skills_sh"],
2958
+ [/agentskills\.co\.il/i, "agentskills_il"],
2959
+ [/registry\.npmjs\.org/i, "npm"]
2960
+ ];
2961
+ function detectSourceType(url) {
2962
+ for (const [pattern, sourceType] of HOST_MAP) if (pattern.test(url)) return sourceType;
2963
+ return "unknown";
2964
+ }
2965
+ /** Returns true if the input looks like a URL rather than a package name. */
2966
+ function isUrl(input) {
2967
+ if (input.startsWith("http://") || input.startsWith("https://")) return true;
2968
+ for (const [pattern] of HOST_MAP) if (pattern.test(input)) return true;
2969
+ return false;
2970
+ }
2971
+ /** Best-effort skill name extraction from a URL. */
2972
+ function inferSkillName(url) {
2973
+ try {
2974
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
2975
+ switch (detectSourceType(url)) {
2976
+ case "github": {
2977
+ if (segments.length < 2) return null;
2978
+ const treeIdx = segments.indexOf("tree");
2979
+ if (treeIdx !== -1 && segments.length > treeIdx + 2) return segments[segments.length - 1] ?? null;
2980
+ return segments[1] ?? null;
2981
+ }
2982
+ case "clawhub": return segments[1] ?? null;
2983
+ case "skills_sh": return segments[2] ?? segments[1] ?? null;
2984
+ case "agentskills_il": return segments[1] ?? null;
2985
+ case "npm": return segments[segments.length - 1] ?? null;
2986
+ default: return segments[segments.length - 1] ?? null;
2987
+ }
2988
+ } catch {
2989
+ return null;
2990
+ }
2991
+ }
2992
+ async function createTempDir() {
2993
+ return mkdtemp(join(tmpdir(), "tank-fetch-"));
2994
+ }
2995
+ async function cleanupDir(dir) {
2996
+ try {
2997
+ await rm(dir, {
2998
+ recursive: true,
2999
+ force: true
3000
+ });
3001
+ } catch {}
3002
+ }
3003
+ function ensureGitInstalled() {
3004
+ try {
3005
+ execSync("git --version", { stdio: "ignore" });
3006
+ } catch {
3007
+ throw new Error("Git is not installed. Install git and try again.");
3008
+ }
3009
+ }
3010
+ function gitCloneShallow(repoUrl, dest) {
3011
+ try {
3012
+ execSync(`git clone --depth 1 ${repoUrl} ${dest}`, {
3013
+ stdio: "pipe",
3014
+ timeout: 6e4
3015
+ });
3016
+ } catch (err) {
3017
+ const msg = err instanceof Error ? err.message : String(err);
3018
+ if (msg.includes("Repository not found") || msg.includes("not found")) throw new Error(`Repository not found: ${repoUrl}`);
3019
+ if (msg.includes("timed out") || msg.includes("ETIMEDOUT")) throw new Error(`Network timeout cloning ${repoUrl}`);
3020
+ throw new Error(`Git clone failed: ${msg}`);
3021
+ }
3022
+ }
3023
+ function gitRevParseHead(dir) {
3024
+ try {
3025
+ return execSync("git rev-parse HEAD", {
3026
+ cwd: dir,
3027
+ stdio: "pipe"
3028
+ }).toString().trim();
3029
+ } catch {
3030
+ return null;
3031
+ }
3032
+ }
3033
+ async function downloadFile(url, dest) {
3034
+ const res = await fetch(url, { signal: AbortSignal.timeout(6e4) });
3035
+ if (!res.ok) {
3036
+ if (res.status === 404) throw new Error(`Not found: ${url}`);
3037
+ throw new Error(`HTTP ${res.status} downloading ${url}`);
3038
+ }
3039
+ if (!res.body) throw new Error(`Empty response body from ${url}`);
3040
+ await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
3041
+ }
3042
+ async function extractZip(zipPath, dest) {
3043
+ try {
3044
+ execSync(`unzip -o -q "${zipPath}" -d "${dest}"`, {
3045
+ stdio: "pipe",
3046
+ timeout: 3e4
3047
+ });
3048
+ } catch (err) {
3049
+ const msg = err instanceof Error ? err.message : String(err);
3050
+ throw new Error(`Zip extraction failed: ${msg}`);
3051
+ }
3052
+ }
3053
+ async function extractTarball(tarPath, dest) {
3054
+ try {
3055
+ execSync(`tar xzf "${tarPath}" -C "${dest}"`, {
3056
+ stdio: "pipe",
3057
+ timeout: 3e4
3058
+ });
3059
+ } catch (err) {
3060
+ const msg = err instanceof Error ? err.message : String(err);
3061
+ throw new Error(`Tarball extraction failed: ${msg}`);
3062
+ }
3063
+ }
3064
+ function parseGitHubUrl(url) {
3065
+ try {
3066
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.replace(/\.git$/, "").split("/").filter(Boolean);
3067
+ if (segments.length < 2) return null;
3068
+ const owner = segments[0];
3069
+ const repo = segments[1];
3070
+ let branch = null;
3071
+ let subpath = null;
3072
+ if (segments[2] === "tree" && segments.length > 3) {
3073
+ branch = segments[3];
3074
+ if (segments.length > 4) subpath = segments.slice(4).join("/");
3075
+ }
3076
+ return {
3077
+ owner,
3078
+ repo,
3079
+ branch,
3080
+ subpath
3081
+ };
3082
+ } catch {
3083
+ return null;
3084
+ }
3085
+ }
3086
+ async function fetchFromGitHub(url, tempDir) {
3087
+ ensureGitInstalled();
3088
+ const parts = parseGitHubUrl(url);
3089
+ if (!parts) throw new Error(`Invalid GitHub URL: ${url}`);
3090
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
3091
+ const cloneDest = join(tempDir, parts.repo);
3092
+ logger.info(`Cloning ${parts.owner}/${parts.repo}...`);
3093
+ gitCloneShallow(cloneUrl, cloneDest);
3094
+ if (parts.branch) try {
3095
+ execSync(`git checkout ${parts.branch}`, {
3096
+ cwd: cloneDest,
3097
+ stdio: "pipe",
3098
+ timeout: 1e4
3099
+ });
3100
+ } catch {}
3101
+ const commitSha = gitRevParseHead(cloneDest);
3102
+ let localPath = cloneDest;
3103
+ if (parts.subpath) {
3104
+ const subDir = join(cloneDest, parts.subpath);
3105
+ try {
3106
+ if ((await stat(subDir)).isDirectory()) localPath = subDir;
3107
+ } catch {
3108
+ throw new Error(`Subpath not found in repo: ${parts.subpath}`);
3109
+ }
3110
+ }
3111
+ return {
3112
+ localPath,
3113
+ sourceType: "github",
3114
+ sourceUrl: url,
3115
+ commitSha,
3116
+ inferredName: parts.subpath ? parts.subpath.split("/").pop() ?? parts.repo : parts.repo,
3117
+ cleanup: () => cleanupDir(tempDir)
3118
+ };
3119
+ }
3120
+ function parseClawHubUrl(url) {
3121
+ try {
3122
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
3123
+ if (segments.length < 2) return null;
3124
+ return {
3125
+ owner: segments[0],
3126
+ skillName: segments[1]
3127
+ };
3128
+ } catch {
3129
+ return null;
3130
+ }
3131
+ }
3132
+ async function fetchFromClawHub(url, tempDir) {
3133
+ const parts = parseClawHubUrl(url);
3134
+ if (!parts) throw new Error(`Invalid ClawHub URL: ${url}`);
3135
+ logger.info(`Fetching ${parts.owner}/${parts.skillName} from ClawHub...`);
3136
+ const pageUrl = url.startsWith("http") ? url : `https://${url}`;
3137
+ const pageRes = await fetch(pageUrl, { signal: AbortSignal.timeout(3e4) });
3138
+ if (!pageRes.ok) {
3139
+ if (pageRes.status === 404) throw new Error(`Skill not found on ClawHub: ${parts.skillName}`);
3140
+ throw new Error(`HTTP ${pageRes.status} fetching ClawHub page`);
3141
+ }
3142
+ const html = await pageRes.text();
3143
+ const downloadUrlMatch = html.match(/https?:\/\/[^\s"']+\.convex\.cloud[^\s"']*download[^\s"']*/i) ?? html.match(/https?:\/\/[^\s"']+\.zip/i);
3144
+ if (!downloadUrlMatch) throw new Error("Could not find download URL on ClawHub page. The skill may not have a downloadable archive.");
3145
+ const zipPath = join(tempDir, `${parts.skillName}.zip`);
3146
+ await downloadFile(downloadUrlMatch[0], zipPath);
3147
+ const extractDir = join(tempDir, parts.skillName);
3148
+ await mkdir(extractDir, { recursive: true });
3149
+ await extractZip(zipPath, extractDir);
3150
+ return {
3151
+ localPath: extractDir,
3152
+ sourceType: "clawhub",
3153
+ sourceUrl: url,
3154
+ commitSha: null,
3155
+ inferredName: parts.skillName,
3156
+ cleanup: () => cleanupDir(tempDir)
3157
+ };
3158
+ }
3159
+ function parseSkillsShUrl(url) {
3160
+ try {
3161
+ const segments = new URL(url.startsWith("http") ? url : `https://${url}`).pathname.split("/").filter(Boolean);
3162
+ if (segments.length < 2) return null;
3163
+ return {
3164
+ owner: segments[0],
3165
+ repo: segments[1],
3166
+ skillName: segments[2] ?? null
3167
+ };
3168
+ } catch {
3169
+ return null;
3170
+ }
3171
+ }
3172
+ async function fetchFromSkillsSh(url, tempDir) {
3173
+ ensureGitInstalled();
3174
+ const parts = parseSkillsShUrl(url);
3175
+ if (!parts) throw new Error(`Invalid skills.sh URL: ${url}`);
3176
+ const cloneUrl = `https://github.com/${parts.owner}/${parts.repo}.git`;
3177
+ const cloneDest = join(tempDir, parts.repo);
3178
+ logger.info(`Cloning ${parts.owner}/${parts.repo} (via skills.sh)...`);
3179
+ gitCloneShallow(cloneUrl, cloneDest);
3180
+ const commitSha = gitRevParseHead(cloneDest);
3181
+ let localPath = cloneDest;
3182
+ const inferredName = parts.skillName ?? parts.repo;
3183
+ if (parts.skillName) {
3184
+ const candidates = [
3185
+ join(cloneDest, "skills", parts.skillName),
3186
+ join(cloneDest, "src", "skills", parts.skillName),
3187
+ join(cloneDest, parts.skillName)
3188
+ ];
3189
+ let found = false;
3190
+ for (const candidate of candidates) try {
3191
+ if ((await stat(candidate)).isDirectory()) {
3192
+ localPath = candidate;
3193
+ found = true;
3194
+ break;
3195
+ }
3196
+ } catch {}
3197
+ if (!found) throw new Error(`Skill "${parts.skillName}" not found in ${parts.repo}. Searched: skills/${parts.skillName}, src/skills/${parts.skillName}, ${parts.skillName}`);
3198
+ }
3199
+ return {
3200
+ localPath,
3201
+ sourceType: "skills_sh",
3202
+ sourceUrl: url,
3203
+ commitSha,
3204
+ inferredName,
3205
+ cleanup: () => cleanupDir(tempDir)
3206
+ };
3207
+ }
3208
+ async function fetchFromGenericUrl(url, tempDir) {
3209
+ const fullUrl = url.startsWith("http") ? url : `https://${url}`;
3210
+ logger.info(`Downloading from ${fullUrl}...`);
3211
+ const isTarball = /\.(tar\.gz|tgz)(\?|$)/i.test(fullUrl);
3212
+ const isZip = /\.zip(\?|$)/i.test(fullUrl);
3213
+ if (!isTarball && !isZip) {
3214
+ const archivePath = join(tempDir, "skill.tar.gz");
3215
+ await downloadFile(fullUrl, archivePath);
3216
+ const extractDir = join(tempDir, "skill");
3217
+ await mkdir(extractDir, { recursive: true });
3218
+ try {
3219
+ await extractTarball(archivePath, extractDir);
3220
+ } catch {
3221
+ try {
3222
+ await extractZip(archivePath, extractDir);
3223
+ } catch {
3224
+ throw new Error(`Failed to extract archive from ${fullUrl}. Expected .tar.gz or .zip format.`);
3225
+ }
3226
+ }
3227
+ return {
3228
+ localPath: extractDir,
3229
+ sourceType: detectSourceType(url),
3230
+ sourceUrl: url,
3231
+ commitSha: null,
3232
+ inferredName: inferSkillName(url),
3233
+ cleanup: () => cleanupDir(tempDir)
3234
+ };
3235
+ }
3236
+ const archivePath = join(tempDir, `skill.${isTarball ? "tar.gz" : "zip"}`);
3237
+ await downloadFile(fullUrl, archivePath);
3238
+ const extractDir = join(tempDir, "skill");
3239
+ await mkdir(extractDir, { recursive: true });
3240
+ if (isTarball) await extractTarball(archivePath, extractDir);
3241
+ else await extractZip(archivePath, extractDir);
3242
+ return {
3243
+ localPath: extractDir,
3244
+ sourceType: detectSourceType(url),
3245
+ sourceUrl: url,
3246
+ commitSha: null,
3247
+ inferredName: inferSkillName(url),
3248
+ cleanup: () => cleanupDir(tempDir)
3249
+ };
3250
+ }
3251
+ /** Fetch a skill from a URL to a local temp directory. */
3252
+ async function fetchFromUrl(url) {
3253
+ const sourceType = detectSourceType(url);
3254
+ let tempDir = null;
3255
+ try {
3256
+ tempDir = await createTempDir();
3257
+ let result;
3258
+ switch (sourceType) {
3259
+ case "github":
3260
+ result = await fetchFromGitHub(url, tempDir);
3261
+ break;
3262
+ case "clawhub":
3263
+ result = await fetchFromClawHub(url, tempDir);
3264
+ break;
3265
+ case "skills_sh":
3266
+ result = await fetchFromSkillsSh(url, tempDir);
3267
+ break;
3268
+ default:
3269
+ result = await fetchFromGenericUrl(url, tempDir);
3270
+ break;
3271
+ }
3272
+ return {
3273
+ success: true,
3274
+ ...result
3275
+ };
3276
+ } catch (err) {
3277
+ if (tempDir) await cleanupDir(tempDir);
3278
+ return {
3279
+ success: false,
3280
+ error: err instanceof Error ? err.message : String(err)
3281
+ };
3282
+ }
3283
+ }
3284
+ //#endregion
1330
3285
  //#region src/commands/install.ts
1331
3286
  function createRegistryFetcher(registry, headers) {
1332
3287
  const versionsCache = /* @__PURE__ */ new Map();
@@ -1755,6 +3710,149 @@ async function installAll(options) {
1755
3710
  function buildIntegrity(buffer) {
1756
3711
  return `sha512-${crypto$1.createHash("sha512").update(buffer).digest("base64")}`;
1757
3712
  }
3713
+ /** Map url-fetcher source types to lockfile SkillSource values. */
3714
+ function mapSourceType(urlSourceType) {
3715
+ switch (urlSourceType) {
3716
+ case "github": return "github";
3717
+ case "clawhub": return "clawhub";
3718
+ case "skills_sh": return "skills_sh";
3719
+ case "agentskills_il": return "agentskills_il";
3720
+ case "npm": return "npm";
3721
+ default: return "local";
3722
+ }
3723
+ }
3724
+ /** Compute SHA-512 integrity hash over all files in a directory (sorted by path). */
3725
+ function computeDirectoryIntegrity(dir) {
3726
+ const files = [];
3727
+ function walkDir(current) {
3728
+ const entries = fs.readdirSync(current, { withFileTypes: true });
3729
+ for (const entry of entries) {
3730
+ if (entry.name === ".git") continue;
3731
+ const fullPath = path.join(current, entry.name);
3732
+ if (entry.isDirectory()) walkDir(fullPath);
3733
+ else if (entry.isFile()) files.push(fullPath);
3734
+ }
3735
+ }
3736
+ walkDir(dir);
3737
+ files.sort();
3738
+ const hash = crypto$1.createHash("sha512");
3739
+ for (const file of files) hash.update(fs.readFileSync(file));
3740
+ return `sha512-${hash.digest("base64")}`;
3741
+ }
3742
+ /** Read a manifest (tank.json or skills.json) from a directory, returning null if missing/invalid. */
3743
+ function readManifestFromDir(dir) {
3744
+ for (const filename of ["tank.json", "skills.json"]) {
3745
+ const manifestPath = path.join(dir, filename);
3746
+ if (fs.existsSync(manifestPath)) try {
3747
+ return JSON.parse(fs.readFileSync(manifestPath, "utf-8"));
3748
+ } catch {
3749
+ return null;
3750
+ }
3751
+ }
3752
+ return null;
3753
+ }
3754
+ async function installFromUrl(url, options) {
3755
+ const { global = false, yes = false } = options;
3756
+ const resolvedHome = os.homedir();
3757
+ const directory = process.cwd();
3758
+ const spinner = ora(`Fetching from URL...`).start();
3759
+ let fetchResult;
3760
+ try {
3761
+ const output = await fetchFromUrl(url);
3762
+ if (!output.success) {
3763
+ spinner.fail("Fetch failed");
3764
+ logger.error(output.error);
3765
+ process.exit(1);
3766
+ }
3767
+ fetchResult = output;
3768
+ spinner.text = "Scanning for security issues...";
3769
+ } catch (err) {
3770
+ spinner.fail("Fetch failed");
3771
+ const msg = err instanceof Error ? err.message : String(err);
3772
+ logger.error(msg);
3773
+ process.exit(1);
3774
+ }
3775
+ try {
3776
+ const scanResult = await scanUrl(url);
3777
+ displayScanResults(scanResult);
3778
+ const enforcement = await enforceVerdict(scanResult, { yes });
3779
+ if (!enforcement.allowed) {
3780
+ spinner.fail(enforcement.reason ?? "Install blocked by security scan");
3781
+ await fetchResult.cleanup();
3782
+ process.exit(1);
3783
+ }
3784
+ const skillMdPath = path.join(fetchResult.localPath, "SKILL.md");
3785
+ if (!fs.existsSync(skillMdPath)) throw new Error("No SKILL.md found. This doesn't appear to be a valid skill.");
3786
+ const existingManifest = readManifestFromDir(fetchResult.localPath);
3787
+ const skillName = existingManifest?.name ?? fetchResult.inferredName ?? path.basename(fetchResult.localPath);
3788
+ const skillVersion = existingManifest?.version ?? "0.0.0";
3789
+ const skillDescription = existingManifest?.description ?? "";
3790
+ if (!existingManifest) {
3791
+ const generatedManifest = {
3792
+ name: skillName,
3793
+ version: skillVersion,
3794
+ description: skillDescription
3795
+ };
3796
+ fs.writeFileSync(path.join(fetchResult.localPath, "tank.json"), `${JSON.stringify(generatedManifest, null, 2)}\n`);
3797
+ logger.info("Generated tank.json (no manifest found in source)");
3798
+ }
3799
+ spinner.text = `Installing ${skillName}...`;
3800
+ const installDir = global ? path.join(resolvedHome, ".tank", "skills", skillName) : path.join(directory, ".tank", "skills", skillName);
3801
+ if (fs.existsSync(installDir)) fs.rmSync(installDir, {
3802
+ recursive: true,
3803
+ force: true
3804
+ });
3805
+ fs.mkdirSync(path.dirname(installDir), { recursive: true });
3806
+ fs.cpSync(fetchResult.localPath, installDir, { recursive: true });
3807
+ const integrity = computeDirectoryIntegrity(installDir);
3808
+ const resolvedLock = resolveLockfilePath(global ? path.join(resolvedHome, ".tank") : directory);
3809
+ const lockPath = resolvedLock.exists ? resolvedLock.path : global ? path.join(resolvedHome, ".tank", LOCKFILE_FILENAME) : path.join(directory, LOCKFILE_FILENAME);
3810
+ const lock = readLockOrFresh(lockPath);
3811
+ const lockKey = `${skillName}@${skillVersion}`;
3812
+ const skillPermissions = existingManifest?.permissions ?? {};
3813
+ lock.skills[lockKey] = {
3814
+ resolved: url.startsWith("http") ? url : `https://${url}`,
3815
+ integrity,
3816
+ permissions: skillPermissions,
3817
+ audit_score: scanResult.auditScore ?? null,
3818
+ source: mapSourceType(fetchResult.sourceType),
3819
+ scan_verdict: scanResult.verdict,
3820
+ scanned_at: (/* @__PURE__ */ new Date()).toISOString()
3821
+ };
3822
+ lock.lockfileVersion = 2;
3823
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
3824
+ fs.writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`);
3825
+ const linkedAgents = [];
3826
+ try {
3827
+ const agentSkillsBaseDir = global ? getGlobalAgentSkillsDir(resolvedHome) : path.join(directory, ".tank", "agent-skills");
3828
+ const linksDir = global ? path.join(resolvedHome, ".tank") : path.join(directory, ".tank");
3829
+ const linkResult = linkSkillToAgents({
3830
+ skillName,
3831
+ sourceDir: prepareAgentSkillDir({
3832
+ skillName,
3833
+ extractDir: installDir,
3834
+ agentSkillsBaseDir,
3835
+ description: skillDescription
3836
+ }),
3837
+ linksDir,
3838
+ source: global ? "global" : "local"
3839
+ });
3840
+ linkedAgents.push(...linkResult.linked);
3841
+ if (linkResult.failed.length > 0) for (const failedLink of linkResult.failed) logger.warn(`Failed to link to ${failedLink.agentId}: ${failedLink.error}`);
3842
+ } catch {
3843
+ logger.warn("Agent linking skipped (non-fatal)");
3844
+ }
3845
+ if (detectInstalledAgents().length === 0) logger.warn("No agents detected for linking");
3846
+ await fetchResult.cleanup();
3847
+ spinner.succeed(`Installed ${skillName} from ${fetchResult.sourceType}`);
3848
+ if (linkedAgents.length > 0) logger.info(`Linked to ${linkedAgents.join(", ")}`);
3849
+ logger.info(`Locked (${integrity.slice(0, 20)}..., scanned ${(/* @__PURE__ */ new Date()).toISOString().split("T")[0]})`);
3850
+ } catch (err) {
3851
+ await fetchResult.cleanup();
3852
+ spinner.fail("Install failed");
3853
+ throw err;
3854
+ }
3855
+ }
1758
3856
  //#endregion
1759
3857
  //#region src/commands/link.ts
1760
3858
  async function linkCommand(options = {}) {
@@ -2125,8 +4223,7 @@ const IGNORE_FILES = [".tankignore", ".gitignore"];
2125
4223
  * Pack a skill directory into a .tgz tarball with integrity hashing.
2126
4224
  *
2127
4225
  * Validates:
2128
- * - skills.json exists and is valid
2129
- * - SKILL.md exists
4226
+ * - tank.json (or skills.json) exists and is valid
2130
4227
  * - No symlinks or hardlinks
2131
4228
  * - No path traversal (.. components)
2132
4229
  * - No absolute paths
@@ -2156,18 +4253,23 @@ async function pack(directory) {
2156
4253
  } catch {
2157
4254
  throw new Error(`Invalid ${manifestFilename}: not valid JSON`);
2158
4255
  }
2159
- const validation = skillsJsonSchema.safeParse(parsed);
4256
+ const validation = publishManifestSchema.safeParse(parsed);
2160
4257
  if (!validation.success) {
2161
4258
  const issues = validation.error.issues.map((i) => ` - ${i.path.join(".")}: ${i.message}`).join("\n");
2162
4259
  throw new Error(`Invalid ${manifestFilename}:\n${issues}`);
2163
4260
  }
4261
+ let readmeContent = "";
2164
4262
  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 {
4263
+ const readmeMdPath = path.join(absDir, "README.md");
4264
+ if (fs.existsSync(skillMdPath)) try {
2168
4265
  readmeContent = fs.readFileSync(skillMdPath, "utf-8");
2169
4266
  } catch {
2170
- throw new Error("Failed to read SKILL.md");
4267
+ readmeContent = "";
4268
+ }
4269
+ else if (fs.existsSync(readmeMdPath)) try {
4270
+ readmeContent = fs.readFileSync(readmeMdPath, "utf-8");
4271
+ } catch {
4272
+ readmeContent = "";
2171
4273
  }
2172
4274
  const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
2173
4275
  if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
@@ -3399,9 +5501,13 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
3399
5501
  process.exit(1);
3400
5502
  }
3401
5503
  });
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) => {
5504
+ 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
5505
  try {
3404
- if (name) await installCommand({
5506
+ if (name && isUrl(name)) await installFromUrl(name, {
5507
+ global: opts.global,
5508
+ yes: opts.yes
5509
+ });
5510
+ else if (name) await installCommand({
3405
5511
  name,
3406
5512
  versionRange,
3407
5513
  global: opts.global