@tankpkg/cli 0.15.8 → 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-BCwL85ni.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";
@@ -2508,6 +2508,122 @@ function getSkillLinkStatus(options) {
2508
2508
  return agents.map((agent) => getStatusForAgent(agent, options.skillName));
2509
2509
  }
2510
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
2511
2627
  //#region src/commands/doctor.ts
2512
2628
  const parseLockKey$3 = (key) => {
2513
2629
  const lastAt = key.lastIndexOf("@");
@@ -2625,6 +2741,8 @@ async function doctorCommand(options) {
2625
2741
  console.log(` ${skillName} ${summary.statusText}`);
2626
2742
  }
2627
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()}`);
2628
2746
  printSectionHeader("Suggestions");
2629
2747
  if (suggestions.size === 0) console.log(" none");
2630
2748
  else for (const suggestion of suggestions) console.log(` • ${suggestion}`);
@@ -2771,6 +2889,7 @@ async function initCommand(options = {}) {
2771
2889
  }
2772
2890
  fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
2773
2891
  logger.success(`Created ${MANIFEST_FILENAME$1}`);
2892
+ await maybePromptForTelemetryConsent();
2774
2893
  return;
2775
2894
  }
2776
2895
  if (resolved.exists) {
@@ -2829,6 +2948,7 @@ async function initCommand(options = {}) {
2829
2948
  }
2830
2949
  fs.writeFileSync(filePath, `${JSON.stringify(manifest, null, 2)}\n`);
2831
2950
  logger.success(`Created ${MANIFEST_FILENAME$1}`);
2951
+ await maybePromptForTelemetryConsent();
2832
2952
  }
2833
2953
  //#endregion
2834
2954
  //#region ../internals-helpers/dist/index.js
@@ -10476,6 +10596,7 @@ async function loginCommand(options = {}) {
10476
10596
  }, configDir);
10477
10597
  const displayName = user.name ?? user.email ?? "unknown";
10478
10598
  logger.success(`Logged in as ${displayName}`);
10599
+ await maybePromptForTelemetryConsent(configDir);
10479
10600
  return;
10480
10601
  }
10481
10602
  if (exchangeRes.status !== 400) {
@@ -11465,6 +11586,39 @@ async function searchCommand(options) {
11465
11586
  console.log(`${data.results.length} skill${data.results.length === 1 ? "" : "s"} found`);
11466
11587
  }
11467
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
11468
11622
  //#region src/commands/unlink.ts
11469
11623
  async function unlinkCommand(options = {}) {
11470
11624
  const resolvedManifest = resolveManifestPath(options.directory ?? process.cwd());
@@ -11923,6 +12077,92 @@ function printUserInfo(user) {
11923
12077
  logger.info(`Email: ${user.email ?? "unknown"}`);
11924
12078
  }
11925
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
11926
12166
  //#region src/lib/upgrade-check.ts
11927
12167
  function isNewerVersion(candidateVersion, currentVersion) {
11928
12168
  if (candidateVersion === currentVersion) return false;
@@ -11967,6 +12207,14 @@ async function checkForUpgrade(configDir) {
11967
12207
  //#region src/bin/tank.ts
11968
12208
  const program = new Command();
11969
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
+ });
11970
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) => {
11971
12219
  try {
11972
12220
  await initCommand({
@@ -12040,26 +12288,54 @@ program.command("publish").alias("pub").description("Pack and publish a skill to
12040
12288
  process.exit(1);
12041
12289
  }
12042
12290
  });
12043
- 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) => {
12044
- try {
12045
- if (name && isUrl(name)) await installFromUrl(name, {
12046
- global: opts.global,
12047
- yes: opts.yes,
12048
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
12049
- });
12050
- else if (name) await installCommand({
12051
- name,
12052
- versionRange,
12053
- global: opts.global,
12054
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
12055
- });
12056
- else await installAll({
12057
- global: opts.global,
12058
- ...opts.dangerouslyNoTankProxy ? { dangerouslyNoTankProxy: true } : {}
12059
- });
12060
- } catch (err) {
12061
- const msg = err instanceof Error ? err.message : String(err);
12062
- 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).`);
12063
12339
  process.exit(1);
12064
12340
  }
12065
12341
  });
@@ -12246,6 +12522,20 @@ program.command("upgrade").description("Update tank to the latest version").argu
12246
12522
  }
12247
12523
  await flushLogs();
12248
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
+ });
12249
12539
  checkForUpgrade().catch(() => {});
12250
12540
  program.parse();
12251
12541
  //#endregion