@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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +32 -0
- package/src/__tests__/clear-command.test.ts +214 -0
- package/src/__tests__/command-handler.test.ts +169 -0
- package/src/__tests__/compact-command.test.ts +80 -0
- package/src/__tests__/config-command.test.ts +240 -0
- package/src/__tests__/config-loader.test.ts +1512 -0
- package/src/__tests__/config.test.ts +429 -0
- package/src/__tests__/cron-command.test.ts +418 -0
- package/src/__tests__/cron-parser.test.ts +259 -0
- package/src/__tests__/daemon.test.ts +346 -0
- package/src/__tests__/dedup.test.ts +404 -0
- package/src/__tests__/e2e-sanitization.ts +168 -0
- package/src/__tests__/e2e-skill-loader.test.ts +176 -0
- package/src/__tests__/fixtures/AGENTS.md +4 -0
- package/src/__tests__/fixtures/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/athena/IDENTITY.md +2 -0
- package/src/__tests__/fixtures/moons/athena/SOUL.md +3 -0
- package/src/__tests__/fixtures/moons/hermes/SOUL.md +3 -0
- package/src/__tests__/fixtures/skills/brain/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/empty/SKILL.md +3 -0
- package/src/__tests__/fixtures/skills/multiline/SKILL.md +7 -0
- package/src/__tests__/fixtures/skills/no-desc/SKILL.md +5 -0
- package/src/__tests__/fixtures/skills/notion/SKILL.md +6 -0
- package/src/__tests__/fixtures/skills/quoted/SKILL.md +6 -0
- package/src/__tests__/hook-runner.test.ts +1689 -0
- package/src/__tests__/input-sanitization.test.ts +367 -0
- package/src/__tests__/logger.test.ts +163 -0
- package/src/__tests__/memory-orchestrator.test.ts +552 -0
- package/src/__tests__/model-catalog.test.ts +215 -0
- package/src/__tests__/model-command.test.ts +185 -0
- package/src/__tests__/moon-loader.test.ts +398 -0
- package/src/__tests__/ping-command.test.ts +85 -0
- package/src/__tests__/plugin.test.ts +258 -0
- package/src/__tests__/remind-command.test.ts +368 -0
- package/src/__tests__/reset-command.test.ts +92 -0
- package/src/__tests__/router.test.ts +1246 -0
- package/src/__tests__/scheduler.test.ts +469 -0
- package/src/__tests__/security.test.ts +214 -0
- package/src/__tests__/session-meta.test.ts +101 -0
- package/src/__tests__/session-tracker.test.ts +389 -0
- package/src/__tests__/session.test.ts +241 -0
- package/src/__tests__/skill-loader.test.ts +153 -0
- package/src/__tests__/status-command.test.ts +153 -0
- package/src/__tests__/stop-command.test.ts +60 -0
- package/src/__tests__/think-command.test.ts +146 -0
- package/src/__tests__/usage-api.test.ts +222 -0
- package/src/__tests__/usage-command-api-fail.test.ts +48 -0
- package/src/__tests__/usage-command-no-oauth.test.ts +48 -0
- package/src/__tests__/usage-command.test.ts +173 -0
- package/src/__tests__/whoami-command.test.ts +124 -0
- package/src/index.ts +122 -0
- package/src/lib/command-handler.ts +135 -0
- package/src/lib/commands/clear.ts +69 -0
- package/src/lib/commands/compact.ts +14 -0
- package/src/lib/commands/config-show.ts +49 -0
- package/src/lib/commands/cron.ts +118 -0
- package/src/lib/commands/help.ts +26 -0
- package/src/lib/commands/model.ts +71 -0
- package/src/lib/commands/ping.ts +24 -0
- package/src/lib/commands/remind.ts +75 -0
- package/src/lib/commands/status.ts +118 -0
- package/src/lib/commands/stop.ts +18 -0
- package/src/lib/commands/think.ts +42 -0
- package/src/lib/commands/usage.ts +56 -0
- package/src/lib/commands/whoami.ts +23 -0
- package/src/lib/config-loader.ts +1449 -0
- package/src/lib/config.ts +202 -0
- package/src/lib/cron-parser.ts +388 -0
- package/src/lib/daemon.ts +216 -0
- package/src/lib/dedup.ts +414 -0
- package/src/lib/hook-runner.ts +1270 -0
- package/src/lib/logger.ts +55 -0
- package/src/lib/memory-orchestrator.ts +415 -0
- package/src/lib/model-catalog.ts +240 -0
- package/src/lib/moon-loader.ts +291 -0
- package/src/lib/plugin.ts +148 -0
- package/src/lib/router.ts +1135 -0
- package/src/lib/scheduler.ts +422 -0
- package/src/lib/security.ts +259 -0
- package/src/lib/session-tracker.ts +222 -0
- package/src/lib/session.ts +158 -0
- package/src/lib/skill-loader.ts +166 -0
- package/src/lib/usage-api.ts +145 -0
- package/src/types/agent.ts +86 -0
- package/src/types/channel.ts +93 -0
- package/src/types/index.ts +32 -0
- package/src/types/memory.ts +92 -0
- package/src/types/moon.ts +56 -0
- 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
|
+
})
|