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 +2 -2
- package/bin/devlyn.js +199 -103
- package/optional-skills/polar-billing-setup/SKILL.md +76 -0
- package/optional-skills/polar-billing-setup/references/gotchas.md +111 -0
- package/optional-skills/polar-billing-setup/references/setup.md +101 -0
- package/optional-skills/polar-billing-setup/scripts/diagnose.mjs +206 -0
- package/package.json +1 -1
- package/scripts/lint-skills.sh +4 -3
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
|
|
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
|
|
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:
|
|
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)
|
|
473
|
-
// same way required skills are. Existing dir, not new dir — we don't
|
|
474
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
log(`\n🤖 Installing agents for ${cli.name}...`, 'cyan');
|
|
638
|
+
log(`\n🤖 Installing agent instructions for ${cli.name}...`, 'cyan');
|
|
568
639
|
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
|
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✅
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
}
|
|
781
|
-
|
|
782
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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": {
|
package/scripts/lint-skills.sh
CHANGED
|
@@ -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 "
|
|
246
|
-
|
|
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"
|