@onmars/lunar-core 0.1.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.
Files changed (92) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +32 -0
  4. package/src/__tests__/clear-command.test.ts +214 -0
  5. package/src/__tests__/command-handler.test.ts +169 -0
  6. package/src/__tests__/compact-command.test.ts +80 -0
  7. package/src/__tests__/config-command.test.ts +240 -0
  8. package/src/__tests__/config-loader.test.ts +1512 -0
  9. package/src/__tests__/config.test.ts +429 -0
  10. package/src/__tests__/cron-command.test.ts +418 -0
  11. package/src/__tests__/cron-parser.test.ts +259 -0
  12. package/src/__tests__/daemon.test.ts +346 -0
  13. package/src/__tests__/dedup.test.ts +404 -0
  14. package/src/__tests__/e2e-sanitization.ts +168 -0
  15. package/src/__tests__/e2e-skill-loader.test.ts +176 -0
  16. package/src/__tests__/fixtures/AGENTS.md +4 -0
  17. package/src/__tests__/fixtures/IDENTITY.md +2 -0
  18. package/src/__tests__/fixtures/SOUL.md +3 -0
  19. package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
  20. package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
  21. package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
  22. package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
  23. package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
  24. package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
  25. package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
  26. package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
  27. package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
  28. package/src/__tests__/hook-runner.test.ts +1689 -0
  29. package/src/__tests__/input-sanitization.test.ts +367 -0
  30. package/src/__tests__/logger.test.ts +163 -0
  31. package/src/__tests__/memory-orchestrator.test.ts +552 -0
  32. package/src/__tests__/model-catalog.test.ts +215 -0
  33. package/src/__tests__/model-command.test.ts +185 -0
  34. package/src/__tests__/moon-loader.test.ts +398 -0
  35. package/src/__tests__/ping-command.test.ts +85 -0
  36. package/src/__tests__/plugin.test.ts +258 -0
  37. package/src/__tests__/remind-command.test.ts +368 -0
  38. package/src/__tests__/reset-command.test.ts +92 -0
  39. package/src/__tests__/router.test.ts +1246 -0
  40. package/src/__tests__/scheduler.test.ts +469 -0
  41. package/src/__tests__/security.test.ts +214 -0
  42. package/src/__tests__/session-meta.test.ts +101 -0
  43. package/src/__tests__/session-tracker.test.ts +389 -0
  44. package/src/__tests__/session.test.ts +241 -0
  45. package/src/__tests__/skill-loader.test.ts +153 -0
  46. package/src/__tests__/status-command.test.ts +153 -0
  47. package/src/__tests__/stop-command.test.ts +60 -0
  48. package/src/__tests__/think-command.test.ts +146 -0
  49. package/src/__tests__/usage-api.test.ts +222 -0
  50. package/src/__tests__/usage-command-api-fail.test.ts +48 -0
  51. package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
  52. package/src/__tests__/usage-command.test.ts +173 -0
  53. package/src/__tests__/whoami-command.test.ts +124 -0
  54. package/src/index.ts +122 -0
  55. package/src/lib/command-handler.ts +135 -0
  56. package/src/lib/commands/clear.ts +69 -0
  57. package/src/lib/commands/compact.ts +14 -0
  58. package/src/lib/commands/config-show.ts +49 -0
  59. package/src/lib/commands/cron.ts +118 -0
  60. package/src/lib/commands/help.ts +26 -0
  61. package/src/lib/commands/model.ts +71 -0
  62. package/src/lib/commands/ping.ts +24 -0
  63. package/src/lib/commands/remind.ts +75 -0
  64. package/src/lib/commands/status.ts +118 -0
  65. package/src/lib/commands/stop.ts +18 -0
  66. package/src/lib/commands/think.ts +42 -0
  67. package/src/lib/commands/usage.ts +56 -0
  68. package/src/lib/commands/whoami.ts +23 -0
  69. package/src/lib/config-loader.ts +1449 -0
  70. package/src/lib/config.ts +202 -0
  71. package/src/lib/cron-parser.ts +388 -0
  72. package/src/lib/daemon.ts +216 -0
  73. package/src/lib/dedup.ts +414 -0
  74. package/src/lib/hook-runner.ts +1270 -0
  75. package/src/lib/logger.ts +55 -0
  76. package/src/lib/memory-orchestrator.ts +415 -0
  77. package/src/lib/model-catalog.ts +240 -0
  78. package/src/lib/moon-loader.ts +291 -0
  79. package/src/lib/plugin.ts +148 -0
  80. package/src/lib/router.ts +1135 -0
  81. package/src/lib/scheduler.ts +422 -0
  82. package/src/lib/security.ts +259 -0
  83. package/src/lib/session-tracker.ts +222 -0
  84. package/src/lib/session.ts +158 -0
  85. package/src/lib/skill-loader.ts +166 -0
  86. package/src/lib/usage-api.ts +145 -0
  87. package/src/types/agent.ts +86 -0
  88. package/src/types/channel.ts +93 -0
  89. package/src/types/index.ts +32 -0
  90. package/src/types/memory.ts +92 -0
  91. package/src/types/moon.ts +56 -0
  92. package/src/types/voice.ts +74 -0
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Session Tracker — Accumulates usage metrics per session.
3
+ *
4
+ * Fed by AgentEvent 'done' events from the Router. Tracks:
5
+ * - Cumulative token counts (input, output, cache read, cache write)
6
+ * - Message count and turn count
7
+ * - Session start time and duration
8
+ * - Last known model and context usage
9
+ * - Cost estimate (via model-catalog pricing)
10
+ *
11
+ * All data comes from the stream-json output that Claude Code CLI
12
+ * already provides — no internal APIs or OAuth tokens needed.
13
+ */
14
+
15
+ import type { AgentUsage } from '../types/agent'
16
+ import { calculateCost, getContextWindowSize, getModelDisplayName } from './model-catalog'
17
+
18
+ export interface SessionUsage {
19
+ /** Total input tokens across all queries */
20
+ inputTokens: number
21
+ /** Total output tokens across all queries */
22
+ outputTokens: number
23
+ /** Total cache read tokens */
24
+ cacheReadTokens: number
25
+ /** Total cache write tokens */
26
+ cacheWriteTokens: number
27
+ /** Number of agent queries completed */
28
+ queryCount: number
29
+ /** Number of user messages processed */
30
+ messageCount: number
31
+ /** Last known model ID */
32
+ model?: string
33
+ /** Context tokens from the last API call (actual context window usage) */
34
+ lastContextTokens: number
35
+ /** Session start timestamp (ms) */
36
+ startedAt: number
37
+ /** Total API duration across all queries (ms) */
38
+ totalApiDurationMs: number
39
+ }
40
+
41
+ export interface SessionStatus {
42
+ /** Session key */
43
+ sessionKey: string
44
+ /** Usage data */
45
+ usage: SessionUsage
46
+ /** Human-readable model name */
47
+ modelDisplayName: string
48
+ /** Context window max for current model */
49
+ contextWindowSize: number
50
+ /** Context usage percentage (0-100) */
51
+ contextUsedPercent: number
52
+ /** Estimated cost in USD */
53
+ estimatedCostUsd: number
54
+ /** Session age in human-readable format */
55
+ sessionAge: string
56
+ /** Session age in ms */
57
+ sessionAgeMs: number
58
+ }
59
+
60
+ /**
61
+ * Tracks usage metrics per session. In-memory — resets on restart.
62
+ * Designed to be lightweight: one Map, no async, no external deps.
63
+ */
64
+ export class SessionTracker {
65
+ private sessions = new Map<string, SessionUsage>()
66
+
67
+ /**
68
+ * Record a completed agent query for a session.
69
+ * Called by Router when it receives an AgentEvent of type 'done'.
70
+ */
71
+ recordQuery(sessionKey: string, usage: AgentUsage): void {
72
+ let session = this.sessions.get(sessionKey)
73
+ if (!session) {
74
+ session = {
75
+ inputTokens: 0,
76
+ outputTokens: 0,
77
+ cacheReadTokens: 0,
78
+ cacheWriteTokens: 0,
79
+ queryCount: 0,
80
+ messageCount: 0,
81
+ model: undefined,
82
+ lastContextTokens: 0,
83
+ startedAt: Date.now(),
84
+ totalApiDurationMs: 0,
85
+ }
86
+ this.sessions.set(sessionKey, session)
87
+ }
88
+
89
+ session.inputTokens += usage.inputTokens
90
+ session.outputTokens += usage.outputTokens
91
+ session.cacheReadTokens += usage.cacheReadTokens ?? 0
92
+ session.cacheWriteTokens += usage.cacheWriteTokens ?? 0
93
+ session.queryCount += 1
94
+ // contextTokens = last API call's real context window usage
95
+ // Falls back to input + cache if not provided
96
+ session.lastContextTokens =
97
+ usage.contextTokens ??
98
+ usage.inputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0)
99
+ session.totalApiDurationMs += usage.durationMs
100
+
101
+ if (usage.model) {
102
+ session.model = usage.model
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Record that a user message was processed (called per incoming message).
108
+ */
109
+ recordMessage(sessionKey: string): void {
110
+ const session = this.sessions.get(sessionKey)
111
+ if (session) {
112
+ session.messageCount += 1
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Get full status for a session.
118
+ */
119
+ getStatus(sessionKey: string): SessionStatus | null {
120
+ const usage = this.sessions.get(sessionKey)
121
+ if (!usage) return null
122
+
123
+ const model = usage.model ?? 'unknown'
124
+ const contextWindowSize = getContextWindowSize(model)
125
+
126
+ // Context percentage from last API call's actual context window usage
127
+ const contextUsedPercent =
128
+ contextWindowSize > 0 ? Math.min(100, (usage.lastContextTokens / contextWindowSize) * 100) : 0
129
+
130
+ const estimatedCostUsd = calculateCost(model, {
131
+ inputTokens: usage.inputTokens,
132
+ outputTokens: usage.outputTokens,
133
+ cacheReadTokens: usage.cacheReadTokens,
134
+ cacheWriteTokens: usage.cacheWriteTokens,
135
+ })
136
+
137
+ const ageMs = Date.now() - usage.startedAt
138
+
139
+ return {
140
+ sessionKey,
141
+ usage,
142
+ modelDisplayName: getModelDisplayName(model),
143
+ contextWindowSize,
144
+ contextUsedPercent,
145
+ estimatedCostUsd,
146
+ sessionAge: formatDuration(ageMs),
147
+ sessionAgeMs: ageMs,
148
+ }
149
+ }
150
+
151
+ /**
152
+ * Get raw usage for a session.
153
+ */
154
+ getUsage(sessionKey: string): SessionUsage | undefined {
155
+ return this.sessions.get(sessionKey)
156
+ }
157
+
158
+ /**
159
+ * Get all tracked sessions.
160
+ */
161
+ listSessions(): string[] {
162
+ return [...this.sessions.keys()]
163
+ }
164
+
165
+ /**
166
+ * Clear tracking for a session (e.g., on session close).
167
+ */
168
+ clear(sessionKey: string): void {
169
+ this.sessions.delete(sessionKey)
170
+ }
171
+
172
+ /**
173
+ * Clear all sessions.
174
+ */
175
+ clearAll(): void {
176
+ this.sessions.clear()
177
+ }
178
+ }
179
+
180
+ // ─── Helpers ──────────────────────────────────────────
181
+
182
+ /**
183
+ * Format milliseconds to human-readable duration.
184
+ * e.g., 7_380_000 → "2h 3m"
185
+ */
186
+ export function formatDuration(ms: number): string {
187
+ const seconds = Math.floor(ms / 1000)
188
+ const minutes = Math.floor(seconds / 60)
189
+ const hours = Math.floor(minutes / 60)
190
+
191
+ if (hours > 0) {
192
+ const remainingMinutes = minutes % 60
193
+ return remainingMinutes > 0 ? `${hours}h ${remainingMinutes}m` : `${hours}h`
194
+ }
195
+ if (minutes > 0) {
196
+ const remainingSeconds = seconds % 60
197
+ return remainingSeconds > 0 ? `${minutes}m ${remainingSeconds}s` : `${minutes}m`
198
+ }
199
+ return `${seconds}s`
200
+ }
201
+
202
+ /**
203
+ * Format token count with K/M suffixes.
204
+ * e.g., 12450 → "12.4K", 1500000 → "1.5M", 850 → "850"
205
+ */
206
+ export function formatTokens(tokens: number): string {
207
+ if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
208
+ if (tokens >= 10_000) return `${(tokens / 1_000).toFixed(0)}K`
209
+ if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
210
+ return String(tokens)
211
+ }
212
+
213
+ /**
214
+ * Build a progress bar string.
215
+ * e.g., buildProgressBar(42, 10) → "████░░░░░░"
216
+ */
217
+ export function buildProgressBar(percent: number, width = 10): string {
218
+ const clamped = Math.max(0, Math.min(100, percent))
219
+ const filled = Math.round((clamped / 100) * width)
220
+ const empty = width - filled
221
+ return '█'.repeat(filled) + '░'.repeat(empty)
222
+ }
@@ -0,0 +1,158 @@
1
+ import { Database } from 'bun:sqlite'
2
+ import { existsSync, mkdirSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+ import { log } from './logger'
5
+
6
+ export interface Session {
7
+ /** Channel + chat composite key */
8
+ key: string
9
+ /** Agent session ID (for resumption) */
10
+ agentSessionId: string
11
+ /** Last activity timestamp */
12
+ lastActive: number
13
+ /** Optional metadata */
14
+ metadata?: string
15
+ }
16
+
17
+ export class SessionStore {
18
+ private db: Database
19
+
20
+ constructor(dbPath: string) {
21
+ // Ensure directory exists
22
+ const dir = dirname(dbPath)
23
+ if (!existsSync(dir)) {
24
+ mkdirSync(dir, { recursive: true })
25
+ }
26
+
27
+ this.db = new Database(dbPath, { create: true })
28
+ this.db.exec('PRAGMA journal_mode = WAL')
29
+ this.db.exec('PRAGMA busy_timeout = 5000')
30
+ this.migrate()
31
+ log.debug({ path: dbPath }, 'Session store initialized')
32
+ }
33
+
34
+ private migrate(): void {
35
+ this.db.exec(`
36
+ CREATE TABLE IF NOT EXISTS sessions (
37
+ key TEXT PRIMARY KEY,
38
+ agent_session_id TEXT NOT NULL,
39
+ last_active INTEGER NOT NULL DEFAULT (unixepoch()),
40
+ metadata TEXT
41
+ );
42
+
43
+ CREATE TABLE IF NOT EXISTS jobs (
44
+ id TEXT PRIMARY KEY,
45
+ name TEXT,
46
+ schedule TEXT NOT NULL,
47
+ payload TEXT NOT NULL,
48
+ enabled INTEGER NOT NULL DEFAULT 1,
49
+ last_run INTEGER,
50
+ next_run INTEGER,
51
+ created_at INTEGER NOT NULL DEFAULT (unixepoch())
52
+ );
53
+
54
+ CREATE INDEX IF NOT EXISTS idx_sessions_active ON sessions(last_active);
55
+ CREATE INDEX IF NOT EXISTS idx_jobs_next ON jobs(next_run) WHERE enabled = 1;
56
+ `)
57
+ }
58
+
59
+ /** Get or create a session for a channel/chat key */
60
+ get(key: string): Session | null {
61
+ const row = this.db
62
+ .query('SELECT key, agent_session_id, last_active, metadata FROM sessions WHERE key = ?')
63
+ .get(key) as {
64
+ key: string
65
+ agent_session_id: string
66
+ last_active: number
67
+ metadata: string | null
68
+ } | null
69
+
70
+ if (!row) return null
71
+ return {
72
+ key: row.key,
73
+ agentSessionId: row.agent_session_id,
74
+ lastActive: row.last_active,
75
+ metadata: row.metadata ?? undefined,
76
+ }
77
+ }
78
+
79
+ /** Upsert a session */
80
+ set(key: string, agentSessionId: string, metadata?: string): void {
81
+ this.db
82
+ .query(
83
+ `INSERT INTO sessions (key, agent_session_id, last_active, metadata)
84
+ VALUES (?, ?, unixepoch(), ?)
85
+ ON CONFLICT(key) DO UPDATE SET
86
+ agent_session_id = excluded.agent_session_id,
87
+ last_active = unixepoch(),
88
+ metadata = excluded.metadata`,
89
+ )
90
+ .run(key, agentSessionId, metadata ?? null)
91
+ }
92
+
93
+ /** Touch session (update last_active) */
94
+ touch(key: string): void {
95
+ this.db.query('UPDATE sessions SET last_active = unixepoch() WHERE key = ?').run(key)
96
+ }
97
+
98
+ /** Delete a session */
99
+ delete(key: string): void {
100
+ this.db.query('DELETE FROM sessions WHERE key = ?').run(key)
101
+ }
102
+
103
+ /** List all sessions, optionally filtered by recency */
104
+ list(maxAgeSeconds?: number): Session[] {
105
+ let query = 'SELECT key, agent_session_id, last_active, metadata FROM sessions'
106
+ const params: number[] = []
107
+
108
+ if (maxAgeSeconds) {
109
+ query += ' WHERE last_active > unixepoch() - ?'
110
+ params.push(maxAgeSeconds)
111
+ }
112
+
113
+ query += ' ORDER BY last_active DESC'
114
+
115
+ const rows = this.db.query(query).all(...params) as Array<{
116
+ key: string
117
+ agent_session_id: string
118
+ last_active: number
119
+ metadata: string | null
120
+ }>
121
+
122
+ return rows.map((row) => ({
123
+ key: row.key,
124
+ agentSessionId: row.agent_session_id,
125
+ lastActive: row.last_active,
126
+ metadata: row.metadata ?? undefined,
127
+ }))
128
+ }
129
+
130
+ /** Update only metadata for an existing session (or create placeholder if none exists) */
131
+ updateMetadata(key: string, metadata: string): void {
132
+ this.db
133
+ .query(
134
+ `INSERT INTO sessions (key, agent_session_id, last_active, metadata)
135
+ VALUES (?, '', unixepoch(), ?)
136
+ ON CONFLICT(key) DO UPDATE SET
137
+ metadata = excluded.metadata,
138
+ last_active = unixepoch()`,
139
+ )
140
+ .run(key, metadata)
141
+ }
142
+
143
+ /** Get parsed metadata for a session */
144
+ getMetadata(key: string): Record<string, unknown> | null {
145
+ const session = this.get(key)
146
+ if (!session?.metadata) return null
147
+ try {
148
+ return JSON.parse(session.metadata) as Record<string, unknown>
149
+ } catch {
150
+ return null
151
+ }
152
+ }
153
+
154
+ /** Close the database */
155
+ close(): void {
156
+ this.db.close()
157
+ }
158
+ }
@@ -0,0 +1,166 @@
1
+ /**
2
+ * SkillLoader — Discovers and indexes skills from the workspace filesystem.
3
+ *
4
+ * Skills are directories containing a SKILL.md file with YAML frontmatter.
5
+ * The loader scans configured paths, extracts metadata, and builds a
6
+ * compact manifest for injection into the agent's system prompt.
7
+ *
8
+ * Design: filesystem IS the database. No SQLite, no cache invalidation.
9
+ * Skills are scanned once at startup and cached in memory.
10
+ */
11
+
12
+ import { readdir } from 'node:fs/promises'
13
+ import { join, resolve } from 'node:path'
14
+ import { log } from './logger'
15
+
16
+ export interface SkillMeta {
17
+ name: string
18
+ description: string
19
+ path: string
20
+ }
21
+
22
+ export class SkillLoader {
23
+ private skills: SkillMeta[] = []
24
+
25
+ public get size(): number {
26
+ return this.skills.length
27
+ }
28
+
29
+ public getSkills(): SkillMeta[] {
30
+ return [...this.skills]
31
+ }
32
+
33
+ public getManifest(allowlist?: string[]): string {
34
+ const skills = allowlist ? this.skills.filter((s) => allowlist.includes(s.name)) : this.skills
35
+
36
+ if (skills.length === 0) return ''
37
+
38
+ let manifest = '## Skills\n\nAvailable in your workspace. Read the SKILL.md before using.\n\n'
39
+
40
+ for (const skill of skills) {
41
+ // Find relative path to workspace if possible
42
+ let displayPath = skill.path
43
+ const workspaceIndex = skill.path.indexOf('/workspace/')
44
+ if (workspaceIndex !== -1) {
45
+ displayPath = skill.path.slice(workspaceIndex + 11)
46
+ } else {
47
+ const skillsIndex = skill.path.indexOf('skills/')
48
+ if (skillsIndex !== -1) {
49
+ displayPath = skill.path.slice(skillsIndex)
50
+ }
51
+ }
52
+
53
+ manifest += `- **${skill.name}** — ${skill.description} → \`${displayPath}\`\n`
54
+ }
55
+
56
+ manifest += '\nRead the relevant SKILL.md before executing specialized tasks.\n'
57
+
58
+ return manifest
59
+ }
60
+
61
+ public async scan(dirs: string[]): Promise<void> {
62
+ const foundSkills: SkillMeta[] = []
63
+
64
+ for (const dir of dirs) {
65
+ try {
66
+ const entries = await readdir(dir, { withFileTypes: true })
67
+ for (const entry of entries) {
68
+ if (entry.isDirectory()) {
69
+ const skillPath = join(dir, entry.name, 'SKILL.md')
70
+ const file = Bun.file(skillPath)
71
+ if (await file.exists()) {
72
+ const content = await file.text()
73
+ const meta = this.parseFrontmatter(content, skillPath)
74
+ if (meta) {
75
+ foundSkills.push(meta)
76
+ } else {
77
+ log.warn(
78
+ { path: skillPath },
79
+ 'Skipped SKILL.md due to missing or invalid frontmatter',
80
+ )
81
+ }
82
+ }
83
+ }
84
+ }
85
+ } catch (err: any) {
86
+ if (err.code !== 'ENOENT') {
87
+ log.warn({ err: err.message, dir }, 'Failed to scan directory for skills')
88
+ }
89
+ }
90
+ }
91
+
92
+ // Sort by name for deterministic output
93
+ foundSkills.sort((a, b) => a.name.localeCompare(b.name))
94
+ this.skills = foundSkills
95
+ }
96
+
97
+ private parseFrontmatter(content: string, filePath: string): SkillMeta | null {
98
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
99
+ if (!match) return null
100
+
101
+ const yaml = match[1]
102
+ const lines = yaml.split(/\r?\n/)
103
+ let name = ''
104
+ let description = ''
105
+
106
+ let inMultilineDesc = false
107
+ let currentDesc = ''
108
+
109
+ for (let i = 0; i < lines.length; i++) {
110
+ const line = lines[i]
111
+ if (!line.trim() && !inMultilineDesc) continue
112
+
113
+ if (!inMultilineDesc && line.includes(':')) {
114
+ const colonIndex = line.indexOf(':')
115
+ const key = line.slice(0, colonIndex).trim()
116
+ const value = line.slice(colonIndex + 1).trim()
117
+
118
+ if (key === 'name') {
119
+ name = this.stripQuotes(value)
120
+ } else if (key === 'description') {
121
+ if (
122
+ value === '' ||
123
+ value.startsWith('"') ||
124
+ (value.startsWith("'") && !value.endsWith('"') && !value.endsWith("'"))
125
+ ) {
126
+ inMultilineDesc = true
127
+ currentDesc = this.stripQuotes(value)
128
+ } else {
129
+ description = this.stripQuotes(value)
130
+ }
131
+ }
132
+ } else if (inMultilineDesc) {
133
+ if (line.includes(':') && !line.startsWith(' ')) {
134
+ inMultilineDesc = false
135
+ description = this.stripQuotes(currentDesc)
136
+ i-- // Re-process this line
137
+ } else {
138
+ currentDesc += (currentDesc ? ' ' : '') + line.trim()
139
+ if (currentDesc.endsWith('"') || currentDesc.endsWith("'")) {
140
+ inMultilineDesc = false
141
+ description = this.stripQuotes(currentDesc)
142
+ }
143
+ }
144
+ }
145
+ }
146
+
147
+ if (inMultilineDesc && !description) {
148
+ description = this.stripQuotes(currentDesc)
149
+ }
150
+
151
+ if (!name || !description) return null
152
+
153
+ return { name, description, path: resolve(filePath) }
154
+ }
155
+
156
+ private stripQuotes(val: string): string {
157
+ const result = val.trim()
158
+ if (result.startsWith('"') && result.endsWith('"')) {
159
+ return result.slice(1, -1)
160
+ }
161
+ if (result.startsWith("'") && result.endsWith("'")) {
162
+ return result.slice(1, -1)
163
+ }
164
+ return result
165
+ }
166
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Usage API — Fetch account-level usage/quota from Anthropic.
3
+ *
4
+ * Reads the OAuth token from Claude Code's credentials file
5
+ * (~/.claude/.credentials.json) and calls the usage endpoint.
6
+ *
7
+ * Returns 5-hour and 7-day rolling window usage vs limits.
8
+ * This is the same data that Claude Code's /usage command shows.
9
+ *
10
+ * NOTE: This directly calls Anthropic's API with the stored OAuth token.
11
+ * It's a pragmatic solution — same auth that Claude Code uses internally.
12
+ */
13
+
14
+ import { existsSync, readFileSync } from 'node:fs'
15
+ import { homedir } from 'node:os'
16
+ import { resolve } from 'node:path'
17
+ import { log } from './logger'
18
+
19
+ const USAGE_API_URL = 'https://api.anthropic.com/api/oauth/usage'
20
+
21
+ export interface UsageWindow {
22
+ /** Tokens used in this window */
23
+ used: number
24
+ /** Token limit for this window */
25
+ limit: number
26
+ /** Percentage used (0-100) */
27
+ percent: number
28
+ /** When the window resets (ISO string, if available) */
29
+ resetsAt?: string
30
+ }
31
+
32
+ export interface AccountUsage {
33
+ /** 5-hour rolling window */
34
+ fiveHour: UsageWindow | null
35
+ /** 7-day rolling window */
36
+ sevenDay: UsageWindow | null
37
+ /** When the data was fetched */
38
+ fetchedAt: number
39
+ }
40
+
41
+ /**
42
+ * Read the OAuth token from Claude Code's credentials file.
43
+ *
44
+ * Looks in:
45
+ * 1. CLAUDE_CODE_OAUTH_TOKEN env var
46
+ * 2. ~/.claude/.credentials.json
47
+ */
48
+ export function readOAuthToken(): string | null {
49
+ // 1. Environment variable (takes precedence)
50
+ const envToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
51
+ if (envToken) return envToken
52
+
53
+ // 2. Credentials file
54
+ const credPath = resolve(homedir(), '.claude', '.credentials.json')
55
+ if (!existsSync(credPath)) {
56
+ log.debug({ path: credPath }, 'Claude credentials file not found')
57
+ return null
58
+ }
59
+
60
+ try {
61
+ const raw = readFileSync(credPath, 'utf-8')
62
+ const creds = JSON.parse(raw)
63
+
64
+ // The file typically has { accessToken, refreshToken, expiresAt }
65
+ const token = creds.claudeAiOauth?.accessToken ?? creds.accessToken ?? creds.access_token
66
+ if (!token) {
67
+ log.debug('No access token found in credentials file')
68
+ return null
69
+ }
70
+
71
+ return token
72
+ } catch (err) {
73
+ log.warn({ err }, 'Failed to read Claude credentials')
74
+ return null
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Parse a raw usage window object into a typed UsageWindow.
80
+ * Returns null if input is not a valid object.
81
+ */
82
+ export function parseWindow(raw: unknown): UsageWindow | null {
83
+ if (!raw || typeof raw !== 'object') return null
84
+ const w = raw as Record<string, unknown>
85
+ const used = typeof w.used === 'number' ? w.used : 0
86
+ const limit = typeof w.limit === 'number' ? w.limit : 0
87
+ return {
88
+ used,
89
+ limit,
90
+ percent: limit > 0 ? Math.round((used / limit) * 100) : 0,
91
+ resetsAt: typeof w.resetsAt === 'string' ? w.resetsAt : undefined,
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Fetch account usage from Anthropic's OAuth usage API.
97
+ *
98
+ * Returns null if:
99
+ * - No OAuth token found
100
+ * - API call fails
101
+ * - Not an OAuth/subscription user (API key users don't have this)
102
+ */
103
+ export async function fetchAccountUsage(): Promise<AccountUsage | null> {
104
+ const token = readOAuthToken()
105
+ if (!token) return null
106
+
107
+ try {
108
+ const response = await fetch(USAGE_API_URL, {
109
+ method: 'GET',
110
+ headers: {
111
+ Accept: 'application/json',
112
+ 'Content-Type': 'application/json',
113
+ Authorization: `Bearer ${token}`,
114
+ 'anthropic-beta': 'oauth-2025-04-20',
115
+ 'User-Agent': 'lunar/1.0',
116
+ },
117
+ })
118
+
119
+ if (!response.ok) {
120
+ log.debug(
121
+ { status: response.status, statusText: response.statusText },
122
+ 'Usage API returned non-OK',
123
+ )
124
+ return null
125
+ }
126
+
127
+ const data = (await response.json()) as Record<string, unknown>
128
+
129
+ return {
130
+ fiveHour: parseWindow(data.five_hour),
131
+ sevenDay: parseWindow(data.seven_day),
132
+ fetchedAt: Date.now(),
133
+ }
134
+ } catch (err) {
135
+ log.debug({ err }, 'Failed to fetch account usage')
136
+ return null
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Check if OAuth credentials are available.
142
+ */
143
+ export function hasOAuthCredentials(): boolean {
144
+ return readOAuthToken() !== null
145
+ }