Skip to content

Commit 3282e32

Browse files
christsoclaude
andauthored
fix(tui): eliminate freezes from network fetches and stdin contention (#67)
* feat(marketplace): add offline option to resolvePluginSpec Allows callers to resolve plugin specs without triggering network fetches. When offline=true, URL-sourced plugins resolve only if already cached locally. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(status): use offline resolution to avoid network fetches Status checks now resolve marketplace plugins locally without triggering git pull. This eliminates the primary cause of TUI freezes — repeated network subprocess spawns during status views. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(cli): await runWizard to ensure proper terminal cleanup Missing await caused the wizard promise to run detached, preventing proper stdin/stdout cleanup on exit. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * fix(core): add stdin ignore to all subprocess calls Prevents child git/gh processes from inheriting the TUI's raw-mode stdin. On Windows, Git Credential Manager could deadlock trying to read from stdin owned by @clack/prompts. This is the root cause of TUI freezes on Windows. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * perf(tui): cache workspace status to avoid duplicate resolution Status results are now cached in the TUI session cache, avoiding redundant getWorkspaceStatus calls when viewing status or managing plugins multiple times without changes. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent af43496 commit 3282e32

9 files changed

Lines changed: 137 additions & 17 deletions

File tree

src/cli/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ if (agentHelp) {
3232
} else if (finalArgs.length === 0 && process.stdout.isTTY && !json) {
3333
// Interactive wizard when no args and running in a terminal
3434
const { runWizard } = await import('./tui/wizard.js');
35-
runWizard();
35+
await runWizard();
3636
} else {
3737
run(app, finalArgs);
3838
}

src/cli/tui/actions/plugins.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,11 @@ export async function runInstallPlugin(context: TuiContext, cache?: TuiCache): P
167167
*/
168168
export async function runManagePlugins(context: TuiContext, cache?: TuiCache): Promise<void> {
169169
try {
170-
const status = await getWorkspaceStatus(context.workspacePath ?? undefined);
170+
let status = cache?.getStatus();
171+
if (!status) {
172+
status = await getWorkspaceStatus(context.workspacePath ?? undefined);
173+
cache?.setStatus(status);
174+
}
171175

172176
if (!status.success || status.plugins.length === 0) {
173177
p.note('No plugins installed in this workspace.', 'Plugins');

src/cli/tui/actions/status.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import * as p from '@clack/prompts';
22
import { getWorkspaceStatus } from '../../../core/status.js';
33
import type { TuiContext } from '../context.js';
4+
import type { TuiCache } from '../cache.js';
45

56
/**
67
* Display workspace and plugin status.
78
* Shows plugin availability and configured clients.
89
*/
9-
export async function runStatus(context: TuiContext): Promise<void> {
10+
export async function runStatus(context: TuiContext, cache?: TuiCache): Promise<void> {
1011
try {
11-
const status = await getWorkspaceStatus(context.workspacePath ?? undefined);
12+
let status = cache?.getStatus();
13+
if (!status) {
14+
status = await getWorkspaceStatus(context.workspacePath ?? undefined);
15+
cache?.setStatus(status);
16+
}
1217

1318
if (!status.success) {
1419
p.note(status.error ?? 'Unknown error', 'Status Error');

src/cli/tui/cache.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { MarketplaceEntry, MarketplacePluginsResult } from '../../core/marketplace.js';
2+
import type { WorkspaceStatusResult } from '../../core/status.js';
23
import type { TuiContext } from './context.js';
34

45
/**
@@ -10,6 +11,7 @@ export class TuiCache {
1011
private marketplaces: MarketplaceEntry[] | undefined;
1112
private context: TuiContext | undefined;
1213
private marketplacePlugins: Map<string, MarketplacePluginsResult> = new Map();
14+
private status: WorkspaceStatusResult | undefined;
1315

1416
/** Get cached marketplace list (undefined if not cached) */
1517
getMarketplaces(): MarketplaceEntry[] | undefined {
@@ -46,10 +48,21 @@ export class TuiCache {
4648
this.marketplacePlugins.set(name, result);
4749
}
4850

51+
/** Get cached workspace status (undefined if not cached) */
52+
getStatus(): WorkspaceStatusResult | undefined {
53+
return this.status;
54+
}
55+
56+
/** Store workspace status in cache */
57+
setStatus(status: WorkspaceStatusResult): void {
58+
this.status = status;
59+
}
60+
4961
/** Clear all cached data. Call after any write operation. */
5062
invalidate(): void {
5163
this.marketplaces = undefined;
5264
this.context = undefined;
5365
this.marketplacePlugins.clear();
66+
this.status = undefined;
5467
}
5568
}

src/cli/tui/wizard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,7 +120,7 @@ export async function runWizard(): Promise<void> {
120120
cache.invalidate();
121121
break;
122122
case 'status':
123-
await runStatus(context);
123+
await runStatus(context, cache);
124124
break;
125125
case 'install':
126126
await runInstallPlugin(context, cache);

src/core/marketplace.ts

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '../utils/marketplace-manifest-parser.js';
99
import { fetchPlugin } from './plugin.js';
1010
import type { FetchResult } from './plugin.js';
11+
import { parseGitHubUrl, getPluginCachePath } from '../utils/plugin-path.js';
1112

1213
/**
1314
* Source types for marketplaces
@@ -220,7 +221,7 @@ export async function addMarketplace(
220221
} else {
221222
// Check if gh CLI is available
222223
try {
223-
await execa('gh', ['--version']);
224+
await execa('gh', ['--version'], { stdin: 'ignore' });
224225
} catch {
225226
return {
226227
success: false,
@@ -236,7 +237,7 @@ export async function addMarketplace(
236237

237238
// Clone repository
238239
try {
239-
await execa('gh', ['repo', 'clone', parsed.location, marketplacePath]);
240+
await execa('gh', ['repo', 'clone', parsed.location, marketplacePath], { stdin: 'ignore' });
240241
} catch (error) {
241242
const msg = error instanceof Error ? error.message : String(error);
242243
if (msg.toLowerCase().includes('not found') || msg.includes('404')) {
@@ -418,7 +419,7 @@ export async function updateMarketplace(
418419
const { stdout } = await execa(
419420
'git',
420421
['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
421-
{ cwd: marketplace.path },
422+
{ cwd: marketplace.path, stdin: 'ignore' },
422423
);
423424
// stdout is like "origin/main" - strip remote prefix to get local branch name
424425
const ref = stdout.trim();
@@ -431,7 +432,7 @@ export async function updateMarketplace(
431432
const { stdout } = await execa(
432433
'git',
433434
['remote', 'show', 'origin'],
434-
{ cwd: marketplace.path },
435+
{ cwd: marketplace.path, stdin: 'ignore' },
435436
);
436437
const match = stdout.match(/HEAD branch:\s*(\S+)/);
437438
if (match?.[1]) {
@@ -443,8 +444,9 @@ export async function updateMarketplace(
443444
}
444445
await execa('git', ['checkout', defaultBranch], {
445446
cwd: marketplace.path,
447+
stdin: 'ignore',
446448
});
447-
await execa('git', ['pull'], { cwd: marketplace.path });
449+
await execa('git', ['pull'], { cwd: marketplace.path, stdin: 'ignore' });
448450

449451
// Update lastUpdated in registry
450452
marketplace.lastUpdated = new Date().toISOString();
@@ -642,6 +644,7 @@ export async function resolvePluginSpec(
642644
subpath?: string;
643645
marketplaceNameOverride?: string;
644646
marketplacePathOverride?: string;
647+
offline?: boolean;
645648
fetchFn?: (url: string) => Promise<FetchResult>;
646649
} = {},
647650
): Promise<{ path: string; marketplace: string; plugin: string } | null> {
@@ -681,6 +684,21 @@ export async function resolvePluginSpec(
681684
};
682685
}
683686
} else {
687+
if (options.offline) {
688+
// Offline mode: check if plugin is already cached, don't fetch
689+
const parsedUrl = parseGitHubUrl(pluginEntry.source.url);
690+
if (parsedUrl) {
691+
const cachePath = getPluginCachePath(parsedUrl.owner, parsedUrl.repo);
692+
if (existsSync(cachePath)) {
693+
return {
694+
path: cachePath,
695+
marketplace: marketplaceName,
696+
plugin: parsed.plugin,
697+
};
698+
}
699+
}
700+
return null;
701+
}
684702
// URL source - fetch/clone the plugin
685703
const fetchFn = options.fetchFn ?? fetchPlugin;
686704
const fetchResult = await fetchFn(pluginEntry.source.url);
@@ -732,6 +750,7 @@ export interface ResolvePluginSpecResult {
732750
*/
733751
export async function resolvePluginSpecWithAutoRegister(
734752
spec: string,
753+
options: { offline?: boolean } = {},
735754
): Promise<ResolvePluginSpecResult> {
736755
// Parse plugin@marketplace using the parser
737756
const parsed = parsePluginSpec(spec);
@@ -778,6 +797,7 @@ export async function resolvePluginSpecWithAutoRegister(
778797
const resolved = await resolvePluginSpec(spec, {
779798
...(subpath && { subpath }),
780799
marketplaceNameOverride: marketplace.name,
800+
...(options.offline != null && { offline: options.offline }),
781801
});
782802
if (!resolved) {
783803
return {

src/core/plugin.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ export async function fetchPlugin(
9595

9696
// Check if gh CLI is available
9797
try {
98-
await execaFn('gh', ['--version']);
98+
await execaFn('gh', ['--version'], { stdin: 'ignore' });
9999
} catch {
100100
return {
101101
success: false,
@@ -120,7 +120,7 @@ export async function fetchPlugin(
120120
try {
121121
if (isCached) {
122122
// Default: pull latest changes
123-
await execaFn('git', ['pull'], { cwd: cachePath });
123+
await execaFn('git', ['pull'], { cwd: cachePath, stdin: 'ignore' });
124124

125125
return {
126126
success: true,
@@ -135,9 +135,9 @@ export async function fetchPlugin(
135135

136136
// Clone repository with specific branch if provided
137137
if (branch) {
138-
await execaFn('gh', ['repo', 'clone', `${owner}/${repo}`, cachePath, '--', '--branch', branch]);
138+
await execaFn('gh', ['repo', 'clone', `${owner}/${repo}`, cachePath, '--', '--branch', branch], { stdin: 'ignore' });
139139
} else {
140-
await execaFn('gh', ['repo', 'clone', `${owner}/${repo}`, cachePath]);
140+
await execaFn('gh', ['repo', 'clone', `${owner}/${repo}`, cachePath], { stdin: 'ignore' });
141141
}
142142

143143
return {
@@ -281,7 +281,7 @@ export async function updateCachedPlugins(
281281

282282
for (const plugin of toUpdate) {
283283
try {
284-
await execa('git', ['pull'], { cwd: plugin.path });
284+
await execa('git', ['pull'], { cwd: plugin.path, stdin: 'ignore' });
285285
results.push({ name: plugin.name, success: true });
286286
} catch (error) {
287287
results.push({

src/core/status.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ async function getUserPluginStatuses(): Promise<PluginStatus[]> {
147147
* Get status of a plugin@marketplace spec
148148
*/
149149
async function getMarketplacePluginStatus(spec: string): Promise<PluginStatus> {
150-
const resolved = await resolvePluginSpecWithAutoRegister(spec);
150+
const resolved = await resolvePluginSpecWithAutoRegister(spec, { offline: true });
151151

152152
return {
153153
source: spec,

tests/unit/core/marketplace.test.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2-
import { mkdirSync, writeFileSync, rmSync } from 'node:fs';
2+
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs';
33
import { join } from 'node:path';
44
import { tmpdir } from 'node:os';
55
import {
@@ -326,3 +326,81 @@ describe('getMarketplacePluginsFromManifest', () => {
326326
expect(result.warnings).toEqual([]);
327327
});
328328
});
329+
330+
describe('resolvePluginSpec offline mode', () => {
331+
it('does not call fetchFn when offline is true', async () => {
332+
const testDir = mkdtempSync(join(tmpdir(), 'mp-offline-'));
333+
const manifestDir = join(testDir, '.claude-plugin');
334+
mkdirSync(manifestDir, { recursive: true });
335+
336+
const manifest = {
337+
name: 'test-marketplace',
338+
plugins: [
339+
{
340+
name: 'remote-plugin',
341+
description: 'A remote plugin',
342+
source: { source: 'url', url: 'https://github.com/owner/repo' },
343+
},
344+
],
345+
};
346+
347+
writeFileSync(
348+
join(manifestDir, 'marketplace.json'),
349+
JSON.stringify(manifest),
350+
);
351+
352+
let fetchCalled = false;
353+
const result = await resolvePluginSpec('remote-plugin@test-marketplace', {
354+
marketplacePathOverride: testDir,
355+
offline: true,
356+
fetchFn: async () => {
357+
fetchCalled = true;
358+
return { success: true, action: 'fetched' as const, cachePath: '/fake' };
359+
},
360+
});
361+
362+
expect(fetchCalled).toBe(false);
363+
expect(result).toBeNull();
364+
365+
rmSync(testDir, { recursive: true, force: true });
366+
});
367+
368+
it('returns cached path when offline and plugin is already cached', async () => {
369+
const testDir = mkdtempSync(join(tmpdir(), 'mp-offline-cached-'));
370+
const manifestDir = join(testDir, '.claude-plugin');
371+
mkdirSync(manifestDir, { recursive: true });
372+
373+
// Create a fake cache directory that matches what getPluginCachePath returns
374+
const { getPluginCachePath: getCachePath } = await import('../../../src/utils/plugin-path.js');
375+
const cachePath = getCachePath('someowner', 'somerepo');
376+
mkdirSync(cachePath, { recursive: true });
377+
378+
const manifest = {
379+
name: 'test-marketplace',
380+
plugins: [
381+
{
382+
name: 'cached-plugin',
383+
description: 'A cached remote plugin',
384+
source: { source: 'url', url: 'https://github.com/someowner/somerepo' },
385+
},
386+
],
387+
};
388+
389+
writeFileSync(
390+
join(manifestDir, 'marketplace.json'),
391+
JSON.stringify(manifest),
392+
);
393+
394+
const result = await resolvePluginSpec('cached-plugin@test-marketplace', {
395+
marketplacePathOverride: testDir,
396+
offline: true,
397+
});
398+
399+
expect(result).not.toBeNull();
400+
expect(result!.path).toBe(cachePath);
401+
402+
// Cleanup
403+
rmSync(cachePath, { recursive: true, force: true });
404+
rmSync(testDir, { recursive: true, force: true });
405+
});
406+
});

0 commit comments

Comments
 (0)