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