From 47b89639d0a833db31638fdebcab2683908c0255 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Fri, 20 Feb 2026 10:41:34 +0200 Subject: [PATCH 1/3] fix(agents): rename --statusline to --status and fix lifecycle hook issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename CLI flag --statusline → --status and CODEMIE_STATUSLINE → CODEMIE_STATUS - Convert codemie-statusline.js to codemie-statusline.mjs (ESM, removes require()) - Fix path quoting in statusLine command to handle spaces in home directory - Fix settings.json data loss: abort injection on JSON parse failure instead of overwriting - Replace CODEMIE_STATUSLINE_MANAGED env var with module-level flag to avoid subprocess leakage - Sanitize logger.warn args with sanitizeLogArgs in afterRun cleanup - Add mkdirSync before copyFileSync in copy-plugins.js to prevent ENOENT in clean builds - Add 13 unit tests covering beforeRun and afterRun statusline lifecycle hooks Generated with AI Co-Authored-By: codemie-ai --- scripts/copy-plugins.js | 24 +- src/agents/core/AgentCLI.ts | 8 +- .../claude.plugin.statusline.test.ts | 313 ++++++++++++++++++ src/agents/plugins/claude/claude.plugin.ts | 106 +++++- .../plugins/claude/codemie-statusline.mjs | 42 +++ 5 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts create mode 100644 src/agents/plugins/claude/codemie-statusline.mjs diff --git a/scripts/copy-plugins.js b/scripts/copy-plugins.js index 0922f31e..b71a93c0 100644 --- a/scripts/copy-plugins.js +++ b/scripts/copy-plugins.js @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { rmSync, mkdirSync, cpSync, existsSync } from 'fs'; +import { rmSync, mkdirSync, cpSync, copyFileSync, existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -54,4 +54,26 @@ for (const config of copyConfigs) { console.log(` ✓ ${config.name} copied successfully\n`); } +const copyFiles = [ + { + name: 'Claude statusline script', + src: join(rootDir, 'src/agents/plugins/claude/codemie-statusline.mjs'), + dest: join(rootDir, 'dist/agents/plugins/claude/codemie-statusline.mjs') + } +]; + +for (const file of copyFiles) { + console.log(`Processing ${file.name}:`); + + if (!existsSync(file.src)) { + console.log(` - Warning: Source ${file.src} does not exist, skipping...`); + continue; + } + + // Ensure destination directory exists before copying + mkdirSync(dirname(file.dest), { recursive: true }); + copyFileSync(file.src, file.dest); + console.log(` ✓ ${file.name} copied successfully\n`); +} + console.log('Plugin assets copied successfully!'); diff --git a/src/agents/core/AgentCLI.ts b/src/agents/core/AgentCLI.ts index 8d695342..13bbca5e 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 00000000..79f06f22 --- /dev/null +++ b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts @@ -0,0 +1,313 @@ +/** + * Tests for Claude Plugin statusline lifecycle hooks (--status flag) + * + * @group unit + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +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 = {}; + // Predictable paths based on mocked resolveHomeDir('.claude') and join + const CLAUDE_HOME = '/home/testuser/claude'; + const SCRIPT_DEST = `${CLAUDE_HOME}/codemie-statusline.mjs`; + const SETTINGS_PATH = `${CLAUDE_HOME}/settings.json`; + const SCRIPT_SRC = '/fake/dist/plugins/claude/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 2121b3e0..3fa9a078 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), '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/codemie-statusline.mjs b/src/agents/plugins/claude/codemie-statusline.mjs new file mode 100644 index 00000000..d07d9d9b --- /dev/null +++ b/src/agents/plugins/claude/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 + } +}); From 0d840766e6bec1d09f85019485d0c3d357257279 Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Fri, 20 Feb 2026 11:10:26 +0200 Subject: [PATCH 2/3] refactor(agents): move codemie-statusline.mjs into plugin/ folder The file is now picked up by the existing recursive cpSync in copy-plugins.js, removing the redundant copyFiles loop and copyFileSync import. Update read path in claude.plugin.ts and test constant accordingly. Generated with AI Co-Authored-By: codemie-ai --- scripts/copy-plugins.js | 24 +------------------ .../claude.plugin.statusline.test.ts | 2 +- src/agents/plugins/claude/claude.plugin.ts | 2 +- .../{ => plugin}/codemie-statusline.mjs | 0 4 files changed, 3 insertions(+), 25 deletions(-) rename src/agents/plugins/claude/{ => plugin}/codemie-statusline.mjs (100%) diff --git a/scripts/copy-plugins.js b/scripts/copy-plugins.js index b71a93c0..0922f31e 100644 --- a/scripts/copy-plugins.js +++ b/scripts/copy-plugins.js @@ -7,7 +7,7 @@ import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; -import { rmSync, mkdirSync, cpSync, copyFileSync, existsSync } from 'fs'; +import { rmSync, mkdirSync, cpSync, existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -54,26 +54,4 @@ for (const config of copyConfigs) { console.log(` ✓ ${config.name} copied successfully\n`); } -const copyFiles = [ - { - name: 'Claude statusline script', - src: join(rootDir, 'src/agents/plugins/claude/codemie-statusline.mjs'), - dest: join(rootDir, 'dist/agents/plugins/claude/codemie-statusline.mjs') - } -]; - -for (const file of copyFiles) { - console.log(`Processing ${file.name}:`); - - if (!existsSync(file.src)) { - console.log(` - Warning: Source ${file.src} does not exist, skipping...`); - continue; - } - - // Ensure destination directory exists before copying - mkdirSync(dirname(file.dest), { recursive: true }); - copyFileSync(file.src, file.dest); - console.log(` ✓ ${file.name} copied successfully\n`); -} - console.log('Plugin assets copied successfully!'); diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts index 79f06f22..dc03d443 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts @@ -52,7 +52,7 @@ describe('Claude Plugin – statusline lifecycle hooks', () => { const CLAUDE_HOME = '/home/testuser/claude'; const SCRIPT_DEST = `${CLAUDE_HOME}/codemie-statusline.mjs`; const SETTINGS_PATH = `${CLAUDE_HOME}/settings.json`; - const SCRIPT_SRC = '/fake/dist/plugins/claude/codemie-statusline.mjs'; + const SCRIPT_SRC = '/fake/dist/plugins/claude/plugin/codemie-statusline.mjs'; beforeEach(async () => { vi.resetModules(); // Reset module cache → resets statuslineManagedThisSession to false diff --git a/src/agents/plugins/claude/claude.plugin.ts b/src/agents/plugins/claude/claude.plugin.ts index 3fa9a078..b6db4239 100644 --- a/src/agents/plugins/claude/claude.plugin.ts +++ b/src/agents/plugins/claude/claude.plugin.ts @@ -153,7 +153,7 @@ export const ClaudePluginMetadata: AgentMetadata = { // Read the statusline script from the compiled output directory const scriptContent = await readFile( - join(getDirname(import.meta.url), 'codemie-statusline.mjs'), + join(getDirname(import.meta.url), 'plugin/codemie-statusline.mjs'), 'utf-8' ); diff --git a/src/agents/plugins/claude/codemie-statusline.mjs b/src/agents/plugins/claude/plugin/codemie-statusline.mjs similarity index 100% rename from src/agents/plugins/claude/codemie-statusline.mjs rename to src/agents/plugins/claude/plugin/codemie-statusline.mjs From f9d5afd6cf25efb7be372ba6edefd243a6419fee Mon Sep 17 00:00:00 2001 From: vadimvlasenko Date: Fri, 20 Feb 2026 11:21:26 +0200 Subject: [PATCH 3/3] fix(tests): fix Windows path separator mismatch in statusline tests Path constants derived via path.join() so they use backslashes on Windows and forward slashes on Unix, matching what the production code produces. CLAUDE_HOME is kept as the raw mock string (not path.join'd) since it is passed directly to mkdir without normalization. Generated with AI Co-Authored-By: codemie-ai --- .../__tests__/claude.plugin.statusline.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts index dc03d443..ee29b4d4 100644 --- a/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts +++ b/src/agents/plugins/claude/__tests__/claude.plugin.statusline.test.ts @@ -5,6 +5,7 @@ */ 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) --- @@ -48,11 +49,14 @@ describe('Claude Plugin – statusline lifecycle hooks', () => { let loggerMod: { logger: Record> }; const mockConfig: AgentConfig = {}; - // Predictable paths based on mocked resolveHomeDir('.claude') and join + // 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'; - const SCRIPT_DEST = `${CLAUDE_HOME}/codemie-statusline.mjs`; - const SETTINGS_PATH = `${CLAUDE_HOME}/settings.json`; - const SCRIPT_SRC = '/fake/dist/plugins/claude/plugin/codemie-statusline.mjs'; + // 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