Skip to content

Commit d168cd7

Browse files
Merge pull request #369 from corbitsdev/cl-5411-5409-plugin-paths
2 parents 7735180 + a6ed466 commit d168cd7

12 files changed

Lines changed: 550 additions & 83 deletions

src/exec/runner.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,11 @@ import type {
5454
} from "../permission/types.js";
5555
import { createAgentToolset, type AgentToolset, type OperatorResult } from "../agent/tools.js";
5656
import { collectToolPlugins, resolveToolPlugins } from "../plugins/tool-plugins.js";
57-
import { expandExistingPluginMembers } from "../plugins/loader.js";
57+
import {
58+
expandExistingPluginMembers,
59+
formatExpandSkip,
60+
type ExpandPluginPathSkip,
61+
} from "../plugins/loader.js";
5862
import { isPluginTrusted, loadProjectTrust } from "../trust/project-trust.js";
5963
import {
6064
isPathPluginTrusted,
@@ -233,9 +237,15 @@ export async function runExec(config: Config): Promise<ExecResult> {
233237
let projectTrust = await loadProjectTrust(config.cwd);
234238
const isProjectPluginTrusted = (pluginPath: string) => isPluginTrusted(projectTrust, pluginPath);
235239
// One-shot migration only when the path-trust file does not exist yet.
240+
// Headless exec has no frame to corrupt, so a skipped marketplace member
241+
// writes straight to stderr here — an explicit choice at this call site,
242+
// not `expandPluginPath` falling back to it on its own.
236243
let pathTrust = await migratePathTrustFromPluginPaths(
237244
config.settings?.pluginPaths ?? [],
238-
(p) => expandExistingPluginMembers(p, config.cwd),
245+
(p) =>
246+
expandExistingPluginMembers(p, config.cwd, (skip: ExpandPluginPathSkip) => {
247+
process.stderr.write(`plugins: ${formatExpandSkip(skip)}\n`);
248+
}),
239249
undefined,
240250
{ onMigrated: reportPathTrustMigration },
241251
);

src/plugins/admin.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export type PluginsAdmin = {
2626
list: () => PluginDescriptor[];
2727
getConfig: () => Record<string, PluginConfig>;
2828
getWebOverride: () => string | undefined;
29-
saveConfig: (id: string, cfg: PluginConfig) => Promise<void> | void;
29+
// A trust-grant load (enabling a metadata-only plugin) can surface skill-miss
30+
// and similar warnings; the optional message lets the caller show them
31+
// instead of dropping them on the floor.
32+
saveConfig: (id: string, cfg: PluginConfig) => Promise<{ message?: string } | void> | void;
3033
setWebOverride: (id: string | undefined) => Promise<void> | void;
3134
verify: (id: string, credentials: Record<string, string>) => Promise<VerifyResult>;
3235
// Register a plugin from an arbitrary file/dir path, persisting it so it loads

src/plugins/data-only.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { loadDataOnlyCommands } from "./data-only-commands.js";
77
import { loadSkillCommands } from "./skill-commands.js";
88
import {
99
resolvePluginWarningHandler,
10+
stderrPluginWarning,
1011
type PluginLoadDiagnostics,
1112
} from "./diagnostics.js";
1213

@@ -80,8 +81,14 @@ export async function loadDataOnlyPlugin(
8081
} = {},
8182
): Promise<DataOnlyPlugin | null> {
8283
const cwd = opts.cwd ?? process.cwd();
83-
// Prefer diagnostics collector; else explicit onWarning; else stderr default.
84-
const onWarning = resolvePluginWarningHandler(opts);
84+
// Prefer diagnostics collector; else explicit onWarning; else stderrPluginWarning.
85+
const onWarning = resolvePluginWarningHandler(
86+
opts.diagnostics !== undefined
87+
? { diagnostics: opts.diagnostics }
88+
: opts.onWarning !== undefined
89+
? { onWarning: opts.onWarning }
90+
: { onWarning: stderrPluginWarning },
91+
);
8592

8693
const [nativeManifest, claudeManifest, agents, commands, skillCmds] = await Promise.all([
8794
readManifestJson(pluginDir),

src/plugins/diagnostics.test.ts

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { join } from "node:path";
44
import { tmpdir } from "node:os";
55
import {
66
createPluginLoadDiagnostics,
7+
emitPluginWarningLog,
78
emitPluginWarningSummary,
89
formatPluginWarningsSummary,
910
pluginWarningSink,
@@ -71,19 +72,35 @@ describe("emitPluginWarningSummary", () => {
7172
});
7273
});
7374

75+
describe("emitPluginWarningLog", () => {
76+
test("never writes to stderr — interactive TUI holds the alt screen and a raw write corrupts the frame", () => {
77+
const diag = createPluginLoadDiagnostics();
78+
diag.warnings.push('agent a: skill "style" referenced but not found in skill search path');
79+
const originalWrite = process.stderr.write.bind(process.stderr);
80+
let stderrCalls = 0;
81+
process.stderr.write = ((..._args: unknown[]) => {
82+
stderrCalls++;
83+
return true;
84+
}) as typeof process.stderr.write;
85+
try {
86+
emitPluginWarningLog(diag);
87+
} finally {
88+
process.stderr.write = originalWrite;
89+
}
90+
expect(stderrCalls).toBe(0);
91+
});
92+
});
93+
7494
describe("plugin load diagnostics wiring", () => {
7595
test("collector records skill misses without calling stderr fallback", async () => {
7696
const dir = await makePlugin({
7797
"agents/a.md": "---\nskills: [nope, also-missing]\n---\nbody\n",
7898
});
7999
const diag = createPluginLoadDiagnostics();
80-
const stderrLines: string[] = [];
81-
const sink = pluginWarningSink(diag, (msg) => stderrLines.push(msg));
100+
const sink = pluginWarningSink(diag);
82101

83-
// Sink itself must not hit fallback when diag is set.
84102
sink("should only land in diag");
85103
expect(diag.warnings).toEqual(["should only land in diag"]);
86-
expect(stderrLines).toEqual([]);
87104

88105
diag.warnings.length = 0;
89106
const mod = await loadPluginEntry(dir, {

src/plugins/diagnostics.ts

Lines changed: 35 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
// interactive TUI) accumulate warnings and emit a single summary instead of
33
// writing one stderr line per miss mid-frame.
44

5+
import { getLogger } from "@intx/log";
6+
import { LOG_NAMESPACE_ROOT } from "../branding.js";
7+
8+
const pluginDiagnosticsLogger = getLogger([LOG_NAMESPACE_ROOT, "plugins"]);
9+
510
export type PluginLoadDiagnostics = {
611
warnings: string[];
712
};
@@ -10,34 +15,28 @@ export function createPluginLoadDiagnostics(): PluginLoadDiagnostics {
1015
return { warnings: [] };
1116
}
1217

18+
/** Build an onWarning callback that records into `diag`. */
19+
export function pluginWarningSink(diag: PluginLoadDiagnostics): (msg: string) => void {
20+
return (msg) => {
21+
diag.warnings.push(msg);
22+
};
23+
}
24+
1325
/**
14-
* Build an onWarning callback that records into `diag` when provided, else
15-
* falls back to the given sink (default: one stderr line per message).
26+
* Resolve the warning sink for a load call. There is no default: callers
27+
* must decide between a diagnostics collector (batched into one summary,
28+
* safe mid-frame) and an explicit onWarning (e.g. a raw stderr writer for
29+
* headless paths where no frame is being held).
1630
*/
17-
export function pluginWarningSink(
18-
diag: PluginLoadDiagnostics | undefined,
19-
fallback: (msg: string) => void = (msg) => process.stderr.write(`plugins: ${msg}\n`),
31+
export function resolvePluginWarningHandler(
32+
opts: { diagnostics: PluginLoadDiagnostics } | { onWarning: (msg: string) => void },
2033
): (msg: string) => void {
21-
if (diag !== undefined) {
22-
return (msg) => {
23-
diag.warnings.push(msg);
24-
};
25-
}
26-
return fallback;
34+
return "diagnostics" in opts ? pluginWarningSink(opts.diagnostics) : opts.onWarning;
2735
}
2836

29-
/**
30-
* Resolve the warning sink for a load call. Prefer a diagnostics collector when
31-
* provided (so batch callers can emit one summary); else an explicit onWarning;
32-
* else one stderr line per message.
33-
*/
34-
export function resolvePluginWarningHandler(opts: {
35-
diagnostics?: PluginLoadDiagnostics;
36-
onWarning?: (msg: string) => void;
37-
}): (msg: string) => void {
38-
if (opts.diagnostics !== undefined) return pluginWarningSink(opts.diagnostics);
39-
if (opts.onWarning !== undefined) return opts.onWarning;
40-
return pluginWarningSink(undefined);
37+
/** Named raw-stderr choice: `{ onWarning: stderrPluginWarning }`. */
38+
export function stderrPluginWarning(msg: string): void {
39+
process.stderr.write(`plugins: ${msg}\n`);
4140
}
4241

4342
/**
@@ -84,3 +83,16 @@ export function emitPluginWarningSummary(
8483
const summary = formatPluginWarningsSummary(diag.warnings);
8584
if (summary !== undefined) write(summary);
8685
}
86+
87+
/**
88+
* Emit a diagnostics summary through the structured logger instead of raw
89+
* stderr. Interactive callers (the TUI holds the alternate screen for the
90+
* whole session) must use this, not the raw-stderr default above — a bare
91+
* write lands mid-frame and corrupts the rendered transcript. The logger is
92+
* already routed to `~/.corbits/logs/corbits.log` by `installFileLogSink`
93+
* (first statement of `mainWithRunners`), so this reuses that sink rather
94+
* than adding a second suppression path.
95+
*/
96+
export function emitPluginWarningLog(diag: PluginLoadDiagnostics): void {
97+
emitPluginWarningSummary(diag, (line) => pluginDiagnosticsLogger.warn(line));
98+
}

src/plugins/loader.ts

Lines changed: 80 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { parsePluginManifest, type PluginManifest } from "./manifest.js";
1010
import { loadDataOnlyPlugin } from "./data-only.js";
1111
import {
1212
resolvePluginWarningHandler,
13+
stderrPluginWarning,
1314
type PluginLoadDiagnostics,
1415
} from "./diagnostics.js";
1516
import {
@@ -116,9 +117,15 @@ export async function loadPluginEntry(
116117
} = {},
117118
): Promise<PluginModule | null> {
118119
const cwd = opts.cwd ?? process.cwd();
119-
// Prefer diagnostics collector when provided so batch discovery can summarize;
120-
// explicit onWarning is for tests / one-off sinks; default is stderr per line.
121-
const onWarning = resolvePluginWarningHandler(opts);
120+
// Prefer diagnostics collector so batch discovery can summarize; explicit
121+
// onWarning for tests; else stderrPluginWarning.
122+
const onWarning = resolvePluginWarningHandler(
123+
opts.diagnostics !== undefined
124+
? { diagnostics: opts.diagnostics }
125+
: opts.onWarning !== undefined
126+
? { onWarning: opts.onWarning }
127+
: { onWarning: stderrPluginWarning },
128+
);
122129
const origin = opts.origin;
123130
let target = entryPath;
124131
let pluginDir = entryPath;
@@ -255,16 +262,53 @@ export type ExpandPluginPathOptions = {
255262
* tree is allowed — multi-level relatives ok).
256263
*/
257264
containRoot?: string;
258-
/** Called for each skipped marketplace source (never silent). */
259-
onSkip?: (skip: ExpandPluginPathSkip) => void;
265+
/**
266+
* Called for each skipped marketplace source (never silent). Required —
267+
* not optional with a stderr default — because an optional sink with a
268+
* silent fallback is exactly the shape that let three review rounds each
269+
* turn up one more call site writing raw stderr mid-frame in the
270+
* interactive TUI (CL-5411). Making it required turns every call site
271+
* into a compile error until it picks a handler on purpose:
272+
* `expandSkipDiagnosticsHandler(diagnostics)` for a batching caller,
273+
* an explicit stderr writer for a headless caller where that is correct
274+
* and visible (see `src/exec/runner.ts`), or `() => {}` to state on the
275+
* record that a caller is deliberately ignoring skips.
276+
*/
277+
onSkip: (skip: ExpandPluginPathSkip) => void;
260278
};
261279

262-
/** Default skip reporter: stderr, same shape as Claude discovery. */
263-
function defaultExpandSkip(skip: ExpandPluginPathSkip): void {
280+
/** One-line description of a skip, shared by every `onSkip` sink (diagnostics or stderr). */
281+
export function formatExpandSkip(skip: ExpandPluginPathSkip): string {
264282
const where = skip.resolved !== undefined ? ` → ${skip.resolved}` : "";
265-
process.stderr.write(
266-
`plugins: skipped marketplace source ${JSON.stringify(skip.source)} (${skip.reason})${where}\n`,
267-
);
283+
return `marketplace source ${JSON.stringify(skip.source)} skipped (${skip.reason})${where}`;
284+
}
285+
286+
/**
287+
* The ordinary `onSkip` handler: collect into `diagnostics` instead of
288+
* writing raw stderr, so a skipped marketplace member lands in the same
289+
* end-of-batch summary as every other plugin-load warning.
290+
*/
291+
export function expandSkipDiagnosticsHandler(
292+
diagnostics: PluginLoadDiagnostics,
293+
): (skip: ExpandPluginPathSkip) => void {
294+
return (skip) => diagnostics.warnings.push(formatExpandSkip(skip));
295+
}
296+
297+
/**
298+
* `onSkip` when no diagnostics collector is in play: one explicit stderr
299+
* line, module-private and only reached by an internal caller's own
300+
* deliberate choice (see `resolveExpandSkip` below) — never `expandPluginPath`
301+
* falling back to it on its own.
302+
*/
303+
function stderrExpandSkip(skip: ExpandPluginPathSkip): void {
304+
process.stderr.write(`plugins: ${formatExpandSkip(skip)}\n`);
305+
}
306+
307+
/** Diagnostics when given, else the explicit stderr line — no silent option. */
308+
function resolveExpandSkip(
309+
diagnostics?: PluginLoadDiagnostics,
310+
): (skip: ExpandPluginPathSkip) => void {
311+
return diagnostics !== undefined ? expandSkipDiagnosticsHandler(diagnostics) : stderrExpandSkip;
268312
}
269313

270314
/**
@@ -313,11 +357,11 @@ function defaultContainRoot(marketplaceRoot: string): string {
313357

314358
export async function expandPluginPath(
315359
path: string,
316-
opts: ExpandPluginPathOptions = {},
360+
opts: ExpandPluginPathOptions,
317361
): Promise<string[]> {
318362
const marketplaceRoot = resolve(path);
319363
const report = (skip: ExpandPluginPathSkip): void => {
320-
(opts.onSkip ?? defaultExpandSkip)(skip);
364+
opts.onSkip(skip);
321365
};
322366

323367
// 1. Declared marketplace: relative `source` list, contained under containRoot
@@ -424,13 +468,17 @@ export async function expandPluginPath(
424468
// Resolve a registered pluginPaths entry to the member plugin directories that
425469
// exist on disk: relative entries resolve against cwd, marketplace roots expand
426470
// to their members. Missing paths are dropped so trust decisions made from this
427-
// list never pre-grant a directory that could appear later with other content.
471+
// list never pre-grant a directory that could appear later with other content
472+
// — that drop is deliberate, but a *skipped* member (bad source, escape) still
473+
// has a reason worth reaching the caller's diagnostics, so `onSkip` is
474+
// required rather than silently defaulted (see `ExpandPluginPathOptions`).
428475
export async function expandExistingPluginMembers(
429476
registeredPath: string,
430477
cwd: string,
478+
onSkip: (skip: ExpandPluginPathSkip) => void,
431479
): Promise<string[]> {
432480
const abs = isAbsolute(registeredPath) ? registeredPath : resolve(cwd, registeredPath);
433-
const members = await expandPluginPath(abs);
481+
const members = await expandPluginPath(abs, { onSkip });
434482
const existing = await Promise.all(members.map((m) => pathExists(m)));
435483
return members.filter((_, i) => existing[i]);
436484
}
@@ -456,8 +504,10 @@ async function scanPluginsDir(
456504

457505
const results: PluginModule[] = [];
458506
for (const entry of entries) {
459-
// Each entry may itself be a marketplace, so expand before loading.
460-
const dirs = await expandPluginPath(join(dir, entry));
507+
// Each entry may itself be a marketplace, so expand before loading. A
508+
// skipped member routes into `diagnostics` when the caller has one, same
509+
// as loadPluginEntry below — otherwise it would bypass the collector.
510+
const dirs = await expandPluginPath(join(dir, entry), { onSkip: resolveExpandSkip(diagnostics) });
461511
for (const d of dirs) {
462512
const abs = resolve(d);
463513
if (originRequiresTrust(origin) && isTrusted !== undefined && !isTrusted(abs)) {
@@ -536,10 +586,13 @@ export async function loadPluginsFromPaths(
536586
diagnostics?: PluginLoadDiagnostics;
537587
} = {},
538588
): Promise<PluginModule[]> {
589+
// A skipped member routes into `diagnostics` when the caller has one, same
590+
// reasoning as scanPluginsDir — otherwise it bypasses the collector.
591+
const onSkip = resolveExpandSkip(opts.diagnostics);
539592
const resolved = await Promise.all(
540593
paths.map(async (p) => {
541594
const abs = isAbsolute(p) ? p : join(cwd, p);
542-
return expandPluginPath(abs);
595+
return expandPluginPath(abs, { onSkip });
543596
}),
544597
);
545598
// Anything under <cwd>/.corbits/plugins/ is project origin no matter how it
@@ -611,12 +664,12 @@ export async function discoverClaudeInstalledPlugins(
611664
try {
612665
parsed = JSON.parse(raw);
613666
} catch {
614-
// Prefer collector when present; else one stderr line (default sink).
667+
// Prefer collector when present; else stderrPluginWarning.
615668
resolvePluginWarningHandler(
616-
opts.diagnostics !== undefined ? { diagnostics: opts.diagnostics } : {},
617-
)(
618-
`failed to parse ${registryPath}`,
619-
);
669+
opts.diagnostics !== undefined
670+
? { diagnostics: opts.diagnostics }
671+
: { onWarning: stderrPluginWarning },
672+
)(`failed to parse ${registryPath}`);
620673
return [];
621674
}
622675
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
@@ -628,10 +681,12 @@ export async function discoverClaudeInstalledPlugins(
628681
}
629682

630683
const pluginsRoot = resolve(home, ".claude", "plugins");
631-
// Default expand-skip sink respects diagnostics when provided so discovery
632-
// can emit one summary; explicit onExpandSkip (tests) still wins.
684+
// Default expand-skip sink: diagnostics when provided, else
685+
// stderrPluginWarning; explicit onExpandSkip (tests) still wins.
633686
const warnExpand = resolvePluginWarningHandler(
634-
opts.diagnostics !== undefined ? { diagnostics: opts.diagnostics } : {},
687+
opts.diagnostics !== undefined
688+
? { diagnostics: opts.diagnostics }
689+
: { onWarning: stderrPluginWarning },
635690
);
636691
const onExpandSkip =
637692
opts.onExpandSkip

0 commit comments

Comments
 (0)