mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(statusline): wrap the user's own real statusline instead of skipping it
Now that the exporter no longer lives in a fixed per-case file, it can compose with the user's actual configured statusline rather than just backing off when one is found. findEffectiveUserStatusLineCommand() walks Claude Code's own settings precedence for a workspace: project-local .claude/settings.local.json > project-shared .claude/settings.json > the user's global ~/.claude/settings.json. A legacy Codeman-marked entry left behind in the project's own settings.local.json is never treated as a real user command — it's skipped and precedence continues to the next layer. The shared exporter script (bumped to a V2 marker so stale copies self-heal) now fires the telemetry POST in a background subshell — its own stdout/stderr discarded so nothing leaks into the visible statusline, and confirmed non-blocking (~4ms, even against an unreachable endpoint) — then, if the pane's environment carries CODEMAN_USER_STATUSLINE_CMD, feeds it the same stdin blob and relays its stdout as ours. Otherwise it falls back to the plain "codeman" marker as before. The discovered command is threaded to the pane via `tmux setenv CODEMAN_USER_STATUSLINE_CMD` (_configureStatusLineUserCommand) rather than embedded in the spawn command line, for the same premature-shell-expansion reason as the parent commit: tmux stores a setenv value verbatim and never re-parses it, so once shellescape()d for that one command, the command's own $/quotes survive untouched into the pane's environment. Verified live via direct shell execution of the generated script (both branches: fallback and user-command wrapping) before deploy. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GyMnFWnUzc41TDeHg9juW
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
d4aa3c8cca
commit
e15e8e43e8
+54
-6
@@ -912,24 +912,72 @@ export async function applyStatusLineConfig(casePath: string, enabled: boolean):
|
||||
* whenever the script content changes so `ensureStatusLineExporterScript`'s
|
||||
* content comparison rewrites stale copies on next use.
|
||||
*/
|
||||
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V1';
|
||||
const STATUSLINE_EXPORTER_SCRIPT_MARKER = 'CODEMAN_STATUSLINE_EXPORTER_V2';
|
||||
|
||||
function statusLineExporterScriptContent(): string {
|
||||
// Byte-identical logic to generateStatusLineCommand(), just as a real file
|
||||
// instead of an inline string — see ensureStatusLineExporterScript for why
|
||||
// that distinction matters (it is NOT cosmetic).
|
||||
// The telemetry POST runs in a BACKGROUND subshell so it can never add
|
||||
// latency to a render, and its own stdout/stderr are discarded so they
|
||||
// never leak into the visible statusline. If the pane's env carries
|
||||
// CODEMAN_USER_STATUSLINE_CMD (set via tmux setenv by TmuxManager when
|
||||
// findEffectiveUserStatusLineCommand found the user's own REAL statusLine —
|
||||
// see that function's doc comment), the SAME stdin blob is fed to it and
|
||||
// ITS stdout becomes ours, so the user keeps seeing their own statusline
|
||||
// untouched. Absent that, fall back to the plain "codeman" marker.
|
||||
return (
|
||||
`#!/bin/sh\n` +
|
||||
`# ${STATUSLINE_EXPORTER_SCRIPT_MARKER} — auto-generated by Codeman; safe to delete, regenerated on demand.\n` +
|
||||
`INPUT=$(cat 2>/dev/null || echo '{}')\n` +
|
||||
`printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`(\n` +
|
||||
` printf '{"sessionId":"%s","data":%s}' "$CODEMAN_SESSION_ID" "$INPUT" | ` +
|
||||
`curl -sk -X POST "$CODEMAN_API_URL${STATUSLINE_MARKER}" ` +
|
||||
`-H 'Content-Type: application/json' ` +
|
||||
`-H "X-Codeman-Hook-Secret: $(cat "$CODEMAN_HOOK_SECRET_FILE" 2>/dev/null)" ` +
|
||||
`--data @- 2>/dev/null || echo codeman\n`
|
||||
`--data @- >/dev/null 2>&1\n` +
|
||||
`) &\n` +
|
||||
`if [ -n "$CODEMAN_USER_STATUSLINE_CMD" ]; then\n` +
|
||||
` printf '%s' "$INPUT" | sh -c "$CODEMAN_USER_STATUSLINE_CMD"\n` +
|
||||
`else\n` +
|
||||
` echo codeman\n` +
|
||||
`fi\n`
|
||||
);
|
||||
}
|
||||
|
||||
async function readStatusLineCommandFromFile(settingsPath: string): Promise<string | undefined> {
|
||||
if (!existsSync(settingsPath)) return undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(await readFile(settingsPath, 'utf-8'));
|
||||
const current = parsed.statusLine as { command?: unknown } | undefined;
|
||||
return current && typeof current.command === 'string' ? current.command : undefined;
|
||||
} catch {
|
||||
return undefined; // Malformed — treat as absent, same posture as applyStatusLineConfig.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk Claude Code's OWN settings precedence for `workingDir` to find whatever
|
||||
* statusLine command is ACTUALLY effective there right now: project-local
|
||||
* `.claude/settings.local.json` > project-shared `.claude/settings.json` >
|
||||
* the user's global `~/.claude/settings.json`. Returns undefined when none of
|
||||
* the three configures one.
|
||||
*
|
||||
* A legacy Codeman-marked entry in the project's OWN settings.local.json
|
||||
* (written by an older build's disk-based mechanism) is never treated as a
|
||||
* real user command — resolveStatusLineCliCommand strips it before this ever
|
||||
* runs, so ordinarily this function never even sees one; the marker check
|
||||
* here is a second, defensive guard in case something else wrote a copy in
|
||||
* between, and precedence simply continues to the next layer instead of
|
||||
* stopping on it.
|
||||
*/
|
||||
export async function findEffectiveUserStatusLineCommand(workingDir: string): Promise<string | undefined> {
|
||||
const projectLocal = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.local.json'));
|
||||
if (projectLocal && !projectLocal.includes(STATUSLINE_MARKER)) return projectLocal;
|
||||
|
||||
const projectShared = await readStatusLineCommandFromFile(join(workingDir, '.claude', 'settings.json'));
|
||||
if (projectShared) return projectShared;
|
||||
|
||||
return readStatusLineCommandFromFile(join(homedir(), '.claude', 'settings.json'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Write (or refresh) the SHARED, single exporter script every claude session
|
||||
* points its ephemeral --settings statusLine flag at, and return its absolute
|
||||
|
||||
+32
-1
@@ -67,7 +67,7 @@ import {
|
||||
legacyConfigForMode,
|
||||
} from './session-cli-registry-bridge.js';
|
||||
import type { CliEntry } from './config/cli-registry/types.js';
|
||||
import { resolveStatusLineCliCommand } from './hooks-config.js';
|
||||
import { resolveStatusLineCliCommand, findEffectiveUserStatusLineCommand } from './hooks-config.js';
|
||||
import {
|
||||
buildSshConnectionArgs,
|
||||
defaultRemoteCommandForMode,
|
||||
@@ -1707,6 +1707,30 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the user's own REAL statusLine command (found by
|
||||
* findEffectiveUserStatusLineCommand) via tmux setenv, so the shared
|
||||
* exporter script (statusLineExporterScriptContent in hooks-config.ts) can
|
||||
* wrap it. Via setenv rather than embedding it in the spawn command line:
|
||||
* tmux stores a setenv value verbatim and never re-parses it as shell
|
||||
* syntax, so once safely escaped for THIS one command, the command's own
|
||||
* `$`/quotes survive untouched into the claude process's environment — the
|
||||
* same reasoning that made the exporter script itself necessary (see
|
||||
* ensureStatusLineExporterScript's doc comment). Only this ONE line needs
|
||||
* shellescape(); the stored value itself is opaque to tmux from then on.
|
||||
*/
|
||||
private _configureStatusLineUserCommand(muxName: string, command: string | undefined): void {
|
||||
if (!command) return;
|
||||
try {
|
||||
execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} CODEMAN_USER_STATUSLINE_CMD ${shellescape(command)}`, {
|
||||
timeout: EXEC_TIMEOUT_MS,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch {
|
||||
// Non-critical — the exporter just falls back to the plain "codeman" marker.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new tmux session wrapping Claude CLI or a shell.
|
||||
* In test mode: creates an in-memory session only (no real tmux session).
|
||||
@@ -1799,6 +1823,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode === 'claude' && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true)
|
||||
: undefined;
|
||||
// The user's own REAL statusLine, if any (walked via Claude Code's own
|
||||
// settings precedence) — exported below so the shared exporter script
|
||||
// can wrap it. Only worth discovering when we're actually injecting.
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
@@ -1879,6 +1907,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode,
|
||||
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
|
||||
);
|
||||
this._configureStatusLineUserCommand(muxName, userStatusLineCommand);
|
||||
|
||||
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
|
||||
// so secret values stay off the bash command line. Must run before respawn-pane.
|
||||
@@ -2062,6 +2091,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode === 'claude' && !remote && !docker
|
||||
? await resolveStatusLineCliCommand(workingDir, statusLineTelemetry === true)
|
||||
: undefined;
|
||||
const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined;
|
||||
|
||||
const baseCmd = buildSpawnCommand({
|
||||
mode,
|
||||
@@ -2099,6 +2129,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
|
||||
mode,
|
||||
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
|
||||
);
|
||||
this._configureStatusLineUserCommand(muxName, userStatusLineCommand);
|
||||
|
||||
// Re-apply user env overrides before respawn so the new shell inherits them.
|
||||
this.applyEnvOverrides(muxName, envOverrides);
|
||||
|
||||
@@ -18,11 +18,12 @@ import {
|
||||
statSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { tmpdir, homedir } from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import {
|
||||
applyStatusLineConfig,
|
||||
ensureCodemanHooks,
|
||||
findEffectiveUserStatusLineCommand,
|
||||
generateBackgroundWakeScript,
|
||||
generateHooksConfig,
|
||||
generateStatusLineCommand,
|
||||
@@ -1387,3 +1388,75 @@ describe('resolveStatusLineCliCommand', () => {
|
||||
expect(JSON.parse(readFileSync(settingsPath, 'utf-8')).statusLine).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEffectiveUserStatusLineCommand', () => {
|
||||
const testDir = join(tmpdir(), 'codeman-statusline-precedence-test-' + Date.now());
|
||||
const userSettingsPath = join(homedir(), '.claude', 'settings.json');
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
rmSync(userSettingsPath, { force: true }); // don't leak into other tests sharing this HOME
|
||||
});
|
||||
|
||||
it('returns undefined when nothing is configured anywhere', async () => {
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('finds the user global ~/.claude/settings.json when nothing else is set', async () => {
|
||||
const userClaudeDir = join(homedir(), '.claude');
|
||||
mkdirSync(userClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(userClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo user-global');
|
||||
});
|
||||
|
||||
it('project-SHARED settings.json wins over user-global', async () => {
|
||||
const userClaudeDir = join(homedir(), '.claude');
|
||||
mkdirSync(userClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(userClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo user-global' } })
|
||||
);
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
mkdirSync(projectClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared');
|
||||
});
|
||||
|
||||
it('project-LOCAL settings.local.json wins over everything', async () => {
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
mkdirSync(projectClaudeDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.local.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-local' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-local');
|
||||
});
|
||||
|
||||
it('skips a legacy Codeman-marked entry in project settings.local.json and falls through', async () => {
|
||||
await applyStatusLineConfig(testDir, true); // simulates a pre-fix disk-written exporter
|
||||
const projectClaudeDir = join(testDir, '.claude');
|
||||
writeFileSync(
|
||||
join(projectClaudeDir, 'settings.json'),
|
||||
JSON.stringify({ statusLine: { type: 'command', command: 'echo project-shared' } })
|
||||
);
|
||||
|
||||
expect(await findEffectiveUserStatusLineCommand(testDir)).toBe('echo project-shared');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user