feat(statusline): delegate to the statusline the exporter shadows

Claude Code ranks a repo's .claude/settings.local.json above
~/.claude/settings.json, so the statusLine Codeman injects for the Plan
Usage chip shadows whatever statusline the user configured globally. The
inline exporter then printed Codeman's own footer in its place, and
running `claude` by hand in a managed repo rendered the bare word
`codeman` — the response the server returns for an unknown session id.

The exporter becomes a generated shim, following the deepseek-status-shim
pattern: a versioned .mjs in the data dir, refreshed on a marker change,
written through temp-and-rename so a live render cannot read a
half-written file. It forwards the same payload to /api/status-telemetry
and, concurrently, resolves the statusline it shadows and prints that.
Codeman's footer still appears when there is nothing to shadow, so the
exporter keeps its value on a machine with no statusline of its own.

The delegate resolves at render time, walking the settings files Claude
Code consults from the render directory upward and then the home ones,
skipping Codeman's own entry in either form. Late resolution means
editing a global statusline needs no reinjection.

Ownership now keys on the version-free `codeman-statusline-shim` token,
and applyStatusLineConfig still reads the old /api/status-telemetry
command as ours, so managed repos upgrade in place instead of being
mistaken for hand-authored. A hand-authored statusLine is left alone
exactly as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 641795821fdbb171f9f47c4909c6e8945465d05b)
This commit is contained in:
Michael Grundberg
2026-09-14 13:32:35 +02:00
committed by Codeman maintainer
parent c4b74415ee
commit c375268879
8 changed files with 845 additions and 17 deletions
+79
View File
@@ -23,6 +23,7 @@ import {
updateCaseModel,
writeHooksConfig,
} from '../src/hooks-config.js';
import { LEGACY_STATUSLINE_MARKER, STATUSLINE_SHIM_TOKEN } from '../src/statusline-shim.js';
describe('generateHooksConfig', () => {
it('should return an object with hooks key', () => {
@@ -1305,3 +1306,81 @@ describe('Hook Config Generation - Extended', () => {
expect(stopHooks[0].hooks[0].command).toContain('stop');
});
});
describe('applyStatusLineConfig', () => {
const testDir = join(tmpdir(), 'codeman-statusline-config-' + Date.now());
const settingsFile = join(testDir, '.claude', 'settings.local.json');
const read = () => JSON.parse(readFileSync(settingsFile, 'utf-8'));
const write = (value: object) => {
mkdirSync(join(testDir, '.claude'), { recursive: true });
writeFileSync(settingsFile, JSON.stringify(value, null, 2));
};
beforeEach(() => {
rmSync(testDir, { recursive: true, force: true });
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
rmSync(testDir, { recursive: true, force: true });
});
it('injects the delegating shim rather than an inline exporter', async () => {
await applyStatusLineConfig(testDir, true);
const { statusLine } = read();
expect(statusLine.type).toBe('command');
expect(statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
// The inline form SHADOWS the user's statusline, which is the whole reason
// the shim exists. It may never be the command we inject by choice.
expect(statusLine.command).not.toContain(LEGACY_STATUSLINE_MARKER);
});
it('upgrades a pre-shim inline exporter in place', async () => {
// Every repo a previous Codeman managed still holds this command. If the
// ownership check missed it, the upgrade would read it as hand-authored,
// refuse to touch it, and leave the user shadowed forever.
write({
statusLine: { type: 'command', command: `curl -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` },
permissions: { allow: ['Read'] },
});
await applyStatusLineConfig(testDir, true);
const settings = read();
expect(settings.statusLine.command).toContain(STATUSLINE_SHIM_TOKEN);
expect(settings.permissions).toEqual({ allow: ['Read'] });
});
it('removes a pre-shim inline exporter on the disable path', async () => {
write({ statusLine: { type: 'command', command: `curl "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}"` } });
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('removes its own shim entry on the disable path', async () => {
await applyStatusLineConfig(testDir, true);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toBeUndefined();
});
it('never touches a statusLine the user wrote themselves', async () => {
// Unchanged contract: a hand-authored entry in the repo's own file stops
// Codeman cold, so it never owns an entry it would have to restore later.
const mine = { type: 'command', command: 'bash ~/.claude/my-statusline.sh' };
write({ statusLine: mine });
await applyStatusLineConfig(testDir, true);
expect(read().statusLine).toEqual(mine);
await applyStatusLineConfig(testDir, false);
expect(read().statusLine).toEqual(mine);
});
it('rewrites nothing when the shim command is already current', async () => {
await applyStatusLineConfig(testDir, true);
const before = readFileSync(settingsFile, 'utf-8');
await applyStatusLineConfig(testDir, true);
expect(readFileSync(settingsFile, 'utf-8')).toBe(before);
});
});
+303
View File
@@ -0,0 +1,303 @@
/**
* The generated plan-usage statusLine shim.
*
* Like the DeepSeek status shim, this file is emitted as a STRING and executed
* by someone else — Claude Code, before every render — so tsc never sees it.
* The assertions therefore run the real file in a real `node` process, with a
* real temp HOME and a real listener, rather than inspecting the source text.
*
* The load-bearing property is the pair: the shim must keep forwarding plan
* usage to Codeman AND give the user back the statusline it shadows. Losing
* either half silently defeats the feature, in one direction by blanking the
* header chip and in the other by stealing the terminal footer.
*/
import { describe, expect, it, beforeAll, beforeEach, afterAll } from 'vitest';
import { execFileSync, spawn } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, join } from 'node:path';
import { tmpdir } from 'node:os';
import {
ensureStatusLineShim,
generateShimStatusLineCommand,
LEGACY_STATUSLINE_MARKER,
resetStatusLineShimForTest,
statusLineShimPath,
STATUSLINE_SHIM_TOKEN,
} from '../src/statusline-shim.js';
const PORT = 3252;
/** A port nothing listens on, for the unreachable-Codeman case. Claimed here so
* the repo-wide `const PORT =` search a contributor runs finds it too. */
const PORT_DEAD = 3253;
describe('statusLine shim: provisioning', () => {
beforeEach(() => {
resetStatusLineShimForTest();
});
it('writes an executable shim that node can actually parse', () => {
const path = ensureStatusLineShim();
expect(path).toBeTruthy();
expect(existsSync(path!)).toBe(true);
expect(statSync(path!).mode & 0o777).toBe(0o700);
// `node --check` on the real file: a template-literal typo in SHIM_SOURCE is
// invisible to tsc, because the shim is a string as far as it is concerned.
expect(() => execFileSync(process.execPath, ['--check', path!], { stdio: 'pipe' })).not.toThrow();
});
it('refreshes a shim written by an older Codeman, and leaves no temp file behind', () => {
const path = statusLineShimPath();
ensureStatusLineShim();
const current = readFileSync(path, 'utf-8');
writeFileSync(path, `#!/usr/bin/env node\n// ${STATUSLINE_SHIM_TOKEN} v0\nprocess.exit(0)\n`, { mode: 0o700 });
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(readFileSync(path, 'utf-8')).toBe(current);
const strays = readdirSync(dirname(path)).filter((f) => f.startsWith(STATUSLINE_SHIM_TOKEN) && f.endsWith('.tmp'));
expect(strays).toEqual([]);
});
it('re-asserts the exec bit even when the content already matches', () => {
const path = ensureStatusLineShim()!;
chmodSync(path, 0o600); // a restored backup / copied data dir
resetStatusLineShimForTest();
ensureStatusLineShim();
expect(statSync(path).mode & 0o777).toBe(0o700);
});
it('names the shim so the injected command carries the ownership token', () => {
// applyStatusLineConfig decides ownership on this substring. If the file is
// ever renamed out from under it, Codeman stops recognising its own entries
// and starts treating them as hand-authored.
const command = generateShimStatusLineCommand();
expect(command).toBeTruthy();
expect(command).toContain(STATUSLINE_SHIM_TOKEN);
// Absolute node, not a bare `node`: a managed session's PATH need not have one.
expect(command).toContain(process.execPath);
});
it('quotes both paths, so a data dir with a space still runs', () => {
const command = generateShimStatusLineCommand()!;
expect(command).toBe(`'${process.execPath}' '${statusLineShimPath()}'`);
});
});
describe('statusLine shim: rendering', () => {
let shim: string;
let server: Server;
let received: Array<{ url: string; body: string }> = [];
let workspace: string;
let fakeHome: string;
/** Run the shim the way Claude Code does: a subprocess, JSON on stdin. */
function render(
payload: object,
env: Record<string, string> = {},
cwd: string = workspace
): Promise<{ stdout: string; code: number | null }> {
return new Promise((resolve) => {
const child = spawn(process.execPath, [shim], {
cwd,
env: { ...process.env, HOME: fakeHome, USERPROFILE: fakeHome, ...env },
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.on('close', (code) => resolve({ stdout, code }));
child.stdin.end(JSON.stringify(payload));
});
}
/** Point a settings file's statusLine at a shell command. */
function writeStatusLine(file: string, command: string): void {
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify({ statusLine: { type: 'command', command } }, null, 2));
}
beforeAll(async () => {
resetStatusLineShimForTest();
shim = ensureStatusLineShim()!;
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push({ url: req.url ?? '', body });
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('CODEMAN-FOOTER');
});
});
await new Promise<void>((r) => server.listen(PORT, '127.0.0.1', r));
});
afterAll(async () => {
await new Promise<void>((r) => server.close(() => r()));
});
beforeEach(() => {
received = [];
const root = mkdtempSync(join(tmpdir(), 'codeman-statusline-'));
fakeHome = join(root, 'home');
workspace = join(root, 'repo');
mkdirSync(join(fakeHome, '.claude'), { recursive: true });
mkdirSync(join(workspace, '.claude'), { recursive: true });
// The entry Codeman injects into the managed repo. Every case below has it,
// because the shim must always skip its own entry while hunting a delegate.
writeStatusLine(join(workspace, '.claude', 'settings.local.json'), `'${process.execPath}' '${shim}'`);
});
it('prints the global statusline it shadows', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
});
it('hands the delegate the same JSON Claude Code sent', async () => {
// The delegate is only useful if it sees the payload: every statusline
// script reads the model, the cwd or the rate limits off this blob.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), `bash -c 'read -r j; echo "GOT:$j"'`);
const { stdout } = await render({ cwd: workspace, model: { display_name: 'Opus 5' } });
expect(stdout).toContain('"display_name":"Opus 5"');
});
it('never delegates to its own entry', async () => {
// No other statusLine exists, so the only candidate is the shim's own. If
// the loop guard failed this would fork until something ran out.
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('codeman');
});
it('never delegates to the pre-shim inline exporter', async () => {
// An upgraded install can still have the old command in a parent settings
// file. Running it would double-report and print Codeman's footer anyway.
writeStatusLine(
join(fakeHome, '.claude', 'settings.json'),
`curl -sk -X POST "$CODEMAN_API_URL${LEGACY_STATUSLINE_MARKER}" || echo codeman`
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('codeman');
});
it('prefers a project statusline to the global one', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo GLOBAL');
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PROJECT');
});
it('finds the workspace statusline from a subdirectory', async () => {
// Claude Code applies a project's settings from the workspace root, which is
// routinely an ancestor of the directory the session sits in.
writeStatusLine(join(workspace, '.claude', 'settings.json'), 'echo PROJECT');
const deep = join(workspace, 'src', 'nested');
mkdirSync(deep, { recursive: true });
const { stdout } = await render({ cwd: deep }, {}, deep);
expect(stdout).toBe('PROJECT');
});
it('forwards telemetry to Codeman WHILE delegating', async () => {
// The whole point: taking the user's line back must not cost the header chip.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render(
{ cwd: workspace, rate_limits: { five_hour: { used_percentage: 12, resets_at: 99 } } },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toHaveLength(1);
expect(received[0].url).toBe(LEGACY_STATUSLINE_MARKER);
const posted = JSON.parse(received[0].body);
expect(posted.sessionId).toBe('sess-1');
expect(posted.data.rate_limits.five_hour.used_percentage).toBe(12);
});
it("prints Codeman's own footer when there is no line to shadow", async () => {
const { stdout } = await render(
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('CODEMAN-FOOTER');
expect(received).toHaveLength(1);
});
it('skips the POST entirely outside a managed session', async () => {
// Running `claude` by hand in a managed repo must cost nothing extra, and
// must not render the old bare-word `codeman` the server returns for an
// unknown session id.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('THE-USERS-LINE');
expect(received).toEqual([]);
});
it('falls back rather than blanking when the delegate fails silently', async () => {
// A blank statusline reads as a broken terminal, so a delegate that exits
// non-zero with no output must not win.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'exit 3');
const { stdout } = await render(
{ cwd: workspace },
{ CODEMAN_SESSION_ID: 'sess-1', CODEMAN_API_URL: `http://127.0.0.1:${PORT}` }
);
expect(stdout).toBe('CODEMAN-FOOTER');
});
it('still prints a failing delegate that produced output', async () => {
// Plenty of statusline scripts end on the exit code of their last command.
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo PARTIAL; exit 1');
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('PARTIAL');
});
it('survives an unreachable Codeman and a malformed payload', async () => {
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const child = spawn(process.execPath, [shim], {
cwd: workspace,
env: {
...process.env,
HOME: fakeHome,
USERPROFILE: fakeHome,
CODEMAN_SESSION_ID: 'sess-1',
// Nothing listens here.
CODEMAN_API_URL: `http://127.0.0.1:${PORT_DEAD}`,
},
stdio: ['pipe', 'pipe', 'ignore'],
});
let stdout = '';
child.stdout.on('data', (c) => (stdout += c));
child.stdin.end('not json at all');
const code = await new Promise<number | null>((r) => child.on('close', r));
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a malformed settings file instead of dying on it', async () => {
writeFileSync(join(workspace, '.claude', 'settings.json'), '{ broken');
writeStatusLine(join(fakeHome, '.claude', 'settings.json'), 'echo THE-USERS-LINE');
const { stdout, code } = await render({ cwd: workspace });
expect(code).toBe(0);
expect(stdout).toBe('THE-USERS-LINE');
});
it('ignores a statusLine that is not a command', async () => {
writeFileSync(
join(fakeHome, '.claude', 'settings.json'),
JSON.stringify({ statusLine: { type: 'something-else', command: 'echo NOPE' } })
);
const { stdout } = await render({ cwd: workspace });
expect(stdout).toBe('codeman');
});
});