devlyn-cli 2.5.6 → 2.6.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/README.md CHANGED
@@ -27,7 +27,7 @@ If devlyn-cli saved you time, [give it a star](https://github.com/fysoul17/devly
27
27
  npx devlyn-cli
28
28
  ```
29
29
 
30
- That's it. The interactive installer handles everything. Claude Code config is installed by default; optional AI CLI instructions can be selected during install. Choose **Codex CLI (OpenAI)** to install `AGENTS.md` and the `devlyn:resolve`, `devlyn:ideate`, and `devlyn:design-ui` skills into `~/.codex/skills/`. In Codex, invoke them as skills with `$devlyn:resolve`, `$devlyn:ideate`, or `$devlyn:design-ui` rather than Claude Code slash commands. Run it again anytime to update.
30
+ That's it. The installer opens with a single **agent selector** — pick any combination of **Claude Code**, **Codex CLI**, **oh-my-pi (omp)**, **Pi**, Gemini, Cursor, Copilot, or Windsurf, and devlyn installs into every one you choose in a single pass. Claude Code and any agent already present on your machine are pre-checked, so the common case is just Enter. Skill-capable agents receive the `devlyn:resolve`, `devlyn:ideate`, and `devlyn:design-ui` skills in the directory each one loads from: Codex → `~/.codex/skills/`, while **omp and Pi share `~/.agents/skills/`** — the cross-agent standard both read — so the bundle is written there once, not duplicated per agent. In Codex / omp / Pi, invoke them as skills (`$devlyn:resolve`, `$devlyn:ideate`, `$devlyn:design-ui`); in Claude Code they're slash commands (`/devlyn:resolve`). Run it again anytime to update. (`npx devlyn-cli -y` installs the Claude core non-interactively; `npx devlyn-cli agents <cli>` adds one agent later.)
31
31
 
32
32
  ---
33
33
 
@@ -39,7 +39,7 @@ devlyn-cli turns Claude Code into a hands-free development pipeline. The pipelin
39
39
  ideate (optional) → resolve → ship
40
40
  ```
41
41
 
42
- Codex note: when the optional Codex install is selected, these workflows are installed as Codex skills. Use `$devlyn:ideate`, `$devlyn:resolve`, or `$devlyn:design-ui` in Codex; the `/devlyn:*` slash-command form is for Claude Code.
42
+ Non-Claude agents (Codex / omp / Pi): when one of these is selected, the workflows install as that agent's skills. Use `$devlyn:ideate`, `$devlyn:resolve`, or `$devlyn:design-ui`; the `/devlyn:*` slash-command form is for Claude Code.
43
43
 
44
44
  ### Step 1 (optional) — Plan with `/devlyn:ideate`
45
45
 
package/bin/devlyn.js CHANGED
@@ -11,6 +11,19 @@ const AGENTS_SOURCE = path.join(__dirname, '..', 'agents-config');
11
11
  const OPTIONAL_SKILLS_SOURCE = path.join(__dirname, '..', 'optional-skills');
12
12
  const PKG = require('../package.json');
13
13
 
14
+ // The devlyn skill bundle installed into every skill-capable agent's loader
15
+ // directory. Single source of truth so codex/omp/pi stay in lockstep — adding a
16
+ // skill here installs it everywhere. Standards skills (code-*, root-cause-*,
17
+ // ui-*) stay Claude-core-only, matching the pre-existing Codex bundle.
18
+ const DEVLYN_CORE_SKILLS = ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui', '_shared'];
19
+
20
+ // Cross-agent shared skills directory read by BOTH oh-my-pi and Pi. Verified
21
+ // from the omp binary's skill-provider strings ("skills from .agents/skills —
22
+ // project walk-up + user home") and Pi's docs ("~/.agents/skills/"). Installing
23
+ // the bundle here once covers both agents — the de-dup driver writes it a single
24
+ // time even when both are selected.
25
+ const SHARED_AGENTS_SKILLS_DIR = path.join(os.homedir(), '.agents', 'skills');
26
+
14
27
  // Cross-CLI agent installation targets
15
28
  // Each entry maps a CLI tool to where its agent instructions should be placed
16
29
  const CLI_TARGETS = {
@@ -22,9 +35,32 @@ const CLI_TARGETS = {
22
35
  // Codex auto-loads skills from ~/.codex/skills/ (user-global). Same
23
36
  // SKILL.md format as Claude Code; descriptions must stay ≤1024 chars.
24
37
  skillsDir: path.join(os.homedir(), '.codex', 'skills'),
25
- skillsToInstall: ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui', '_shared'],
38
+ skillsToInstall: DEVLYN_CORE_SKILLS,
26
39
  detect: () => fs.existsSync(path.join(process.cwd(), 'AGENTS.md')) || fs.existsSync(path.join(process.cwd(), '.codex')),
27
40
  },
41
+ omp: {
42
+ name: 'oh-my-pi (omp)',
43
+ instructionsFile: 'AGENTS.md',
44
+ baseInstructionsFile: 'AGENTS.md',
45
+ configDir: null, // omp discovers AGENTS.md at project root + user home
46
+ // omp loads skills from ~/.agents/skills (user home) — shared with Pi.
47
+ skillsDir: SHARED_AGENTS_SKILLS_DIR,
48
+ skillsToInstall: DEVLYN_CORE_SKILLS,
49
+ // Project-scoped only: machine-level ~/.omp must not auto-trigger a project
50
+ // AGENTS.md write via `npx devlyn-cli agents` in an unrelated repo.
51
+ detect: () => fs.existsSync(path.join(process.cwd(), '.omp')) || fs.existsSync(path.join(process.cwd(), '.agents')),
52
+ },
53
+ pi: {
54
+ name: 'Pi (earendil-works)',
55
+ instructionsFile: 'AGENTS.md',
56
+ baseInstructionsFile: 'AGENTS.md',
57
+ configDir: null, // Pi reads AGENTS.md / CLAUDE.md walking up from cwd
58
+ // Pi loads skills from ~/.agents/skills — shared with oh-my-pi.
59
+ skillsDir: SHARED_AGENTS_SKILLS_DIR,
60
+ skillsToInstall: DEVLYN_CORE_SKILLS,
61
+ // Project-scoped only (see omp): machine-level ~/.pi must not auto-trigger.
62
+ detect: () => fs.existsSync(path.join(process.cwd(), '.pi')) || fs.existsSync(path.join(process.cwd(), '.agents')),
63
+ },
28
64
  gemini: {
29
65
  name: 'Gemini CLI (Google)',
30
66
  instructionsFile: 'GEMINI.md',
@@ -180,6 +216,7 @@ const OPTIONAL_ADDONS = [
180
216
  { name: 'generate-skill', desc: 'Create well-structured Claude Code skills following Anthropic best practices', type: 'local' },
181
217
  { name: 'prompt-engineering', desc: 'Claude 4 prompt optimization using Anthropic best practices', type: 'local' },
182
218
  { name: 'better-auth-setup', desc: 'Production-ready Better Auth + Hono + Drizzle + PostgreSQL auth setup', type: 'local' },
219
+ { name: 'polar-billing-setup', desc: 'Polar usage-based / metered billing — correct setup + diagnose silent $0-billing failures', type: 'local' },
183
220
  { name: 'pyx-scan', desc: 'Check whether an AI agent skill is safe before installing', type: 'local' },
184
221
  { name: 'dokkit', desc: 'Document template filling for DOCX/HWPX — ingest, fill, review, export', type: 'local' },
185
222
  { name: 'devlyn:pencil-pull', desc: 'Pull Pencil designs into code with exact visual fidelity', type: 'local' },
@@ -362,9 +399,9 @@ function cleanManagedSkillDirs(sourceSkillsDir, targetSkillsDir) {
362
399
  return cleaned;
363
400
  }
364
401
 
365
- function multiSelect(items) {
402
+ function multiSelect(items, preselectedIndices = []) {
366
403
  return new Promise((resolve) => {
367
- const selected = new Set();
404
+ const selected = new Set(preselectedIndices.filter((i) => i >= 0 && i < items.length));
368
405
  let cursor = 0;
369
406
  let firstRender = true;
370
407
 
@@ -469,11 +506,15 @@ function installLocalSkill(skillName) {
469
506
  copyRecursive(src, dest, targetDir);
470
507
 
471
508
  // Mirror to every CLI skill-loader directory that already exists so optional
472
- // skills are picked up by Codex (and any future CLI with a skillsDir) the
473
- // same way required skills are. Existing dir, not new dir — we don't create
474
- // a Codex install just because someone opted into a Claude-side skill.
509
+ // skills are picked up by Codex/omp/Pi (and any future CLI with a skillsDir)
510
+ // the same way required skills are. Existing dir, not new dir — we don't
511
+ // create an agent install just because someone opted into a Claude-side skill.
512
+ // De-dup by directory: omp and Pi share ~/.agents/skills, so the mirror runs
513
+ // once per unique destination.
514
+ const mirrored = new Set();
475
515
  for (const cli of Object.values(CLI_TARGETS)) {
476
- if (!cli.skillsDir || !fs.existsSync(cli.skillsDir)) continue;
516
+ if (!cli.skillsDir || mirrored.has(cli.skillsDir) || !fs.existsSync(cli.skillsDir)) continue;
517
+ mirrored.add(cli.skillsDir);
477
518
  const cliDest = path.join(cli.skillsDir, skillName);
478
519
  if (fs.existsSync(cliDest)) fs.rmSync(cliDest, { recursive: true, force: true });
479
520
  copyRecursive(src, cliDest, cli.skillsDir);
@@ -555,94 +596,136 @@ function installSkillsForCLI(cliKey) {
555
596
  return copied;
556
597
  }
557
598
 
558
- function installAgentsForCLI(cliKey) {
599
+ // Strip devlyn's previously-appended "# Devlyn Agent Instructions" block from an
600
+ // instruction file (or the packaged base) before re-appending, so installs stay
601
+ // idempotent and never duplicate the block. Anchors on the LAST marker, not the
602
+ // first: the packaged AGENTS.md ships its own "# Devlyn Agent Instructions"
603
+ // section, so indexOf would truncate that real content — lastIndexOf targets the
604
+ // devlyn-appended block (always last) and preserves everything above it.
605
+ function stripManagedBlock(content) {
606
+ const marker = '# Devlyn Agent Instructions';
607
+ const markerIdx = content.lastIndexOf(marker);
608
+ if (markerIdx > 0) {
609
+ const sepIdx = content.lastIndexOf('\n---', markerIdx);
610
+ return content.slice(0, sepIdx > 0 ? sepIdx : markerIdx).trimEnd();
611
+ }
612
+ return content.trimEnd();
613
+ }
614
+
615
+ // One-line description of exactly what selecting a CLI target installs, so the
616
+ // unified selector stays honest — "Gemini" (instructions only) must not look
617
+ // identical to "Codex" (instructions + skills).
618
+ function targetDesc(cli) {
619
+ if (cli.skillsDir) {
620
+ return `${cli.instructionsFile} + devlyn skills → ${cli.skillsDir.replace(os.homedir(), '~')}`;
621
+ }
622
+ if (cli.configDir) return `Agent instructions → ${cli.configDir}/`;
623
+ return `${cli.instructionsFile} instructions only`;
624
+ }
625
+
626
+ // Install only the instruction file(s) for a CLI (no skills). Returns true when
627
+ // instruction content was written. Split from skills so the de-dup driver can
628
+ // write a shared destination (e.g. project AGENTS.md for codex/omp/pi) once.
629
+ function installInstructionsForCLI(cliKey) {
559
630
  const cli = CLI_TARGETS[cliKey];
560
631
  if (!cli) return false;
561
632
 
562
633
  const agents = fs.existsSync(AGENTS_SOURCE)
563
634
  ? fs.readdirSync(AGENTS_SOURCE).filter((f) => f.endsWith('.md'))
564
635
  : [];
636
+ if (agents.length === 0) return false;
565
637
 
566
- if (agents.length > 0) {
567
- log(`\n🤖 Installing agents for ${cli.name}...`, 'cyan');
638
+ log(`\n🤖 Installing agent instructions for ${cli.name}...`, 'cyan');
568
639
 
569
- if (cli.configDir) {
570
- // CLI supports an agents directory — copy agent files there
571
- const destDir = path.join(process.cwd(), cli.configDir);
572
- if (!fs.existsSync(destDir)) {
573
- fs.mkdirSync(destDir, { recursive: true });
574
- }
575
- for (const file of agents) {
576
- const src = path.join(AGENTS_SOURCE, file);
577
- const dest = path.join(destDir, file);
578
- fs.copyFileSync(src, dest);
579
- log(` → ${cli.configDir}/${file}`, 'dim');
580
- }
581
- } else {
582
- // CLI uses a single instructions file — append agent content
583
- const destFile = path.join(process.cwd(), cli.instructionsFile);
584
- const separator = '\n\n---\n\n# Devlyn Agent Instructions\n\n';
585
- const agentContent = agents.map((file) => {
586
- return fs.readFileSync(path.join(AGENTS_SOURCE, file), 'utf8');
587
- }).join('\n\n---\n\n');
588
-
589
- let existing = '';
590
- if (fs.existsSync(destFile)) {
591
- existing = fs.readFileSync(destFile, 'utf8');
592
- // Remove previous devlyn agent section if present
593
- const devlynMarker = '# Devlyn Agent Instructions';
594
- const markerIdx = existing.indexOf(devlynMarker);
595
- if (markerIdx > 0) {
596
- // Find the separator before the marker (---\n\n)
597
- const sepIdx = existing.lastIndexOf('---', markerIdx);
598
- existing = existing.slice(0, sepIdx > 0 ? sepIdx : markerIdx).trimEnd();
599
- }
600
- } else if (cli.baseInstructionsFile) {
601
- const baseInstructionsSrc = path.join(__dirname, '..', cli.baseInstructionsFile);
602
- if (fs.existsSync(baseInstructionsSrc)) {
603
- existing = fs.readFileSync(baseInstructionsSrc, 'utf8').trimEnd();
604
- }
640
+ if (cli.configDir) {
641
+ // CLI supports an agents directory — copy agent files there
642
+ const destDir = path.join(process.cwd(), cli.configDir);
643
+ if (!fs.existsSync(destDir)) {
644
+ fs.mkdirSync(destDir, { recursive: true });
645
+ }
646
+ for (const file of agents) {
647
+ const src = path.join(AGENTS_SOURCE, file);
648
+ const dest = path.join(destDir, file);
649
+ fs.copyFileSync(src, dest);
650
+ log(` → ${cli.configDir}/${file}`, 'dim');
651
+ }
652
+ } else {
653
+ // CLI uses a single instructions file — append agent content
654
+ const destFile = path.join(process.cwd(), cli.instructionsFile);
655
+ const separator = '\n\n---\n\n# Devlyn Agent Instructions\n\n';
656
+ const agentContent = agents.map((file) => {
657
+ return fs.readFileSync(path.join(AGENTS_SOURCE, file), 'utf8');
658
+ }).join('\n\n---\n\n');
659
+
660
+ let existing = '';
661
+ if (fs.existsSync(destFile)) {
662
+ existing = stripManagedBlock(fs.readFileSync(destFile, 'utf8'));
663
+ } else if (cli.baseInstructionsFile) {
664
+ const baseInstructionsSrc = path.join(__dirname, '..', cli.baseInstructionsFile);
665
+ if (fs.existsSync(baseInstructionsSrc)) {
666
+ existing = stripManagedBlock(fs.readFileSync(baseInstructionsSrc, 'utf8'));
605
667
  }
606
-
607
- fs.writeFileSync(destFile, existing + separator + agentContent + '\n');
608
- log(` → ${cli.instructionsFile} (agent instructions appended)`, 'dim');
609
668
  }
669
+
670
+ fs.writeFileSync(destFile, existing + separator + agentContent + '\n');
671
+ log(` → ${cli.instructionsFile} (agent instructions appended)`, 'dim');
610
672
  }
673
+ return true;
674
+ }
611
675
 
612
- // Always install global skills (Codex-style skill loader) regardless of
613
- // whether agent files were present. Agent install and skill install are
614
- // independent capabilities.
676
+ // Install both instructions and skills for a single CLI. Used by the
677
+ // `agents <cli>` command; multi-target paths use installSelectedCLITargets.
678
+ function installAgentsForCLI(cliKey) {
679
+ const instr = installInstructionsForCLI(cliKey);
615
680
  const skillsCopied = installSkillsForCLI(cliKey);
616
681
  if (skillsCopied > 0) {
617
682
  log(` → ${skillsCopied} skill${skillsCopied > 1 ? 's' : ''} installed (devlyn:resolve / devlyn:ideate / devlyn:design-ui / _shared)`, 'dim');
618
683
  }
684
+ return instr || skillsCopied > 0;
685
+ }
619
686
 
620
- return agents.length > 0 || skillsCopied > 0;
687
+ // Install instructions + skills for a set of selected CLI targets, writing each
688
+ // unique destination exactly once. omp and Pi share project AGENTS.md and the
689
+ // ~/.agents/skills dir, so selecting both does not duplicate writes — this is
690
+ // the researched "group the common" model: targets are the user's selection,
691
+ // destinations are the unit of work.
692
+ function installSelectedCLITargets(cliKeys) {
693
+ const instrDone = new Set();
694
+ const skillsDone = new Set();
695
+ let count = 0;
696
+ for (const cliKey of cliKeys) {
697
+ const cli = CLI_TARGETS[cliKey];
698
+ if (!cli) continue;
699
+ const instrKey = cli.configDir ? `dir:${cli.configDir}` : `file:${cli.instructionsFile}`;
700
+ if (!instrDone.has(instrKey)) {
701
+ installInstructionsForCLI(cliKey);
702
+ instrDone.add(instrKey);
703
+ }
704
+ if (cli.skillsDir && !skillsDone.has(cli.skillsDir)) {
705
+ const copied = installSkillsForCLI(cliKey);
706
+ if (copied > 0) {
707
+ log(` → ${copied} skill${copied > 1 ? 's' : ''} → ${cli.skillsDir.replace(os.homedir(), '~')}`, 'dim');
708
+ }
709
+ skillsDone.add(cli.skillsDir);
710
+ }
711
+ count++;
712
+ }
713
+ return count;
621
714
  }
622
715
 
623
716
  function installAgentsForAllDetected() {
624
717
  const detected = detectOtherCLIs();
625
718
  if (detected.length === 0) return 0;
626
-
627
- let installed = 0;
628
- for (const cliKey of detected) {
629
- if (installAgentsForCLI(cliKey)) installed++;
630
- }
631
- return installed;
719
+ return installSelectedCLITargets(detected);
632
720
  }
633
721
 
634
- async function init(skipPrompts = false) {
635
- showLogo();
636
- log('─'.repeat(44), 'dim');
637
-
638
- if (!fs.existsSync(CONFIG_SOURCE)) {
639
- log('❌ Config source not found', 'yellow');
640
- process.exit(1);
641
- }
642
-
643
- // Install core config
722
+ // Install the Claude Code core config: project .claude/ (skills, templates,
723
+ // settings, CLAUDE.md, .gitignore) plus global ~/.claude/settings.json tweaks.
724
+ // Extracted so the unified target selector installs it only when "Claude Code"
725
+ // is chosen. Behavior is unchanged from the previous unconditional core install.
726
+ function installClaudeCore() {
644
727
  const targetDir = getTargetDir();
645
- log('\n📁 Installing core config to .claude/', 'green');
728
+ log('\n📁 Installing Claude Code config to .claude/', 'green');
646
729
  const refreshed = cleanManagedSkillDirs(
647
730
  path.join(CONFIG_SOURCE, 'skills'),
648
731
  path.join(targetDir, 'skills'),
@@ -746,40 +829,55 @@ async function init(skipPrompts = false) {
746
829
  log(' → ~/.claude/settings.json (disabled adaptive thinking, enabled 1h prompt caching)', 'dim');
747
830
  }
748
831
 
749
- log('\n✅ Core config installed!', 'green');
832
+ log('\n✅ Claude Code config installed!', 'green');
833
+ }
834
+
835
+ async function init(skipPrompts = false) {
836
+ showLogo();
837
+ log('─'.repeat(44), 'dim');
750
838
 
751
- // Skip prompts if -y flag or non-interactive
839
+ if (!fs.existsSync(CONFIG_SOURCE)) {
840
+ log('❌ Config source not found', 'yellow');
841
+ process.exit(1);
842
+ }
843
+
844
+ // Non-interactive / -y: preserve historical behavior — install Claude core
845
+ // only. Multi-target selection needs a TTY; scripted runs add other agents
846
+ // via `npx devlyn-cli agents <cli>`.
752
847
  if (skipPrompts || !process.stdin.isTTY) {
848
+ installClaudeCore();
753
849
  log('\n💡 Add optional addons later: run `npx devlyn-cli` without -y', 'dim');
754
- log(' Add Codex instructions + skills later: run `npx devlyn-cli agents codex`', 'dim');
850
+ log(' Add another agent (Codex / omp / Pi / …) later: `npx devlyn-cli agents <cli>`', 'dim');
755
851
  log(`\n${COLORS.dim} Enjoying devlyn? Star it on GitHub — it helps others find it:${COLORS.reset}`);
756
852
  log(` ${COLORS.purple}→ https://github.com/fysoul17/devlyn-cli${COLORS.reset}\n`);
757
853
  return;
758
854
  }
759
855
 
760
- // Ask which non-Claude CLIs should receive instruction files.
761
- log('\n🤖 Optional AI CLI instructions:\n', 'blue');
762
- const cliOptions = Object.entries(CLI_TARGETS).map(([key, cli]) => {
763
- let desc;
764
- if (cli.configDir) {
765
- desc = `Install agents into ${cli.configDir}/`;
766
- } else if (cli.skillsDir) {
767
- desc = `Install ${cli.instructionsFile} + devlyn:resolve/devlyn:ideate/devlyn:design-ui skills (~/.codex/skills/; use $devlyn:* in Codex)`;
768
- } else {
769
- desc = `Install ${cli.instructionsFile}`;
770
- }
771
- return { key, name: cli.name, desc, type: 'cli' };
772
- });
773
- const selectedClis = await multiSelect(cliOptions);
774
- if (selectedClis.length > 0) {
775
- let agentsInstalled = 0;
776
- for (const selectedCli of selectedClis) {
777
- if (installAgentsForCLI(selectedCli.key)) agentsInstalled++;
778
- }
779
- log(` ✅ Agent instructions installed for ${agentsInstalled} CLI${agentsInstalled !== 1 ? 's' : ''}`, 'green');
780
- } else {
781
- log('💡 No additional CLI instructions selected', 'dim');
782
- log(' Run `npx devlyn-cli agents codex` later to install Codex AGENTS.md + devlyn skills', 'dim');
856
+ // Pick every agent to install devlyn into, up front. Claude Code is
857
+ // pre-checked (the happy path); every other agent is an explicit opt-in so a
858
+ // bare Enter never mutates a project's AGENTS.md or a shared skills dir for an
859
+ // agent the user didn't choose.
860
+ log('\n🎯 Select the agents to install devlyn into:\n', 'blue');
861
+ const targetOptions = [
862
+ { key: 'claude', name: 'Claude Code', desc: '.claude/ config — skills, templates, settings, CLAUDE.md', type: 'cli' },
863
+ ...Object.entries(CLI_TARGETS).map(([key, cli]) => ({ key, name: cli.name, desc: targetDesc(cli), type: 'cli' })),
864
+ ];
865
+ const preselected = [targetOptions.findIndex((o) => o.key === 'claude')];
866
+ const selectedKeys = (await multiSelect(targetOptions, preselected)).map((t) => t.key);
867
+
868
+ if (selectedKeys.length === 0) {
869
+ log('\n💡 No agents selected — nothing installed.', 'yellow');
870
+ log(' Run `npx devlyn-cli` again and pick at least one agent.\n', 'dim');
871
+ return;
872
+ }
873
+
874
+ if (selectedKeys.includes('claude')) {
875
+ installClaudeCore();
876
+ }
877
+ const cliKeys = selectedKeys.filter((k) => k !== 'claude');
878
+ if (cliKeys.length > 0) {
879
+ const installed = installSelectedCLITargets(cliKeys);
880
+ log(`\n ✅ Installed devlyn into ${installed} agent${installed !== 1 ? 's' : ''}`, 'green');
783
881
  }
784
882
 
785
883
  // Ask about optional addons (local skills + external packs)
@@ -805,9 +903,9 @@ async function init(skipPrompts = false) {
805
903
  function showHelp() {
806
904
  showLogo();
807
905
  log('Usage:', 'green');
808
- log(' npx devlyn-cli Install/update .claude config');
906
+ log(' npx devlyn-cli Install/update devlyn (pick agents: Claude / Codex / omp / Pi / …)');
809
907
  log(' npx devlyn-cli list List available skills & templates');
810
- log(' npx devlyn-cli -y Install without prompts');
908
+ log(' npx devlyn-cli -y Install Claude core without prompts');
811
909
  log(' npx devlyn-cli agents Install agents for detected CLIs');
812
910
  log(' npx devlyn-cli agents all Install agents for all supported CLIs');
813
911
  log(' npx devlyn-cli benchmark Run the resolve benchmark suite');
@@ -1064,12 +1162,10 @@ switch (command) {
1064
1162
  log('─'.repeat(44), 'dim');
1065
1163
  const subArg = args[1];
1066
1164
  if (subArg === 'all') {
1067
- // Install for all supported CLIs regardless of detection
1165
+ // Install for all supported CLIs regardless of detection. De-duped so the
1166
+ // shared ~/.agents/skills dir (omp + Pi) and shared AGENTS.md write once.
1068
1167
  log('\n🤖 Installing agents for all supported CLIs...', 'blue');
1069
- let count = 0;
1070
- for (const cliKey of Object.keys(CLI_TARGETS)) {
1071
- if (installAgentsForCLI(cliKey)) count++;
1072
- }
1168
+ const count = installSelectedCLITargets(Object.keys(CLI_TARGETS));
1073
1169
  log(`\n✅ Agents installed for ${count} CLI${count !== 1 ? 's' : ''}`, 'green');
1074
1170
  } else if (subArg && CLI_TARGETS[subArg]) {
1075
1171
  // Install for a specific CLI
@@ -0,0 +1,76 @@
1
+ ---
2
+ name: polar-billing-setup
3
+ description: Set up, integrate, and DEBUG Polar (polar.sh) usage-based / metered billing the right way — events ingestion, meters, metered prices, checkout, webhooks, and free-credit reconciliation. Use this whenever the user mentions Polar billing, usage-based or metered billing, "ingest events to Polar", a Polar meter, a metered price, or — critically — when billing looks broken: "Polar isn't charging", "my invoice is $0", "usage events aren't billing", "consumed_units is 0", "metering not working", or "why are customers not being charged". This skill encodes the silent-failure traps that make Polar metering quietly bill $0, and ships a diagnostic script that tells you exactly where the chain is broken. Prefer it over guessing whenever Polar metered billing is involved, even if the user doesn't name the specific failure.
4
+ allowed-tools: Read, Write, Edit, Bash
5
+ argument-hint: [setup | verify | diagnose --customer <id>]
6
+ ---
7
+
8
+ # Polar usage-based billing — setup & verification
9
+
10
+ Polar metered billing is a **chain**, and any single broken link silently bills **$0** with no error. Events still return `inserted: 1`, the dashboard still shows the subscription as active, orders still get created — they're just all `$0`. The failure is invisible unless you check the one number that matters: the meter's `consumed_units`.
11
+
12
+ This skill exists because that chain is easy to misconfigure and the failures are silent. Read this top section always; pull in `references/` and run `scripts/diagnose.mjs` as the task dictates.
13
+
14
+ ## The billing chain (every link must hold)
15
+
16
+ ```
17
+ app emits event ─► meter FILTER matches it ─► meter AGGREGATION sums a property
18
+ (name + (by event name) that EXISTS on the event
19
+ metadata) │
20
+ ▼
21
+ customer is linked ◄─ metered PRICE is attached ◄─ consumed_units > 0
22
+ to a subscription to the meter (not flat/free)
23
+ │
24
+ ▼
25
+ invoice charges at cycle end
26
+ ```
27
+
28
+ Break any arrow and the invoice is `$0`. The most common break — and the one that burns hours — is the **aggregation property name not matching the emitted metadata key** (a `cost_cent` vs `cost_cents` typo, a casing/spacing mismatch). The filter still matches by event name, so events flow in and look fine, but the sum targets a property that doesn't exist, so `consumed_units` stays 0.
29
+
30
+ ## Golden rules (the why behind each)
31
+
32
+ 1. **The aggregation property must byte-match the emitted metadata key.** Polar sums `metadata.<property>`; if the emitter sends `cost_cents` and the meter sums `cost_cent`, the sum is over a missing field → 0. Decide the exact key ONCE and assert it on both ends. Singular/plural/camelCase/snake_case are different keys.
33
+
34
+ 2. **A meter's aggregation is immutable once it has aggregated events.** `PATCH /v1/meters/{id}` on `aggregation` returns `422 "can't be updated because the meter is already aggregating events"`. So you cannot fix a property typo on the meter after go-live. Either get it right at creation, or — the cheaper fix — **change the emitter to match the meter's existing (even misspelled) property**, because that fixes existing subscriptions without re-pointing prices. Recreating the meter means creating a new price and migrating every existing subscription. Document any locked-in misspelling at the emitter so nobody "corrects" it and re-breaks billing.
35
+
36
+ 3. **`inserted: 1` is NOT proof of billing.** It means the event was accepted, not metered. The only proof is `consumed_units > 0` on the customer's meter (`GET /v1/customers/{id}/state` or `GET /v1/meters/{id}/quantities`). Always verify after a change.
37
+
38
+ 4. **Polar does not backfill.** Events ingested before the meter/aggregation/price was correct are NOT retro-counted when you fix the config. Only events after the fix aggregate. Plan a manual order/credit adjustment if past usage must be recovered.
39
+
40
+ 5. **The price must be a metered price bound to the meter.** A product can have a `free`/`fixed` price AND a `metered_unit` price; only the `metered_unit` one with the right `meter_id` produces usage charges. Verify `amount_type: "metered_unit"` and `meter_id` match.
41
+
42
+ 6. **Usage attributes to a linked customer.** Events carry `customer_id` (Polar's id) or `external_customer_id` (your id, set at checkout). If the customer isn't created/linked, the usage is orphaned and never billed. Confirm the customer exists and its `external_id` maps back to your tenant.
43
+
44
+ 7. **Sandbox ≠ production.** Token, API base (`sandbox-api.polar.sh` vs `api.polar.sh`), and product/meter IDs all differ per environment. A sandbox token in prod means no real money moves — and it fails silently. Drive the base + IDs off one env switch.
45
+
46
+ 8. **Token scopes are split.** Ingestion needs `events:write`; reading meters/orders for diagnosis needs `meters:read` / `orders:read`. The minimal ingestion token usually CANNOT read meters, so diagnosis returns `insufficient_scope` — provision a separate read-scoped token for debugging (never widen the production ingestion token just to debug).
47
+
48
+ 9. **Reconcile local free credit with Polar.** If you grant local "first $X free" credit, the emitter must send Polar only the **billable overflow** (`max(0, cost − remainingCredit)`), or Polar will bill the free tier too. Conversely, if Polar grants the credit (a credit benefit), don't also withhold locally. Pick one source of truth and net consistently.
49
+
50
+ 10. **Webhooks must fail loud, not 200-ack.** Returning `200` when customer resolution fails makes Polar treat delivery as successful and never retry — the link is lost forever. Return non-2xx on unresolved linkage; keep handlers idempotent (dedupe by event id).
51
+
52
+ ## Workflow
53
+
54
+ **Setup a new integration** → read `references/setup.md` and follow it in order (meter → product+metered price → checkout → webhook → ingestion). Get the property name and aggregation right the first time (rule 2).
55
+
56
+ **Add metering to an existing integration** → confirm the meter's exact `aggregation.property`, then make the emitter send that exact key. Don't invent a "nicer" key.
57
+
58
+ **Debug "$0 invoice" / "not charging" / "consumed_units is 0"** → run the diagnostic FIRST, before theorizing:
59
+
60
+ ```bash
61
+ POLAR_ACCESS_TOKEN=<read-scoped token> POLAR_SERVER=production \
62
+ node scripts/diagnose.mjs --customer <polar_customer_id> --event-name <name>
63
+ ```
64
+
65
+ It reports, link by link, where the chain breaks: token scope, meter filter+aggregation, whether recent events' metadata keys actually match the aggregation property, `consumed_units`, the product's price type, and recent order totals. Read its verdict, then fix the one broken link. See `references/gotchas.md` for the full failure catalog mapped to symptoms.
66
+
67
+ **After ANY fix** → verify with rule 3: emit one real (or synthetic `cost:1`) event and confirm `consumed_units` increments. A passing unit test on the emitter is necessary but not sufficient — the meter config lives in the Polar dashboard, outside your repo.
68
+
69
+ ## Reference files
70
+
71
+ - `references/setup.md` — step-by-step correct setup (meter, product, metered price, checkout, webhook, ingestion), with the exact API shapes.
72
+ - `references/gotchas.md` — the full silent-failure catalog: symptom → cause → fix, including everything that produced a real $0-billing incident.
73
+
74
+ ## Diagnostic script
75
+
76
+ `scripts/diagnose.mjs` — Node 18+ (global `fetch`). Read-only. Walks the whole chain for one customer and prints a per-link PASS/FAIL verdict. This is the fastest path from "billing seems broken" to "here is the exact broken link." Requires a token with `meters:read` + `orders:read` (see rule 8).
@@ -0,0 +1,111 @@
1
+ # Polar metering failure catalog — symptom → cause → fix
2
+
3
+ Every entry here has produced a real silent-$0-billing incident or is a near-miss the diagnostic checks for. Match the symptom, confirm the cause with `scripts/diagnose.mjs`, apply the fix. Ordered by how often it's the culprit.
4
+
5
+ ---
6
+
7
+ ## 1. Invoice is $0 but events show `inserted:1` — aggregation property typo
8
+
9
+ **Symptom:** Events ingest fine (`{"inserted":1}`), the subscription is active, orders are created — all `$0`. `consumed_units` is 0.
10
+
11
+ **Cause:** The meter's `aggregation.property` doesn't byte-match the emitted `metadata` key. Classic: meter sums `cost_cent` (singular) while the app emits `cost_cents` (plural). The filter still matches by event **name**, so events flow and look healthy, but `sum` targets a non-existent property → 0. Casing (`costCents` vs `cost_cents`) and spaces do the same.
12
+
13
+ **Confirm:** diagnostic link "emitted metadata key matches meter aggregation property" → FAIL. Or by hand: `GET /v1/meters/{id}` `aggregation.property` vs `GET /v1/events/?customer_id=...` metadata keys.
14
+
15
+ **Fix:** The aggregation is **immutable once aggregating** (`PATCH` → `422`), so you usually fix the **emitter** to send the meter's existing property name — even if it's the misspelled one — because that fixes all existing subscriptions without re-pointing prices. Add a comment at the emitter so nobody "corrects" the spelling and re-breaks billing. Then re-emit and confirm `consumed_units` increments. Past events do NOT backfill.
16
+
17
+ ---
18
+
19
+ ## 2. `PATCH /v1/meters/{id}` returns 422 — meter aggregation is locked
20
+
21
+ **Symptom:** Trying to fix the property on the meter returns `422 "This field can't be updated because the meter is already aggregating events."`
22
+
23
+ **Cause:** Polar freezes a meter's aggregation once it has counted any event. By design.
24
+
25
+ **Fix:** Don't fight it. Match the emitter to the meter (entry 1). Creating a *new* meter is a last resort — it forces a new metered price and a migration of every existing subscription to it; the old meter and its subscriptions keep producing $0.
26
+
27
+ ---
28
+
29
+ ## 3. `consumed_units` is 0 but everything else looks right
30
+
31
+ **Symptom:** Filter matches, events exist, but the customer meter shows `consumed: 0`.
32
+
33
+ **Causes (check in order):** (a) aggregation property mismatch (entry 1) — most likely; (b) events ingested *before* the meter/aggregation existed and you're expecting backfill — there is none; (c) the events carry the property but as a **string** when `sum` expects a number — send numbers, not `"1"`; (d) you're reading a different environment than the events landed in (entry 6).
34
+
35
+ **Fix:** Run the diagnostic; it isolates a–d. Remember: only events ingested *after* the config is correct will count.
36
+
37
+ ---
38
+
39
+ ## 4. "It's billing, just small" — actually it's $0
40
+
41
+ **Symptom:** Assuming tiny charges when the real number is exactly 0.
42
+
43
+ **Cause:** Conflating local usage counters / dashboards with the Polar invoice. Your app's "total usage" can be accurate while Polar bills $0 because the meter isn't aggregating.
44
+
45
+ **Fix:** Trust `consumed_units` and `GET /v1/orders` `total_amount`, not your own dashboard, as the billing source of truth. The diagnostic reads both.
46
+
47
+ ---
48
+
49
+ ## 5. New users charged for their "free" credit — local credit not reconciled
50
+
51
+ **Symptom:** Once metering works, users are billed for usage that local logic treated as free signup credit.
52
+
53
+ **Cause:** The emitter sends the **full** cost to Polar while local code separately decrements a free-credit balance. The two ledgers don't talk, so the free tier is billed by Polar anyway.
54
+
55
+ **Fix:** Send Polar only the billable overflow: `billable = max(0, cost − remainingCreditBefore)`, computed atomically (read credit under a row lock in the same transaction as the decrement). Forward `billable` to ingestion; skip ingestion when it's 0. Alternatively configure a matching credit benefit on the Polar product and don't withhold locally — but pick exactly one source of truth.
56
+
57
+ ---
58
+
59
+ ## 6. Works in sandbox, $0 in production (or vice-versa)
60
+
61
+ **Symptom:** Behaves differently across environments, or prod moves no money.
62
+
63
+ **Cause:** A sandbox token / sandbox API base / sandbox product+meter IDs leaked into production (or the inverse). Sandbox and production are separate Polar orgs. A sandbox token in prod silently bills nothing.
64
+
65
+ **Fix:** One env switch driving base URL + token + product/meter IDs. Sandbox tokens commonly look like `polar_oat_…` on `sandbox-api.polar.sh`; verify with `GET /v1/customers/{id}` that the customer/org is the one you expect. The diagnostic takes `POLAR_SERVER=sandbox|production` — run it against both if unsure.
66
+
67
+ ---
68
+
69
+ ## 7. Diagnosis returns `insufficient_scope`
70
+
71
+ **Symptom:** `GET /v1/meters` or `/v1/orders` → `{"error":"insufficient_scope"}`.
72
+
73
+ **Cause:** The production **ingestion** token only needs `events:write`, so it's usually scoped down and can't read meters/orders.
74
+
75
+ **Fix:** Create a separate token with `meters:read` + `orders:read` (+ `customers:read`, `events:read`) just for diagnosis. Don't widen the production ingestion token. Keep the diagnostic token in a local file, never echo it into logs or chat, never commit it.
76
+
77
+ ---
78
+
79
+ ## 8. Customer linked but usage orphaned
80
+
81
+ **Symptom:** Subscription exists, events ingest, but a specific customer's usage never bills.
82
+
83
+ **Cause:** Events sent with a `customer_id`/`external_customer_id` that doesn't resolve to a linked Polar customer, or the customer was never created. Orphaned events aren't billed.
84
+
85
+ **Fix:** Ensure the Polar customer exists and its `external_id` maps to your tenant (set `external_customer_id` at checkout; store the returned `customer_id`). Only emit usage for customers you've confirmed are linked.
86
+
87
+ ---
88
+
89
+ ## 9. Webhook 200-ack on failure loses the link forever
90
+
91
+ **Symptom:** A subscription exists in Polar but your DB never recorded the `customer_id`; usage for that org silently drops.
92
+
93
+ **Cause:** The webhook handler caught a resolution failure and still returned `200`. Polar marks delivery successful and never retries, so the one chance to link the customer is gone.
94
+
95
+ **Fix:** Return non-2xx whenever customer/org resolution fails so Polar retries. Keep the handler idempotent (dedupe by `webhook-id`) so retries are safe. Verify the HMAC signature.
96
+
97
+ ---
98
+
99
+ ## 10. Ingestion failures vanish
100
+
101
+ **Symptom:** Occasional gaps in metered usage with no error surfaced.
102
+
103
+ **Cause:** Fire-and-forget ingestion — failures are logged and swallowed, never retried. A Polar API blip drops events permanently.
104
+
105
+ **Fix:** At minimum, log+alert on ingestion failure. If usage is high-value, put ingestion behind a durable queue with retry. Accept the tradeoff explicitly rather than discovering it as missing revenue.
106
+
107
+ ---
108
+
109
+ ## The one-line rule
110
+
111
+ After **any** billing change, emit one event and confirm `consumed_units` went up. Unit tests on the emitter are necessary but not sufficient — the meter config lives in the Polar dashboard, outside your repo, and is the half that silently breaks.
@@ -0,0 +1,101 @@
1
+ # Correct setup order — Polar usage-based billing
2
+
3
+ Set these up in order. The ordering matters because the price binds to the meter, and the emitter must match the meter's aggregation property (which is immutable after the first event — see rule 2 in SKILL.md).
4
+
5
+ ## 0. Decide the contract first (on paper)
6
+
7
+ Before clicking anything, fix three strings and never deviate:
8
+
9
+ - **event name** — e.g. `compute-usage`
10
+ - **metadata property** to aggregate — e.g. `cost_cents` (pick the spelling now; you cannot change it on the meter later)
11
+ - **unit semantics** — what one unit costs. If the meter sums `cost_cents` (integer cents of cost) and the price is `unit_amount = 1` (1 cent/unit), then charge = cents-of-cost = the dollar cost. Keep the math trivially invertible.
12
+
13
+ ## 1. Meter (dashboard or API)
14
+
15
+ A meter has a **filter** (which events it counts) and an **aggregation** (what number it produces).
16
+
17
+ ```
18
+ POST /v1/meters
19
+ {
20
+ "name": "compute-usage",
21
+ "filter": { "conjunction": "and", "clauses": [
22
+ { "conjunction": "or", "clauses": [
23
+ { "property": "name", "operator": "eq", "value": "compute-usage" }
24
+ ]}
25
+ ]},
26
+ "aggregation": { "func": "sum", "property": "cost_cents" } // ← property MUST equal the emitted metadata key
27
+ }
28
+ ```
29
+
30
+ - `func` is `count` | `sum` | `avg` | `min` | `max`. For `count`, `property` is irrelevant. For the rest, `property` must be a metadata key present on the events.
31
+ - **This `aggregation` cannot be edited once events have been counted** (`422`). Triple-check the property spelling here.
32
+
33
+ ## 2. Product + metered price
34
+
35
+ ```
36
+ POST /v1/products
37
+ { "name": "Pay-as-you-go", "recurring_interval": "month",
38
+ "prices": [
39
+ { "amount_type": "free" }, // $0/mo base
40
+ { "amount_type": "metered_unit", "unit_amount": 1, // 1 cent per unit
41
+ "meter_id": "<meter id from step 1>" } // ← binds price to the meter
42
+ ]
43
+ }
44
+ ```
45
+
46
+ - Without a `metered_unit` price bound to the meter, usage never turns into money.
47
+ - `unit_amount` is per unit in the smallest currency unit (supports fractional via decimal string).
48
+
49
+ ## 3. Checkout (links a customer to the subscription)
50
+
51
+ ```
52
+ POST /v1/checkouts/
53
+ { "products": ["<product id>"],
54
+ "customer_email": "<email>",
55
+ "external_customer_id": "<your tenant/org id>", // ← so webhooks + events map back to you
56
+ "success_url": "https://app.example.com/success" }
57
+ ```
58
+
59
+ - `external_customer_id` is the bridge: later you read it back from the Polar customer (`external_id`) to link the Polar `customer_id` to your tenant. Set it, always.
60
+
61
+ ## 4. Webhook (subscription lifecycle → your DB)
62
+
63
+ Handle `subscription.created` / `.updated` / `.canceled` / `.revoked`. On `created`/`updated`, resolve your org from `customer_id` (DB lookup, then `GET /v1/customers/{id}` → `external_id` fallback) and store the Polar `customer_id` on your org.
64
+
65
+ - **Fail loud.** If you can't resolve the org, return non-2xx so Polar retries. A `200` ack on failure loses the link permanently.
66
+ - Verify the `webhook-signature` HMAC; dedupe by `webhook-id` for idempotency.
67
+
68
+ ## 5. Ingestion (emit usage)
69
+
70
+ ```
71
+ POST /v1/events/ingest
72
+ { "events": [{
73
+ "name": "compute-usage", // ← must match the meter filter
74
+ "customer_id": "<polar customer id>", // (or external_customer_id)
75
+ "metadata": { "cost_cents": <amount> } // ← key MUST match meter aggregation.property
76
+ }]}
77
+ ```
78
+
79
+ - Emit only the **billable** amount. If you grant local free credit, send `max(0, cost − remainingCredit)` so the free tier isn't billed (rule 9).
80
+ - Ingestion is fire-and-forget on most integrations; failures are logged, not retried. Monitor for `ingestion failed` and decide if you need a retry/queue.
81
+
82
+ ## 6. Verify (do NOT skip)
83
+
84
+ ```
85
+ node scripts/diagnose.mjs --customer <id> --event-name compute-usage
86
+ ```
87
+
88
+ Emit one event, then confirm `consumed_units > 0`. `inserted:1` is not enough. There is no backfill, so verify before you assume past usage will be captured.
89
+
90
+ ## Environment switch
91
+
92
+ Drive everything off one flag:
93
+
94
+ ```
95
+ const PROD = process.env.NODE_ENV === "production";
96
+ const BASE = PROD ? "https://api.polar.sh" : "https://sandbox-api.polar.sh";
97
+ const PRODUCT_ID = PROD ? "<prod product>" : "<sandbox product>";
98
+ // token + webhook secret come from env per environment
99
+ ```
100
+
101
+ Sandbox and production are separate orgs with separate tokens, products, and meters. A sandbox token in prod moves no real money and fails silently.
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ // Polar usage-based billing diagnostic — walks the metering chain for one
3
+ // customer and reports, link by link, where billing breaks. Read-only.
4
+ //
5
+ // Usage:
6
+ // POLAR_ACCESS_TOKEN=<token with meters:read + orders:read> \
7
+ // POLAR_SERVER=production|sandbox \
8
+ // node diagnose.mjs --customer <polar_customer_id> [--event-name <name>] [--meter <meter_id>]
9
+ //
10
+ // Why this exists: Polar metered billing fails silently. Events return
11
+ // `inserted:1`, subscriptions look active, orders get created — all at $0 —
12
+ // when a single link in the chain is misconfigured. This script checks each
13
+ // link and names the broken one instead of leaving you to guess.
14
+
15
+ const args = Object.fromEntries(
16
+ process.argv.slice(2).reduce((acc, a, i, arr) => {
17
+ if (a.startsWith("--")) acc.push([a.slice(2), arr[i + 1]?.startsWith("--") ? true : arr[i + 1]]);
18
+ return acc;
19
+ }, []),
20
+ );
21
+
22
+ const TOKEN = process.env.POLAR_ACCESS_TOKEN;
23
+ const SERVER = (process.env.POLAR_SERVER || "production").toLowerCase();
24
+ const BASE = SERVER === "sandbox" ? "https://sandbox-api.polar.sh" : "https://api.polar.sh";
25
+ const CUSTOMER = args.customer;
26
+ const EVENT_NAME = args["event-name"];
27
+ const METER_ARG = args.meter;
28
+
29
+ const C = { g: "\x1b[32m", r: "\x1b[31m", y: "\x1b[33m", d: "\x1b[2m", x: "\x1b[0m" };
30
+ const PASS = `${C.g}✓ PASS${C.x}`;
31
+ const FAIL = `${C.r}✗ FAIL${C.x}`;
32
+ const WARN = `${C.y}? UNKNOWN${C.x}`;
33
+
34
+ if (!TOKEN || !CUSTOMER) {
35
+ console.error("Required: POLAR_ACCESS_TOKEN env + --customer <id>.");
36
+ console.error("Optional: POLAR_SERVER=sandbox|production, --event-name <name>, --meter <id>.");
37
+ process.exit(2);
38
+ }
39
+
40
+ const findings = [];
41
+ const note = (link, status, detail) => {
42
+ findings.push({ link, status });
43
+ const tag = status === "pass" ? PASS : status === "fail" ? FAIL : WARN;
44
+ console.log(`${tag} ${link}`);
45
+ if (detail) console.log(` ${C.d}${detail}${C.x}`);
46
+ };
47
+
48
+ async function get(path) {
49
+ try {
50
+ const res = await fetch(`${BASE}${path}`, { headers: { Authorization: `Bearer ${TOKEN}` } });
51
+ let json = null;
52
+ try { json = await res.json(); } catch { /* non-JSON */ }
53
+ return { status: res.status, json };
54
+ } catch (err) {
55
+ return { status: 0, json: { error: String(err) } };
56
+ }
57
+ }
58
+
59
+ console.log(`\nPolar billing diagnostic — ${SERVER} — customer ${CUSTOMER}\n`);
60
+
61
+ // ── Link 0: token scope ───────────────────────────────────────────────
62
+ const meterScopeProbe = await get(`/v1/meters/?limit=1`);
63
+ const canReadMeters = meterScopeProbe.status === 200;
64
+ if (canReadMeters) {
65
+ note("token scope: meters:read", "pass");
66
+ } else if (meterScopeProbe.json?.error === "insufficient_scope") {
67
+ note("token scope: meters:read", "fail",
68
+ "Token lacks meters:read — meter/price checks below will be skipped. Create a read-scoped token (rule 8).");
69
+ } else {
70
+ note("token scope: meters:read", "warn", `Unexpected status ${meterScopeProbe.status}.`);
71
+ }
72
+
73
+ // ── Link 1: customer state (consumed_units — the number that matters) ──
74
+ const state = await get(`/v1/customers/${CUSTOMER}/state`);
75
+ if (state.status !== 200) {
76
+ note("customer state", "fail", `GET /v1/customers/${CUSTOMER}/state → ${state.status}. Wrong id, env, or customer:read scope.`);
77
+ summarize();
78
+ process.exit(0);
79
+ }
80
+ const activeMeters = state.json.active_meters || [];
81
+ const activeSubs = state.json.active_subscriptions || [];
82
+ const totalConsumed = activeMeters.reduce((s, m) => s + (m.consumed_units || 0), 0);
83
+ if (activeMeters.length === 0) {
84
+ note("customer has an active meter", "fail", "No active_meters on the customer — no metered subscription, or meter not bound to the product price.");
85
+ } else if (totalConsumed > 0) {
86
+ note("consumed_units > 0 (billing IS flowing)", "pass",
87
+ activeMeters.map((m) => `meter ${m.meter_id}: consumed=${m.consumed_units}, balance=${m.balance}`).join("; "));
88
+ } else {
89
+ note("consumed_units > 0", "fail",
90
+ `consumed_units is 0 across ${activeMeters.length} meter(s) — usage is NOT being counted. Continue below to find why.`);
91
+ }
92
+
93
+ // ── Link 2: meter filter + aggregation ────────────────────────────────
94
+ const meterIds = METER_ARG ? [METER_ARG] : activeMeters.map((m) => m.meter_id).filter(Boolean);
95
+ let aggProperty = null;
96
+ let filterEventNames = [];
97
+ if (!canReadMeters) {
98
+ note("meter filter + aggregation", "warn", "Skipped — no meters:read scope.");
99
+ } else if (meterIds.length === 0) {
100
+ note("meter filter + aggregation", "warn", "No meter id resolved (pass --meter <id>).");
101
+ } else {
102
+ for (const mid of meterIds) {
103
+ const m = await get(`/v1/meters/${mid}`);
104
+ if (m.status !== 200) { note(`meter ${mid}`, "warn", `→ ${m.status}`); continue; }
105
+ aggProperty = m.json.aggregation?.property ?? null;
106
+ const func = m.json.aggregation?.func;
107
+ const clauses = JSON.stringify(m.json.filter);
108
+ filterEventNames = [...clauses.matchAll(/"value"\s*:\s*"([^"]+)"/g)].map((x) => x[1]);
109
+ note(`meter "${m.json.name}" config`, "pass",
110
+ `aggregation: ${func}${aggProperty ? `(metadata.${aggProperty})` : "()"} · filter matches event name(s): ${filterEventNames.join(", ") || "?"}`);
111
+ }
112
+ }
113
+
114
+ // ── Link 3: do real events carry the aggregated property? (the typo trap) ──
115
+ const evName = EVENT_NAME || filterEventNames[0];
116
+ const evResp = await get(`/v1/events/?customer_id=${CUSTOMER}&limit=25`);
117
+ if (evResp.status !== 200) {
118
+ note("events carry the aggregated property", "warn", `Cannot read events → ${evResp.status} (needs events:read).`);
119
+ } else {
120
+ const items = (evResp.json.items || []).filter((e) => !evName || e.name === evName);
121
+ if (items.length === 0) {
122
+ note("recent events exist for this customer", "fail",
123
+ `No events${evName ? ` named "${evName}"` : ""} found. The app isn't emitting (or wrong name).`);
124
+ } else {
125
+ const keys = new Set();
126
+ items.forEach((e) => Object.keys(e.metadata || {}).forEach((k) => keys.add(k)));
127
+ const userEvents = items.filter((e) => e.source !== "system");
128
+ console.log(` ${C.d}recent "${evName}" event metadata keys: ${[...keys].join(", ")}${C.x}`);
129
+ if (aggProperty) {
130
+ const withProp = userEvents.filter((e) => e.metadata && aggProperty in e.metadata).length;
131
+ if (userEvents.length > 0 && withProp === 0) {
132
+ note("emitted metadata key matches meter aggregation property", "fail",
133
+ `Meter sums metadata.${aggProperty} but NONE of the recent events carry that key. THIS is the silent $0 bug (rule 1). Emit "${aggProperty}", or note the locked-in spelling and match it.`);
134
+ } else if (withProp < userEvents.length) {
135
+ note("emitted metadata key matches meter aggregation property", "warn",
136
+ `Only ${withProp}/${userEvents.length} events carry metadata.${aggProperty}. Mixed keys — likely mid-migration.`);
137
+ } else {
138
+ note("emitted metadata key matches meter aggregation property", "pass",
139
+ `All ${userEvents.length} recent events carry metadata.${aggProperty}.`);
140
+ }
141
+ } else {
142
+ note("recent events exist", "pass", `${items.length} event(s); aggregation property unknown (no meters:read) — verify key by hand.`);
143
+ }
144
+ }
145
+ }
146
+
147
+ // ── Link 4: product has a metered price bound to the meter ─────────────
148
+ const productId = activeSubs[0]?.product_id;
149
+ if (!canReadMeters) {
150
+ note("metered price attached to meter", "warn", "Skipped — no scope to read product.");
151
+ } else if (!productId) {
152
+ note("metered price attached to meter", "warn", "No active subscription product_id in customer state.");
153
+ } else {
154
+ const p = await get(`/v1/products/${productId}`);
155
+ if (p.status !== 200) {
156
+ note("metered price attached to meter", "warn", `GET product → ${p.status}.`);
157
+ } else {
158
+ const prices = p.json.prices || [];
159
+ const metered = prices.find((pr) => pr.amount_type === "metered_unit");
160
+ if (!metered) {
161
+ note("metered price attached to meter", "fail",
162
+ `Product has prices [${prices.map((pr) => pr.amount_type).join(", ")}] but NO metered_unit price — usage can't bill.`);
163
+ } else if (meterIds.length && !meterIds.includes(metered.meter_id)) {
164
+ note("metered price attached to meter", "fail",
165
+ `Metered price points at meter ${metered.meter_id}, not the customer's active meter(s) ${meterIds.join(",")}.`);
166
+ } else {
167
+ note("metered price attached to meter", "pass",
168
+ `metered_unit price, unit_amount=${metered.unit_amount}, meter_id=${metered.meter_id}.`);
169
+ }
170
+ }
171
+ }
172
+
173
+ // ── Link 5: orders (are invoices actually non-zero?) ──────────────────
174
+ const orders = await get(`/v1/orders/?customer_id=${CUSTOMER}&limit=5`);
175
+ if (orders.status !== 200) {
176
+ note("orders show non-zero billing", "warn", `Cannot read orders → ${orders.status} (needs orders:read).`);
177
+ } else {
178
+ const items = orders.json.items || [];
179
+ const nonZero = items.filter((o) => (o.total_amount ?? 0) > 0).length;
180
+ if (items.length === 0) {
181
+ note("orders exist", "warn", "No orders yet (new subscription or pre-first-cycle).");
182
+ } else if (nonZero > 0) {
183
+ note("orders show non-zero billing", "pass", `${nonZero}/${items.length} recent orders are non-zero.`);
184
+ } else if (totalConsumed > 0) {
185
+ // Usage is accruing now, but these orders are $0 — they predate the fix or
186
+ // the current cycle hasn't closed. Not a broken link; the next invoice bills.
187
+ note("orders show non-zero billing", "warn",
188
+ `All ${items.length} recent orders total $0, but consumed_units=${totalConsumed} now — these orders predate the fix / current cycle hasn't closed. The next invoice will bill the accrued usage.`);
189
+ } else {
190
+ note("orders show non-zero billing", "fail",
191
+ `All ${items.length} recent orders total $0 and consumed_units is 0 — corroborates broken metering above.`);
192
+ }
193
+ }
194
+
195
+ summarize();
196
+
197
+ function summarize() {
198
+ const fails = findings.filter((f) => f.status === "fail");
199
+ console.log("");
200
+ if (fails.length === 0) {
201
+ console.log(`${C.g}Billing chain looks intact.${C.x} If invoices are still $0, check sandbox-vs-production (rule 7) and that you're inspecting the right environment.`);
202
+ } else {
203
+ console.log(`${C.r}Broken link(s):${C.x} ${fails.map((f) => f.link).join(" | ")}`);
204
+ console.log(`${C.d}Fix the first broken link, re-emit one event, and re-run this to confirm consumed_units increments (no backfill — rule 4).${C.x}`);
205
+ }
206
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "devlyn-cli",
3
- "version": "2.5.6",
3
+ "version": "2.6.0",
4
4
  "description": "AI development toolkit for Claude Code — ideate, resolve, and ship with context engineering and agent orchestration",
5
5
  "homepage": "https://github.com/fysoul17/devlyn-cli#readme",
6
6
  "bin": {
@@ -242,10 +242,11 @@ if [ ! -e "optional-skills/devlyn:design-ui" ]; then
242
242
  else
243
243
  bad "devlyn:design-ui must not be installed as an optional addon"
244
244
  fi
245
- if grep -Fq "skillsToInstall: ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui', '_shared']" bin/devlyn.js; then
246
- ok "Codex install includes devlyn:design-ui"
245
+ if grep -Fq "const DEVLYN_CORE_SKILLS = ['devlyn:resolve', 'devlyn:ideate', 'devlyn:design-ui', '_shared'];" bin/devlyn.js \
246
+ && grep -Fq "skillsToInstall: DEVLYN_CORE_SKILLS" bin/devlyn.js; then
247
+ ok "Codex install includes devlyn:design-ui (via shared DEVLYN_CORE_SKILLS bundle)"
247
248
  else
248
- bad "Codex skillsToInstall must include devlyn:design-ui"
249
+ bad "Codex skillsToInstall must include devlyn:design-ui (shared DEVLYN_CORE_SKILLS bundle)"
249
250
  fi
250
251
  if ! grep -F "name: 'devlyn:design-ui'" bin/devlyn.js >/dev/null 2>&1; then
251
252
  ok "devlyn:design-ui is absent from OPTIONAL_ADDONS"