luckiest-co 1.0.6 → 1.0.8

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.
@@ -9,7 +9,7 @@
9
9
  "name": "luckiest",
10
10
  "source": "./",
11
11
  "description": "Plan, go, finish, plus bundled free Luckiest skills that work in Claude Code, web chat, and Cowork.",
12
- "version": "0.1.7",
12
+ "version": "0.1.8",
13
13
  "author": { "name": "Luckiest" }
14
14
  }
15
15
  ]
@@ -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.7",
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() {
@@ -299,15 +304,42 @@ async function syncSkills() {
299
304
  }
300
305
 
301
306
  /**
302
- * True when the luckiest plugin is already installed through the Claude Code
303
- * plugin marketplace (which registers commands itself).
307
+ * True when the luckiest plugin is installed AND enabled through the Claude
308
+ * Code plugin marketplace (which registers commands itself). A disabled
309
+ * plugin still appears in installed_plugins.json but registers nothing, so
310
+ * treating it as present would delete the user's commands/luckiest copy and
311
+ * leave them with no /luckiest:* commands at all.
304
312
  */
305
313
  function hasMarketplacePlugin(globalClaudeDir) {
306
314
  try {
307
315
  const manifest = JSON.parse(
308
316
  fs.readFileSync(path.join(globalClaudeDir, 'plugins', 'installed_plugins.json'), 'utf8')
309
317
  );
310
- return Object.keys(manifest.plugins || {}).some((k) => k.startsWith('luckiest@'));
318
+ const entries = Object.entries(manifest.plugins || {}).filter(([k]) => k.startsWith('luckiest@'));
319
+ if (entries.length === 0) return false;
320
+
321
+ // Some Claude Code versions record enabled state on the install entry itself.
322
+ const entryDisabled = entries.every(([, v]) => {
323
+ const items = Array.isArray(v) ? v : [v];
324
+ return items.every((item) => item && typeof item === 'object' && item.enabled === false);
325
+ });
326
+ if (entryDisabled) return false;
327
+
328
+ // Others track it in settings.json under enabledPlugins ("name@marketplace": bool).
329
+ try {
330
+ const settings = JSON.parse(
331
+ fs.readFileSync(path.join(globalClaudeDir, 'settings.json'), 'utf8')
332
+ );
333
+ const enabledMap = settings.enabledPlugins || {};
334
+ const flags = entries
335
+ .map(([k]) => enabledMap[k])
336
+ .filter((flag) => typeof flag === 'boolean');
337
+ if (flags.length > 0 && flags.every((flag) => flag === false)) return false;
338
+ } catch {
339
+ // No readable settings.json — fall through to "installed means enabled".
340
+ }
341
+
342
+ return true;
311
343
  } catch {
312
344
  return false;
313
345
  }
@@ -361,7 +393,7 @@ function install(isGlobal) {
361
393
  }
362
394
 
363
395
  // Copy references/, templates/, .claude-plugin/ into the target root
364
- const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin'];
396
+ const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin', 'hooks'];
365
397
  for (const dir of topLevelDirs) {
366
398
  const dirSrc = path.join(src, dir);
367
399
  const dirDest = path.join(claudeDir, dir);
@@ -374,6 +406,129 @@ function install(isGlobal) {
374
406
  console.log(`
375
407
  ${green}Done!${reset} Launch Claude Code and run ${cyan}/luckiest:plan${reset}.
376
408
  `);
409
+
410
+ return { claudeDir, globalClaudeDir: defaultGlobalDir };
411
+ }
412
+
413
+ /**
414
+ * Ask before touching settings.json. Returns true when we may register.
415
+ *
416
+ * Order: explicit flags win, then a prompt when there's a terminal to ask at.
417
+ * With no TTY (CI, piped installs) we decline rather than default to yes —
418
+ * silently editing a config file nobody was asked about is not a default worth
419
+ * having. --hook opts in for those cases.
420
+ */
421
+ async function shouldRegisterHook() {
422
+ if (hasNoHook) return false;
423
+ if (hasHook) return true;
424
+ if (!process.stdin.isTTY) {
425
+ console.log(` ${dim}Skipped usage-hook setup (no terminal to ask at). Re-run with ${cyan}--hook${dim} to enable.${reset}`);
426
+ return false;
427
+ }
428
+
429
+ console.log(`
430
+ ${yellow}Track your skill usage?${reset}
431
+ ${dim}Adds a hook to settings.json that reports which Luckiest skill ran, and
432
+ its version — never your prompts, tool output, or file contents. Powers your
433
+ usage stats at luckiest.co. You can remove it any time.${reset}
434
+ `);
435
+ return new Promise((resolve) => {
436
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
437
+ let answered = false;
438
+ rl.question(` Enable? ${dim}[Y/n]${reset}: `, (answer) => {
439
+ answered = true;
440
+ rl.close();
441
+ resolve(!/^n/i.test(answer.trim()));
442
+ });
443
+ // Input closed before an answer arrived (piped stdin that ran dry, ^D).
444
+ // Decline: an unanswered consent prompt is not consent.
445
+ rl.on('close', () => {
446
+ if (!answered) resolve(false);
447
+ });
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Register the PostToolUse(Skill) telemetry hook in the user's settings.json.
453
+ *
454
+ * The marketplace plugin ships hooks/hooks.json and Claude Code registers it
455
+ * automatically; an npx install has no such mechanism, so until now the npx
456
+ * path shipped no hook at all and reported nothing. This closes that gap.
457
+ *
458
+ * settings.json belongs to the user, so this is a merge, never an overwrite:
459
+ * unknown keys are preserved, an existing PostToolUse array is appended to, and
460
+ * a previous Luckiest entry is replaced rather than duplicated (the install path
461
+ * can change between runs). Best-effort — an unreadable or malformed settings
462
+ * file warns and leaves the install otherwise complete.
463
+ */
464
+ function registerUsageHook(claudeDir, globalClaudeDir, consented) {
465
+ const hookPath = path.join(claudeDir, 'hooks', 'report-skill-usage.mjs');
466
+ const settingsPath = path.join(claudeDir, 'settings.json');
467
+ // Identifies our entry across installs even when the absolute path changed.
468
+ const isOurs = (entry) =>
469
+ (entry?.hooks || []).some((h) => String(h?.command || '').includes('report-skill-usage.mjs'));
470
+
471
+ let settings = {};
472
+ if (fs.existsSync(settingsPath)) {
473
+ try {
474
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
475
+ } catch {
476
+ console.log(` ${yellow}!${reset} Couldn't parse settings.json — skipped hook registration.`);
477
+ console.log(` ${dim}Skill usage won't be tracked from Claude Code until it's valid JSON.${reset}`);
478
+ return;
479
+ }
480
+ }
481
+
482
+ settings.hooks = settings.hooks || {};
483
+ const existing = Array.isArray(settings.hooks.PostToolUse) ? settings.hooks.PostToolUse : [];
484
+ const others = existing.filter((e) => !isOurs(e));
485
+ const hadOurs = others.length !== existing.length;
486
+
487
+ // Declining is also an instruction to remove a hook a past install added.
488
+ // Cleanup never needs consent — only adding does.
489
+ if (!consented) {
490
+ if (!hadOurs) return;
491
+ settings.hooks.PostToolUse = others;
492
+ if (writeSettings(settingsPath, settings)) {
493
+ console.log(` ${green}✓${reset} Removed the usage hook from settings.json`);
494
+ }
495
+ return;
496
+ }
497
+
498
+ // The marketplace plugin already registers this hook via hooks.json. Adding a
499
+ // second registration would fire it twice and double every usage count, so
500
+ // when the plugin is present we only clean up a stale copy of our own.
501
+ if (hasMarketplacePlugin(globalClaudeDir)) {
502
+ if (!hadOurs) {
503
+ console.log(` ${dim}Skipped usage hook (plugin marketplace already provides it)${reset}`);
504
+ return;
505
+ }
506
+ settings.hooks.PostToolUse = others;
507
+ writeSettings(settingsPath, settings);
508
+ console.log(` ${green}✓${reset} Removed duplicate usage hook (plugin marketplace already provides it)`);
509
+ return;
510
+ }
511
+
512
+ settings.hooks.PostToolUse = [
513
+ ...others,
514
+ {
515
+ matcher: 'Skill',
516
+ hooks: [{ type: 'command', command: `node "${hookPath}"` }],
517
+ },
518
+ ];
519
+ if (writeSettings(settingsPath, settings)) {
520
+ console.log(` ${green}✓${reset} Registered skill usage hook in settings.json`);
521
+ }
522
+ }
523
+
524
+ function writeSettings(settingsPath, settings) {
525
+ try {
526
+ fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
527
+ return true;
528
+ } catch (err) {
529
+ console.log(` ${yellow}!${reset} Couldn't write settings.json (${err.message}) — skipped hook registration.`);
530
+ return false;
531
+ }
377
532
  }
378
533
 
379
534
  /**
@@ -427,7 +582,14 @@ async function main() {
427
582
  isGlobal = await promptLocation();
428
583
  }
429
584
 
430
- install(isGlobal);
585
+ const { claudeDir, globalClaudeDir } = install(isGlobal);
586
+
587
+ // Global installs only: a project-level hook would land in a shared repo and
588
+ // fire for every collaborator, so ./.claude installs get the files but no
589
+ // registration.
590
+ if (isGlobal) {
591
+ registerUsageHook(claudeDir, globalClaudeDir, await shouldRegisterHook());
592
+ }
431
593
 
432
594
  const alreadyHasKey = !!readSavedKey();
433
595
  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.6",
3
+ "version": "1.0.8",
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",