burner-cc 0.1.2 → 0.3.1
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 +79 -19
- package/bin/burner.js +2 -0
- package/package.json +11 -8
- package/src/analyze.js +241 -0
- package/src/build-receipt.js +118 -11
- package/src/classifier.js +140 -0
- package/src/cli.js +181 -84
- package/src/config.js +222 -0
- package/src/discover.js +10 -7
- package/src/install.js +215 -0
- package/src/model/prior.json +1 -0
- package/src/model/prior.meta.json +320 -0
- package/src/parse.js +234 -42
- package/src/pricing-fallback.json +199 -6
- package/src/pricing.js +176 -98
- package/src/receipt.js +351 -0
- package/src/report.js +873 -0
- package/src/server.js +261 -0
- package/src/settings-page.js +752 -0
- package/src/tag-end.js +94 -0
- package/src/tagging.js +122 -0
- package/src/tags.js +79 -0
- package/src/themes.js +122 -0
- package/src/yaml.js +163 -0
- package/web/receipt.css +0 -298
- package/web/receipt.html +0 -32
- package/web/receipt.js +0 -315
- /package/{web → assets}/logo-mark.png +0 -0
- /package/{web → assets}/logo-with-text.png +0 -0
- /package/{web/vendor → vendor}/html2canvas.min.js +0 -0
package/src/discover.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { readdirSync, statSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
3
|
+
import { CLAUDE_HOME } from './config.js';
|
|
4
4
|
|
|
5
|
-
function claudeProjectsRoot() {
|
|
6
|
-
return path.join(
|
|
5
|
+
function claudeProjectsRoot(claudeHome = CLAUDE_HOME) {
|
|
6
|
+
return path.join(claudeHome, 'projects');
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
function walk(dir, out = []) {
|
|
@@ -66,15 +66,15 @@ function readSessionTitle(mainFile) {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
|
-
return title
|
|
69
|
+
return { title, slug };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
// A session is identified by its top-level "<sessionId>.jsonl" file. Any
|
|
73
73
|
// "<sessionId>/subagents/agent-*.jsonl" files sit alongside it and are
|
|
74
74
|
// billed separately by the API, so their cost is real but attributed back
|
|
75
75
|
// to the parent session rather than shown as their own session.
|
|
76
|
-
export function discoverSessions() {
|
|
77
|
-
const root = claudeProjectsRoot();
|
|
76
|
+
export function discoverSessions(claudeHome) {
|
|
77
|
+
const root = claudeProjectsRoot(claudeHome);
|
|
78
78
|
const files = walk(root);
|
|
79
79
|
|
|
80
80
|
const sessions = new Map();
|
|
@@ -103,7 +103,10 @@ export function discoverSessions() {
|
|
|
103
103
|
|
|
104
104
|
for (const s of result) {
|
|
105
105
|
s.mtime = statSync(s.mainFile).mtimeMs;
|
|
106
|
-
|
|
106
|
+
const { title, slug } = readSessionTitle(s.mainFile) ?? {};
|
|
107
|
+
s.customTitle = title ?? null;
|
|
108
|
+
s.slug = slug ?? null;
|
|
109
|
+
s.title = title || slug || null;
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
result.sort((a, b) => b.mtime - a.mtime);
|
package/src/install.js
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Wires burner into Claude Code globally (~/.claude), never per-repo:
|
|
2
|
+
// skills/<SKILL_NAME>/SKILL.md — the tag skill, available in every project
|
|
3
|
+
// CLAUDE.md block — "Session end" / "Every task" levels tell Claude to use it
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { CLAUDE_HOME } from './config.js';
|
|
9
|
+
|
|
10
|
+
export const SKILL_NAME = 'burner-tag';
|
|
11
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const BIN = path.resolve(__dirname, '..', 'bin', 'burner.js');
|
|
13
|
+
const START = '<!-- burner:start -->';
|
|
14
|
+
const END = '<!-- burner:end -->';
|
|
15
|
+
|
|
16
|
+
const paths = () => ({
|
|
17
|
+
skillDir: path.join(CLAUDE_HOME, 'skills', SKILL_NAME),
|
|
18
|
+
skillFile: path.join(CLAUDE_HOME, 'skills', SKILL_NAME, 'SKILL.md'),
|
|
19
|
+
claudeMd: path.join(CLAUDE_HOME, 'CLAUDE.md'),
|
|
20
|
+
settings: path.join(CLAUDE_HOME, 'settings.json'),
|
|
21
|
+
legacyCommand: path.join(CLAUDE_HOME, 'commands', 'tag-my-session.md'),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const NPM_CLI = 'burner';
|
|
25
|
+
|
|
26
|
+
function nodeCmd() {
|
|
27
|
+
return `node "${BIN}"`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathNorm(p) {
|
|
31
|
+
return path.resolve(p).replace(/\\/g, '/').toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** True when the running entrypoint is this repo's bin/burner.js (not npm global link). */
|
|
35
|
+
export function isDevCheckout() {
|
|
36
|
+
const entry = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
|
37
|
+
return entry && pathNorm(entry) === pathNorm(BIN);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Shell command users should run (global npm: `burner …`; dev checkout: `node …/burner.js …`). */
|
|
41
|
+
export function burnerShell(sub = '') {
|
|
42
|
+
const tail = sub ? ` ${sub}` : '';
|
|
43
|
+
return isDevCheckout() ? `${nodeCmd()}${tail}` : `${NPM_CLI}${tail}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Paste into Claude Code / Cursor chat if /burner-tag is missing after you changed categories. */
|
|
47
|
+
export function skillSetupPrompt() {
|
|
48
|
+
const cmd = burnerShell('install');
|
|
49
|
+
const home = CLAUDE_HOME;
|
|
50
|
+
const dev = isDevCheckout() ? `\n(While developing this repo, that resolves to: ${nodeCmd()} install)` : '';
|
|
51
|
+
return `Set up burner session tagging on this machine (global, not per-repo):
|
|
52
|
+
1. Run in a terminal: ${cmd}${dev}
|
|
53
|
+
(After npm i -g burner-cc, the same command is just: burner install)
|
|
54
|
+
2. That writes ${path.join(home, 'skills', SKILL_NAME, 'SKILL.md')} and applies the tagging level from ~/.burner/config.yaml (CLAUDE.md block and/or SessionEnd hook).
|
|
55
|
+
3. Re-run install after you change categories or tagging level on the Settings page.
|
|
56
|
+
Do not edit project repos for this — only files under ${home}.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function skillMarkdown(cfg) {
|
|
60
|
+
const cats = cfg.categories.map((c) => `- \`${c.id}\` — ${c.description || c.label}`).join('\n');
|
|
61
|
+
return `---
|
|
62
|
+
name: ${SKILL_NAME}
|
|
63
|
+
description: Tag the current Claude Code session with what it was used for (coding, debugging, planning, research, writing, …) for burner usage analytics. Use when the user runs /${SKILL_NAME} or asks to tag/categorize this session, and when the burner block in CLAUDE.md asks you to tag at the end of a session or task.
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# ${SKILL_NAME}
|
|
67
|
+
|
|
68
|
+
Pick the one category that best describes the **user's intent** for the work being tagged — what they wanted done, not which tools you happened to use. Works for any kind of work, technical or not. Reading files to fix a bug is \`debugging\`, not \`research\`.
|
|
69
|
+
|
|
70
|
+
${cats}
|
|
71
|
+
|
|
72
|
+
Then run exactly one command (no extra exploration first):
|
|
73
|
+
|
|
74
|
+
\`\`\`bash
|
|
75
|
+
${burnerShell('tag')} <category> --session "\${CLAUDE_SESSION_ID}" --reason "<under 12 words>"
|
|
76
|
+
\`\`\`
|
|
77
|
+
|
|
78
|
+
- When asked to tag a single finished task inside a longer session, add \`--task\` so the session can be split across categories.
|
|
79
|
+
- If the command says the category isn't allowed for this project, pick the closest allowed one it lists and run it again.
|
|
80
|
+
- Don't tell the user about it unless they ran /${SKILL_NAME} themselves or the command failed. Then reply with one line: \`tagged <category>\`.
|
|
81
|
+
`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function claudeMdBlock(level) {
|
|
85
|
+
if (level === 'session') {
|
|
86
|
+
return `${START}
|
|
87
|
+
## burner
|
|
88
|
+
When you've finished the work the user asked for in this session — before your final summary — use the \`${SKILL_NAME}\` skill once to tag the session. Tag again only if the session's main purpose changed. Keep it silent.
|
|
89
|
+
${END}`;
|
|
90
|
+
}
|
|
91
|
+
if (level === 'task') {
|
|
92
|
+
return `${START}
|
|
93
|
+
## burner
|
|
94
|
+
Each time you finish a distinct task the user asked for, use the \`${SKILL_NAME}\` skill with \`--task\` to tag that task before replying. One tag per task, silent.
|
|
95
|
+
${END}`;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function readJsonFile(file) {
|
|
101
|
+
if (!existsSync(file)) return {};
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
104
|
+
} catch (err) {
|
|
105
|
+
throw new Error(`${file} is not valid JSON (${err.message}); fix it before burner can add its hook`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function isOurHook(h) {
|
|
110
|
+
return typeof h?.command === 'string' && h.command.includes('burner') && h.command.includes('hook session-end');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function ensureSessionEndHook() {
|
|
114
|
+
const { settings: file } = paths();
|
|
115
|
+
let settings;
|
|
116
|
+
try { settings = readJsonFile(file); } catch { settings = {}; }
|
|
117
|
+
const cmd = burnerShell('hook session-end');
|
|
118
|
+
const groups = [...(settings.hooks?.SessionEnd ?? [])];
|
|
119
|
+
if (groups.some((g) => (g.hooks ?? []).some(isOurHook))) return;
|
|
120
|
+
groups.push({ hooks: [{ type: 'command', command: cmd }] });
|
|
121
|
+
settings.hooks = { ...(settings.hooks ?? {}), SessionEnd: groups };
|
|
122
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
123
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Remove burner's SessionEnd hook (legacy "auto" mode used the same command).
|
|
127
|
+
function removeSessionEndHook() {
|
|
128
|
+
const { settings: file } = paths();
|
|
129
|
+
let settings;
|
|
130
|
+
try { settings = readJsonFile(file); } catch { return; }
|
|
131
|
+
const before = JSON.stringify(settings);
|
|
132
|
+
const hooks = settings.hooks ?? {};
|
|
133
|
+
const groups = (hooks.SessionEnd ?? [])
|
|
134
|
+
.map((g) => ({ ...g, hooks: (g.hooks ?? []).filter((h) => !isOurHook(h)) }))
|
|
135
|
+
.filter((g) => g.hooks.length);
|
|
136
|
+
if (groups.length) hooks.SessionEnd = groups;
|
|
137
|
+
else delete hooks.SessionEnd;
|
|
138
|
+
if (Object.keys(hooks).length) settings.hooks = hooks;
|
|
139
|
+
else delete settings.hooks;
|
|
140
|
+
if (JSON.stringify(settings) === before) return;
|
|
141
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
142
|
+
writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function setClaudeMd(level) {
|
|
146
|
+
const { claudeMd } = paths();
|
|
147
|
+
const current = existsSync(claudeMd) ? readFileSync(claudeMd, 'utf-8') : '';
|
|
148
|
+
const re = new RegExp(`\\n?${START}[\\s\\S]*?${END}\\n?`, 'g');
|
|
149
|
+
let next = current.replace(re, '\n').replace(/\s+$/, '');
|
|
150
|
+
const block = claudeMdBlock(level);
|
|
151
|
+
if (block) next = (next ? next + '\n\n' : '') + block;
|
|
152
|
+
next = next ? next + '\n' : '';
|
|
153
|
+
if (next.trim() === current.trim()) return;
|
|
154
|
+
if (!next.trim() && !existsSync(claudeMd)) return;
|
|
155
|
+
mkdirSync(path.dirname(claudeMd), { recursive: true });
|
|
156
|
+
writeFileSync(claudeMd, next, 'utf-8');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function installSkill(cfg) {
|
|
160
|
+
const p = paths();
|
|
161
|
+
mkdirSync(p.skillDir, { recursive: true });
|
|
162
|
+
writeFileSync(p.skillFile, skillMarkdown(cfg), 'utf-8');
|
|
163
|
+
if (existsSync(p.legacyCommand)) rmSync(p.legacyCommand);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Applies the tagging level from config to ~/.claude. Idempotent.
|
|
167
|
+
export function applyTagging(cfg) {
|
|
168
|
+
const level = cfg.tagging?.level ?? 'manual';
|
|
169
|
+
const endModel = String(cfg.tagging?.endModel ?? '').trim();
|
|
170
|
+
if (level === 'off') {
|
|
171
|
+
const p = paths();
|
|
172
|
+
if (existsSync(p.skillDir)) rmSync(p.skillDir, { recursive: true, force: true });
|
|
173
|
+
} else {
|
|
174
|
+
installSkill(cfg);
|
|
175
|
+
}
|
|
176
|
+
removeSessionEndHook();
|
|
177
|
+
if (level === 'session' && endModel) {
|
|
178
|
+
ensureSessionEndHook();
|
|
179
|
+
setClaudeMd('off');
|
|
180
|
+
} else {
|
|
181
|
+
setClaudeMd(level === 'off' ? 'off' : level);
|
|
182
|
+
}
|
|
183
|
+
return installStatus(cfg);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function uninstall() {
|
|
187
|
+
const p = paths();
|
|
188
|
+
if (existsSync(p.skillDir)) rmSync(p.skillDir, { recursive: true, force: true });
|
|
189
|
+
setClaudeMd('off');
|
|
190
|
+
removeSessionEndHook();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function installStatus(cfg = null) {
|
|
194
|
+
const p = paths();
|
|
195
|
+
const md = existsSync(p.claudeMd) ? readFileSync(p.claudeMd, 'utf-8') : '';
|
|
196
|
+
const block = md.match(new RegExp(`${START}[\\s\\S]*?${END}`))?.[0] ?? '';
|
|
197
|
+
let hookInstalled = false;
|
|
198
|
+
try {
|
|
199
|
+
const settings = readJsonFile(p.settings);
|
|
200
|
+
hookInstalled = (settings.hooks?.SessionEnd ?? []).some((g) => (g.hooks ?? []).some(isOurHook));
|
|
201
|
+
} catch { /* no settings */ }
|
|
202
|
+
const endModel = String(cfg?.tagging?.endModel ?? '').trim();
|
|
203
|
+
return {
|
|
204
|
+
skillName: SKILL_NAME,
|
|
205
|
+
skillInstalled: existsSync(p.skillFile),
|
|
206
|
+
skillPath: p.skillFile,
|
|
207
|
+
skillInstallCmd: burnerShell('install'),
|
|
208
|
+
skillInstallCmdNpm: `${NPM_CLI} install`,
|
|
209
|
+
devCheckout: isDevCheckout(),
|
|
210
|
+
claudeMdLevel: block.includes('--task') ? 'task' : block ? 'session' : null,
|
|
211
|
+
sessionEndHook: hookInstalled,
|
|
212
|
+
endModel,
|
|
213
|
+
claudeHome: CLAUDE_HOME,
|
|
214
|
+
};
|
|
215
|
+
}
|