Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ The interactive configuration tool provides a terminal UI where you can:
> export CLAUDE_CONFIG_DIR=/custom/path/to/.claude
> ```

> 🌐 **HTTPS proxy:** Usage and Claude Status requests honor uppercase `HTTPS_PROXY`, with exclusions from both `NO_PROXY` and `no_proxy`. See [proxy settings](docs/USAGE.md#proxy-settings) for host, domain, and port matching.
> 🌐 **HTTPS proxy:** Usage, Claude Status and update-check requests honor uppercase `HTTPS_PROXY`, with exclusions from both `NO_PROXY` and `no_proxy`. See [proxy settings](docs/USAGE.md#proxy-settings) for host, domain, and port matching.

> 🪟 **Windows Support:** PowerShell examples, installation notes, fonts, troubleshooting, WSL, and Windows Terminal configuration are in [docs/WINDOWS.md](docs/WINDOWS.md).

Expand Down
2 changes: 1 addition & 1 deletion docs/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ Stale-cache fallback removes each expired window's usage and reset fields while
- **Sandbox Status** reads `sandbox.enabled` from Claude Code's layered project-local, project, user-local, and user settings on every refresh. This reflects `/sandbox` file updates but remains a best-effort indicator when managed or CLI settings take precedence.
- **Skills** reads only logs for session IDs accepted by `getSkillsFilePath()`. Rejected IDs return no metrics, and the hook handler exits before creating directories or writing a log.

Usage, account profile, and service-status requests honor uppercase `HTTPS_PROXY` and share `isExcludedFromProxy()` for the `NO_PROXY`/`no_proxy` rules described in [Usage](USAGE.md#proxy-settings). Each request has a total five-second deadline in addition to its socket timeout. On expiry, `createProxyAgent().close()` aborts the pending proxy connection when `AbortController` is available; on older runtimes without it, that connection can remain open. The proxy integration tests require permission to listen on a local socket.
Usage, account profile, service-status and update-check (npm registry) requests honor uppercase `HTTPS_PROXY` and share `isExcludedFromProxy()` for the `NO_PROXY`/`no_proxy` rules described in [Usage](USAGE.md#proxy-settings). Each request has a total five-second deadline; the usage, account profile and service-status requests also set a socket timeout. On expiry, `createProxyAgent().close()` aborts the pending proxy connection when `AbortController` is available; on older runtimes without it, that connection can remain open. The proxy integration tests require permission to listen on a local socket.

## Widget Implementation

Expand Down
2 changes: 1 addition & 1 deletion docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ Claude Account Email uses the same credentials to look up the account's email fr

### Proxy Settings

Usage and Claude Account Email requests to `api.anthropic.com` and Claude Status requests to `status.claude.com` honor uppercase `HTTPS_PROXY`. Both `NO_PROXY` and `no_proxy` supply exclusions; matching either variable sends the request directly.
Usage and Claude Account Email requests to `api.anthropic.com`, Claude Status requests to `status.claude.com`, and the TUI's **Check for Updates** request to `registry.npmjs.org` honor uppercase `HTTPS_PROXY`. Both `NO_PROXY` and `no_proxy` supply exclusions; matching either variable sends the request directly.

Exclusions are separated by commas or whitespace and matched without regard to case. Use `*` for all hosts, a hostname such as `api.anthropic.com`, or a domain such as `anthropic.com`, `.anthropic.com`, or `*.anthropic.com` to match that domain and its subdomains. An optional `:443` suffix is supported; entries for other ports do not match these HTTPS requests.

Expand Down
44 changes: 40 additions & 4 deletions src/tui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getPackageCommandAvailability,
installStatusLine,
isClaudeCodeVersionAtLeast,
isGlobalStatusLineCommand,
isInstalled,
isKnownCommand,
setRefreshInterval,
Expand Down Expand Up @@ -452,6 +453,32 @@ export function getPathInferredInstallation(
return installation;
}

// The version Check for Updates compares with the latest release: the global
// package's, when the status line runs the global `ccstatusline` and its
// version is known, since this TUI may be a different copy (npx, bunx), else
// this TUI's own version. A checkout also counts as self-managed, but Claude
// Code runs the checkout, not the global on PATH.
function getUpdateCheckVersion(
installation: ResolvedInstallationMetadata,
activeCommand: ActiveGlobalCommandResolution | null,
statusLineCommand: string | null,
runningVersion: string
): string {
if (installation.method === 'pinned' && installation.installedVersion) {
return installation.installedVersion;
}

if (
installation.method === 'self-managed'
&& activeCommand?.version
&& isGlobalStatusLineCommand(statusLineCommand)
) {
return activeCommand.version;
}

return runningVersion;
}

export function getConfirmCancelScreen(confirmDialog: ConfirmDialogState | null): Exclude<AppScreen, 'confirm'> {
return confirmDialog?.cancelScreen ?? 'main';
}
Expand Down Expand Up @@ -545,6 +572,7 @@ export const App: React.FC = () => {
const [supportsRefreshInterval] = useState(() => isClaudeCodeVersionAtLeast('2.1.97'));
const [commandAvailability] = useState(() => getPackageCommandAvailability());
const [updateCheckerState, setUpdateCheckerState] = useState<UpdateCheckerState>({ status: 'checking' });
const [updateRecheckPending, setUpdateRecheckPending] = useState(false);
const [flowNotice, setFlowNotice] = useState<FlowNoticeState | null>(null);
const [globalPackageInstallations, setGlobalPackageInstallations] = useState<GlobalPackageInstallation[]>([]);
const [updatesReturnScreen, setUpdatesReturnScreen] = useState<'main' | 'manageInstallation'>('main');
Expand Down Expand Up @@ -838,18 +866,24 @@ export const App: React.FC = () => {
? inspectActiveGlobalCommand({ commandAvailability })
: null;
const effectiveUpdateInstallation = getPathInferredInstallation(installation, activeCommand);
const currentUpdateVersion = effectiveUpdateInstallation.method === 'pinned' && effectiveUpdateInstallation.installedVersion
? effectiveUpdateInstallation.installedVersion
: getPackageVersion();

void checkForUpdates({
currentVersion: currentUpdateVersion,
currentVersion: getUpdateCheckVersion(effectiveUpdateInstallation, activeCommand, existingStatusLine, getPackageVersion()),
installedCommand: existingStatusLine,
installationMetadata: effectiveUpdateInstallation,
commandAvailability
}).then(setUpdateCheckerState);
}, [commandAvailability, existingStatusLine, isClaudeInstalled, settings]);

// A finished global update asks for this, so the check runs once the new
// installation metadata is in `settings`, not with the settings from before
useEffect(() => {
if (updateRecheckPending) {
setUpdateRecheckPending(false);
handleUpdateCheck();
}
}, [updateRecheckPending, handleUpdateCheck]);

const handleRunUpdateAction = useCallback((action: UpdateAction) => {
setConfirmDialog({
message: `Run global update command?\n\n${action.command}\n\nClaude settings will not be changed.`,
Expand All @@ -872,6 +906,8 @@ export const App: React.FC = () => {
setOriginalSettings(prev => prev
? { ...prev, installation }
: prev);
// The Updates screen still shows the result from before the update
setUpdateRecheckPending(true);
const resolutionWarning = getGlobalResolutionWarning(action.packageManager);

if (resolutionWarning) {
Expand Down
138 changes: 138 additions & 0 deletions src/tui/__tests__/App.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1126,3 +1126,141 @@ describe('App while an install or update runs', () => {
}
});
});

describe('App Check for Updates', () => {
let sandbox: AppSandbox;

beforeEach(() => {
sandbox = setUpAppSandbox();
});

afterEach(() => {
sandbox.restore();
});

// The real check, against a registry whose latest release is `latestVersion`
function stubLatestRelease(latestVersion: string) {
const realCheckForUpdates = updateChecker.checkForUpdates;
return vi.spyOn(updateChecker, 'checkForUpdates').mockImplementation(options => realCheckForUpdates({
...options,
latestVersionFetcher: () => Promise.resolve(latestVersion)
}));
}

function stubNpmGlobalInstall(getVersion: () => string) {
vi.spyOn(globalPackageManager, 'inspectActiveGlobalCommand').mockImplementation(() => ({
packageManager: 'npm',
resolvedPath: '/usr/local/bin/ccstatusline',
resolvedPaths: ['/usr/local/bin/ccstatusline'],
binDir: '/usr/local/bin',
version: getVersion(),
warning: null
}));
}

async function openCheckForUpdates(rendered: ReturnType<typeof renderApp>, expectedText: string): Promise<void> {
await waitFor(() => {
expect(rendered.getFrame()).toContain('Main Menu');
});
await pressKey(rendered, KEYS.up, '▶ ⭐ Like ccstatusline?');
await pressKey(rendered, KEYS.up, '▶ 🚪 Exit');
await pressKey(rendered, KEYS.up, '▶ 🧰 Manage Installation');
await pressKey(rendered, KEYS.enter, '▶ 🔄 Check for Updates');
await pressKey(rendered, KEYS.enter, expectedText);
}

// Started through npx or bunx, the TUI can be a different version from the
// global package that Claude Code's `ccstatusline` command runs
it.each([
['offers the update to a global install older than the latest release', '2.0.0', getPackageVersion(), 'An update is available.'],
['reports a global install already at the latest release as up to date', '99.0.0', '99.0.0', 'ccstatusline is up to date.']
])('on a self-managed global install, %s', async (_label, globalVersion, latestVersion, expectedResult) => {
vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'ccstatusline', refreshInterval: null });
vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true);
stubNpmGlobalInstall(() => globalVersion);
stubLatestRelease(latestVersion);
const rendered = renderApp();

try {
await openCheckForUpdates(rendered, expectedResult);
expect(rendered.getFrame()).toContain(`Current: ${globalVersion}`);
expect(rendered.getFrame()).toContain(`Latest: ${latestVersion}`);
expect(rendered.getFrame()).toContain('Install: Self-managed/global install');
if (globalVersion === latestVersion) {
expect(rendered.getFrame()).not.toContain('Run npm install -g');
} else {
expect(rendered.getFrame()).toContain(`Run npm install -g ccstatusline@${latestVersion}`);
}
} finally {
rendered.cleanup();
}
});

// Claude Code runs the checkout, so a global elsewhere on PATH says nothing
// about whether the status line is current
it.each([
['reports a checkout at the latest release as up to date, with an older global on PATH', '2.0.0', getPackageVersion(), 'ccstatusline is up to date.'],
['offers the update to a checkout older than the latest release, with that release on PATH', '99.0.0', '99.0.0', 'An update is available.']
])('on a status line that runs a local checkout, %s', async (_label, globalVersion, latestVersion, expectedResult) => {
vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'bun /Users/dev/ccstatusline/src/ccstatusline.ts', refreshInterval: null });
vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true);
stubNpmGlobalInstall(() => globalVersion);
stubLatestRelease(latestVersion);
const rendered = renderApp();

try {
await openCheckForUpdates(rendered, expectedResult);
expect(rendered.getFrame()).toContain(`Current: ${getPackageVersion()}`);
expect(rendered.getFrame()).toContain(`Latest: ${latestVersion}`);
} finally {
rendered.cleanup();
}
});

it.each([
['', null],
[' and its PATH warning', '⚠ Multiple ccstatusline binaries are on PATH.']
])('checks again after a global update%s, instead of offering the same update', async (_label, resolutionWarning) => {
const runningVersion = getPackageVersion();
fs.writeFileSync(sandbox.settingsPath, JSON.stringify({
...DEFAULT_SETTINGS,
installation: { method: 'pinned', installedVersion: runningVersion }
}));
vi.spyOn(claudeStatus, 'loadClaudeStatusLineState').mockResolvedValue({ existingStatusLine: 'ccstatusline', refreshInterval: null });
vi.spyOn(claudeSettings, 'isInstalled').mockResolvedValue(true);
vi.spyOn(globalCommandResolution, 'inspectGlobalCommandResolution').mockReturnValue({
resolvedPaths: ['/usr/local/bin/ccstatusline'],
firstResolvedPath: '/usr/local/bin/ccstatusline',
expectedBinDir: '/usr/local/bin',
warning: resolutionWarning
});
let globalVersion = runningVersion;
stubNpmGlobalInstall(() => globalVersion);
const checkForUpdates = stubLatestRelease('99.0.0');
const runUpdate = vi.spyOn(updateChecker, 'runGlobalUpdateAction').mockImplementation((action) => {
globalVersion = action.version;
return Promise.resolve();
});
const rendered = renderApp();

try {
await openCheckForUpdates(rendered, 'An update is available.');
await pressKey(rendered, KEYS.enter, 'Run global update command?');
// Yes, then Continue past the Update Complete notice when there is one
if (resolutionWarning) {
await pressKey(rendered, KEYS.enter, resolutionWarning);
}
rendered.stdin.write(KEYS.enter);
await waitFor(() => {
expect(rendered.getFrame()).toContain('ccstatusline is up to date.');
});
expect(rendered.getFrame()).toContain('Current: 99.0.0');
expect(rendered.getFrame()).toContain('Install: Pinned global install via npm 99.0.0');
expect(rendered.getFrame()).not.toContain('Run npm install -g');
expect(runUpdate).toHaveBeenCalledOnce();
expect(checkForUpdates).toHaveBeenCalledTimes(2);
} finally {
rendered.cleanup();
}
});
});
32 changes: 1 addition & 31 deletions src/utils/__tests__/claude-service-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from '../claude-service-status';

import {
isolateHttpsProxyEnv,
startStalledProxy,
type StalledProxy
} from './proxy-test-helpers';
Expand All @@ -41,37 +42,6 @@ type StatusPageRequestFn = NonNullable<Parameters<typeof __testing.fetchStatusPa
const HOUR_MS = 60 * 60 * 1000;
const NOW = Date.parse('2026-08-15T12:00:00Z');

// Tests set HTTPS_PROXY and NO_PROXY themselves; the environment running the
// suite must not leak in.
function isolateHttpsProxyEnv(): void {
let originalProxy: string | undefined;
let originalNoProxy: string | undefined;
let originalLowercaseNoProxy: string | undefined;

beforeEach(() => {
originalProxy = process.env.HTTPS_PROXY;
originalNoProxy = process.env.NO_PROXY;
originalLowercaseNoProxy = process.env.no_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
});

afterEach(() => {
restoreEnv('HTTPS_PROXY', originalProxy);
restoreEnv('NO_PROXY', originalNoProxy);
restoreEnv('no_proxy', originalLowercaseNoProxy);
});
}

function restoreEnv(name: 'HTTPS_PROXY' | 'NO_PROXY' | 'no_proxy', value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, name);
} else {
process.env[name] = value;
}
}

function incident(impact: ClaudeIncidentWindow['impact'], startHoursAgo: number, endHoursAgo: number | null): ClaudeIncidentWindow {
return {
impact,
Expand Down
35 changes: 35 additions & 0 deletions src/utils/__tests__/proxy-test-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import * as net from 'node:net';
import {
afterEach,
beforeEach
} from 'vitest';

export interface StalledProxy {
url: string;
Expand Down Expand Up @@ -43,3 +47,34 @@ export async function startStalledProxy(): Promise<StalledProxy> {
})
};
}

// Tests set HTTPS_PROXY and NO_PROXY themselves; the environment running the
// suite must not leak in.
export function isolateHttpsProxyEnv(): void {
let originalProxy: string | undefined;
let originalNoProxy: string | undefined;
let originalLowercaseNoProxy: string | undefined;

beforeEach(() => {
originalProxy = process.env.HTTPS_PROXY;
originalNoProxy = process.env.NO_PROXY;
originalLowercaseNoProxy = process.env.no_proxy;
delete process.env.HTTPS_PROXY;
delete process.env.NO_PROXY;
delete process.env.no_proxy;
});

afterEach(() => {
restoreEnv('HTTPS_PROXY', originalProxy);
restoreEnv('NO_PROXY', originalNoProxy);
restoreEnv('no_proxy', originalLowercaseNoProxy);
});
}

function restoreEnv(name: 'HTTPS_PROXY' | 'NO_PROXY' | 'no_proxy', value: string | undefined): void {
if (value === undefined) {
Reflect.deleteProperty(process.env, name);
} else {
process.env[name] = value;
}
}
Loading
Loading