diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 8d69534..13bbca5 100644 --- a/src/agents/core/AgentCLI.ts +++ b/src/agents/core/AgentCLI.ts @@ -58,6 +58,7 @@ export class AgentCLI { .description(`CodeMie ${this.adapter.displayName} - ${this.adapter.description}`) .version(this.version) .option('-s, --silent', 'Enable silent mode') + .option('--status', 'Enable status bar (shows model, context usage, git branch, and cost)') .option('--profile ', 'Use specific provider profile') .option('--provider ', 'Override provider (ai-run-sso, litellm, ollama)') .option('-m, --model ', 'Override model') @@ -197,6 +198,11 @@ export class AgentCLI { providerEnv.CODEMIE_PROFILE_NAME = config.name || 'default'; providerEnv.CODEMIE_CLI_VERSION = this.version; + // Pass status flag to lifecycle hooks + if (options.status) { + providerEnv.CODEMIE_STATUS = '1'; + } + // Serialize full profile config for proxy plugins (read once at CLI level) providerEnv.CODEMIE_PROFILE_CONFIG = JSON.stringify(config); @@ -339,7 +345,7 @@ export class AgentCLI { ): string[] { const agentArgs = [...args]; // Config-only options (not passed to agent, handled by CodeMie CLI) - const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent']; + const configOnlyOptions = ['profile', 'provider', 'apiKey', 'baseUrl', 'timeout', 'model', 'silent', 'status']; for (const [key, value] of Object.entries(options)) { // Skip config-only options (handled by CodeMie CLI layer) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts new file mode 100644 index 0000000..ee29b4d --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts @@ -0,0 +1,317 @@ +/** + * Tests for Claude Plugin statusline lifecycle hooks (--status flag) + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { join } from 'path'; +import type { AgentConfig } from '../../../core/types.js'; + +// --- Module mocks (hoisted before imports) --- + +vi.mock('fs/promises'); +vi.mock('fs'); + +vi.mock('../../../../utils/paths.js', () => ({ + resolveHomeDir: vi.fn((dir: string) => `/home/testuser/${dir.replace(/^\./, '')}`), + getDirname: vi.fn(() => '/fake/dist/plugins/claude'), +})); + +vi.mock('../../../../utils/logger.js', () => ({ + logger: { + debug: vi.fn(), + warn: vi.fn(), + info: vi.fn(), + error: vi.fn(), + success: vi.fn(), + setAgentName: vi.fn(), + setProfileName: vi.fn(), + setSessionId: vi.fn(), + }, +})); + +vi.mock('../../../../utils/security.js', () => ({ + sanitizeLogArgs: vi.fn((...args: unknown[]) => args), +})); + +// --- + +type HookEnv = NodeJS.ProcessEnv; +type BeforeRunFn = (env: HookEnv, config: AgentConfig) => Promise; +type AfterRunFn = (exitCode: number, env: HookEnv) => Promise; + +describe('Claude Plugin – statusline lifecycle hooks', () => { + let beforeRun: BeforeRunFn; + let afterRun: AfterRunFn; + let fsp: typeof import('fs/promises'); + let fsMod: typeof import('fs'); + let loggerMod: { logger: Record> }; + + const mockConfig: AgentConfig = {}; + // CLAUDE_HOME is used directly from the resolveHomeDir mock (not passed through path.join), + // so it keeps forward slashes on all OSes. + const CLAUDE_HOME = '/home/testuser/claude'; + // Derived paths go through path.join in production, so compute them the same way + // to get the correct separator on each OS (backslashes on Windows). + const SCRIPT_DEST = join(CLAUDE_HOME, 'codemie-statusline.mjs'); + const SETTINGS_PATH = join(CLAUDE_HOME, 'settings.json'); + const SCRIPT_SRC = join('/fake/dist/plugins/claude', 'plugin', 'codemie-statusline.mjs'); + + beforeEach(async () => { + vi.resetModules(); // Reset module cache → resets statuslineManagedThisSession to false + vi.resetAllMocks(); // Reset mock implementations and call counts + + // Re-import after reset to get fresh module instances + const mod = await import('../claude.plugin.js'); + beforeRun = mod.ClaudePluginMetadata.lifecycle!.beforeRun!; + afterRun = mod.ClaudePluginMetadata.lifecycle!.afterRun!; + + fsp = await import('fs/promises'); + fsMod = await import('fs'); + loggerMod = (await import('../../../../utils/logger.js')) as any; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // beforeRun + // --------------------------------------------------------------------------- + + describe('beforeRun', () => { + it('should not touch files when CODEMIE_STATUS is not set', async () => { + const env: HookEnv = { CODEMIE_PROFILE_NAME: 'default' }; + const result = await beforeRun(env, mockConfig); + + expect(result).toBe(env); + expect(fsp.readFile).not.toHaveBeenCalled(); + expect(fsp.writeFile).not.toHaveBeenCalled(); + }); + + it('should deploy script and inject statusLine when CODEMIE_STATUS=1 and no settings.json', async () => { + // Script source read → dummy content + vi.mocked(fsp.readFile).mockResolvedValueOnce('#!/usr/bin/env node\n// statusline' as any); + // claudeHome exists, settings.json does not + vi.mocked(fsMod.existsSync) + .mockReturnValueOnce(true) // claudeHome exists + .mockReturnValueOnce(false); // settings.json absent + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + const env: HookEnv = { CODEMIE_STATUS: '1' }; + const result = await beforeRun(env, mockConfig); + + expect(result).toBe(env); + // Script written to ~/.claude/codemie-statusline.mjs + expect(fsp.writeFile).toHaveBeenCalledWith(SCRIPT_DEST, expect.any(String), 'utf-8'); + // settings.json written with statusLine + const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => p === SETTINGS_PATH + ); + expect(settingsWriteCall).toBeDefined(); + const written = JSON.parse(settingsWriteCall![1] as string); + expect(written.statusLine).toBeDefined(); + expect(written.statusLine.type).toBe('command'); + }); + + it('should read the script from the compiled plugin directory', async () => { + vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + + expect(fsp.readFile).toHaveBeenCalledWith(SCRIPT_SRC, 'utf-8'); + }); + + it('should quote the script path in the command to handle spaces in home dir', async () => { + vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + + const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => p === SETTINGS_PATH + ); + const written = JSON.parse(settingsWriteCall![1] as string); + // Command must wrap the path in double quotes: node "/path/to/script.mjs" + expect(written.statusLine.command).toMatch(/^node ".*"$/); + expect(written.statusLine.command).toContain(SCRIPT_DEST); + }); + + it('should not re-inject statusLine if it already exists in settings.json', async () => { + const existingSettings = { statusLine: { type: 'command', command: 'node "/existing/script.mjs"' }, theme: 'dark' }; + vi.mocked(fsp.readFile) + .mockResolvedValueOnce('// content' as any) // script source + .mockResolvedValueOnce(JSON.stringify(existingSettings) as any); // settings.json + vi.mocked(fsMod.existsSync).mockReturnValue(true); // both paths exist + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + + // writeFile called once for the script, NOT for settings.json + const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => p === SETTINGS_PATH + ); + expect(settingsWriteCall).toBeUndefined(); + }); + + it('should return env early and not overwrite settings.json when it contains malformed JSON', async () => { + vi.mocked(fsp.readFile) + .mockResolvedValueOnce('// content' as any) // script source + .mockResolvedValueOnce('{ invalid: json' as any); // corrupt settings.json + vi.mocked(fsMod.existsSync).mockReturnValue(true); // both paths exist + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + const env: HookEnv = { CODEMIE_STATUS: '1' }; + const result = await beforeRun(env, mockConfig); + + expect(result).toBe(env); + // settings.json must NOT be written + const settingsWriteCall = vi.mocked(fsp.writeFile).mock.calls.find( + ([p]) => p === SETTINGS_PATH + ); + expect(settingsWriteCall).toBeUndefined(); + // Warning must be logged + expect(loggerMod.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not parse settings.json'), + expect.anything(), + ); + }); + + it('should create ~/.claude directory when it does not exist', async () => { + vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any); + vi.mocked(fsMod.existsSync) + .mockReturnValueOnce(false) // claudeHome does NOT exist + .mockReturnValueOnce(false); // settings.json absent + vi.mocked(fsp.mkdir).mockResolvedValue(undefined); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + + expect(fsp.mkdir).toHaveBeenCalledWith(CLAUDE_HOME, { recursive: true }); + }); + + it('should not set CODEMIE_STATUS_MANAGED env var (uses module-level flag instead)', async () => { + vi.mocked(fsp.readFile).mockResolvedValueOnce('// content' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + + const env: HookEnv = { CODEMIE_STATUS: '1' }; + await beforeRun(env, mockConfig); + + // The env object must not contain any managed/internal tracking keys + expect(Object.keys(env)).not.toContain('CODEMIE_STATUS_MANAGED'); + expect(Object.keys(env)).not.toContain('CODEMIE_STATUSLINE_MANAGED'); + }); + }); + + // --------------------------------------------------------------------------- + // afterRun + // --------------------------------------------------------------------------- + + describe('afterRun', () => { + it('should not touch files when statusline was not managed in this session', async () => { + // Do NOT call beforeRun → statuslineManagedThisSession stays false + await afterRun(0, {}); + + expect(fsp.readFile).not.toHaveBeenCalled(); + expect(fsp.writeFile).not.toHaveBeenCalled(); + }); + + it('should remove statusLine from settings.json after a managed session', async () => { + // --- Set up the flag via beforeRun --- + vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + vi.resetAllMocks(); + + // --- afterRun --- + const existingSettings = { statusLine: { type: 'command', command: 'node "/x/y.mjs"' }, theme: 'dark' }; + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify(existingSettings) as any); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + + await afterRun(0, {}); + + expect(fsp.writeFile).toHaveBeenCalledTimes(1); + const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1] as string); + expect(written.statusLine).toBeUndefined(); + // Other settings are preserved + expect(written.theme).toBe('dark'); + }); + + it('should reset the module-level flag so a second afterRun call is a no-op', async () => { + // Set the flag via beforeRun + vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + vi.resetAllMocks(); + + // First afterRun – performs cleanup + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValueOnce(JSON.stringify({ statusLine: {} }) as any); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + await afterRun(0, {}); + vi.resetAllMocks(); + + // Second afterRun – must be a no-op (flag already reset) + await afterRun(0, {}); + + expect(fsp.readFile).not.toHaveBeenCalled(); + expect(fsp.writeFile).not.toHaveBeenCalled(); + }); + + it('should log a sanitized warning when settings cleanup fails', async () => { + // Set the flag via beforeRun + vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + vi.resetAllMocks(); + + // afterRun encounters malformed settings.json + vi.mocked(fsMod.existsSync).mockReturnValue(true); + vi.mocked(fsp.readFile).mockResolvedValueOnce('{ bad json' as any); + + await afterRun(0, {}); + + expect(loggerMod.logger.warn).toHaveBeenCalledWith( + expect.stringContaining('Failed to clean up statusLine'), + expect.anything(), + ); + }); + + it('should skip cleanup when settings.json does not exist', async () => { + // Set the flag via beforeRun + vi.mocked(fsp.readFile).mockResolvedValueOnce('// script' as any); + vi.mocked(fsMod.existsSync).mockReturnValueOnce(true).mockReturnValueOnce(false); + vi.mocked(fsp.writeFile).mockResolvedValue(undefined); + vi.mocked(fsp.chmod).mockResolvedValue(undefined); + await beforeRun({ CODEMIE_STATUS: '1' }, mockConfig); + vi.resetAllMocks(); + + // settings.json does not exist at cleanup time + vi.mocked(fsMod.existsSync).mockReturnValue(false); + + await afterRun(0, {}); + + expect(fsp.readFile).not.toHaveBeenCalled(); + expect(fsp.writeFile).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 2121b3e..b6db423 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -15,20 +15,25 @@ import { getErrorMessage, } from '../../../utils/errors.js'; import { logger } from '../../../utils/logger.js'; +import { sanitizeLogArgs } from '../../../utils/security.js'; import chalk from 'chalk'; -import { resolveHomeDir } from '../../../utils/paths.js'; +import { resolveHomeDir, getDirname } from '../../../utils/paths.js'; import { detectInstallationMethod, type InstallationMethod, } from '../../../utils/installation-detector.js'; +// Module-level flag to track statusline management within a session. +// Using module scope (not env var) avoids leaking internal state into subprocess environments. +let statuslineManagedThisSession = false; + /** * Supported Claude Code version * Latest version tested and verified with CodeMie backend * * **UPDATE THIS WHEN BUMPING CLAUDE VERSION** */ -const CLAUDE_SUPPORTED_VERSION = '2.1.31'; +const CLAUDE_SUPPORTED_VERSION = '2.1.41'; /** * Claude Code installer URLs @@ -134,8 +139,105 @@ export const ClaudePluginMetadata: AgentMetadata = { env.DISABLE_AUTOUPDATER = '1'; } + // Statusline setup: when --status flag is passed, configure Claude Code + // status bar with a multi-line display showing model, context, git, cost + // https://code.claude.com/docs/en/statusline + if (env.CODEMIE_STATUS === '1') { + const { writeFile, readFile, mkdir, chmod } = await import('fs/promises'); + const { existsSync } = await import('fs'); + const { join } = await import('path'); + + const claudeHome = resolveHomeDir('.claude'); + const scriptPath = join(claudeHome, 'codemie-statusline.mjs'); + const settingsPath = join(claudeHome, 'settings.json'); + + // Read the statusline script from the compiled output directory + const scriptContent = await readFile( + join(getDirname(import.meta.url), 'plugin/codemie-statusline.mjs'), + 'utf-8' + ); + + // Ensure ~/.claude directory exists + if (!existsSync(claudeHome)) { + await mkdir(claudeHome, { recursive: true }); + } + + // Write script (always update to latest version) + await writeFile(scriptPath, scriptContent, 'utf-8'); + + // Make script executable on Unix systems + if (process.platform !== 'win32') { + await chmod(scriptPath, 0o755); + } + + // Inject statusLine into ~/.claude/settings.json if not already configured + let settings: Record = {}; + if (existsSync(settingsPath)) { + try { + const raw = await readFile(settingsPath, 'utf-8'); + settings = JSON.parse(raw) as Record; + } catch (parseError) { + // Abort injection to prevent overwriting potentially valid settings + // that are temporarily unreadable (e.g., concurrent write, partial flush) + logger.warn( + '[Claude] Could not parse settings.json, skipping statusline injection to avoid data loss', + ...sanitizeLogArgs({ + settingsPath, + error: parseError instanceof Error ? parseError.message : String(parseError), + }) + ); + return env; + } + } + + if (!settings.statusLine) { + settings.statusLine = { + type: 'command', + // Quote the path to handle spaces in home directory (e.g. /Users/John Doe/) + command: `node "${scriptPath}"`, + }; + await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + // Use module-level flag (not env var) to avoid leaking into subprocess env + statuslineManagedThisSession = true; + logger.debug('[Claude] Statusline configured', { scriptPath }); + } + } + return env; }, + + // Clean up injected statusLine from settings.json after the session ends + async afterRun(_exitCode, _env) { + if (!statuslineManagedThisSession) return; + statuslineManagedThisSession = false; + + const { readFile, writeFile } = await import('fs/promises'); + const { existsSync } = await import('fs'); + const { join } = await import('path'); + + const settingsPath = join(resolveHomeDir('.claude'), 'settings.json'); + + if (existsSync(settingsPath)) { + try { + const raw = await readFile(settingsPath, 'utf-8'); + const settings = JSON.parse(raw) as Record; + + if (settings.statusLine) { + delete settings.statusLine; + await writeFile(settingsPath, JSON.stringify(settings, null, 2), 'utf-8'); + logger.debug('[Claude] Statusline config removed from settings.json'); + } + } catch (error) { + logger.warn( + '[Claude] Failed to clean up statusLine from settings.json', + ...sanitizeLogArgs({ + settingsPath, + error: error instanceof Error ? error.message : String(error), + }) + ); + } + } + }, }, }; diff --git a/src/agents/plugins/claude/plugin/codemie-statusline.mjs b/src/agents/plugins/claude/plugin/codemie-statusline.mjs new file mode 100644 index 0000000..d07d9d9 --- /dev/null +++ b/src/agents/plugins/claude/plugin/codemie-statusline.mjs @@ -0,0 +1,42 @@ +#!/usr/bin/env node +// CodeMie Statusline - auto-generated by codemie-claude --status +// Shows: [Model] dir | branch / context bar % | $cost | duration +// +// NOTE: This file is deployed as a standalone script to ~/.claude/ and has no +// access to the CodeMie utils layer. Direct child_process and path imports are +// intentional here - this script runs independently without the project runtime. +import { execSync } from 'child_process'; +import { basename } from 'path'; + +let input = ''; +process.stdin.on('data', chunk => input += chunk); +process.stdin.on('end', () => { + try { + const data = JSON.parse(input); + const model = data.model?.display_name || 'Claude'; + const dir = basename(data.workspace?.current_dir || process.cwd()); + const cost = data.cost?.total_cost_usd || 0; + const pct = Math.floor(data.context_window?.used_percentage || 0); + const durationMs = data.cost?.total_duration_ms || 0; + + const CYAN = '\x1b[36m', GREEN = '\x1b[32m', YELLOW = '\x1b[33m', RED = '\x1b[31m', RESET = '\x1b[0m'; + + const barColor = pct >= 90 ? RED : pct >= 70 ? YELLOW : GREEN; + const filled = Math.floor(pct / 10); + const bar = '█'.repeat(filled) + '░'.repeat(10 - filled); + + const mins = Math.floor(durationMs / 60000); + const secs = Math.floor((durationMs % 60000) / 1000); + + let branch = ''; + try { + branch = execSync('git branch --show-current', { encoding: 'utf8', stdio: ['pipe', 'pipe', 'ignore'] }).trim(); + branch = branch ? ' | 🌿 ' + branch : ''; + } catch {} + + process.stdout.write(CYAN + '[' + model + ']' + RESET + ' 📁 ' + dir + branch + '\n'); + process.stdout.write(barColor + bar + RESET + ' ' + pct + '% | ' + YELLOW + '$' + cost.toFixed(4) + RESET + ' | ⏱️ ' + mins + 'm ' + secs + 's\n'); + } catch { + // Silent fail - statusline must never crash Claude Code + } +});