@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 +2128 -22
- package/dist/bin/tank.js.map +1 -1
- package/dist/{debug-logger-DMCCDELn.js → debug-logger-CsLqvzf2.js} +2 -2
- package/dist/{debug-logger-DMCCDELn.js.map → debug-logger-CsLqvzf2.js.map} +1 -1
- package/dist/index.js +1 -1
- package/dist/package.json +1 -1
- package/package.json +1 -1
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-
|
|
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
|
|
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
|
-
|
|
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$
|
|
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$
|
|
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 =
|
|
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
|
-
|
|
2166
|
-
|
|
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
|
-
|
|
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
|
|
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
|