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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -0
- package/bin/install.js +164 -2
- package/hooks/hooks.json +16 -0
- package/hooks/report-skill-usage.mjs +115 -0
- package/package.json +2 -1
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) {
|
package/hooks/hooks.json
ADDED
|
@@ -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.
|
|
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",
|