@tankpkg/cli 0.15.7 → 0.16.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 +565 -144
- package/dist/bin/tank.js.map +1 -1
- package/dist/{debug-logger-B6uLj2h_.js → debug-logger-C7P_qoCR.js} +2 -2
- package/dist/debug-logger-C7P_qoCR.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/package.json +11 -1
- package/package.json +14 -2
- package/dist/debug-logger-B6uLj2h_.js.map +0 -1
package/dist/bin/tank.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-
|
|
2
|
+
import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-C7P_qoCR.js";
|
|
3
3
|
import { t as logger } from "../logger-BhULz3Uz.js";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { Command } from "commander";
|
|
@@ -10,12 +10,12 @@ import path, { join } from "node:path";
|
|
|
10
10
|
import { z } from "zod";
|
|
11
11
|
import semver from "semver";
|
|
12
12
|
import ora from "ora";
|
|
13
|
+
import crypto$1, { randomUUID } from "node:crypto";
|
|
14
|
+
import { createInterface } from "node:readline";
|
|
13
15
|
import { confirm, input } from "@inquirer/prompts";
|
|
14
16
|
import { execSync, spawn } from "node:child_process";
|
|
15
|
-
import crypto$1 from "node:crypto";
|
|
16
|
-
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
|
17
17
|
import { create, extract } from "tar";
|
|
18
|
-
import {
|
|
18
|
+
import { mkdir, mkdtemp, rm, stat } from "node:fs/promises";
|
|
19
19
|
import { Readable } from "node:stream";
|
|
20
20
|
import { pipeline } from "node:stream/promises";
|
|
21
21
|
import open from "open";
|
|
@@ -231,6 +231,54 @@ z.enum([
|
|
|
231
231
|
"org.member.remove",
|
|
232
232
|
"org.delete"
|
|
233
233
|
]);
|
|
234
|
+
const commandSchema$1 = z.string().min(1, "command must not be empty");
|
|
235
|
+
const argSchema$1 = z.array(z.string()).default([]);
|
|
236
|
+
const envSchema$1 = z.record(z.string(), z.string()).optional();
|
|
237
|
+
const remoteUrlSchema$1 = z.string().url("remote must be a valid URL");
|
|
238
|
+
const localMcpServerSchema = z.object({
|
|
239
|
+
command: commandSchema$1,
|
|
240
|
+
args: argSchema$1,
|
|
241
|
+
env: envSchema$1,
|
|
242
|
+
requires_auth: z.literal(false).optional()
|
|
243
|
+
}).strict();
|
|
244
|
+
const remoteMcpServerSchema = z.object({
|
|
245
|
+
remote: remoteUrlSchema$1,
|
|
246
|
+
requires_auth: z.boolean().default(false),
|
|
247
|
+
env: envSchema$1
|
|
248
|
+
}).strict();
|
|
249
|
+
const mcpServerSchema$1 = z.union([localMcpServerSchema, remoteMcpServerSchema]);
|
|
250
|
+
function isRemoteMcpServer(server) {
|
|
251
|
+
return "remote" in server;
|
|
252
|
+
}
|
|
253
|
+
const baseManifestFields$1 = {
|
|
254
|
+
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"),
|
|
255
|
+
version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
|
|
256
|
+
description: z.string().max(500, `Description must be 500 characters or fewer`).optional(),
|
|
257
|
+
skills: z.record(z.string(), z.string()).optional(),
|
|
258
|
+
permissions: permissionsSchema$1.optional(),
|
|
259
|
+
repository: z.string().url("Repository must be a valid URL").optional(),
|
|
260
|
+
visibility: z.enum(["public", "private"]).optional(),
|
|
261
|
+
audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional(),
|
|
262
|
+
mcp_server: mcpServerSchema$1.optional()
|
|
263
|
+
};
|
|
264
|
+
/** Legacy skills.json schema — strict, no atoms. Used for backward-compatible consumers. */
|
|
265
|
+
const skillsJsonSchema = z.object(baseManifestFields$1).strict();
|
|
266
|
+
const publishConfigSchema$1 = z.object({
|
|
267
|
+
build: z.string().min(1, "publish.build must be a non-empty shell command").optional(),
|
|
268
|
+
files: z.array(z.string().min(1)).optional()
|
|
269
|
+
}).strict();
|
|
270
|
+
/**
|
|
271
|
+
* Publish manifest schema — accepts both legacy skills.json AND atom-enriched tank.json.
|
|
272
|
+
* The `atoms` and `includes` fields are passed through as opaque JSON arrays,
|
|
273
|
+
* validated only at surface level. Full atom IR validation happens at build time.
|
|
274
|
+
* The `publish` block is a CLI-only lifecycle config (build hook + files allow-list).
|
|
275
|
+
*/
|
|
276
|
+
const publishManifestSchema = z.object({
|
|
277
|
+
...baseManifestFields$1,
|
|
278
|
+
atoms: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
279
|
+
includes: z.array(z.string()).optional(),
|
|
280
|
+
publish: publishConfigSchema$1.optional()
|
|
281
|
+
}).strict();
|
|
234
282
|
const promptIRSchema$1 = z.object({
|
|
235
283
|
kind: z.literal("prompt"),
|
|
236
284
|
name: z.string().min(1, "Prompt name must not be empty"),
|
|
@@ -269,8 +317,9 @@ const mcpServerConfigSchema$1 = z.object({
|
|
|
269
317
|
args: z.array(z.string()).optional(),
|
|
270
318
|
env: z.record(z.string(), z.string()).optional(),
|
|
271
319
|
runtime: z.string().min(1).optional(),
|
|
272
|
-
entry: z.string().min(1).optional()
|
|
273
|
-
|
|
320
|
+
entry: z.string().min(1).optional(),
|
|
321
|
+
package: z.string().min(1).optional()
|
|
322
|
+
}).strict().refine((data) => Boolean(data.command) || Boolean(data.runtime && (data.entry || data.package)), "MCP config must have either \"command\" or \"runtime\" plus one of \"entry\"/\"package\"");
|
|
274
323
|
const toolIRSchema$1 = z.object({
|
|
275
324
|
kind: z.literal("tool"),
|
|
276
325
|
name: z.string().min(1, "Tool name must not be empty"),
|
|
@@ -299,27 +348,9 @@ const packageIRSchema = z.object({
|
|
|
299
348
|
permissions: permissionsSchema$1.optional(),
|
|
300
349
|
repository: z.string().url("Repository must be a valid URL").optional(),
|
|
301
350
|
visibility: z.enum(["public", "private"]).optional(),
|
|
302
|
-
audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional()
|
|
303
|
-
|
|
304
|
-
const commandSchema$1 = z.string().min(1, "command must not be empty");
|
|
305
|
-
const argSchema$1 = z.array(z.string()).default([]);
|
|
306
|
-
const envSchema$1 = z.record(z.string(), z.string()).optional();
|
|
307
|
-
const remoteUrlSchema$1 = z.string().url("remote must be a valid URL");
|
|
308
|
-
const localMcpServerSchema = z.object({
|
|
309
|
-
command: commandSchema$1,
|
|
310
|
-
args: argSchema$1,
|
|
311
|
-
env: envSchema$1,
|
|
312
|
-
requires_auth: z.literal(false).optional()
|
|
313
|
-
}).strict();
|
|
314
|
-
const remoteMcpServerSchema = z.object({
|
|
315
|
-
remote: remoteUrlSchema$1,
|
|
316
|
-
requires_auth: z.boolean().default(false),
|
|
317
|
-
env: envSchema$1
|
|
351
|
+
audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional(),
|
|
352
|
+
publish: publishConfigSchema$1.optional()
|
|
318
353
|
}).strict();
|
|
319
|
-
const mcpServerSchema$1 = z.union([localMcpServerSchema, remoteMcpServerSchema]);
|
|
320
|
-
function isRemoteMcpServer(server) {
|
|
321
|
-
return "remote" in server;
|
|
322
|
-
}
|
|
323
354
|
const perToolOverrideSchema$1 = z.object({
|
|
324
355
|
scan: z.boolean().optional(),
|
|
325
356
|
blockOnMatch: z.boolean().optional()
|
|
@@ -330,29 +361,6 @@ z.object({
|
|
|
330
361
|
resetPinsOnMismatch: z.boolean().optional(),
|
|
331
362
|
perTool: z.record(z.string(), perToolOverrideSchema$1).optional()
|
|
332
363
|
}).strict();
|
|
333
|
-
const baseManifestFields$1 = {
|
|
334
|
-
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"),
|
|
335
|
-
version: z.string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
|
|
336
|
-
description: z.string().max(500, `Description must be 500 characters or fewer`).optional(),
|
|
337
|
-
skills: z.record(z.string(), z.string()).optional(),
|
|
338
|
-
permissions: permissionsSchema$1.optional(),
|
|
339
|
-
repository: z.string().url("Repository must be a valid URL").optional(),
|
|
340
|
-
visibility: z.enum(["public", "private"]).optional(),
|
|
341
|
-
audit: z.object({ min_score: z.number().min(0).max(10) }).strict().optional(),
|
|
342
|
-
mcp_server: mcpServerSchema$1.optional()
|
|
343
|
-
};
|
|
344
|
-
/** Legacy skills.json schema — strict, no atoms. Used for backward-compatible consumers. */
|
|
345
|
-
const skillsJsonSchema = z.object(baseManifestFields$1).strict();
|
|
346
|
-
/**
|
|
347
|
-
* Publish manifest schema — accepts both legacy skills.json AND atom-enriched tank.json.
|
|
348
|
-
* The `atoms` and `includes` fields are passed through as opaque JSON arrays,
|
|
349
|
-
* validated only at surface level. Full atom IR validation happens at build time.
|
|
350
|
-
*/
|
|
351
|
-
const publishManifestSchema = z.object({
|
|
352
|
-
...baseManifestFields$1,
|
|
353
|
-
atoms: z.array(z.record(z.string(), z.unknown())).optional(),
|
|
354
|
-
includes: z.array(z.string()).optional()
|
|
355
|
-
}).strict();
|
|
356
364
|
const SKILL_SOURCES$1 = [
|
|
357
365
|
"registry",
|
|
358
366
|
"github",
|
|
@@ -633,6 +641,89 @@ async function auditCommand(options) {
|
|
|
633
641
|
}
|
|
634
642
|
//#endregion
|
|
635
643
|
//#region ../adapters/dist/index.mjs
|
|
644
|
+
function resolveMcpCommand(atom, adapterName) {
|
|
645
|
+
if (atom.mcp) return resolveFromMcpBlock(atom.mcp);
|
|
646
|
+
const fromExtensions = resolveFromExtensions(atom.extensions, adapterName);
|
|
647
|
+
if (fromExtensions) return fromExtensions;
|
|
648
|
+
return null;
|
|
649
|
+
}
|
|
650
|
+
function resolveFromMcpBlock(mcp) {
|
|
651
|
+
const env = mcp.env;
|
|
652
|
+
if (mcp.command) return {
|
|
653
|
+
command: mcp.command,
|
|
654
|
+
args: mcp.args ?? [],
|
|
655
|
+
...env ? { env } : {}
|
|
656
|
+
};
|
|
657
|
+
if (!mcp.runtime) return null;
|
|
658
|
+
const args = mcp.args ?? [];
|
|
659
|
+
switch (mcp.runtime) {
|
|
660
|
+
case "uvx":
|
|
661
|
+
if (!mcp.package) return null;
|
|
662
|
+
return {
|
|
663
|
+
command: "uvx",
|
|
664
|
+
args: [mcp.package, ...args],
|
|
665
|
+
...env ? { env } : {}
|
|
666
|
+
};
|
|
667
|
+
case "npx":
|
|
668
|
+
if (!mcp.package) return null;
|
|
669
|
+
return {
|
|
670
|
+
command: "npx",
|
|
671
|
+
args: [
|
|
672
|
+
"-y",
|
|
673
|
+
mcp.package,
|
|
674
|
+
...args
|
|
675
|
+
],
|
|
676
|
+
...env ? { env } : {}
|
|
677
|
+
};
|
|
678
|
+
case "bunx":
|
|
679
|
+
if (!mcp.package) return null;
|
|
680
|
+
return {
|
|
681
|
+
command: "bunx",
|
|
682
|
+
args: [mcp.package, ...args],
|
|
683
|
+
...env ? { env } : {}
|
|
684
|
+
};
|
|
685
|
+
case "pipx":
|
|
686
|
+
if (!mcp.package) return null;
|
|
687
|
+
return {
|
|
688
|
+
command: "pipx",
|
|
689
|
+
args: [
|
|
690
|
+
"run",
|
|
691
|
+
mcp.package,
|
|
692
|
+
...args
|
|
693
|
+
],
|
|
694
|
+
...env ? { env } : {}
|
|
695
|
+
};
|
|
696
|
+
case "node":
|
|
697
|
+
if (!mcp.entry) return null;
|
|
698
|
+
return {
|
|
699
|
+
command: "node",
|
|
700
|
+
args: [mcp.entry, ...args],
|
|
701
|
+
...env ? { env } : {}
|
|
702
|
+
};
|
|
703
|
+
case "python":
|
|
704
|
+
if (!mcp.entry) return null;
|
|
705
|
+
return {
|
|
706
|
+
command: "python",
|
|
707
|
+
args: [mcp.entry, ...args],
|
|
708
|
+
...env ? { env } : {}
|
|
709
|
+
};
|
|
710
|
+
default: return null;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function resolveFromExtensions(extensions, adapterName) {
|
|
714
|
+
if (!extensions) return null;
|
|
715
|
+
const bag = extensions[adapterName];
|
|
716
|
+
if (!bag || typeof bag !== "object") return null;
|
|
717
|
+
const candidate = bag;
|
|
718
|
+
if (typeof candidate.command !== "string" || candidate.command.length === 0) return null;
|
|
719
|
+
const args = Array.isArray(candidate.args) ? candidate.args.filter((a) => typeof a === "string") : [];
|
|
720
|
+
const env = candidate.env && typeof candidate.env === "object" && !Array.isArray(candidate.env) ? Object.fromEntries(Object.entries(candidate.env).filter(([, v]) => typeof v === "string")) : void 0;
|
|
721
|
+
return {
|
|
722
|
+
command: candidate.command,
|
|
723
|
+
args,
|
|
724
|
+
...env ? { env } : {}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
636
727
|
function emitInstruction$5(atom) {
|
|
637
728
|
const globs = atom.globs?.length ? atom.globs : void 0;
|
|
638
729
|
if (globs) {
|
|
@@ -733,7 +824,8 @@ function emitAgent$5(atom) {
|
|
|
733
824
|
};
|
|
734
825
|
}
|
|
735
826
|
function emitTool$5(atom) {
|
|
736
|
-
|
|
827
|
+
const resolved = resolveMcpCommand(atom, "claude-code");
|
|
828
|
+
if (!resolved) return {
|
|
737
829
|
files: [],
|
|
738
830
|
warnings: [{
|
|
739
831
|
level: "skipped",
|
|
@@ -742,9 +834,9 @@ function emitTool$5(atom) {
|
|
|
742
834
|
}]
|
|
743
835
|
};
|
|
744
836
|
const mcpConfig = { mcpServers: { [atom.name]: {
|
|
745
|
-
command:
|
|
746
|
-
args:
|
|
747
|
-
...
|
|
837
|
+
command: resolved.command,
|
|
838
|
+
args: resolved.args,
|
|
839
|
+
...resolved.env ? { env: resolved.env } : {}
|
|
748
840
|
} } };
|
|
749
841
|
return {
|
|
750
842
|
files: [{
|
|
@@ -944,7 +1036,8 @@ function emitAgent$4(atom) {
|
|
|
944
1036
|
};
|
|
945
1037
|
}
|
|
946
1038
|
function emitTool$4(atom) {
|
|
947
|
-
|
|
1039
|
+
const resolved = resolveMcpCommand(atom, "cline");
|
|
1040
|
+
if (!resolved) return {
|
|
948
1041
|
files: [],
|
|
949
1042
|
warnings: [{
|
|
950
1043
|
level: "skipped",
|
|
@@ -953,10 +1046,10 @@ function emitTool$4(atom) {
|
|
|
953
1046
|
}]
|
|
954
1047
|
};
|
|
955
1048
|
const config = { mcpServers: { [atom.name]: {
|
|
956
|
-
command:
|
|
957
|
-
args:
|
|
1049
|
+
command: resolved.command,
|
|
1050
|
+
args: resolved.args,
|
|
958
1051
|
disabled: false,
|
|
959
|
-
...
|
|
1052
|
+
...resolved.env ? { env: resolved.env } : {}
|
|
960
1053
|
} } };
|
|
961
1054
|
return {
|
|
962
1055
|
files: [{
|
|
@@ -1126,7 +1219,8 @@ function emitAgent$3(atom) {
|
|
|
1126
1219
|
};
|
|
1127
1220
|
}
|
|
1128
1221
|
function emitTool$3(atom) {
|
|
1129
|
-
|
|
1222
|
+
const resolved = resolveMcpCommand(atom, "cursor");
|
|
1223
|
+
if (!resolved) return {
|
|
1130
1224
|
files: [],
|
|
1131
1225
|
warnings: [{
|
|
1132
1226
|
level: "skipped",
|
|
@@ -1135,9 +1229,9 @@ function emitTool$3(atom) {
|
|
|
1135
1229
|
}]
|
|
1136
1230
|
};
|
|
1137
1231
|
const config = { mcpServers: { [atom.name]: {
|
|
1138
|
-
command:
|
|
1139
|
-
args:
|
|
1140
|
-
...
|
|
1232
|
+
command: resolved.command,
|
|
1233
|
+
args: resolved.args,
|
|
1234
|
+
...resolved.env ? { env: resolved.env } : {}
|
|
1141
1235
|
} } };
|
|
1142
1236
|
return {
|
|
1143
1237
|
files: [{
|
|
@@ -1324,7 +1418,8 @@ function emitAgent$2(atom) {
|
|
|
1324
1418
|
};
|
|
1325
1419
|
}
|
|
1326
1420
|
function emitTool$2(atom) {
|
|
1327
|
-
|
|
1421
|
+
const resolved = resolveMcpCommand(atom, "opencode");
|
|
1422
|
+
if (!resolved) return {
|
|
1328
1423
|
files: [],
|
|
1329
1424
|
warnings: [{
|
|
1330
1425
|
level: "skipped",
|
|
@@ -1334,8 +1429,8 @@ function emitTool$2(atom) {
|
|
|
1334
1429
|
};
|
|
1335
1430
|
const config = { [atom.name]: {
|
|
1336
1431
|
type: "local",
|
|
1337
|
-
command: [
|
|
1338
|
-
...
|
|
1432
|
+
command: [resolved.command, ...resolved.args],
|
|
1433
|
+
...resolved.env ? { environment: resolved.env } : {}
|
|
1339
1434
|
} };
|
|
1340
1435
|
return {
|
|
1341
1436
|
files: [{
|
|
@@ -1598,7 +1693,8 @@ function emitAgent$1(atom) {
|
|
|
1598
1693
|
};
|
|
1599
1694
|
}
|
|
1600
1695
|
function emitTool$1(atom) {
|
|
1601
|
-
|
|
1696
|
+
const resolved = resolveMcpCommand(atom, "roo-code");
|
|
1697
|
+
if (!resolved) return {
|
|
1602
1698
|
files: [],
|
|
1603
1699
|
warnings: [{
|
|
1604
1700
|
level: "skipped",
|
|
@@ -1607,10 +1703,10 @@ function emitTool$1(atom) {
|
|
|
1607
1703
|
}]
|
|
1608
1704
|
};
|
|
1609
1705
|
const config = { mcpServers: { [atom.name]: {
|
|
1610
|
-
command:
|
|
1611
|
-
args:
|
|
1706
|
+
command: resolved.command,
|
|
1707
|
+
args: resolved.args,
|
|
1612
1708
|
disabled: false,
|
|
1613
|
-
...
|
|
1709
|
+
...resolved.env ? { env: resolved.env } : {}
|
|
1614
1710
|
} } };
|
|
1615
1711
|
return {
|
|
1616
1712
|
files: [{
|
|
@@ -1768,7 +1864,8 @@ function emitAgent(atom) {
|
|
|
1768
1864
|
};
|
|
1769
1865
|
}
|
|
1770
1866
|
function emitTool(atom) {
|
|
1771
|
-
|
|
1867
|
+
const resolved = resolveMcpCommand(atom, "windsurf");
|
|
1868
|
+
if (!resolved) return {
|
|
1772
1869
|
files: [],
|
|
1773
1870
|
warnings: [{
|
|
1774
1871
|
level: "skipped",
|
|
@@ -1777,9 +1874,9 @@ function emitTool(atom) {
|
|
|
1777
1874
|
}]
|
|
1778
1875
|
};
|
|
1779
1876
|
const config = { mcpServers: { [atom.name]: {
|
|
1780
|
-
command:
|
|
1781
|
-
args:
|
|
1782
|
-
...
|
|
1877
|
+
command: resolved.command,
|
|
1878
|
+
args: resolved.args,
|
|
1879
|
+
...resolved.env ? { env: resolved.env } : {}
|
|
1783
1880
|
} } };
|
|
1784
1881
|
return {
|
|
1785
1882
|
files: [{
|
|
@@ -2411,6 +2508,122 @@ function getSkillLinkStatus(options) {
|
|
|
2411
2508
|
return agents.map((agent) => getStatusForAgent(agent, options.skillName));
|
|
2412
2509
|
}
|
|
2413
2510
|
//#endregion
|
|
2511
|
+
//#region src/lib/telemetry.ts
|
|
2512
|
+
const POSTHOG_PROJECT_KEY = process.env.TANK_POSTHOG_KEY ?? "phc_j9KjoTTYWsM4k40f2h61x8TRe8cx4ZhIMIKIVri0G7Z";
|
|
2513
|
+
const POSTHOG_DEFAULT_HOST = "https://eu.i.posthog.com";
|
|
2514
|
+
function getHost() {
|
|
2515
|
+
return process.env.TANK_TELEMETRY_HOST ?? POSTHOG_DEFAULT_HOST;
|
|
2516
|
+
}
|
|
2517
|
+
function isSelfhosted() {
|
|
2518
|
+
return process.env.TANK_MODE === "selfhosted";
|
|
2519
|
+
}
|
|
2520
|
+
function getTelemetryStatus(configDir) {
|
|
2521
|
+
if (isSelfhosted()) return {
|
|
2522
|
+
enabled: false,
|
|
2523
|
+
reason: "onprem"
|
|
2524
|
+
};
|
|
2525
|
+
if (!POSTHOG_PROJECT_KEY) return {
|
|
2526
|
+
enabled: false,
|
|
2527
|
+
reason: "no-key"
|
|
2528
|
+
};
|
|
2529
|
+
const env = process.env.TANK_TELEMETRY?.trim();
|
|
2530
|
+
if (env === "0" || env === "false" || env === "off") return {
|
|
2531
|
+
enabled: false,
|
|
2532
|
+
reason: "env-off"
|
|
2533
|
+
};
|
|
2534
|
+
if (env === "1" || env === "true" || env === "on") return {
|
|
2535
|
+
enabled: true,
|
|
2536
|
+
reason: "env-on"
|
|
2537
|
+
};
|
|
2538
|
+
if (getConfig(configDir).telemetry === true) return {
|
|
2539
|
+
enabled: true,
|
|
2540
|
+
reason: "config"
|
|
2541
|
+
};
|
|
2542
|
+
return {
|
|
2543
|
+
enabled: false,
|
|
2544
|
+
reason: "default-off"
|
|
2545
|
+
};
|
|
2546
|
+
}
|
|
2547
|
+
function getOrCreateDistinctId(configDir) {
|
|
2548
|
+
const cfg = getConfig(configDir);
|
|
2549
|
+
if (cfg.telemetryDistinctId) return cfg.telemetryDistinctId;
|
|
2550
|
+
const id = randomUUID();
|
|
2551
|
+
setConfig({ telemetryDistinctId: id }, configDir);
|
|
2552
|
+
return id;
|
|
2553
|
+
}
|
|
2554
|
+
function setTelemetry(enabled, configDir) {
|
|
2555
|
+
setConfig({ telemetry: enabled }, configDir);
|
|
2556
|
+
}
|
|
2557
|
+
function captureEvent(evt, configDir) {
|
|
2558
|
+
if (!getTelemetryStatus(configDir).enabled) return;
|
|
2559
|
+
const distinctId = getOrCreateDistinctId(configDir);
|
|
2560
|
+
const payload = {
|
|
2561
|
+
api_key: POSTHOG_PROJECT_KEY,
|
|
2562
|
+
event: evt.event,
|
|
2563
|
+
distinct_id: distinctId,
|
|
2564
|
+
properties: {
|
|
2565
|
+
...evt.properties,
|
|
2566
|
+
cli_version: VERSION,
|
|
2567
|
+
platform: process.platform,
|
|
2568
|
+
node_version: process.versions.node,
|
|
2569
|
+
$lib: "tank-cli"
|
|
2570
|
+
},
|
|
2571
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2572
|
+
};
|
|
2573
|
+
const url = `${getHost()}/i/v0/e/`;
|
|
2574
|
+
const controller = new AbortController();
|
|
2575
|
+
const timer = setTimeout(() => controller.abort(), 2e3);
|
|
2576
|
+
fetch(url, {
|
|
2577
|
+
method: "POST",
|
|
2578
|
+
headers: { "Content-Type": "application/json" },
|
|
2579
|
+
body: JSON.stringify(payload),
|
|
2580
|
+
signal: controller.signal
|
|
2581
|
+
}).catch(() => {}).finally(() => clearTimeout(timer));
|
|
2582
|
+
}
|
|
2583
|
+
function describeTelemetryState(configDir) {
|
|
2584
|
+
const status = getTelemetryStatus(configDir);
|
|
2585
|
+
if (status.reason === "onprem") return "Telemetry: disabled (on-prem mode)";
|
|
2586
|
+
if (status.reason === "no-key") return "Telemetry: disabled (no key compiled in this build)";
|
|
2587
|
+
if (status.reason === "env-off") return "Telemetry: disabled (overridden by TANK_TELEMETRY env var)";
|
|
2588
|
+
if (status.reason === "env-on") return "Telemetry: enabled (overridden by TANK_TELEMETRY env var)";
|
|
2589
|
+
if (status.reason === "config") return "Telemetry: enabled";
|
|
2590
|
+
return "Telemetry: disabled. Run `tank telemetry on` to opt in.";
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Prompt the user once for telemetry consent on first interactive use.
|
|
2594
|
+
* Skipped if: a decision is already recorded, no TTY (CI), on-prem, no key compiled.
|
|
2595
|
+
* The decision (true or false) is persisted to config so we never prompt again.
|
|
2596
|
+
*/
|
|
2597
|
+
async function maybePromptForTelemetryConsent(configDir) {
|
|
2598
|
+
if (isSelfhosted()) return;
|
|
2599
|
+
if (!POSTHOG_PROJECT_KEY) return;
|
|
2600
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) return;
|
|
2601
|
+
if (process.env.CI || process.env.TANK_TELEMETRY) return;
|
|
2602
|
+
if (typeof getConfig(configDir).telemetry === "boolean") return;
|
|
2603
|
+
const answer = await askYesNo("Help improve Tank by sending anonymous usage analytics? (No package names, paths, or keys are ever sent.)");
|
|
2604
|
+
setConfig({ telemetry: answer }, configDir);
|
|
2605
|
+
if (answer) {
|
|
2606
|
+
captureEvent({
|
|
2607
|
+
event: "cli_opted_in",
|
|
2608
|
+
properties: { source: "first-run-prompt" }
|
|
2609
|
+
}, configDir);
|
|
2610
|
+
process.stderr.write("Telemetry: enabled. Disable any time with `tank telemetry off`.\n");
|
|
2611
|
+
} else process.stderr.write("Telemetry: disabled. Re-enable any time with `tank telemetry on`.\n");
|
|
2612
|
+
}
|
|
2613
|
+
function askYesNo(question) {
|
|
2614
|
+
return new Promise((resolve) => {
|
|
2615
|
+
const rl = createInterface({
|
|
2616
|
+
input: process.stdin,
|
|
2617
|
+
output: process.stderr
|
|
2618
|
+
});
|
|
2619
|
+
rl.question(`${question} [y/N] `, (raw) => {
|
|
2620
|
+
rl.close();
|
|
2621
|
+
const a = raw.trim().toLowerCase();
|
|
2622
|
+
resolve(a === "y" || a === "yes");
|
|
2623
|
+
});
|
|
2624
|
+
});
|
|
2625
|
+
}
|
|
2626
|
+
//#endregion
|
|
2414
2627
|
//#region src/commands/doctor.ts
|
|
2415
2628
|
const parseLockKey$3 = (key) => {
|
|
2416
2629
|
const lastAt = key.lastIndexOf("@");
|
|
@@ -2528,6 +2741,8 @@ async function doctorCommand(options) {
|
|
|
2528
2741
|
console.log(` ${skillName} ${summary.statusText}`);
|
|
2529
2742
|
}
|
|
2530
2743
|
if (localSkills.length === 0 && uniqueGlobal.length === 0 && devLinks.length === 0) suggestions.add("Run `tank install @tank/typescript` to add your first skill");
|
|
2744
|
+
printSectionHeader("Telemetry");
|
|
2745
|
+
console.log(` ${describeTelemetryState()}`);
|
|
2531
2746
|
printSectionHeader("Suggestions");
|
|
2532
2747
|
if (suggestions.size === 0) console.log(" none");
|
|
2533
2748
|
else for (const suggestion of suggestions) console.log(` • ${suggestion}`);
|
|
@@ -2674,6 +2889,7 @@ async function initCommand(options = {}) {
|
|
|
2674
2889
|
}
|
|
2675
2890
|
fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
2676
2891
|
logger.success(`Created ${MANIFEST_FILENAME$1}`);
|
|
2892
|
+
await maybePromptForTelemetryConsent();
|
|
2677
2893
|
return;
|
|
2678
2894
|
}
|
|
2679
2895
|
if (resolved.exists) {
|
|
@@ -2732,6 +2948,7 @@ async function initCommand(options = {}) {
|
|
|
2732
2948
|
}
|
|
2733
2949
|
fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
2734
2950
|
logger.success(`Created ${MANIFEST_FILENAME$1}`);
|
|
2951
|
+
await maybePromptForTelemetryConsent();
|
|
2735
2952
|
}
|
|
2736
2953
|
//#endregion
|
|
2737
2954
|
//#region ../internals-helpers/dist/index.js
|
|
@@ -7065,6 +7282,42 @@ _enum([
|
|
|
7065
7282
|
"org.member.remove",
|
|
7066
7283
|
"org.delete"
|
|
7067
7284
|
]);
|
|
7285
|
+
const commandSchema = string().min(1, "command must not be empty");
|
|
7286
|
+
const argSchema = array(string()).default([]);
|
|
7287
|
+
const envSchema = record(string(), string()).optional();
|
|
7288
|
+
const remoteUrlSchema = string().url("remote must be a valid URL");
|
|
7289
|
+
const mcpServerSchema = union([object({
|
|
7290
|
+
command: commandSchema,
|
|
7291
|
+
args: argSchema,
|
|
7292
|
+
env: envSchema,
|
|
7293
|
+
requires_auth: literal(false).optional()
|
|
7294
|
+
}).strict(), object({
|
|
7295
|
+
remote: remoteUrlSchema,
|
|
7296
|
+
requires_auth: boolean().default(false),
|
|
7297
|
+
env: envSchema
|
|
7298
|
+
}).strict()]);
|
|
7299
|
+
const baseManifestFields = {
|
|
7300
|
+
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"),
|
|
7301
|
+
version: string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
|
|
7302
|
+
description: string().max(500, `Description must be 500 characters or fewer`).optional(),
|
|
7303
|
+
skills: record(string(), string()).optional(),
|
|
7304
|
+
permissions: permissionsSchema.optional(),
|
|
7305
|
+
repository: string().url("Repository must be a valid URL").optional(),
|
|
7306
|
+
visibility: _enum(["public", "private"]).optional(),
|
|
7307
|
+
audit: object({ min_score: number().min(0).max(10) }).strict().optional(),
|
|
7308
|
+
mcp_server: mcpServerSchema.optional()
|
|
7309
|
+
};
|
|
7310
|
+
object(baseManifestFields).strict();
|
|
7311
|
+
const publishConfigSchema = object({
|
|
7312
|
+
build: string().min(1, "publish.build must be a non-empty shell command").optional(),
|
|
7313
|
+
files: array(string().min(1)).optional()
|
|
7314
|
+
}).strict();
|
|
7315
|
+
object({
|
|
7316
|
+
...baseManifestFields,
|
|
7317
|
+
atoms: array(record(string(), unknown())).optional(),
|
|
7318
|
+
includes: array(string()).optional(),
|
|
7319
|
+
publish: publishConfigSchema.optional()
|
|
7320
|
+
}).strict();
|
|
7068
7321
|
const promptIRSchema = object({
|
|
7069
7322
|
kind: literal("prompt"),
|
|
7070
7323
|
name: string().min(1, "Prompt name must not be empty"),
|
|
@@ -7103,8 +7356,9 @@ const mcpServerConfigSchema = object({
|
|
|
7103
7356
|
args: array(string()).optional(),
|
|
7104
7357
|
env: record(string(), string()).optional(),
|
|
7105
7358
|
runtime: string().min(1).optional(),
|
|
7106
|
-
entry: string().min(1).optional()
|
|
7107
|
-
|
|
7359
|
+
entry: string().min(1).optional(),
|
|
7360
|
+
package: string().min(1).optional()
|
|
7361
|
+
}).strict().refine((data) => Boolean(data.command) || Boolean(data.runtime && (data.entry || data.package)), "MCP config must have either \"command\" or \"runtime\" plus one of \"entry\"/\"package\"");
|
|
7108
7362
|
const toolIRSchema = object({
|
|
7109
7363
|
kind: literal("tool"),
|
|
7110
7364
|
name: string().min(1, "Tool name must not be empty"),
|
|
@@ -7133,22 +7387,9 @@ object({
|
|
|
7133
7387
|
permissions: permissionsSchema.optional(),
|
|
7134
7388
|
repository: string().url("Repository must be a valid URL").optional(),
|
|
7135
7389
|
visibility: _enum(["public", "private"]).optional(),
|
|
7136
|
-
audit: object({ min_score: number().min(0).max(10) }).strict().optional()
|
|
7390
|
+
audit: object({ min_score: number().min(0).max(10) }).strict().optional(),
|
|
7391
|
+
publish: publishConfigSchema.optional()
|
|
7137
7392
|
}).strict();
|
|
7138
|
-
const commandSchema = string().min(1, "command must not be empty");
|
|
7139
|
-
const argSchema = array(string()).default([]);
|
|
7140
|
-
const envSchema = record(string(), string()).optional();
|
|
7141
|
-
const remoteUrlSchema = string().url("remote must be a valid URL");
|
|
7142
|
-
const mcpServerSchema = union([object({
|
|
7143
|
-
command: commandSchema,
|
|
7144
|
-
args: argSchema,
|
|
7145
|
-
env: envSchema,
|
|
7146
|
-
requires_auth: literal(false).optional()
|
|
7147
|
-
}).strict(), object({
|
|
7148
|
-
remote: remoteUrlSchema,
|
|
7149
|
-
requires_auth: boolean().default(false),
|
|
7150
|
-
env: envSchema
|
|
7151
|
-
}).strict()]);
|
|
7152
7393
|
const perToolOverrideSchema = object({
|
|
7153
7394
|
scan: boolean().optional(),
|
|
7154
7395
|
blockOnMatch: boolean().optional()
|
|
@@ -7159,23 +7400,6 @@ object({
|
|
|
7159
7400
|
resetPinsOnMismatch: boolean().optional(),
|
|
7160
7401
|
perTool: record(string(), perToolOverrideSchema).optional()
|
|
7161
7402
|
}).strict();
|
|
7162
|
-
const baseManifestFields = {
|
|
7163
|
-
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"),
|
|
7164
|
-
version: string().regex(/^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/, "Version must be valid semver"),
|
|
7165
|
-
description: string().max(500, `Description must be 500 characters or fewer`).optional(),
|
|
7166
|
-
skills: record(string(), string()).optional(),
|
|
7167
|
-
permissions: permissionsSchema.optional(),
|
|
7168
|
-
repository: string().url("Repository must be a valid URL").optional(),
|
|
7169
|
-
visibility: _enum(["public", "private"]).optional(),
|
|
7170
|
-
audit: object({ min_score: number().min(0).max(10) }).strict().optional(),
|
|
7171
|
-
mcp_server: mcpServerSchema.optional()
|
|
7172
|
-
};
|
|
7173
|
-
object(baseManifestFields).strict();
|
|
7174
|
-
object({
|
|
7175
|
-
...baseManifestFields,
|
|
7176
|
-
atoms: array(record(string(), unknown())).optional(),
|
|
7177
|
-
includes: array(string()).optional()
|
|
7178
|
-
}).strict();
|
|
7179
7403
|
const SKILL_SOURCES = [
|
|
7180
7404
|
"registry",
|
|
7181
7405
|
"github",
|
|
@@ -10372,6 +10596,7 @@ async function loginCommand(options = {}) {
|
|
|
10372
10596
|
}, configDir);
|
|
10373
10597
|
const displayName = user.name ?? user.email ?? "unknown";
|
|
10374
10598
|
logger.success(`Logged in as ${displayName}`);
|
|
10599
|
+
await maybePromptForTelemetryConsent(configDir);
|
|
10375
10600
|
return;
|
|
10376
10601
|
}
|
|
10377
10602
|
if (exchangeRes.status !== 400) {
|
|
@@ -10607,6 +10832,31 @@ async function proxyCommand(options) {
|
|
|
10607
10832
|
process.exit(code);
|
|
10608
10833
|
}
|
|
10609
10834
|
//#endregion
|
|
10835
|
+
//#region src/lib/build-hook.ts
|
|
10836
|
+
function runBuildHook(directory, command) {
|
|
10837
|
+
return new Promise((resolve, reject) => {
|
|
10838
|
+
const child = spawn(command, {
|
|
10839
|
+
cwd: directory,
|
|
10840
|
+
shell: true,
|
|
10841
|
+
stdio: "inherit"
|
|
10842
|
+
});
|
|
10843
|
+
child.on("error", (err) => {
|
|
10844
|
+
reject(/* @__PURE__ */ new Error(`Build hook failed to start: ${err.message}`));
|
|
10845
|
+
});
|
|
10846
|
+
child.on("close", (code, signal) => {
|
|
10847
|
+
if (code === 0) {
|
|
10848
|
+
resolve();
|
|
10849
|
+
return;
|
|
10850
|
+
}
|
|
10851
|
+
if (signal) {
|
|
10852
|
+
reject(/* @__PURE__ */ new Error(`Build hook terminated by signal ${signal}`));
|
|
10853
|
+
return;
|
|
10854
|
+
}
|
|
10855
|
+
reject(/* @__PURE__ */ new Error(`Build hook exited with code ${code ?? "unknown"}`));
|
|
10856
|
+
});
|
|
10857
|
+
});
|
|
10858
|
+
}
|
|
10859
|
+
//#endregion
|
|
10610
10860
|
//#region src/lib/packer.ts
|
|
10611
10861
|
const MAX_PACKAGE_SIZE = 50 * 1024 * 1024;
|
|
10612
10862
|
const MAX_FILE_COUNT = 1e3;
|
|
@@ -10620,18 +10870,7 @@ const DEFAULT_IGNORES = [
|
|
|
10620
10870
|
];
|
|
10621
10871
|
const ALWAYS_IGNORED = ["node_modules", ".git"];
|
|
10622
10872
|
const IGNORE_FILES = [".tankignore", ".gitignore"];
|
|
10623
|
-
|
|
10624
|
-
* Pack a skill directory into a .tgz tarball with integrity hashing.
|
|
10625
|
-
*
|
|
10626
|
-
* Validates:
|
|
10627
|
-
* - tank.json (or skills.json) exists and is valid
|
|
10628
|
-
* - No symlinks or hardlinks
|
|
10629
|
-
* - No path traversal (.. components)
|
|
10630
|
-
* - No absolute paths
|
|
10631
|
-
* - File count <= 1000
|
|
10632
|
-
* - Tarball size <= 50MB
|
|
10633
|
-
*/
|
|
10634
|
-
async function pack(directory) {
|
|
10873
|
+
async function pack(directory, options = {}) {
|
|
10635
10874
|
const absDir = path.resolve(directory);
|
|
10636
10875
|
if (!fs.existsSync(absDir)) throw new Error(`Directory does not exist: ${absDir}`);
|
|
10637
10876
|
if (!fs.statSync(absDir).isDirectory()) throw new Error(`Not a directory: ${absDir}`);
|
|
@@ -10672,7 +10911,7 @@ async function pack(directory) {
|
|
|
10672
10911
|
} catch {
|
|
10673
10912
|
readmeContent = "";
|
|
10674
10913
|
}
|
|
10675
|
-
const files = collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
|
|
10914
|
+
const files = options.files && options.files.length > 0 ? collectFromAllowList(absDir, options.files, manifestFilename) : collectFiles(absDir, absDir, buildIgnoreFilter(absDir));
|
|
10676
10915
|
if (files.length > MAX_FILE_COUNT) throw new Error(`Too many files: ${files.length} exceeds maximum of ${MAX_FILE_COUNT}`);
|
|
10677
10916
|
let totalSize = 0;
|
|
10678
10917
|
for (const file of files) {
|
|
@@ -10741,9 +10980,15 @@ async function packForScan(directory) {
|
|
|
10741
10980
|
files
|
|
10742
10981
|
};
|
|
10743
10982
|
}
|
|
10744
|
-
|
|
10745
|
-
|
|
10746
|
-
|
|
10983
|
+
function collectFromAllowList(baseDir, globs, manifestFilename) {
|
|
10984
|
+
const securityFilter = ignore().add(ALWAYS_IGNORED);
|
|
10985
|
+
const allowMatcher = ignore().add(globs);
|
|
10986
|
+
const all = collectFiles(baseDir, baseDir, securityFilter);
|
|
10987
|
+
const always = new Set([manifestFilename]);
|
|
10988
|
+
if (fs.existsSync(path.join(baseDir, "SKILL.md"))) always.add("SKILL.md");
|
|
10989
|
+
if (fs.existsSync(path.join(baseDir, "README.md"))) always.add("README.md");
|
|
10990
|
+
return all.filter((rel) => always.has(rel) || allowMatcher.ignores(rel));
|
|
10991
|
+
}
|
|
10747
10992
|
function buildIgnoreFilter(dir) {
|
|
10748
10993
|
const ig = ignore();
|
|
10749
10994
|
ig.add(ALWAYS_IGNORED);
|
|
@@ -10848,14 +11093,21 @@ async function publishCommand(options = {}) {
|
|
|
10848
11093
|
if (effectiveVisibility) manifest.visibility = effectiveVisibility;
|
|
10849
11094
|
const name = manifest.name;
|
|
10850
11095
|
const version = manifest.version;
|
|
11096
|
+
const publishConfig = manifest.publish ?? void 0;
|
|
11097
|
+
if (publishConfig?.build) {
|
|
11098
|
+
logger.info(`Running build: ${publishConfig.build}`);
|
|
11099
|
+
await runBuildHook(directory, publishConfig.build);
|
|
11100
|
+
}
|
|
10851
11101
|
const spinner = ora("Packing...").start();
|
|
10852
11102
|
let packResult;
|
|
10853
11103
|
try {
|
|
10854
|
-
packResult = await pack(directory);
|
|
11104
|
+
packResult = await pack(directory, publishConfig?.files ? { files: publishConfig.files } : {});
|
|
10855
11105
|
} catch (err) {
|
|
10856
11106
|
spinner.fail("Packing failed");
|
|
10857
11107
|
throw err;
|
|
10858
11108
|
}
|
|
11109
|
+
const outboundManifest = { ...manifest };
|
|
11110
|
+
delete outboundManifest.publish;
|
|
10859
11111
|
const { tarball, integrity, fileCount, totalSize, readme, files } = packResult;
|
|
10860
11112
|
if (dryRun) {
|
|
10861
11113
|
spinner.stop();
|
|
@@ -10891,7 +11143,7 @@ async function publishCommand(options = {}) {
|
|
|
10891
11143
|
method: "POST",
|
|
10892
11144
|
headers,
|
|
10893
11145
|
body: JSON.stringify({
|
|
10894
|
-
manifest,
|
|
11146
|
+
manifest: outboundManifest,
|
|
10895
11147
|
readme,
|
|
10896
11148
|
files
|
|
10897
11149
|
})
|
|
@@ -11334,6 +11586,39 @@ async function searchCommand(options) {
|
|
|
11334
11586
|
console.log(`${data.results.length} skill${data.results.length === 1 ? "" : "s"} found`);
|
|
11335
11587
|
}
|
|
11336
11588
|
//#endregion
|
|
11589
|
+
//#region src/commands/telemetry.ts
|
|
11590
|
+
async function telemetryCommand(opts) {
|
|
11591
|
+
const { action, configDir } = opts;
|
|
11592
|
+
if (action === "status") {
|
|
11593
|
+
logger.info(describeTelemetryState(configDir));
|
|
11594
|
+
return;
|
|
11595
|
+
}
|
|
11596
|
+
if (action === "on") {
|
|
11597
|
+
setTelemetry(true, configDir);
|
|
11598
|
+
const status = getTelemetryStatus(configDir);
|
|
11599
|
+
if (status.reason === "onprem") {
|
|
11600
|
+
logger.warn("Telemetry config written, but disabled because TANK_MODE=selfhosted.");
|
|
11601
|
+
return;
|
|
11602
|
+
}
|
|
11603
|
+
if (status.reason === "no-key") {
|
|
11604
|
+
logger.warn("Telemetry config written, but this build has no telemetry key compiled in.");
|
|
11605
|
+
return;
|
|
11606
|
+
}
|
|
11607
|
+
captureEvent({ event: "cli_opted_in" }, configDir);
|
|
11608
|
+
logger.info("Telemetry: enabled. Thanks for helping improve Tank.");
|
|
11609
|
+
logger.info("Disable any time: tank telemetry off");
|
|
11610
|
+
return;
|
|
11611
|
+
}
|
|
11612
|
+
if (action === "off") {
|
|
11613
|
+
captureEvent({ event: "cli_opted_out" }, configDir);
|
|
11614
|
+
setTelemetry(false, configDir);
|
|
11615
|
+
logger.info("Telemetry: disabled.");
|
|
11616
|
+
return;
|
|
11617
|
+
}
|
|
11618
|
+
logger.error(`Unknown telemetry action: ${action}. Use on | off | status.`);
|
|
11619
|
+
process.exitCode = 1;
|
|
11620
|
+
}
|
|
11621
|
+
//#endregion
|
|
11337
11622
|
//#region src/commands/unlink.ts
|
|
11338
11623
|
async function unlinkCommand(options = {}) {
|
|
11339
11624
|
const resolvedManifest = resolveManifestPath(options.directory ?? process.cwd());
|
|
@@ -11792,6 +12077,92 @@ function printUserInfo(user) {
|
|
|
11792
12077
|
logger.info(`Email: ${user.email ?? "unknown"}`);
|
|
11793
12078
|
}
|
|
11794
12079
|
//#endregion
|
|
12080
|
+
//#region src/lib/install-suggestions.ts
|
|
12081
|
+
/**
|
|
12082
|
+
* Best-effort fuzzy lookup of similar skill names. Hits the public /api/v1/search
|
|
12083
|
+
* endpoint (no auth needed for public skills). Returns up to `limit` matches.
|
|
12084
|
+
* Silent failure: never throws — suggestions are advisory, not critical-path.
|
|
12085
|
+
*/
|
|
12086
|
+
async function fetchSimilarSkillNames(query, opts = {}) {
|
|
12087
|
+
const { configDir, limit = 3, timeoutMs = 2e3 } = opts;
|
|
12088
|
+
const config = getConfig(configDir);
|
|
12089
|
+
const searchTerm = query.replace(/^@[^/]+\//, "").replace(/^[^a-z0-9]+/i, "") || query;
|
|
12090
|
+
const controller = new AbortController();
|
|
12091
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
12092
|
+
try {
|
|
12093
|
+
const res = await fetch(`${config.registry}/api/v1/search?q=${encodeURIComponent(searchTerm)}&limit=${limit}`, {
|
|
12094
|
+
headers: { "User-Agent": USER_AGENT },
|
|
12095
|
+
signal: controller.signal
|
|
12096
|
+
});
|
|
12097
|
+
clearTimeout(timer);
|
|
12098
|
+
if (!res.ok) return [];
|
|
12099
|
+
return ((await res.json()).results ?? []).filter((r) => r.name && r.name !== query).slice(0, limit);
|
|
12100
|
+
} catch {
|
|
12101
|
+
clearTimeout(timer);
|
|
12102
|
+
return [];
|
|
12103
|
+
}
|
|
12104
|
+
}
|
|
12105
|
+
function formatInstallSuggestions(name, suggestions) {
|
|
12106
|
+
if (suggestions.length === 0) return `Try \`tank search ${name}\` to find similar packages.`;
|
|
12107
|
+
const lines = ["Did you mean one of these?"];
|
|
12108
|
+
for (const s of suggestions) lines.push(` • ${s.name}${s.description ? ` — ${s.description.slice(0, 60)}` : ""}`);
|
|
12109
|
+
lines.push(`\nOr search: \`tank search ${name}\``);
|
|
12110
|
+
return lines.join("\n");
|
|
12111
|
+
}
|
|
12112
|
+
//#endregion
|
|
12113
|
+
//#region src/lib/install-target.ts
|
|
12114
|
+
/**
|
|
12115
|
+
* Parse a single install target string into a structured target.
|
|
12116
|
+
*
|
|
12117
|
+
* Accepted forms (npm-compatible):
|
|
12118
|
+
* - `@org/pkg` → name with no range
|
|
12119
|
+
* - `@org/pkg@^1.0.0` → name + range (split on the LAST `@` for scoped names)
|
|
12120
|
+
* - `pkg` → unscoped name with no range
|
|
12121
|
+
* - `pkg@1.0.0` → unscoped name + range
|
|
12122
|
+
* - `https://github.com/...` → URL install
|
|
12123
|
+
*
|
|
12124
|
+
* The `@` that separates name from range is the FIRST `@` that is NOT at position 0
|
|
12125
|
+
* (position 0 is the scope marker for `@org/...`).
|
|
12126
|
+
*/
|
|
12127
|
+
function parseInstallTarget(target) {
|
|
12128
|
+
if (isUrl(target)) return {
|
|
12129
|
+
kind: "url",
|
|
12130
|
+
url: target
|
|
12131
|
+
};
|
|
12132
|
+
const searchStart = target.startsWith("@") ? 1 : 0;
|
|
12133
|
+
const versionAt = target.indexOf("@", searchStart);
|
|
12134
|
+
if (versionAt === -1) return {
|
|
12135
|
+
kind: "name",
|
|
12136
|
+
name: target
|
|
12137
|
+
};
|
|
12138
|
+
const name = target.slice(0, versionAt);
|
|
12139
|
+
const versionRange = target.slice(versionAt + 1);
|
|
12140
|
+
if (!name || !versionRange) return {
|
|
12141
|
+
kind: "name",
|
|
12142
|
+
name: target
|
|
12143
|
+
};
|
|
12144
|
+
return {
|
|
12145
|
+
kind: "name",
|
|
12146
|
+
name,
|
|
12147
|
+
versionRange
|
|
12148
|
+
};
|
|
12149
|
+
}
|
|
12150
|
+
/**
|
|
12151
|
+
* Heuristic: does this string look like a bare semver range rather than a skill name?
|
|
12152
|
+
* Used to detect the legacy `tank install @org/skill ^1.0.0` positional form so we can
|
|
12153
|
+
* preserve back-compat with the previous CLI signature.
|
|
12154
|
+
*
|
|
12155
|
+
* Returns true for strings like `^1.0.0`, `~1`, `>=2`, `1.x`, `*`, `latest`, `next`, `1.2.3`.
|
|
12156
|
+
* Returns false for skill names (contain `/`, start with `@`, or are URLs).
|
|
12157
|
+
*/
|
|
12158
|
+
function looksLikeVersionRange(s) {
|
|
12159
|
+
if (!s || s.includes("/") || s.startsWith("@") || isUrl(s)) return false;
|
|
12160
|
+
if (s === "*" || s === "latest" || s === "next") return true;
|
|
12161
|
+
if (/^[\^~><=]/.test(s)) return true;
|
|
12162
|
+
if (/^\d/.test(s)) return true;
|
|
12163
|
+
return false;
|
|
12164
|
+
}
|
|
12165
|
+
//#endregion
|
|
11795
12166
|
//#region src/lib/upgrade-check.ts
|
|
11796
12167
|
function isNewerVersion(candidateVersion, currentVersion) {
|
|
11797
12168
|
if (candidateVersion === currentVersion) return false;
|
|
@@ -11836,6 +12207,14 @@ async function checkForUpgrade(configDir) {
|
|
|
11836
12207
|
//#region src/bin/tank.ts
|
|
11837
12208
|
const program = new Command();
|
|
11838
12209
|
program.name("tank").description("Security-first package manager for AI agent skills").version(VERSION);
|
|
12210
|
+
program.hook("preAction", (_thisCommand, actionCommand) => {
|
|
12211
|
+
const name = actionCommand.name();
|
|
12212
|
+
if (name === "telemetry") return;
|
|
12213
|
+
captureEvent({
|
|
12214
|
+
event: "cli_command",
|
|
12215
|
+
properties: { command: name }
|
|
12216
|
+
});
|
|
12217
|
+
});
|
|
11839
12218
|
program.command("init").description("Create a new tank.json in the current directory").option("-y, --yes", "Skip prompts, use defaults").option("--name <name>", "Skill name").option("--skill-version <version>", "Skill version (default: 0.1.0)").option("--description <desc>", "Skill description").option("--private", "Make skill private").option("--force", "Overwrite existing tank.json").action(async (opts) => {
|
|
11840
12219
|
try {
|
|
11841
12220
|
await initCommand({
|
|
@@ -11909,26 +12288,54 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
|
|
|
11909
12288
|
process.exit(1);
|
|
11910
12289
|
}
|
|
11911
12290
|
});
|
|
11912
|
-
program.command("install").alias("i").description("Install
|
|
11913
|
-
|
|
11914
|
-
|
|
11915
|
-
|
|
11916
|
-
|
|
11917
|
-
|
|
11918
|
-
|
|
11919
|
-
|
|
11920
|
-
|
|
11921
|
-
|
|
11922
|
-
|
|
11923
|
-
|
|
11924
|
-
}
|
|
11925
|
-
|
|
11926
|
-
|
|
11927
|
-
|
|
11928
|
-
|
|
11929
|
-
|
|
11930
|
-
const
|
|
11931
|
-
|
|
12291
|
+
program.command("install").alias("i").description("Install one or more skills from the Tank registry, URLs, or all skills from lockfile").argument("[targets...]", "One or more skill specs or URLs (e.g. @org/skill, @org/skill@^1.0.0, https://github.com/owner/repo). Omit to install from lockfile.").option("-g, --global", "Install skill globally (available to all projects)").option("-y, --yes", "Auto-accept flagged scan verdicts").option("--dangerously-no-tank-proxy", "Skip wrapping MCP servers with the tank proxy (no scanning, no enforcement)").action(async (targets, opts) => {
|
|
12292
|
+
const proxyOpt = opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {};
|
|
12293
|
+
if (targets.length === 0) {
|
|
12294
|
+
try {
|
|
12295
|
+
await installAll({
|
|
12296
|
+
global: opts.global,
|
|
12297
|
+
...proxyOpt
|
|
12298
|
+
});
|
|
12299
|
+
} catch (err) {
|
|
12300
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
12301
|
+
console.error(`Install failed: ${msg}`);
|
|
12302
|
+
process.exit(1);
|
|
12303
|
+
}
|
|
12304
|
+
return;
|
|
12305
|
+
}
|
|
12306
|
+
const effectiveTargets = targets.length === 2 && looksLikeVersionRange(targets[1]) ? [`${targets[0]}@${targets[1]}`] : targets;
|
|
12307
|
+
const failures = [];
|
|
12308
|
+
for (const target of effectiveTargets) {
|
|
12309
|
+
const parsed = parseInstallTarget(target);
|
|
12310
|
+
try {
|
|
12311
|
+
if (parsed.kind === "url") await installFromUrl(parsed.url, {
|
|
12312
|
+
global: opts.global,
|
|
12313
|
+
yes: opts.yes,
|
|
12314
|
+
...proxyOpt
|
|
12315
|
+
});
|
|
12316
|
+
else await installCommand({
|
|
12317
|
+
name: parsed.name,
|
|
12318
|
+
versionRange: parsed.versionRange ?? "*",
|
|
12319
|
+
global: opts.global,
|
|
12320
|
+
...proxyOpt
|
|
12321
|
+
});
|
|
12322
|
+
} catch (err) {
|
|
12323
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
12324
|
+
failures.push({
|
|
12325
|
+
target,
|
|
12326
|
+
error: msg,
|
|
12327
|
+
parsedName: parsed.kind === "name" ? parsed.name : void 0
|
|
12328
|
+
});
|
|
12329
|
+
console.error(`Install failed for ${target}: ${msg}`);
|
|
12330
|
+
}
|
|
12331
|
+
}
|
|
12332
|
+
for (const failure of failures) {
|
|
12333
|
+
if (!failure.parsedName || !/not found/i.test(failure.error)) continue;
|
|
12334
|
+
const suggestions = await fetchSimilarSkillNames(failure.parsedName);
|
|
12335
|
+
console.error(`\n${formatInstallSuggestions(failure.parsedName, suggestions)}`);
|
|
12336
|
+
}
|
|
12337
|
+
if (failures.length > 0) {
|
|
12338
|
+
console.error(`\nInstall finished with ${failures.length}/${effectiveTargets.length} failure(s).`);
|
|
11932
12339
|
process.exit(1);
|
|
11933
12340
|
}
|
|
11934
12341
|
});
|
|
@@ -12115,6 +12522,20 @@ program.command("upgrade").description("Update tank to the latest version").argu
|
|
|
12115
12522
|
}
|
|
12116
12523
|
await flushLogs();
|
|
12117
12524
|
});
|
|
12525
|
+
program.command("telemetry <action>").description("Manage anonymous usage telemetry (on | off | status). Opt-in only, never enabled by default.").action(async (action) => {
|
|
12526
|
+
try {
|
|
12527
|
+
const normalized = action.toLowerCase();
|
|
12528
|
+
if (normalized !== "on" && normalized !== "off" && normalized !== "status") {
|
|
12529
|
+
console.error(`Unknown telemetry action: ${action}. Use: on | off | status.`);
|
|
12530
|
+
process.exit(1);
|
|
12531
|
+
}
|
|
12532
|
+
await telemetryCommand({ action: normalized });
|
|
12533
|
+
} catch (err) {
|
|
12534
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
12535
|
+
console.error(`Telemetry command failed: ${msg}`);
|
|
12536
|
+
process.exit(1);
|
|
12537
|
+
}
|
|
12538
|
+
});
|
|
12118
12539
|
checkForUpgrade().catch(() => {});
|
|
12119
12540
|
program.parse();
|
|
12120
12541
|
//#endregion
|