Skip to content

Commit 0833656

Browse files
feat(agents): add --status flag to display live session info in Claude Code status bar (#154)
Co-authored-by: codemie-ai <codemie.ai@gmail.com>
1 parent a2e9cb4 commit 0833656

4 files changed

Lines changed: 470 additions & 3 deletions

File tree

src/agents/core/AgentCLI.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export class AgentCLI {
5858
.description(`CodeMie ${this.adapter.displayName} - ${this.adapter.description}`)
5959
.version(this.version)
6060
.option('-s, --silent', 'Enable silent mode')
61+
.option('--status', 'Enable status bar (shows model, context usage, git branch, and cost)')
6162
.option('--profile <name>', 'Use specific provider profile')
6263
.option('--provider <provider>', 'Override provider (ai-run-sso, litellm, ollama)')
6364
.option('-m, --model <model>', 'Override model')
@@ -197,6 +198,11 @@ export class AgentCLI {
197198
providerEnv.CODEMIE_PROFILE_NAME = config.name || 'default';
198199
providerEnv.CODEMIE_CLI_VERSION = this.version;
199200

201+
// Pass status flag to lifecycle hooks
202+
if (options.status) {
203+
providerEnv.CODEMIE_STATUS = '1';
204+
}
205+
200206
// Serialize full profile config for proxy plugins (read once at CLI level)
201207
providerEnv.CODEMIE_PROFILE_CONFIG = JSON.stringify(config);
202208

@@ -339,7 +345,7 @@ export class AgentCLI {
339345
): string[] {
340346
const agentArgs = [...args];
341347
// Config-only options (not passed to agent, handled by CodeMie CLI)
342-
const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent'];
348+
const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent', 'status'];
343349

344350
for (const [key, value] of Object.entries(options)) {
345351
// Skip config-only options (handled by CodeMie CLI layer)
Lines changed: 317 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,317 @@
1+
/**
2+
* Tests for Claude Plugin statusline lifecycle hooks (--status flag)
3+
*
4+
* @group unit
5+
*/
6+
7+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
8+
import { join } from 'path';
9+
import type { AgentConfig } from '../../../core/types.js';
10+
11+
// --- Module mocks (hoisted before imports) ---
12+
13+
vi.mock('fs/promises');
14+
vi.mock('fs');
15+
16+
vi.mock('../../../../utils/paths.js', () => ({
17+
resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`),
18+
getDirname: vi.fn(() => '/fake/dist/plugins/claude'),
19+
}));
20+
21+
vi.mock('../../../../utils/logger.js', () => ({
22+
logger: {
23+
debug: vi.fn(),
24+
warn: vi.fn(),
25+
info: vi.fn(),
26+
error: vi.fn(),
27+
success: vi.fn(),
28+
setAgentName: vi.fn(),
29+
setProfileName: vi.fn(),
30+
setSessionId: vi.fn(),
31+
},
32+
}));
33+
34+
vi.mock('../../../../utils/security.js', () => ({
35+
sanitizeLogArgs: vi.fn((...args: unknown[]) => args),
36+
}));
37+
38+
// ---
39+
40+
type HookEnv = NodeJS.ProcessEnv;
41+
type BeforeRunFn = (env: HookEnv, config: AgentConfig) => Promise<HookEnv>;
42+
type AfterRunFn = (exitCode: number, env: HookEnv) => Promise<void>;
43+
44+
describe('Claude Plugin – statusline lifecycle hooks', () => {
45+
let beforeRun: BeforeRunFn;
46+
let afterRun: AfterRunFn;
47+
let fsp: typeof import('fs/promises');
48+
let fsMod: typeof import('fs');
49+
let loggerMod: { logger: Record<string, ReturnType<typeof vi.fn>> };
50+
51+
const mockConfig: AgentConfig = {};
52+
// CLAUDE_HOME is used directly from the resolveHomeDir mock (not passed through path.join),
53+
// so it keeps forward slashes on all OSes.
54+
const CLAUDE_HOME = '/home/testuser/claude';
55+
// Derived paths go through path.join in production, so compute them the same way
56+
// to get the correct separator on each OS (backslashes on Windows).
57+
const SCRIPT_DEST = join(CLAUDE_HOME, 'codemie-statusline.mjs');
58+
const SETTINGS_PATH = join(CLAUDE_HOME, 'settings.json');
59+
const SCRIPT_SRC = join('/fake/dist/plugins/claude', 'plugin', 'codemie-statusline.mjs');
60+
61+
beforeEach(async () => {
62+
vi.resetModules(); // Reset module cache → resets statuslineManagedThisSession to false
63+
vi.resetAllMocks(); // Reset mock implementations and call counts
64+
65+
// Re-import after reset to get fresh module instances
66+
const mod = await import('../claude.plugin.js');
67+
beforeRun = mod.ClaudePluginMetadata.lifecycle!.beforeRun!;
68+
afterRun = mod.ClaudePluginMetadata.lifecycle!.afterRun!;
69+
70+
fsp = await import('fs/promises');
71+
fsMod = await import('fs');
72+
loggerMod = (await import('../../../../utils/logger.js')) as any;
73+
});
74+
75+
afterEach(() => {
76+
vi.restoreAllMocks();
77+
});
78+
79+
// ---------------------------------------------------------------------------
80+
// beforeRun
81+
// ---------------------------------------------------------------------------
82+
83+
describe('beforeRun', () => {
84+
it('should not touch files when CODEMIE_STATUS is not set', async () => {
85+
const env: HookEnv = { CODEMIE_PROFILE_NAME: 'default' };
86+
const result = await beforeRun(env, mockConfig);
87+
88+
expect(result).toBe(env);
89+
expect(fsp.readFile).not.toHaveBeenCalled();
90+
expect(fsp.writeFile).not.toHaveBeenCalled();
91+
});
92+
93+
it('should deploy script and inject statusLine when CODEMIE_STATUS=1 and no settings.json', async () => {
94+
// Script source read → dummy content
95+
vi.mocked(fsp.readFile).mockResolvedValueOnce('#!/usr/bin/env node\n// statusline' as any);
96+
// claudeHome exists, settings.json does not
97+
vi.mocked(fsMod.existsSync)
98+
.mockReturnValueOnce(true) // claudeHome exists
99+
.mockReturnValueOnce(false); // settings.json absent
100+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
101+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
102+
103+
const env: HookEnv = { CODEMIE_STATUS: '1' };
104+
const result = await beforeRun(env, mockConfig);
105+
106+
expect(result).toBe(env);
107+
// Script written to ~/.claude/codemie-statusline.mjs
108+
expect(fsp.writeFile).toHaveBeenCalledWith(SCRIPT_DEST, expect.any(String), 'utf-8');
109+
// settings.json written with statusLine
110+
const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find(
111+
([p]) => p === SETTINGS_PATH
112+
);
113+
expect(settingsWriteCall).toBeDefined();
114+
const written = JSON.parse(settingsWriteCall![1] as string);
115+
expect(written.statusLine).toBeDefined();
116+
expect(written.statusLine.type).toBe('command');
117+
});
118+
119+
it('should read the script from the compiled plugin directory', async () => {
120+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any);
121+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
122+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
123+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
124+
125+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
126+
127+
expect(fsp.readFile).toHaveBeenCalledWith(SCRIPT_SRC, 'utf-8');
128+
});
129+
130+
it('should quote the script path in the command to handle spaces in home dir', async () => {
131+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any);
132+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
133+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
134+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
135+
136+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
137+
138+
const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find(
139+
([p]) => p === SETTINGS_PATH
140+
);
141+
const written = JSON.parse(settingsWriteCall![1] as string);
142+
// Command must wrap the path in double quotes: node "/path/to/script.mjs"
143+
expect(written.statusLine.command).toMatch(/^node ".*"$/);
144+
expect(written.statusLine.command).toContain(SCRIPT_DEST);
145+
});
146+
147+
it('should not re-inject statusLine if it already exists in settings.json', async () => {
148+
const existingSettings = { statusLine: { type: 'command', command: 'node "/existing/script.mjs"' }, theme: 'dark' };
149+
vi.mocked(fsp.readFile)
150+
.mockResolvedValueOnce('// content' as any) // script source
151+
.mockResolvedValueOnce(JSON.stringify(existingSettings) as any); // settings.json
152+
vi.mocked(fsMod.existsSync).mockReturnValue(true); // both paths exist
153+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
154+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
155+
156+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
157+
158+
// writeFile called once for the script, NOT for settings.json
159+
const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find(
160+
([p]) => p === SETTINGS_PATH
161+
);
162+
expect(settingsWriteCall).toBeUndefined();
163+
});
164+
165+
it('should return env early and not overwrite settings.json when it contains malformed JSON', async () => {
166+
vi.mocked(fsp.readFile)
167+
.mockResolvedValueOnce('// content' as any) // script source
168+
.mockResolvedValueOnce('{ invalid: json' as any); // corrupt settings.json
169+
vi.mocked(fsMod.existsSync).mockReturnValue(true); // both paths exist
170+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
171+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
172+
173+
const env: HookEnv = { CODEMIE_STATUS: '1' };
174+
const result = await beforeRun(env, mockConfig);
175+
176+
expect(result).toBe(env);
177+
// settings.json must NOT be written
178+
const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find(
179+
([p]) => p === SETTINGS_PATH
180+
);
181+
expect(settingsWriteCall).toBeUndefined();
182+
// Warning must be logged
183+
expect(loggerMod.logger.warn).toHaveBeenCalledWith(
184+
expect.stringContaining('Could not parse settings.json'),
185+
expect.anything(),
186+
);
187+
});
188+
189+
it('should create ~/.claude directory when it does not exist', async () => {
190+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any);
191+
vi.mocked(fsMod.existsSync)
192+
.mockReturnValueOnce(false) // claudeHome does NOT exist
193+
.mockReturnValueOnce(false); // settings.json absent
194+
vi.mocked(fsp.mkdir).mockResolvedValue(undefined);
195+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
196+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
197+
198+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
199+
200+
expect(fsp.mkdir).toHaveBeenCalledWith(CLAUDE_HOME, { recursive: true });
201+
});
202+
203+
it('should not set CODEMIE_STATUS_MANAGED env var (uses module-level flag instead)', async () => {
204+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any);
205+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
206+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
207+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
208+
209+
const env: HookEnv = { CODEMIE_STATUS: '1' };
210+
await beforeRun(env, mockConfig);
211+
212+
// The env object must not contain any managed/internal tracking keys
213+
expect(Object.keys(env)).not.toContain('CODEMIE_STATUS_MANAGED');
214+
expect(Object.keys(env)).not.toContain('CODEMIE_STATUSLINE_MANAGED');
215+
});
216+
});
217+
218+
// ---------------------------------------------------------------------------
219+
// afterRun
220+
// ---------------------------------------------------------------------------
221+
222+
describe('afterRun', () => {
223+
it('should not touch files when statusline was not managed in this session', async () => {
224+
// Do NOT call beforeRun → statuslineManagedThisSession stays false
225+
await afterRun(0, {});
226+
227+
expect(fsp.readFile).not.toHaveBeenCalled();
228+
expect(fsp.writeFile).not.toHaveBeenCalled();
229+
});
230+
231+
it('should remove statusLine from settings.json after a managed session', async () => {
232+
// --- Set up the flag via beforeRun ---
233+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any);
234+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
235+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
236+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
237+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
238+
vi.resetAllMocks();
239+
240+
// --- afterRun ---
241+
const existingSettings = { statusLine: { type: 'command', command: 'node "/x/y.mjs"' }, theme: 'dark' };
242+
vi.mocked(fsMod.existsSync).mockReturnValue(true);
243+
vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify(existingSettings) as any);
244+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
245+
246+
await afterRun(0, {});
247+
248+
expect(fsp.writeFile).toHaveBeenCalledTimes(1);
249+
const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1] as string);
250+
expect(written.statusLine).toBeUndefined();
251+
// Other settings are preserved
252+
expect(written.theme).toBe('dark');
253+
});
254+
255+
it('should reset the module-level flag so a second afterRun call is a no-op', async () => {
256+
// Set the flag via beforeRun
257+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any);
258+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
259+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
260+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
261+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
262+
vi.resetAllMocks();
263+
264+
// First afterRun – performs cleanup
265+
vi.mocked(fsMod.existsSync).mockReturnValue(true);
266+
vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify({ statusLine: {} }) as any);
267+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
268+
await afterRun(0, {});
269+
vi.resetAllMocks();
270+
271+
// Second afterRun – must be a no-op (flag already reset)
272+
await afterRun(0, {});
273+
274+
expect(fsp.readFile).not.toHaveBeenCalled();
275+
expect(fsp.writeFile).not.toHaveBeenCalled();
276+
});
277+
278+
it('should log a sanitized warning when settings cleanup fails', async () => {
279+
// Set the flag via beforeRun
280+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any);
281+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
282+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
283+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
284+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
285+
vi.resetAllMocks();
286+
287+
// afterRun encounters malformed settings.json
288+
vi.mocked(fsMod.existsSync).mockReturnValue(true);
289+
vi.mocked(fsp.readFile).mockResolvedValueOnce('{ bad json' as any);
290+
291+
await afterRun(0, {});
292+
293+
expect(loggerMod.logger.warn).toHaveBeenCalledWith(
294+
expect.stringContaining('Failed to clean up statusLine'),
295+
expect.anything(),
296+
);
297+
});
298+
299+
it('should skip cleanup when settings.json does not exist', async () => {
300+
// Set the flag via beforeRun
301+
vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any);
302+
vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false);
303+
vi.mocked(fsp.writeFile).mockResolvedValue(undefined);
304+
vi.mocked(fsp.chmod).mockResolvedValue(undefined);
305+
await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig);
306+
vi.resetAllMocks();
307+
308+
// settings.json does not exist at cleanup time
309+
vi.mocked(fsMod.existsSync).mockReturnValue(false);
310+
311+
await afterRun(0, {});
312+
313+
expect(fsp.readFile).not.toHaveBeenCalled();
314+
expect(fsp.writeFile).not.toHaveBeenCalled();
315+
});
316+
});
317+
});

0 commit comments

Comments
 (0)