@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 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-B6uLj2h_.js";
2
+ import { a as VERSION, i as USER_AGENT, l as setConfig, n as flushLogs, o as getConfig, s as getConfigDir, t as authFlowLog } from "../debug-logger-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 { createInterface } from "node:readline";
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
- }).strict().refine((data) => data.command || data.runtime && data.entry, "MCP config must have either \"command\" or both \"runtime\" and \"entry\"");
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
- }).strict();
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
- if (!atom.mcp) return {
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: atom.mcp.command,
746
- args: atom.mcp.args ?? [],
747
- ...atom.mcp.env ? { env: atom.mcp.env } : {}
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
- if (!atom.mcp) return {
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: atom.mcp.command,
957
- args: atom.mcp.args ?? [],
1049
+ command: resolved.command,
1050
+ args: resolved.args,
958
1051
  disabled: false,
959
- ...atom.mcp.env ? { env: atom.mcp.env } : {}
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
- if (!atom.mcp) return {
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: atom.mcp.command,
1139
- args: atom.mcp.args ?? [],
1140
- ...atom.mcp.env ? { env: atom.mcp.env } : {}
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
- if (!atom.mcp) return {
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: [atom.mcp.command, ...atom.mcp.args ?? []],
1338
- ...atom.mcp.env ? { environment: atom.mcp.env } : {}
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
- if (!atom.mcp) return {
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: atom.mcp.command,
1611
- args: atom.mcp.args ?? [],
1706
+ command: resolved.command,
1707
+ args: resolved.args,
1612
1708
  disabled: false,
1613
- ...atom.mcp.env ? { env: atom.mcp.env } : {}
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
- if (!atom.mcp) return {
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: atom.mcp.command,
1781
- args: atom.mcp.args ?? [],
1782
- ...atom.mcp.env ? { env: atom.mcp.env } : {}
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
- }).strict().refine((data) => data.command || data.runtime && data.entry, "MCP config must have either \"command\" or both \"runtime\" and \"entry\"");
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
- * Build an ignore filter from .tankignore, .gitignore, or defaults.
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 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").option("--dangerously-no-tank-proxy", "Skip wrapping MCP servers with the tank proxy (no scanning, no enforcement)").action(async (name, versionRange, opts) => {
11913
- try {
11914
- if (name && isUrl(name)) await installFromUrl(name, {
11915
- global: opts.global,
11916
- yes: opts.yes,
11917
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
11918
- });
11919
- else if (name) await installCommand({
11920
- name,
11921
- versionRange,
11922
- global: opts.global,
11923
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
11924
- });
11925
- else await installAll({
11926
- global: opts.global,
11927
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
11928
- });
11929
- } catch (err) {
11930
- const msg = err instanceof Error ? err.message : String(err);
11931
- console.error(`Install failed: ${msg}`);
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