@usagefleet/cli 1.2.152 → 1.2.153

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
@@ -97,7 +97,8 @@ directory, git branch. Prompts, responses and file contents are never read.
97
97
 
98
98
  The first time a window crosses a threshold (default `80` and `95`) you get a
99
99
  desktop notification — once per threshold per window, re-armed when the window
100
- resets. macOS uses `osascript`, Linux `notify-send` (falling back to
100
+ resets. macOS shows it from a small bundled UsageFleet app (allow it once when
101
+ macOS asks; a click opens the dashboard), Linux uses `notify-send` (falling back to
101
102
  `kdialog --passivepopup`; install `libnotify-bin` if neither exists), Windows a
102
103
  WinRT toast via `powershell.exe`.
103
104
 
@@ -0,0 +1,26 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>CFBundleIdentifier</key>
6
+ <string>dev.usagefleet.notifier</string>
7
+ <key>CFBundleName</key>
8
+ <string>UsageFleet</string>
9
+ <key>CFBundleDisplayName</key>
10
+ <string>UsageFleet</string>
11
+ <key>CFBundleExecutable</key>
12
+ <string>usagefleet-notifier</string>
13
+ <key>CFBundleIconFile</key>
14
+ <string>AppIcon</string>
15
+ <key>CFBundlePackageType</key>
16
+ <string>APPL</string>
17
+ <key>CFBundleShortVersionString</key>
18
+ <string>1.0</string>
19
+ <key>CFBundleVersion</key>
20
+ <string>1</string>
21
+ <key>LSMinimumSystemVersion</key>
22
+ <string>11.0</string>
23
+ <key>LSUIElement</key>
24
+ <true/>
25
+ </dict>
26
+ </plist>
@@ -0,0 +1,128 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>files</key>
6
+ <dict>
7
+ <key>Resources/AppIcon.icns</key>
8
+ <data>
9
+ Qsu2PdOnquUAUG4LLkSUe3XpdqA=
10
+ </data>
11
+ </dict>
12
+ <key>files2</key>
13
+ <dict>
14
+ <key>Resources/AppIcon.icns</key>
15
+ <dict>
16
+ <key>hash2</key>
17
+ <data>
18
+ yxmneUbIT7WvXoxaR+DXAKCF/pbzjexThOZ9bg8UK3E=
19
+ </data>
20
+ </dict>
21
+ </dict>
22
+ <key>rules</key>
23
+ <dict>
24
+ <key>^Resources/</key>
25
+ <true/>
26
+ <key>^Resources/.*\.lproj/</key>
27
+ <dict>
28
+ <key>optional</key>
29
+ <true/>
30
+ <key>weight</key>
31
+ <real>1000</real>
32
+ </dict>
33
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
34
+ <dict>
35
+ <key>omit</key>
36
+ <true/>
37
+ <key>weight</key>
38
+ <real>1100</real>
39
+ </dict>
40
+ <key>^Resources/Base\.lproj/</key>
41
+ <dict>
42
+ <key>weight</key>
43
+ <real>1010</real>
44
+ </dict>
45
+ <key>^version.plist$</key>
46
+ <true/>
47
+ </dict>
48
+ <key>rules2</key>
49
+ <dict>
50
+ <key>.*\.dSYM($|/)</key>
51
+ <dict>
52
+ <key>weight</key>
53
+ <real>11</real>
54
+ </dict>
55
+ <key>^(.*/)?\.DS_Store$</key>
56
+ <dict>
57
+ <key>omit</key>
58
+ <true/>
59
+ <key>weight</key>
60
+ <real>2000</real>
61
+ </dict>
62
+ <key>^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/</key>
63
+ <dict>
64
+ <key>nested</key>
65
+ <true/>
66
+ <key>weight</key>
67
+ <real>10</real>
68
+ </dict>
69
+ <key>^.*</key>
70
+ <true/>
71
+ <key>^Info\.plist$</key>
72
+ <dict>
73
+ <key>omit</key>
74
+ <true/>
75
+ <key>weight</key>
76
+ <real>20</real>
77
+ </dict>
78
+ <key>^PkgInfo$</key>
79
+ <dict>
80
+ <key>omit</key>
81
+ <true/>
82
+ <key>weight</key>
83
+ <real>20</real>
84
+ </dict>
85
+ <key>^Resources/</key>
86
+ <dict>
87
+ <key>weight</key>
88
+ <real>20</real>
89
+ </dict>
90
+ <key>^Resources/.*\.lproj/</key>
91
+ <dict>
92
+ <key>optional</key>
93
+ <true/>
94
+ <key>weight</key>
95
+ <real>1000</real>
96
+ </dict>
97
+ <key>^Resources/.*\.lproj/locversion.plist$</key>
98
+ <dict>
99
+ <key>omit</key>
100
+ <true/>
101
+ <key>weight</key>
102
+ <real>1100</real>
103
+ </dict>
104
+ <key>^Resources/Base\.lproj/</key>
105
+ <dict>
106
+ <key>weight</key>
107
+ <real>1010</real>
108
+ </dict>
109
+ <key>^[^/]+$</key>
110
+ <dict>
111
+ <key>nested</key>
112
+ <true/>
113
+ <key>weight</key>
114
+ <real>10</real>
115
+ </dict>
116
+ <key>^embedded\.provisionprofile$</key>
117
+ <dict>
118
+ <key>weight</key>
119
+ <real>20</real>
120
+ </dict>
121
+ <key>^version\.plist$</key>
122
+ <dict>
123
+ <key>weight</key>
124
+ <real>20</real>
125
+ </dict>
126
+ </dict>
127
+ </dict>
128
+ </plist>
package/dist/collector.js CHANGED
@@ -18,6 +18,8 @@ import { fetchGroupLimits, postLimits, uploadBatch } from './uploader.js';
18
18
  * uuids) and other JSONL — restricting to this subtree mirrors Claude Code and
19
19
  * avoids re-uploading every desktop record twice. */
20
20
  const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
21
+ /** `status` shows the cached reading's age, so it stays within this of the last check. */
22
+ const LIMITS_CACHE_REFRESH_MS = 10 * 60_000;
21
23
  /**
22
24
  * One full scan: tail every JSONL file from its stored offset, upload new usage
23
25
  * records (chunked to batchSize), and commit each file's offset only after all
@@ -25,7 +27,7 @@ const PROJECTS_SUBPATH = `${sep}.claude${sep}projects${sep}`;
25
27
  */
26
28
  export async function runOnce(cfg, log = () => {
27
29
  /* empty */
28
- }) {
30
+ }, dirCache = new Map()) {
29
31
  const { state } = readStore(cfg.storePath);
30
32
  // Each scan root is tagged with the app that owns it. Claude Code's projects
31
33
  // dir is scanned whole; the Claude Desktop sessions root is filtered to its
@@ -41,7 +43,7 @@ export async function runOnce(cfg, log = () => {
41
43
  }
42
44
  const files = [];
43
45
  for (const root of roots) {
44
- for (const fp of listJsonlFiles(root.dir)) {
46
+ for (const fp of listJsonlFiles(root.dir, dirCache)) {
45
47
  if (root.onlyProjects && !fp.includes(PROJECTS_SUBPATH)) {
46
48
  continue;
47
49
  }
@@ -202,14 +204,17 @@ function chunks(segments, step) {
202
204
  * Drop offsets for logs that no longer exist on disk, so a long-lived install
203
205
  * does not grow its state file by one entry per Claude session forever. Only
204
206
  * paths absent from this cycle's scan are stat'd, and only a real ENOENT prunes
205
- * — a root that is merely unconfigured right now keeps its offsets.
207
+ * — a root that is merely unconfigured right now keeps its offsets. A path that
208
+ * still exists goes too when its inode is one this scan reached under another
209
+ * path: a root now read through a symlink (~/.pi -> .dotfiles/.pi).
206
210
  * Returns whether anything was removed.
207
211
  */
208
212
  function pruneMissingFiles(state, scanned) {
209
213
  const seen = new Set(scanned.map(f => f.fp));
214
+ const seenInodes = new Set(scanned.map(f => state.files[f.fp]?.inode));
210
215
  let removed = false;
211
- for (const fp of Object.keys(state.files)) {
212
- if (seen.has(fp) || existsSync(fp)) {
216
+ for (const [fp, file] of Object.entries(state.files)) {
217
+ if (seen.has(fp) || (existsSync(fp) && !seenInodes.has(file.inode))) {
213
218
  continue;
214
219
  }
215
220
  // oxlint-disable-next-line typescript/no-dynamic-delete -- state.files is a JSON blob keyed by path
@@ -282,8 +287,16 @@ export async function reportLimitsOnce(cfg, log = () => {
282
287
  log('warn', 'limits upload failed · retrying next cycle');
283
288
  }
284
289
  // Cache the reading so `status` can show current usage without spending
285
- // another billable API call.
290
+ // another billable API call. An unchanged reading refreshes its timestamp only
291
+ // every LIMITS_CACHE_REFRESH_MS: each save rewrites the whole store.
286
292
  updateStore(cfg.storePath, store => {
293
+ const cached = store.limits;
294
+ const unchanged = cached?.fiveHourPct === report.fiveHourPct &&
295
+ cached.sevenDayPct === report.sevenDayPct &&
296
+ cached.source === report.source;
297
+ if (unchanged && Date.now() - Date.parse(cached.at) < LIMITS_CACHE_REFRESH_MS) {
298
+ return;
299
+ }
287
300
  store.limits = {
288
301
  at: new Date().toISOString(),
289
302
  fiveHourPct: report.fiveHourPct,
package/dist/index.js CHANGED
@@ -96,13 +96,14 @@ async function cmdWatch() {
96
96
  let stopping = false;
97
97
  let timer = null;
98
98
  let running = false;
99
+ const dirCache = new Map();
99
100
  const tick = async () => {
100
101
  if (stopping) {
101
102
  return;
102
103
  }
103
104
  running = true;
104
105
  try {
105
- const r = await runOnce(cfg, stream);
106
+ const r = await runOnce(cfg, stream, dirCache);
106
107
  // Dropped records are real data loss, so they must show up even in a
107
108
  // cycle that uploaded nothing.
108
109
  if (r.sent > 0 || r.dropped > 0) {
package/dist/notifier.js CHANGED
@@ -18,17 +18,19 @@ export function loadNotifyConfig(env = process.env, file = readStore()) {
18
18
  * Decide whether a window crosses a not-yet-notified threshold. Pure — no IO.
19
19
  * Returns the threshold to fire (or null) and the next persisted state.
20
20
  *
21
- * A window rollover (resetsAt change) resets the high-water mark first, so the
21
+ * A window rollover (resetsAt moves) resets the high-water mark first, so the
22
22
  * first crossing in a new window always re-notifies. If utilization later drops
23
23
  * below the mark within the SAME window (e.g. a server correction), the mark is
24
24
  * lowered so a subsequent re-cross notifies again.
25
25
  */
26
26
  export function evaluateWindow(prev, pct, resetsAt, thresholds) {
27
- const rolledOver = !prev || prev.resetsAt !== resetsAt;
27
+ const rolledOver = !prev || !sameWindow(prev.resetsAt, resetsAt);
28
28
  const lastBucket = rolledOver ? 0 : prev.lastBucket;
29
+ // Within a window the first resetsAt stays, so the jitter alone never rewrites the store.
30
+ const windowResetsAt = rolledOver ? resetsAt : prev.resetsAt;
29
31
  if (pct == null) {
30
32
  // No reading this cycle — keep the mark, just track the (possibly new) window.
31
- return { fire: null, next: { lastBucket, resetsAt } };
33
+ return { fire: null, next: { lastBucket, resetsAt: windowResetsAt } };
32
34
  }
33
35
  // Highest threshold the current pct has reached (thresholds are ascending).
34
36
  let top = 0;
@@ -38,10 +40,17 @@ export function evaluateWindow(prev, pct, resetsAt, thresholds) {
38
40
  }
39
41
  }
40
42
  if (top > lastBucket) {
41
- return { fire: top, next: { lastBucket: top, resetsAt } };
43
+ return { fire: top, next: { lastBucket: top, resetsAt: windowResetsAt } };
42
44
  }
43
45
  // top <= lastBucket: the mark follows pct down (or holds); nothing fires.
44
- return { fire: null, next: { lastBucket: top, resetsAt } };
46
+ return { fire: null, next: { lastBucket: top, resetsAt: windowResetsAt } };
47
+ }
48
+ // oauth/usage jitters resets_at by milliseconds per request; a real rollover moves it by hours.
49
+ function sameWindow(a, b) {
50
+ if (a === null || b === null) {
51
+ return a === b;
52
+ }
53
+ return Math.abs(Date.parse(a) - Date.parse(b)) < 60_000;
45
54
  }
46
55
  /** Relative "resets in 12m" / "resets in 2h" suffix, or "" if unknown/past. */
47
56
  function resetSuffix(resetsAt) {
package/dist/notify.js CHANGED
@@ -1,4 +1,10 @@
1
- import { execFile } from 'node:child_process';
1
+ import { execFile, spawn } from 'node:child_process';
2
+ import { cpSync, existsSync, readFileSync, renameSync, rmSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ENDPOINT } from './config.js';
6
+ import { notifierAppPath } from './service.js';
7
+ const NOTIFIER_EXE = join('Contents', 'MacOS', 'usagefleet-notifier');
2
8
  /** Collapse every whitespace run (including the raw newlines AppleScript string
3
9
  * literals forbid) to a single space, leaving hyphens and other punctuation
4
10
  * intact. */
@@ -26,6 +32,36 @@ function spawnQuiet(cmd, args, onError) {
26
32
  }
27
33
  }
28
34
  function notifyMac(title, message) {
35
+ // macos/build.sh puts the app next to this file in the npm release; dev runs have none.
36
+ const bundled = fileURLToPath(new URL('UsageFleet.app', import.meta.url));
37
+ if (!existsSync(bundled)) {
38
+ notifyOsascript(title, message);
39
+ return;
40
+ }
41
+ const app = notifierAppPath();
42
+ installNotifierApp(bundled, app);
43
+ const child = spawn(join(app, NOTIFIER_EXE), [oneLine(title), oneLine(message), `${ENDPOINT}/dashboard`], {
44
+ // Own process group: launchd kills the collector's group when it restarts, and the
45
+ // first-run permission prompt keeps the app alive until the user answers it.
46
+ detached: true,
47
+ stdio: 'ignore',
48
+ });
49
+ child.on('error', () => notifyOsascript(title, message));
50
+ child.unref();
51
+ }
52
+ /** Copies the app to one fixed path, so macOS sees a single UsageFleet app whatever
53
+ * Node install runs the CLI. Replaces it only when the build changed. */
54
+ export function installNotifierApp(from, to) {
55
+ const exe = join(to, NOTIFIER_EXE);
56
+ if (existsSync(exe) && readFileSync(exe).equals(readFileSync(join(from, NOTIFIER_EXE)))) {
57
+ return;
58
+ }
59
+ const tmp = `${to}.${process.pid}.tmp`;
60
+ cpSync(from, tmp, { recursive: true });
61
+ rmSync(to, { recursive: true, force: true });
62
+ renameSync(tmp, to);
63
+ }
64
+ function notifyOsascript(title, message) {
29
65
  // execFile (no shell) — the only interpolation surface is the AppleScript
30
66
  // string, which osaEscape neutralizes.
31
67
  const script = `display notification "${osaEscape(message)}" with title "${osaEscape(title)}"`;
@@ -64,7 +100,7 @@ function notifyWindows(title, message) {
64
100
  /**
65
101
  * Show a desktop notification. Best-effort and non-blocking: it never throws and
66
102
  * never blocks the caller. Supported platforms:
67
- * - macOS: `osascript` -> Notification Center.
103
+ * - macOS: the bundled UsageFleet.app, or `osascript` when it is missing.
68
104
  * - Linux: `notify-send` (KDE Plasma + other freedesktop daemons), falling
69
105
  * back to `kdialog --passivepopup` on KDE.
70
106
  * - Windows: WinRT toast via `powershell.exe` -> Action Center.
package/dist/paths.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { realpathSync } from 'node:fs';
1
2
  import { homedir } from 'node:os';
2
3
  import { join } from 'node:path';
3
4
  // USAGEFLEET_PROJECTS is resolved in config.ts's precedence chain, not here —
@@ -42,7 +43,25 @@ export function defaultPiSessionsDirs() {
42
43
  if (agent) {
43
44
  dirs.push(join(agent, 'sessions'));
44
45
  }
45
- return [...new Set(dirs)];
46
+ // pi exports its dir resolved (~/.pi -> .dotfiles/.pi gives the .dotfiles path), so one
47
+ // root can arrive twice. The first path wins: it is the one the service, without pi's env, scans.
48
+ const unique = new Map();
49
+ for (const dir of dirs) {
50
+ const real = realDir(dir);
51
+ if (!unique.has(real)) {
52
+ unique.set(real, dir);
53
+ }
54
+ }
55
+ return [...unique.values()];
56
+ }
57
+ function realDir(dir) {
58
+ try {
59
+ return realpathSync(dir);
60
+ }
61
+ catch {
62
+ // Missing roots are normal (pi never ran here) and scan to nothing.
63
+ return dir;
64
+ }
46
65
  }
47
66
  /** pi's agent dir, the root for its sessions and extensions.
48
67
  * PI_CODING_AGENT_DIR is pi's own relocation knob; unlike the service-side
package/dist/release.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // Generated by .github/workflows/release.yml.
2
- export const RELEASE_VERSION = "1.2.152";
2
+ export const RELEASE_VERSION = "1.2.153";
package/dist/scanner.js CHANGED
@@ -1,24 +1,58 @@
1
- import { readdirSync } from 'node:fs';
1
+ import { readdirSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
+ // Linux stamps mtimes with a jiffy-coarse clock, HFS+ by the second, FAT by two: a change
4
+ // right after a read can carry the listing's mtime. Only a folder quiet this long is trusted.
5
+ const QUIET_MS = 10_000;
3
6
  /** Recursively list all *.jsonl files under a directory. Returns [] if the
4
- * directory is missing. */
5
- export function listJsonlFiles(dir) {
6
- const out = [];
7
+ * directory is missing. A folder whose mtime has not moved reuses its listing
8
+ * from `cache`: adding or removing an entry moves it, appending to a log does not. */
9
+ export function listJsonlFiles(dir, cache = new Map()) {
10
+ const listing = listDir(dir, cache);
11
+ const out = [...listing.files];
12
+ for (const sub of listing.dirs) {
13
+ out.push(...listJsonlFiles(sub, cache));
14
+ }
15
+ return out;
16
+ }
17
+ function listDir(dir, cache) {
18
+ const cached = cache.get(dir);
19
+ const readAt = Date.now();
20
+ let mtimeMs;
7
21
  let entries;
8
22
  try {
23
+ mtimeMs = statSync(dir).mtimeMs;
24
+ if (cached?.mtimeMs === mtimeMs) {
25
+ return cached;
26
+ }
9
27
  entries = readdirSync(dir, { withFileTypes: true });
10
28
  }
11
29
  catch {
12
- return out;
30
+ forget(cache, dir);
31
+ return { dirs: [], files: [], mtimeMs: null };
13
32
  }
33
+ const listing = { dirs: [], files: [], mtimeMs: readAt - mtimeMs > QUIET_MS ? mtimeMs : null };
14
34
  for (const e of entries) {
15
35
  const full = join(dir, e.name);
16
36
  if (e.isDirectory()) {
17
- out.push(...listJsonlFiles(full));
37
+ listing.dirs.push(full);
18
38
  }
19
39
  else if (e.isFile() && e.name.endsWith('.jsonl')) {
20
- out.push(full);
40
+ listing.files.push(full);
21
41
  }
22
42
  }
23
- return out;
43
+ const kept = new Set(listing.dirs);
44
+ for (const sub of cached?.dirs ?? []) {
45
+ if (!kept.has(sub)) {
46
+ forget(cache, sub);
47
+ }
48
+ }
49
+ cache.set(dir, listing);
50
+ return listing;
51
+ }
52
+ function forget(cache, dir) {
53
+ const listing = cache.get(dir);
54
+ cache.delete(dir);
55
+ for (const sub of listing?.dirs ?? []) {
56
+ forget(cache, sub);
57
+ }
24
58
  }
package/dist/service.js CHANGED
@@ -15,7 +15,7 @@ const TASK = 'usagefleet';
15
15
  * service that lost it would silently report the default account instead. */
16
16
  const EXTRA_PASSTHROUGH_ENV = new Set(['ANTHROPIC_API_KEY', 'CLAUDE_CONFIG_DIR']);
17
17
  /** Per-user dir for the collector's own runtime files: the Windows launcher and
18
- * its log, plus the binary copy that pre-npm releases left there. */
18
+ * its log, the macOS notifier app, plus the binary copy that pre-npm releases left there. */
19
19
  function stableBinDir() {
20
20
  if (process.platform === 'darwin') {
21
21
  return join(homedir(), 'Library', 'Application Support', 'usagefleet');
@@ -39,6 +39,10 @@ function macLogDir() {
39
39
  function windowsVbsPath() {
40
40
  return join(stableBinDir(), 'usagefleet-watch.vbs');
41
41
  }
42
+ /** One fixed path for the macOS notifier, whatever Node install runs the CLI. */
43
+ export function notifierAppPath() {
44
+ return join(stableBinDir(), 'UsageFleet.app');
45
+ }
42
46
  /** Where releases before the npm switch parked their copy of the binary. Only
43
47
  * cleanup touches it now. */
44
48
  function stableBinPath() {
@@ -553,7 +557,8 @@ export function uninstall() {
553
557
  }
554
558
  }
555
559
  removeStableBin();
556
- console.log(step('removed', 'launchd agent'));
560
+ rmSync(notifierAppPath(), { force: true, recursive: true });
561
+ console.log(step('removed', 'launchd agent · notification app'));
557
562
  console.log(row('leftover', `${tilde(path)} · delete to fully clean up`));
558
563
  return;
559
564
  }
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { mkdirSync, readFileSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { writeFileAtomic } from './atomic-write.js';
@@ -108,7 +108,13 @@ export function readStore(path = storePath()) {
108
108
  */
109
109
  export function updateStore(path, mutate) {
110
110
  const store = readStore(path);
111
+ const before = JSON.stringify(store);
111
112
  mutate(store);
113
+ // The offsets make the store ~0.5 MB, so an unchanged one is not rewritten. A missing
114
+ // file always is: it pins the deviceId normalize() just generated and folds the legacy files.
115
+ if (existsSync(path) && JSON.stringify(store) === before) {
116
+ return;
117
+ }
112
118
  mkdirSync(dirname(path), { recursive: true });
113
119
  // 0600: the file holds the device token.
114
120
  writeFileAtomic(path, `${JSON.stringify(store, null, 2)}\n`, 0o600);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usagefleet/cli",
3
- "version": "1.2.152",
3
+ "version": "1.2.153",
4
4
  "description": "Tails Claude Code, Claude Desktop, and pi agent JSONL logs and reports token usage to a UsageFleet server.",
5
5
  "keywords": [
6
6
  "claude",