@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,146 @@
1
+ /**
2
+ * /think Command — Test Suite
3
+ *
4
+ * Spec: set the thinking level for the current session.
5
+ *
6
+ * Key contracts:
7
+ * - No args: shows current level + available options
8
+ * - Valid levels: off, low, medium, high
9
+ * - Each level shows a description after being set
10
+ * - Invalid level: error with available options listed
11
+ * - When updateSessionMeta is not available: returns error
12
+ */
13
+ import { describe, expect, test } from 'bun:test'
14
+ import type { CommandContext } from '../lib/command-handler'
15
+ import { thinkCommand } from '../lib/commands/think'
16
+ import { SessionTracker } from '../lib/session-tracker'
17
+
18
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
19
+ return {
20
+ sessionKey: 'test:channel-1',
21
+ message: {
22
+ id: 'msg-1',
23
+ channelId: 'channel-1',
24
+ sender: { id: 'user-1', name: 'Test User' },
25
+ text: '/think',
26
+ timestamp: new Date(),
27
+ },
28
+ tracker: new SessionTracker(),
29
+ daemonStartedAt: Date.now(),
30
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
31
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
32
+ ...overrides,
33
+ }
34
+ }
35
+
36
+ // ═══════════════════════════════════════════════════════════
37
+ // No args — show current level
38
+ // ═══════════════════════════════════════════════════════════
39
+
40
+ describe('/think no args', () => {
41
+ test('shows "default" when no metadata', () => {
42
+ const result = thinkCommand('', makeCtx())
43
+ expect(result).toContain('default')
44
+ })
45
+
46
+ test('shows current level from session metadata', () => {
47
+ const ctx = makeCtx({
48
+ getSessionMeta: () => ({ thinkingLevel: 'high' }),
49
+ })
50
+ const result = thinkCommand('', ctx)
51
+ expect(result).toContain('high')
52
+ })
53
+
54
+ test('shows available levels list', () => {
55
+ const result = thinkCommand('', makeCtx())
56
+ expect(result).toContain('off')
57
+ expect(result).toContain('low')
58
+ expect(result).toContain('medium')
59
+ expect(result).toContain('high')
60
+ })
61
+ })
62
+
63
+ // ═══════════════════════════════════════════════════════════
64
+ // Set thinking level
65
+ // ═══════════════════════════════════════════════════════════
66
+
67
+ describe('/think set level', () => {
68
+ test('/think off sets thinkingLevel to off', () => {
69
+ let captured: unknown = null
70
+ const ctx = makeCtx({
71
+ updateSessionMeta: (u) => { captured = u },
72
+ })
73
+ thinkCommand('off', ctx)
74
+ expect(captured).toEqual({ thinkingLevel: 'off' })
75
+ })
76
+
77
+ test('/think low sets thinkingLevel to low', () => {
78
+ let captured: unknown = null
79
+ const ctx = makeCtx({
80
+ updateSessionMeta: (u) => { captured = u },
81
+ })
82
+ thinkCommand('low', ctx)
83
+ expect(captured).toEqual({ thinkingLevel: 'low' })
84
+ })
85
+
86
+ test('/think medium sets thinkingLevel to medium', () => {
87
+ let captured: unknown = null
88
+ const ctx = makeCtx({
89
+ updateSessionMeta: (u) => { captured = u },
90
+ })
91
+ thinkCommand('medium', ctx)
92
+ expect(captured).toEqual({ thinkingLevel: 'medium' })
93
+ })
94
+
95
+ test('/think high sets thinkingLevel to high', () => {
96
+ let captured: unknown = null
97
+ const ctx = makeCtx({
98
+ updateSessionMeta: (u) => { captured = u },
99
+ })
100
+ thinkCommand('high', ctx)
101
+ expect(captured).toEqual({ thinkingLevel: 'high' })
102
+ })
103
+
104
+ test('shows description for each level', () => {
105
+ const ctx = makeCtx({ updateSessionMeta: () => {} })
106
+
107
+ const offResult = thinkCommand('off', ctx)
108
+ expect(offResult).toContain('fastest')
109
+
110
+ const lowResult = thinkCommand('low', ctx)
111
+ expect(lowResult).toContain('Brief')
112
+
113
+ const medResult = thinkCommand('medium', ctx)
114
+ expect(medResult).toContain('Moderate')
115
+
116
+ const highResult = thinkCommand('high', ctx)
117
+ expect(highResult).toContain('Deep')
118
+ })
119
+ })
120
+
121
+ // ═══════════════════════════════════════════════════════════
122
+ // Invalid level
123
+ // ═══════════════════════════════════════════════════════════
124
+
125
+ describe('/think invalid level', () => {
126
+ test('shows error with available options', () => {
127
+ const result = thinkCommand('turbo', makeCtx())
128
+ expect(result).toContain('Invalid level')
129
+ expect(result).toContain('turbo')
130
+ expect(result).toContain('off')
131
+ expect(result).toContain('low')
132
+ expect(result).toContain('medium')
133
+ expect(result).toContain('high')
134
+ })
135
+ })
136
+
137
+ // ═══════════════════════════════════════════════════════════
138
+ // Error: updateSessionMeta not available
139
+ // ═══════════════════════════════════════════════════════════
140
+
141
+ describe('/think without updateSessionMeta', () => {
142
+ test('returns "not available" when setting level', () => {
143
+ const result = thinkCommand('high', makeCtx())
144
+ expect(result).toContain('not available')
145
+ })
146
+ })
@@ -0,0 +1,222 @@
1
+ /**
2
+ * # Usage API — Tests
3
+ *
4
+ * Tests the exported functions from usage-api.ts:
5
+ * - readOAuthToken() — reads from env var or ~/.claude/.credentials.json
6
+ * - parseWindow() — parses raw usage window data
7
+ * - hasOAuthCredentials() — checks if OAuth token is available
8
+ * - fetchAccountUsage() — calls Anthropic's usage endpoint
9
+ *
10
+ * All functions are imported from the real module. No reimplementation.
11
+ *
12
+ * NOTE: readOAuthToken uses homedir() which cannot be overridden at runtime
13
+ * in Bun (it's cached). Tests for the env-var path work directly. Tests for
14
+ * the file-reading path are validated through hasOAuthCredentials + fetchAccountUsage
15
+ * integration, and parseWindow is tested thoroughly as a pure function.
16
+ */
17
+ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
18
+ import {
19
+ fetchAccountUsage,
20
+ hasOAuthCredentials,
21
+ parseWindow,
22
+ readOAuthToken,
23
+ } from '../lib/usage-api'
24
+
25
+ // Save original env
26
+ let savedOAuthToken: string | undefined
27
+
28
+ beforeEach(() => {
29
+ savedOAuthToken = process.env.CLAUDE_CODE_OAUTH_TOKEN
30
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN
31
+ })
32
+
33
+ afterEach(() => {
34
+ if (savedOAuthToken !== undefined) {
35
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = savedOAuthToken
36
+ } else {
37
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN
38
+ }
39
+ })
40
+
41
+ // ═══════════════════════════════════════════════════════════════════
42
+ // readOAuthToken — env var path
43
+ // ═══════════════════════════════════════════════════════════════════
44
+
45
+ describe('readOAuthToken', () => {
46
+ it('returns env var token when CLAUDE_CODE_OAUTH_TOKEN is set', () => {
47
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-oauth-token'
48
+ expect(readOAuthToken()).toBe('env-oauth-token')
49
+ })
50
+
51
+ it('env var takes precedence — always returns env token regardless of file', () => {
52
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'env-wins'
53
+ // Even if ~/.claude/.credentials.json exists, env var wins
54
+ expect(readOAuthToken()).toBe('env-wins')
55
+ })
56
+
57
+ it('returns a string or null without env var (depends on real credentials file)', () => {
58
+ // Without env var, falls back to ~/.claude/.credentials.json
59
+ // In environments with real Claude credentials, returns a token string.
60
+ // In clean environments, returns null.
61
+ const result = readOAuthToken()
62
+ expect(result === null || typeof result === 'string').toBe(true)
63
+ })
64
+ })
65
+
66
+ // ═══════════════════════════════════════════════════════════════════
67
+ // parseWindow — usage window parsing (pure function, fully testable)
68
+ // ═══════════════════════════════════════════════════════════════════
69
+
70
+ describe('parseWindow', () => {
71
+ it('returns null for null input', () => {
72
+ expect(parseWindow(null)).toBeNull()
73
+ })
74
+
75
+ it('returns null for undefined input', () => {
76
+ expect(parseWindow(undefined)).toBeNull()
77
+ })
78
+
79
+ it('returns null for non-object input', () => {
80
+ expect(parseWindow('string')).toBeNull()
81
+ expect(parseWindow(42)).toBeNull()
82
+ expect(parseWindow(true)).toBeNull()
83
+ })
84
+
85
+ it('parses valid usage window with all fields', () => {
86
+ const result = parseWindow({
87
+ used: 50000,
88
+ limit: 100000,
89
+ resetsAt: '2026-04-05T15:00:00Z',
90
+ })
91
+ expect(result).toEqual({
92
+ used: 50000,
93
+ limit: 100000,
94
+ percent: 50,
95
+ resetsAt: '2026-04-05T15:00:00Z',
96
+ })
97
+ })
98
+
99
+ it('calculates percent correctly', () => {
100
+ const result = parseWindow({ used: 75000, limit: 100000 })
101
+ expect(result!.percent).toBe(75)
102
+ })
103
+
104
+ it('rounds percent to nearest integer', () => {
105
+ const result = parseWindow({ used: 33333, limit: 100000 })
106
+ expect(result!.percent).toBe(33)
107
+ })
108
+
109
+ it('handles 100% usage', () => {
110
+ const result = parseWindow({ used: 100000, limit: 100000 })
111
+ expect(result!.percent).toBe(100)
112
+ })
113
+
114
+ it('handles over 100% usage', () => {
115
+ const result = parseWindow({ used: 150000, limit: 100000 })
116
+ expect(result!.percent).toBe(150)
117
+ })
118
+
119
+ it('returns 0 percent when limit is 0', () => {
120
+ const result = parseWindow({ used: 50000, limit: 0 })
121
+ expect(result!.percent).toBe(0)
122
+ })
123
+
124
+ it('defaults used to 0 when not a number', () => {
125
+ const result = parseWindow({ used: 'invalid', limit: 100000 })
126
+ expect(result!.used).toBe(0)
127
+ expect(result!.percent).toBe(0)
128
+ })
129
+
130
+ it('defaults limit to 0 when not a number', () => {
131
+ const result = parseWindow({ used: 50000, limit: 'invalid' })
132
+ expect(result!.limit).toBe(0)
133
+ expect(result!.percent).toBe(0)
134
+ })
135
+
136
+ it('returns undefined resetsAt when not a string', () => {
137
+ const result = parseWindow({ used: 50000, limit: 100000, resetsAt: 12345 })
138
+ expect(result!.resetsAt).toBeUndefined()
139
+ })
140
+
141
+ it('handles empty object', () => {
142
+ const result = parseWindow({})
143
+ expect(result).toEqual({
144
+ used: 0,
145
+ limit: 0,
146
+ percent: 0,
147
+ resetsAt: undefined,
148
+ })
149
+ })
150
+
151
+ it('composes correctly for a full API response', () => {
152
+ const apiResponse = {
153
+ five_hour: { used: 50000, limit: 100000, resetsAt: '2026-04-05T20:00:00Z' },
154
+ seven_day: { used: 200000, limit: 500000, resetsAt: '2026-04-12T00:00:00Z' },
155
+ }
156
+
157
+ const fiveHour = parseWindow(apiResponse.five_hour)
158
+ const sevenDay = parseWindow(apiResponse.seven_day)
159
+
160
+ expect(fiveHour).toEqual({
161
+ used: 50000,
162
+ limit: 100000,
163
+ percent: 50,
164
+ resetsAt: '2026-04-05T20:00:00Z',
165
+ })
166
+ expect(sevenDay).toEqual({
167
+ used: 200000,
168
+ limit: 500000,
169
+ percent: 40,
170
+ resetsAt: '2026-04-12T00:00:00Z',
171
+ })
172
+ })
173
+
174
+ it('handles missing windows in API response', () => {
175
+ const apiResponse: Record<string, unknown> = {}
176
+ expect(parseWindow(apiResponse.five_hour)).toBeNull()
177
+ expect(parseWindow(apiResponse.seven_day)).toBeNull()
178
+ })
179
+ })
180
+
181
+ // ═══════════════════════════════════════════════════════════════════
182
+ // hasOAuthCredentials
183
+ // ═══════════════════════════════════════════════════════════════════
184
+
185
+ describe('hasOAuthCredentials', () => {
186
+ it('returns true when env var is set', () => {
187
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'test-token'
188
+ expect(hasOAuthCredentials()).toBe(true)
189
+ })
190
+
191
+ it('returns a boolean without env var (depends on real credentials)', () => {
192
+ // On machines with ~/.claude/.credentials.json: true
193
+ // On clean machines: false
194
+ expect(typeof hasOAuthCredentials()).toBe('boolean')
195
+ })
196
+ })
197
+
198
+ // ═══════════════════════════════════════════════════════════════════
199
+ // fetchAccountUsage — integration
200
+ // ═══════════════════════════════════════════════════════════════════
201
+
202
+ describe('fetchAccountUsage', () => {
203
+ it('returns null on API error (invalid token)', async () => {
204
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'invalid-token-for-test'
205
+ const result = await fetchAccountUsage()
206
+ expect(result).toBeNull()
207
+ })
208
+
209
+ it('returns AccountUsage shape or null with env token', async () => {
210
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = 'another-invalid-token'
211
+ const result = await fetchAccountUsage()
212
+ // Invalid token should fail authentication
213
+ if (result !== null) {
214
+ expect(result).toHaveProperty('fiveHour')
215
+ expect(result).toHaveProperty('sevenDay')
216
+ expect(result).toHaveProperty('fetchedAt')
217
+ expect(result.fetchedAt).toBeGreaterThan(0)
218
+ } else {
219
+ expect(result).toBeNull()
220
+ }
221
+ })
222
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * /usage Command — API Failure Tests
3
+ *
4
+ * Separate file because mock.module is file-scoped.
5
+ * Tests behavior when OAuth is available but API call fails.
6
+ */
7
+
8
+ import { mock } from 'bun:test'
9
+
10
+ mock.module('../lib/usage-api', () => ({
11
+ hasOAuthCredentials: () => true,
12
+ fetchAccountUsage: async () => null,
13
+ }))
14
+
15
+ import { describe, expect, test } from 'bun:test'
16
+ import type { CommandContext } from '../lib/command-handler'
17
+ import { usageCommand } from '../lib/commands/usage'
18
+ import { SessionTracker } from '../lib/session-tracker'
19
+
20
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
21
+ return {
22
+ sessionKey: 'test:channel-1',
23
+ message: {
24
+ id: 'msg-1',
25
+ channelId: 'channel-1',
26
+ sender: { id: 'user-1', name: 'Test User' },
27
+ text: '/usage',
28
+ timestamp: new Date(),
29
+ },
30
+ tracker: new SessionTracker(),
31
+ daemonStartedAt: Date.now(),
32
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
33
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ describe('/usage API failure', () => {
39
+ test('shows "could not fetch quota" when API returns null', async () => {
40
+ const result = await usageCommand('', makeCtx())
41
+ expect(result).toContain('could not fetch quota')
42
+ })
43
+
44
+ test('does not show Account Quota section', async () => {
45
+ const result = await usageCommand('', makeCtx())
46
+ expect(result).not.toContain('**Account Quota:**')
47
+ })
48
+ })
@@ -0,0 +1,48 @@
1
+ /**
2
+ * /usage Command — No OAuth Tests
3
+ *
4
+ * Separate file because mock.module is file-scoped.
5
+ * Tests behavior when OAuth credentials are not available.
6
+ */
7
+
8
+ import { mock } from 'bun:test'
9
+
10
+ mock.module('../lib/usage-api', () => ({
11
+ hasOAuthCredentials: () => false,
12
+ fetchAccountUsage: async () => null,
13
+ }))
14
+
15
+ import { describe, expect, test } from 'bun:test'
16
+ import type { CommandContext } from '../lib/command-handler'
17
+ import { usageCommand } from '../lib/commands/usage'
18
+ import { SessionTracker } from '../lib/session-tracker'
19
+
20
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
21
+ return {
22
+ sessionKey: 'test:channel-1',
23
+ message: {
24
+ id: 'msg-1',
25
+ channelId: 'channel-1',
26
+ sender: { id: 'user-1', name: 'Test User' },
27
+ text: '/usage',
28
+ timestamp: new Date(),
29
+ },
30
+ tracker: new SessionTracker(),
31
+ daemonStartedAt: Date.now(),
32
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
33
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ describe('/usage no OAuth', () => {
39
+ test('shows "no OAuth credentials" when no token available', async () => {
40
+ const result = await usageCommand('', makeCtx())
41
+ expect(result).toContain('no OAuth credentials')
42
+ })
43
+
44
+ test('does not show Account Quota section', async () => {
45
+ const result = await usageCommand('', makeCtx())
46
+ expect(result).not.toContain('**Account Quota:**')
47
+ })
48
+ })
@@ -0,0 +1,173 @@
1
+ /**
2
+ * /usage Command — Test Suite
3
+ *
4
+ * Spec: shows session usage (tokens, turns, cost) and account quota
5
+ * (5h/7d rolling windows from Anthropic OAuth API).
6
+ *
7
+ * Key contracts:
8
+ * - Shows session token counts when session exists
9
+ * - Shows "no queries yet" when no session data
10
+ * - Shows cost estimate
11
+ * - Shows account quota progress bars when OAuth available
12
+ * - Shows "no OAuth credentials" when no token
13
+ * - Shows "could not fetch" when API fails
14
+ *
15
+ * Mocking: usage-api is mocked per describe block to control
16
+ * hasOAuthCredentials and fetchAccountUsage behavior.
17
+ */
18
+
19
+ // ═══════════════════════════════════════════════════════════
20
+ // Tests WITH OAuth credentials (quota available)
21
+ // ═══════════════════════════════════════════════════════════
22
+
23
+ import { mock } from 'bun:test'
24
+
25
+ // Default mock: OAuth available with quota data
26
+ mock.module('../lib/usage-api', () => ({
27
+ hasOAuthCredentials: () => true,
28
+ fetchAccountUsage: async () => ({
29
+ fiveHour: { used: 50000, limit: 100000, percent: 50 },
30
+ sevenDay: { used: 200000, limit: 1000000, percent: 20 },
31
+ fetchedAt: Date.now(),
32
+ }),
33
+ }))
34
+
35
+ import { describe, expect, test } from 'bun:test'
36
+ import type { CommandContext } from '../lib/command-handler'
37
+ import { usageCommand } from '../lib/commands/usage'
38
+ import { SessionTracker } from '../lib/session-tracker'
39
+ import type { Moon } from '../types/moon'
40
+
41
+ function makeMoon(overrides?: Partial<Moon>): Moon {
42
+ return {
43
+ name: 'TestMoon',
44
+ soul: '',
45
+ agents: '',
46
+ identity: '',
47
+ path: '/tmp/test',
48
+ ...overrides,
49
+ }
50
+ }
51
+
52
+ function makeCtx(overrides?: Partial<CommandContext>): CommandContext {
53
+ return {
54
+ sessionKey: 'test:channel-1',
55
+ message: {
56
+ id: 'msg-1',
57
+ channelId: 'channel-1',
58
+ sender: { id: 'user-1', name: 'Test User' },
59
+ text: '/usage',
60
+ timestamp: new Date(),
61
+ },
62
+ tracker: new SessionTracker(),
63
+ daemonStartedAt: Date.now(),
64
+ agent: { id: 'claude', name: 'Claude Code (CLI)' },
65
+ clearSession: async () => ({ sessionCleared: true, hooksRan: false }),
66
+ ...overrides,
67
+ }
68
+ }
69
+
70
+ function makeCtxWithUsage(opts?: { moon?: Moon }): CommandContext {
71
+ const tracker = new SessionTracker()
72
+
73
+ tracker.recordQuery('test:channel-1', {
74
+ inputTokens: 5000,
75
+ outputTokens: 2000,
76
+ cacheReadTokens: 150000,
77
+ cacheWriteTokens: 10000,
78
+ contextTokens: 165000,
79
+ model: 'claude-opus-4-6-20260301',
80
+ durationMs: 12000,
81
+ })
82
+ tracker.recordMessage('test:channel-1')
83
+
84
+ return makeCtx({ tracker, moon: opts?.moon ?? makeMoon() })
85
+ }
86
+
87
+ // ═══════════════════════════════════════════════════════════
88
+ // Header
89
+ // ═══════════════════════════════════════════════════════════
90
+
91
+ describe('/usage header', () => {
92
+ test('shows moon name in header', async () => {
93
+ const ctx = makeCtx({ moon: makeMoon({ name: 'Deimos' }) })
94
+ const result = await usageCommand('', ctx)
95
+ expect(result).toContain('**Deimos** — Usage')
96
+ })
97
+
98
+ test('defaults to "Lunar" when no moon', async () => {
99
+ const result = await usageCommand('', makeCtx())
100
+ expect(result).toContain('**Lunar** — Usage')
101
+ })
102
+ })
103
+
104
+ // ═══════════════════════════════════════════════════════════
105
+ // Session usage
106
+ // ═══════════════════════════════════════════════════════════
107
+
108
+ describe('/usage session', () => {
109
+ test('shows session tokens when session exists', async () => {
110
+ const result = await usageCommand('', makeCtxWithUsage())
111
+ expect(result).toContain('**Session:**')
112
+ expect(result).toContain('Tokens:')
113
+ expect(result).toContain('in /')
114
+ expect(result).toContain('out')
115
+ })
116
+
117
+ test('shows turns and messages', async () => {
118
+ const result = await usageCommand('', makeCtxWithUsage())
119
+ expect(result).toContain('Turns: 1')
120
+ expect(result).toContain('Messages: 1')
121
+ })
122
+
123
+ test('shows "no queries yet" when no session', async () => {
124
+ const result = await usageCommand('', makeCtx())
125
+ expect(result).toContain('**Session:** no queries yet')
126
+ })
127
+
128
+ test('shows cost estimate', async () => {
129
+ const result = await usageCommand('', makeCtxWithUsage())
130
+ // Cost should be present as either <$0.01 or ~$X.XX
131
+ expect(result).toMatch(/Cost:.*(\$|<)/)
132
+ })
133
+
134
+ test('shows model display name', async () => {
135
+ const result = await usageCommand('', makeCtxWithUsage())
136
+ expect(result).toContain('Model:')
137
+ })
138
+
139
+ test('shows session duration', async () => {
140
+ const result = await usageCommand('', makeCtxWithUsage())
141
+ expect(result).toContain('Duration:')
142
+ })
143
+ })
144
+
145
+ // ═══════════════════════════════════════════════════════════
146
+ // Account quota (OAuth available — default mock)
147
+ // ═══════════════════════════════════════════════════════════
148
+
149
+ describe('/usage account quota', () => {
150
+ test('shows account quota section', async () => {
151
+ const result = await usageCommand('', makeCtx())
152
+ expect(result).toContain('**Account Quota:**')
153
+ })
154
+
155
+ test('shows 5h progress bar with percentage', async () => {
156
+ const result = await usageCommand('', makeCtx())
157
+ expect(result).toContain('5h:')
158
+ expect(result).toContain('50%')
159
+ })
160
+
161
+ test('shows 7d progress bar with percentage', async () => {
162
+ const result = await usageCommand('', makeCtx())
163
+ expect(result).toContain('7d:')
164
+ expect(result).toContain('20%')
165
+ })
166
+
167
+ test('shows progress bars with block characters', async () => {
168
+ const result = await usageCommand('', makeCtx())
169
+ // Progress bars use █ and ░
170
+ expect(result).toContain('█')
171
+ expect(result).toContain('░')
172
+ })
173
+ })