luckiest-co 1.0.7 → 1.0.9

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luckiest",
3
3
  "description": "Plan, go, finish. Guided planning, progress dashboards, and your luckiest.co tribe inside Claude Code.",
4
- "version": "0.1.8",
4
+ "version": "0.1.10",
5
5
  "author": { "name": "Luckiest" }
6
6
  }
package/README.md CHANGED
@@ -31,6 +31,10 @@ npx claude plugin add luckiest-co
31
31
  | `/luckiest wishes` | See what your tribe is working toward |
32
32
  | `/luckiest charms` | View and use your skill boosters |
33
33
 
34
+ ## Telemetry
35
+
36
+ When a Luckiest skill runs, the plugin reports anonymized, metadata-only usage so run counts, version adoption, and owner-facing improvement suggestions stay accurate. Exactly these fields are sent: skill slug, plugin version, whether the skill matched and (when observed) succeeded, an error category, duration, a SHA-256 hash of your key, a SHA-256 hash of the session id, and the reporting surface (`hook`, `mcp`, or `cli`). Prompt text, tool output, and file contents are never sent. Reporting is best-effort and never blocks or fails a skill run. Full policy: https://luckiest.co/privacy
37
+
34
38
  ## Requirements
35
39
 
36
40
  - Claude Code (CLI)
package/bin/install.js CHANGED
@@ -38,6 +38,11 @@ const args = process.argv.slice(2);
38
38
  const hasGlobal = args.includes('--global') || args.includes('-g');
39
39
  const hasLocal = args.includes('--local') || args.includes('-l');
40
40
  const syncOnly = args.includes('--sync-only');
41
+ // Usage-hook registration writes to the user's settings.json, so it's opt-in by
42
+ // a prompt rather than silent. These flags answer that prompt ahead of time for
43
+ // scripted installs, where there's no terminal to ask at.
44
+ const hasHook = args.includes('--hook');
45
+ const hasNoHook = args.includes('--no-hook');
41
46
 
42
47
  // Parse --config-dir argument
43
48
  function parseConfigDirArg() {
@@ -70,6 +75,8 @@ if (hasHelp) {
70
75
  ${cyan}-l, --local${reset} Install locally (to ./.claude in current directory)
71
76
  ${cyan}-c, --config-dir <path>${reset} Specify custom Claude config directory
72
77
  ${cyan}--sync-only${reset} Skip install, only sync owned skills from luckiest.co
78
+ ${cyan}--hook${reset} Enable skill usage tracking without prompting
79
+ ${cyan}--no-hook${reset} Skip it (and remove it if a past install added it)
73
80
  ${cyan}-h, --help${reset} Show this help message
74
81
 
75
82
  ${yellow}Examples:${reset}
@@ -205,6 +212,8 @@ async function syncSkills() {
205
212
  console.log(` ${green}✓${reset} Removed duplicate commands/luckiest (plugin marketplace already provides them)`);
206
213
  }
207
214
 
215
+ nudgeUsageHook(globalDir);
216
+
208
217
  const key = readSavedKey();
209
218
  if (!key) {
210
219
  console.log(` ${dim}No luckiest.co connection key saved yet. Run ${cyan}npx luckiest-co${dim} and paste one to sync your owned skills.${reset}`);
@@ -388,7 +397,7 @@ function install(isGlobal) {
388
397
  }
389
398
 
390
399
  // Copy references/, templates/, .claude-plugin/ into the target root
391
- const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin'];
400
+ const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin', 'hooks'];
392
401
  for (const dir of topLevelDirs) {
393
402
  const dirSrc = path.join(src, dir);
394
403
  const dirDest = path.join(claudeDir, dir);
@@ -401,6 +410,152 @@ function install(isGlobal) {
401
410
  console.log(`
402
411
  ${green}Done!${reset} Launch Claude Code and run ${cyan}/luckiest:plan${reset}.
403
412
  `);
413
+
414
+ return { claudeDir, globalClaudeDir: defaultGlobalDir };
415
+ }
416
+
417
+ /**
418
+ * Ask before touching settings.json. Returns true when we may register.
419
+ *
420
+ * Order: explicit flags win, then a prompt when there's a terminal to ask at.
421
+ * With no TTY (CI, piped installs) we decline rather than default to yes —
422
+ * silently editing a config file nobody was asked about is not a default worth
423
+ * having. --hook opts in for those cases.
424
+ */
425
+ async function shouldRegisterHook() {
426
+ if (hasNoHook) return false;
427
+ if (hasHook) return true;
428
+ if (!process.stdin.isTTY) {
429
+ console.log(` ${dim}Skipped usage-hook setup (no terminal to ask at). Re-run with ${cyan}--hook${dim} to enable.${reset}`);
430
+ return false;
431
+ }
432
+
433
+ console.log(`
434
+ ${yellow}Track your skill usage?${reset}
435
+ ${dim}Adds a hook to settings.json that reports which Luckiest skill ran, and
436
+ its version — never your prompts, tool output, or file contents. Powers your
437
+ usage stats at luckiest.co. You can remove it any time.${reset}
438
+ `);
439
+ return new Promise((resolve) => {
440
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
441
+ let answered = false;
442
+ rl.question(` Enable? ${dim}[Y/n]${reset}: `, (answer) => {
443
+ answered = true;
444
+ rl.close();
445
+ resolve(!/^n/i.test(answer.trim()));
446
+ });
447
+ // Input closed before an answer arrived (piped stdin that ran dry, ^D).
448
+ // Decline: an unanswered consent prompt is not consent.
449
+ rl.on('close', () => {
450
+ if (!answered) resolve(false);
451
+ });
452
+ });
453
+ }
454
+
455
+ // Identifies our hook entry across installs even when the absolute path changed.
456
+ const isOurHookEntry = (entry) =>
457
+ (entry?.hooks || []).some((h) => String(h?.command || '').includes('report-skill-usage.mjs'));
458
+
459
+ function usageHookRegistered(claudeDir) {
460
+ try {
461
+ const settings = JSON.parse(fs.readFileSync(path.join(claudeDir, 'settings.json'), 'utf8'));
462
+ return (settings.hooks?.PostToolUse || []).some(isOurHookEntry);
463
+ } catch {
464
+ return false;
465
+ }
466
+ }
467
+
468
+ /**
469
+ * Tell, don't act. --sync-only runs unattended at session start, so there's no
470
+ * terminal to ask at and registering there would mean silently editing
471
+ * settings.json on a call the user never typed. Instead, say the tracking is off
472
+ * once and let them opt in deliberately. Silent when it's already handled, so
473
+ * this adds nothing to the common session-start path.
474
+ */
475
+ function nudgeUsageHook(globalClaudeDir) {
476
+ if (hasMarketplacePlugin(globalClaudeDir)) return; // plugin registers its own
477
+ if (usageHookRegistered(globalClaudeDir)) return;
478
+ console.log(` ${dim}Skill usage tracking is off. Run ${cyan}npx luckiest-co@latest --hook${dim} to turn it on.${reset}`);
479
+ }
480
+
481
+ /**
482
+ * Register the PostToolUse(Skill) telemetry hook in the user's settings.json.
483
+ *
484
+ * The marketplace plugin ships hooks/hooks.json and Claude Code registers it
485
+ * automatically; an npx install has no such mechanism, so until now the npx
486
+ * path shipped no hook at all and reported nothing. This closes that gap.
487
+ *
488
+ * settings.json belongs to the user, so this is a merge, never an overwrite:
489
+ * unknown keys are preserved, an existing PostToolUse array is appended to, and
490
+ * a previous Luckiest entry is replaced rather than duplicated (the install path
491
+ * can change between runs). Best-effort — an unreadable or malformed settings
492
+ * file warns and leaves the install otherwise complete.
493
+ */
494
+ function registerUsageHook(claudeDir, globalClaudeDir, consented) {
495
+ const hookPath = path.join(claudeDir, 'hooks', 'report-skill-usage.mjs');
496
+ const settingsPath = path.join(claudeDir, 'settings.json');
497
+
498
+ let settings = {};
499
+ if (fs.existsSync(settingsPath)) {
500
+ try {
501
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
502
+ } catch {
503
+ console.log(` ${yellow}!${reset} Couldn't parse settings.json — skipped hook registration.`);
504
+ console.log(` ${dim}Skill usage won't be tracked from Claude Code until it's valid JSON.${reset}`);
505
+ return;
506
+ }
507
+ }
508
+
509
+ settings.hooks = settings.hooks || {};
510
+ const existing = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
511
+ const others = existing.filter((e) => !isOurHookEntry(e));
512
+ const hadOurs = others.length !== existing.length;
513
+
514
+ // Declining is also an instruction to remove a hook a past install added.
515
+ // Cleanup never needs consent — only adding does.
516
+ if (!consented) {
517
+ if (!hadOurs) return;
518
+ settings.hooks.PostToolUse = others;
519
+ if (writeSettings(settingsPath, settings)) {
520
+ console.log(` ${green}✓${reset} Removed the usage hook from settings.json`);
521
+ }
522
+ return;
523
+ }
524
+
525
+ // The marketplace plugin already registers this hook via hooks.json. Adding a
526
+ // second registration would fire it twice and double every usage count, so
527
+ // when the plugin is present we only clean up a stale copy of our own.
528
+ if (hasMarketplacePlugin(globalClaudeDir)) {
529
+ if (!hadOurs) {
530
+ console.log(` ${dim}Skipped usage hook (plugin marketplace already provides it)${reset}`);
531
+ return;
532
+ }
533
+ settings.hooks.PostToolUse = others;
534
+ writeSettings(settingsPath, settings);
535
+ console.log(` ${green}✓${reset} Removed duplicate usage hook (plugin marketplace already provides it)`);
536
+ return;
537
+ }
538
+
539
+ settings.hooks.PostToolUse = [
540
+ ...others,
541
+ {
542
+ matcher: 'Skill',
543
+ hooks: [{ type: 'command', command: `node "${hookPath}"` }],
544
+ },
545
+ ];
546
+ if (writeSettings(settingsPath, settings)) {
547
+ console.log(` ${green}✓${reset} Registered skill usage hook in settings.json`);
548
+ }
549
+ }
550
+
551
+ function writeSettings(settingsPath, settings) {
552
+ try {
553
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
554
+ return true;
555
+ } catch (err) {
556
+ console.log(` ${yellow}!${reset} Couldn't write settings.json (${err.message}) — skipped hook registration.`);
557
+ return false;
558
+ }
404
559
  }
405
560
 
406
561
  /**
@@ -454,7 +609,14 @@ async function main() {
454
609
  isGlobal = await promptLocation();
455
610
  }
456
611
 
457
- install(isGlobal);
612
+ const { claudeDir, globalClaudeDir } = install(isGlobal);
613
+
614
+ // Global installs only: a project-level hook would land in a shared repo and
615
+ // fire for every collaborator, so ./.claude installs get the files but no
616
+ // registration.
617
+ if (isGlobal) {
618
+ registerUsageHook(claudeDir, globalClaudeDir, await shouldRegisterHook());
619
+ }
458
620
 
459
621
  const alreadyHasKey = !!readSavedKey();
460
622
  if (!alreadyHasKey) {
@@ -0,0 +1,16 @@
1
+ {
2
+ "description": "Luckiest plugin hooks — deterministic skill-usage telemetry on every Luckiest skill run.",
3
+ "hooks": {
4
+ "PostToolUse": [
5
+ {
6
+ "matcher": "Skill",
7
+ "hooks": [
8
+ {
9
+ "type": "command",
10
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/report-skill-usage.mjs\""
11
+ }
12
+ ]
13
+ }
14
+ ]
15
+ }
16
+ }
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse(Skill) hook: records one skill-usage telemetry row per Luckiest
3
+ // skill run, deterministically — no dependence on the model remembering to call
4
+ // report_usage, and no MCP tool required. Metadata only (slug + matched/success);
5
+ // never sends prompt text or tool output. Best-effort: any failure is swallowed
6
+ // so a tracking hiccup can never break the user's skill run.
7
+ // ponytail: fire-and-forget POST; if telemetry ever needs delivery guarantees,
8
+ // queue to disk and flush on SessionStart instead.
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join, dirname } from "node:path";
12
+ import { fileURLToPath } from "node:url";
13
+ import { homedir, hostname } from "node:os";
14
+ import { createHash } from "node:crypto";
15
+
16
+ const API = (process.env.LUCKIEST_API_URL || "https://api.luckiest.co").replace(/\/$/, "");
17
+ // Env var wins; otherwise fall back to the key the installer saves to
18
+ // ~/.luckiest/key, so usage is attributed without any settings.json env block.
19
+ const KEY = process.env.LUCKIEST_SKILL_KEY || (() => {
20
+ try {
21
+ return readFileSync(join(homedir(), ".luckiest", "key"), "utf8").trim();
22
+ } catch {
23
+ return "";
24
+ }
25
+ })();
26
+
27
+ // Marketplace installs never run bin/install.js, so they have no key and used to
28
+ // report with a null reporter_hash — invisible to count(DISTINCT reporter_hash),
29
+ // which undercounted unique installs to zero for that whole population. When no
30
+ // key exists, send a stable per-install id instead: a salted sha256 of hostname
31
+ // + home dir. Not an account and not reversible to one; it only says "same
32
+ // install as last time". A real key always wins, so keyed members still link to
33
+ // their member id server-side.
34
+ // ponytail: hostname+homedir is the cheapest stable pair available to a hook;
35
+ // if installs ever need to survive a machine rename, write a random id to
36
+ // ~/.luckiest/install-id instead.
37
+ const INSTALL_ID = KEY
38
+ ? ""
39
+ : createHash("sha256").update(`luckiest-install:${hostname()}:${homedir()}`).digest("hex");
40
+
41
+ // The plugin's own version, read once from its manifest. Every Luckiest skill
42
+ // ships inside this one plugin and bumps with it, so reporting this per run lets
43
+ // the server see which plugin version each member is actually on. Best-effort:
44
+ // if the manifest can't be read, we just omit the field.
45
+ // CLAUDE_PLUGIN_ROOT is set only for marketplace plugin installs. An npx
46
+ // install registers this hook by absolute path with no such env var, so fall
47
+ // back to the manifest sitting next to the installed hook (…/hooks/../
48
+ // .claude-plugin/plugin.json). Without this every npx row reported a null
49
+ // version and silently skewed version-adoption numbers.
50
+ const PLUGIN_VERSION = (() => {
51
+ const roots = [
52
+ process.env.CLAUDE_PLUGIN_ROOT,
53
+ join(dirname(fileURLToPath(import.meta.url)), ".."),
54
+ ].filter(Boolean);
55
+ for (const root of roots) {
56
+ try {
57
+ const manifest = JSON.parse(readFileSync(join(root, ".claude-plugin", "plugin.json"), "utf8"));
58
+ if (manifest?.version) return manifest.version;
59
+ } catch {
60
+ /* try the next root */
61
+ }
62
+ }
63
+ return null;
64
+ })();
65
+
66
+ const ok = () => { process.stdout.write('{"continue":true,"suppressOutput":true}'); process.exit(0); };
67
+
68
+ let raw = "";
69
+ process.stdin.on("data", (c) => (raw += c));
70
+ process.stdin.on("end", async () => {
71
+ try {
72
+ const evt = JSON.parse(raw || "{}");
73
+ // Skill tool input: { skill: "luckiest:luckiest-aso" | "luckiest:charms" | ... }
74
+ const skill = String(evt?.tool_input?.skill || "");
75
+ // Identify our plugin's skills two ways, so tracking survives whether the host
76
+ // passes the namespaced form or a bare name. Foreign skills (superpowers,
77
+ // vercel, gsd, …) match neither and are ignored so we don't post their runs.
78
+ // "luckiest:<slug>" — namespaced plugin skill (charms, plan, luckiest-aso, …)
79
+ // "luckiest-<slug>" — bare marketing/coder skill name
80
+ let slug;
81
+ if (skill.startsWith("luckiest:")) slug = skill.slice("luckiest:".length);
82
+ else if (skill.startsWith("luckiest-")) slug = skill;
83
+ else return ok();
84
+ // server resolves slug -> listing id
85
+
86
+ const headers = { "content-type": "application/json" };
87
+ if (KEY) headers.authorization = `Bearer ${KEY}`; // sets reporter_hash; optional
88
+
89
+ // 3s cap: telemetry must never stall the session.
90
+ const ctrl = new AbortController();
91
+ const timer = setTimeout(() => ctrl.abort(), 3000);
92
+ await fetch(`${API}/api/skills/telemetry`, {
93
+ method: "POST",
94
+ headers,
95
+ // No success flag: the hook fires at PostToolUse time and never observes
96
+ // the outcome. Real outcomes arrive via report_usage (outcome_known=true).
97
+ body: JSON.stringify({
98
+ listing_id: slug,
99
+ matched: true,
100
+ skill_version: PLUGIN_VERSION,
101
+ surface: "hook",
102
+ // Only sent when there's no key; the server prefers the key's hash.
103
+ install_hash: INSTALL_ID || undefined,
104
+ session_hash: evt?.session_id
105
+ ? createHash("sha256").update(String(evt.session_id)).digest("hex")
106
+ : undefined,
107
+ }),
108
+ signal: ctrl.signal,
109
+ }).catch(() => {});
110
+ clearTimeout(timer);
111
+ } catch {
112
+ /* best-effort — never fail the tool */
113
+ }
114
+ ok();
115
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "luckiest-co",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "Luckiest for Claude Code: plan, go, finish. Your luckiest.co skills and tribe, inside Claude.",
5
5
  "bin": {
6
6
  "luckiest-co": "./bin/install.js"
@@ -11,6 +11,7 @@
11
11
  "references",
12
12
  "templates",
13
13
  "skills",
14
+ "hooks",
14
15
  ".claude-plugin"
15
16
  ],
16
17
  "license": "SEE LICENSE IN LICENSE",