luckiest-co 1.0.7 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -0
- package/bin/install.js +137 -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() {
|
|
@@ -388,7 +393,7 @@ function install(isGlobal) {
|
|
|
388
393
|
}
|
|
389
394
|
|
|
390
395
|
// Copy references/, templates/, .claude-plugin/ into the target root
|
|
391
|
-
const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin'];
|
|
396
|
+
const topLevelDirs = ['references', 'templates', 'skills', '.claude-plugin', 'hooks'];
|
|
392
397
|
for (const dir of topLevelDirs) {
|
|
393
398
|
const dirSrc = path.join(src, dir);
|
|
394
399
|
const dirDest = path.join(claudeDir, dir);
|
|
@@ -401,6 +406,129 @@ function install(isGlobal) {
|
|
|
401
406
|
console.log(`
|
|
402
407
|
${green}Done!${reset} Launch Claude Code and run ${cyan}/luckiest:plan${reset}.
|
|
403
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
|
+
}
|
|
404
532
|
}
|
|
405
533
|
|
|
406
534
|
/**
|
|
@@ -454,7 +582,14 @@ async function main() {
|
|
|
454
582
|
isGlobal = await promptLocation();
|
|
455
583
|
}
|
|
456
584
|
|
|
457
|
-
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
|
+
}
|
|
458
593
|
|
|
459
594
|
const alreadyHasKey = !!readSavedKey();
|
|
460
595
|
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.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",
|