diff --git a/src/plugins/data-only.ts b/src/plugins/data-only.ts index 65b23302b..d6fb06844 100644 --- a/src/plugins/data-only.ts +++ b/src/plugins/data-only.ts @@ -1,6 +1,7 @@ import { readFile } from "node:fs/promises"; import { basename, join } from "node:path"; -import { parsePluginManifest, type PluginManifest } from "./manifest.js"; +import { type } from "arktype"; +import { PluginManifestSchema, type PluginManifest } from "./manifest.js"; import type { CommandDefinition, CommandPlugin, @@ -25,13 +26,45 @@ export interface DataOnlyPlugin { commandPlugin?: CommandPlugin; } -async function readManifestJson(dir: string): Promise { +function isENOENT(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "ENOENT" + ); +} + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +async function readManifestJson( + dir: string, + onWarning: (msg: string) => void, +): Promise { + const manifestPath = join(dir, "manifest.json"); + let raw: string; try { - const raw = await readFile(join(dir, "manifest.json"), "utf8"); - return parsePluginManifest(JSON.parse(raw)); - } catch { + raw = await readFile(manifestPath, "utf8"); + } catch (err) { + if (isENOENT(err)) return null; + onWarning(`failed to read ${manifestPath}: ${errorText(err)}`); + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + onWarning(`failed to parse ${manifestPath}: ${errorText(err)}`); + return null; + } + const result = PluginManifestSchema(parsed); + if (result instanceof type.errors) { + onWarning(`invalid plugin manifest at ${manifestPath}: ${result.summary}`); return null; } + return result as PluginManifest; } // Claude Code marketplace plugins self-describe via `.claude-plugin/plugin.json` @@ -106,7 +139,7 @@ export async function loadDataOnlyPlugin( const [nativeManifest, claudeManifest, agents, commands, skillCmds] = await Promise.all([ - readManifestJson(pluginDir), + readManifestJson(pluginDir, onWarning), readClaudePluginManifest(pluginDir), loadDataOnlyAgentPlugin(pluginDir, { cwd, onWarning }), loadDataOnlyCommands(pluginDir, { onWarning }), diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index b8df0ade9..ba7f38a6e 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -1,6 +1,15 @@ import { defined } from "../../tests/helpers/defined.js"; import { describe, test, expect } from "bun:test"; -import { dedupePluginModules, type PluginModule } from "./loader.js"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createPluginLoadDiagnostics } from "./diagnostics.js"; +import { + dedupePluginModules, + loadPluginEntry, + loadPluginsFromPaths, + type PluginModule, +} from "./loader.js"; import { isPluginModuleEnabled } from "./register.js"; import { disablePluginSettings } from "./uninstall.js"; @@ -101,3 +110,135 @@ describe("isPluginModuleEnabled with dedupe shadowing", () => { expect(isPluginModuleEnabled(user, {})).toBe(false); }); }); + +async function makeJsPlugin(files: Record): Promise { + const dir = await mkdtemp(join(tmpdir(), "manifest-plugin-")); + for (const [rel, body] of Object.entries(files)) { + const abs = join(dir, rel); + await mkdir(join(abs, ".."), { recursive: true }); + await writeFile(abs, body); + } + return dir; +} + +describe("readManifestJson malformed vs missing", () => { + test("malformed manifest.json warns with path and parse error", async () => { + const dir = await makeJsPlugin({ + "index.js": "export {};\n", + "manifest.json": "{not-json", + }); + const warnings: string[] = []; + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); + const manifestPath = join(dir, "manifest.json"); + expect(warnings.length).toBeGreaterThan(0); + expect(warnings.some((w) => w.includes(manifestPath))).toBe(true); + expect( + warnings.some( + (w) => w.includes(manifestPath) && w.includes("failed to parse"), + ), + ).toBe(true); + }); + + test("invalid manifest.json schema warns with path and validation error", async () => { + const dir = await makeJsPlugin({ + "index.js": "export {};\n", + "manifest.json": JSON.stringify({ id: "x", name: "X" }), + }); + const warnings: string[] = []; + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); + const manifestPath = join(dir, "manifest.json"); + expect(warnings.some((w) => w.includes(manifestPath))).toBe(true); + expect( + warnings.some((w) => w.includes(manifestPath) && w.includes("kind")), + ).toBe(true); + }); + + test("missing manifest.json stays silent", async () => { + const dir = await makeJsPlugin({ + "index.js": "export {};\n", + }); + const warnings: string[] = []; + await loadPluginEntry(dir, { onWarning: (msg) => warnings.push(msg) }); + expect(warnings).toEqual([]); + }); + + test("malformed .claude-plugin/manifest.json warns on metadata-only load", async () => { + const dir = await makeJsPlugin({ + ".claude-plugin/manifest.json": "{not-json", + }); + const diag = createPluginLoadDiagnostics(); + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); + const mods = await loadPluginsFromPaths([dir], cwd, { + isPluginTrusted: () => false, + diagnostics: diag, + }); + expect(mods).toEqual([]); + const manifestPath = join(dir, ".claude-plugin", "manifest.json"); + expect(diag.warnings.some((w) => w.includes(manifestPath))).toBe(true); + expect( + diag.warnings.some( + (w) => w.includes(manifestPath) && w.includes("failed to parse"), + ), + ).toBe(true); + }); + + test("missing manifest on metadata-only load stays silent", async () => { + const dir = await mkdtemp(join(tmpdir(), "manifest-empty-")); + const diag = createPluginLoadDiagnostics(); + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); + const mods = await loadPluginsFromPaths([dir], cwd, { + isPluginTrusted: () => false, + diagnostics: diag, + }); + expect(mods).toEqual([]); + expect(diag.warnings).toEqual([]); + }); + + test("malformed native manifest.json on data-only plugin warns and does not silently infer kind", async () => { + const dir = await makeJsPlugin({ + "agents/a.md": "---\nname: a\n---\nbody\n", + "manifest.json": "{not-json", + }); + const warnings: string[] = []; + const mod = await loadPluginEntry(dir, { + onWarning: (msg) => warnings.push(msg), + }); + const manifestPath = join(dir, "manifest.json"); + expect( + warnings.some( + (w) => w.includes(manifestPath) && w.includes("failed to parse"), + ), + ).toBe(true); + expect(mod).not.toBeNull(); + expect(mod?.agentPlugin).toBeDefined(); + }); + + test("Claude-format .claude-plugin/manifest.json does not warn missing id/kind on metadata-only load", async () => { + const dir = await makeJsPlugin({ + ".claude-plugin/manifest.json": JSON.stringify({ + name: "cmo", + description: "Marketing ops", + }), + }); + const diag = createPluginLoadDiagnostics(); + const cwd = await mkdtemp(join(tmpdir(), "manifest-cwd-")); + const mods = await loadPluginsFromPaths([dir], cwd, { + isPluginTrusted: () => false, + diagnostics: diag, + }); + expect( + diag.warnings.some((w) => w.includes("invalid plugin manifest")), + ).toBe(false); + expect( + diag.warnings.some( + (w) => + w.includes(join(dir, ".claude-plugin", "manifest.json")) && + (w.includes("id") || w.includes("kind")), + ), + ).toBe(false); + const mod = mods.find((m) => m.manifest?.id === "cmo"); + expect(mod?.metadataOnly).toBe(true); + expect(mod?.manifest?.name).toBe("cmo"); + expect(mod?.manifest?.description).toBe("Marketing ops"); + }); +}); diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 7ab6cb85a..f2089f0fc 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -3,12 +3,17 @@ import { readFile, readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, parse, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { type } from "arktype"; import type { WorkflowPlugin } from "../workflows/types.js"; import { SETTINGS_DIR_NAME } from "../branding.js"; import type { CommandPlugin } from "../tui/commands/registry.js"; import { pathIsInsideOrEqual } from "../util/path-contain.js"; -import { parsePluginManifest, type PluginManifest } from "./manifest.js"; +import { + parsePluginManifest, + PluginManifestSchema, + type PluginManifest, +} from "./manifest.js"; import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js"; import type { PluginLoadReporter } from "../telemetry/product-events.js"; import { runtimePluginLoadReporter } from "../telemetry/singleton.js"; @@ -71,34 +76,112 @@ export interface PluginModule { shadowedRepoDefaultEnabled?: boolean; } +function isENOENT(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "ENOENT" + ); +} + +function errorText(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + // Read and validate a manifest.json beside the module. Plugins may declare // their manifest as a JS export (mod.manifest) or a sibling manifest.json file; // this covers the JSON path so plugins that are pure data + commands work too. -async function readManifestJson(dir: string): Promise { +// Missing file stays silent; parse or schema failure warns and still skips. +async function readJsonFile( + path: string, + onWarning: (msg: string) => void, +): Promise { + let raw: string; try { - const raw = await readFile(join(dir, "manifest.json"), "utf8"); - return parsePluginManifest(JSON.parse(raw)); - } catch { + raw = await readFile(path, "utf8"); + } catch (err) { + if (isENOENT(err)) return undefined; + onWarning(`failed to read ${path}: ${errorText(err)}`); + return undefined; + } + try { + return JSON.parse(raw) as unknown; + } catch (err) { + onWarning(`failed to parse ${path}: ${errorText(err)}`); + return undefined; + } +} + +async function readManifestJson( + dir: string, + onWarning: (msg: string) => void, +): Promise { + const manifestPath = join(dir, "manifest.json"); + const parsed = await readJsonFile(manifestPath, onWarning); + if (parsed === undefined) return null; + const result = PluginManifestSchema(parsed); + if (result instanceof type.errors) { + onWarning(`invalid plugin manifest at ${manifestPath}: ${result.summary}`); return null; } + return result as PluginManifest; +} + +// Claude marketplace `.claude-plugin/manifest.json` is `{name, description?}`, +// not a corbits PluginManifest. Parse it without PluginManifestSchema so a +// valid Claude layout does not warn about missing id/kind. Malformed JSON +// still warns. Kind is filled in so the metadata-only module can list. +async function readClaudeFormatManifestJson( + dir: string, + onWarning: (msg: string) => void, +): Promise { + const manifestPath = join(dir, "manifest.json"); + const parsed = await readJsonFile(manifestPath, onWarning); + if (parsed === undefined) return null; + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + return null; + } + const obj = parsed as Record; + const nameRaw = + typeof obj.name === "string" && obj.name.trim().length > 0 + ? obj.name.trim() + : typeof obj.id === "string" && obj.id.trim().length > 0 + ? obj.id.trim() + : null; + if (nameRaw === null) return null; + const manifest: PluginManifest = { + id: nameRaw, + name: nameRaw, + kind: "command", + }; + if (typeof obj.description === "string") { + manifest.description = obj.description; + } + return manifest; } // Safe metadata-only view: never import()s and never loads markdown agents/commands. async function readPluginMetadataOnly( entryPath: string, origin: PluginOrigin, + onWarning: (msg: string) => void, ): Promise { let dir = entryPath; try { const info = await stat(entryPath); if (!info.isDirectory()) dir = dirname(entryPath); } catch { + // Path missing or unreadable — treat as not a plugin. return null; } const abs = resolve(dir); const manifest = - (await readManifestJson(abs)) ?? - (await readManifestJson(join(abs, ".claude-plugin"))); + (await readManifestJson(abs, onWarning)) ?? + (await readClaudeFormatManifestJson( + join(abs, ".claude-plugin"), + onWarning, + )); if (manifest === null) { return null; } @@ -160,7 +243,7 @@ export async function loadPluginEntry( target = candidatePath; break; } catch { - // not found, try next + // Candidate missing; try the next index filename. } } // No JS entry — fall back to a data-only plugin (agents/*.md and/or @@ -198,6 +281,7 @@ export async function loadPluginEntry( pluginDir = dirname(entryPath); } } catch { + // Entry path missing or unreadable — not a loadable plugin. return null; } @@ -209,8 +293,8 @@ export async function loadPluginEntry( const result: PluginModule = { dir: dirname(importTarget) }; const manifest = parsePluginManifest(mod.manifest) ?? - (await readManifestJson(dirname(importTarget))) ?? - (await readManifestJson(dirname(dirname(importTarget)))); + (await readManifestJson(dirname(importTarget), onWarning)) ?? + (await readManifestJson(dirname(dirname(importTarget)), onWarning)); if (manifest !== null) result.manifest = manifest; if ( mod.workflowPlugin != null && @@ -284,6 +368,7 @@ async function pathExists(p: string): Promise { await stat(p); return true; } catch { + // Existence probe: missing or unreadable counts as absent. return false; } } @@ -475,7 +560,7 @@ export async function expandPluginPath( return surviving; } } catch { - // not a declared marketplace — fall through to the layout heuristic + // No marketplace.json (or unreadable) — fall through to the layout heuristic. } // 2. Layout heuristic: a `plugins/` subdir whose root is not itself a plugin. @@ -504,6 +589,7 @@ export async function expandPluginPath( withFileTypes: true, }); } catch { + // plugins/ vanished between the existence probe and readdir. return [marketplaceRoot]; } const dirs: string[] = []; @@ -560,10 +646,16 @@ async function scanPluginsDir( diagnostics?: PluginLoadDiagnostics, telemetry?: Telemetry, ): Promise { + const onWarning = resolvePluginWarningHandler( + diagnostics !== undefined + ? { diagnostics } + : { onWarning: stderrPluginWarning }, + ); let entries: string[]; try { entries = await readdir(dir); } catch { + // Plugins root missing — discovery is empty, not an error. return []; } @@ -582,7 +674,7 @@ async function scanPluginsDir( isTrusted !== undefined && !isTrusted(abs) ) { - const meta = await readPluginMetadataOnly(abs, origin); + const meta = await readPluginMetadataOnly(abs, origin, onWarning); if (meta !== null) results.push(meta); continue; } @@ -691,6 +783,11 @@ export async function loadPluginsFromPaths( // A skipped member routes into `diagnostics` when the caller has one, same // reasoning as scanPluginsDir — otherwise it bypasses the collector. const onSkip = resolveExpandSkip(opts.diagnostics); + const onWarning = resolvePluginWarningHandler( + opts.diagnostics !== undefined + ? { diagnostics: opts.diagnostics } + : { onWarning: stderrPluginWarning }, + ); const resolved = await Promise.all( paths.map(async (p) => { const abs = isAbsolute(p) ? p : join(cwd, p); @@ -709,7 +806,7 @@ export async function loadPluginsFromPaths( .map(async (p) => { const abs = resolve(p); if (opts.isPluginTrusted !== undefined && !opts.isPluginTrusted(abs)) { - return readPluginMetadataOnly(abs, "path"); + return readPluginMetadataOnly(abs, "path", onWarning); } return loadPluginEntry(p, { cwd, @@ -734,6 +831,7 @@ function isExistingDirectory(path: string): boolean { try { return existsSync(path) && statSync(path).isDirectory(); } catch { + // Race or permission: treat as missing so discovery skips this locator. return false; } } @@ -822,6 +920,7 @@ export async function discoverClaudeInstalledPlugins( try { raw = await readFile(registryPath, "utf8"); } catch { + // No Claude installed_plugins.json — nothing to import. return []; } let parsed: unknown; diff --git a/src/session/index.ts b/src/session/index.ts index 2f0d77ce5..75be05ae0 100644 --- a/src/session/index.ts +++ b/src/session/index.ts @@ -14,9 +14,23 @@ import { existsSync, realpathSync } from "node:fs"; import { join, dirname } from "node:path"; import { homedir } from "node:os"; +import { getLogger } from "@intx/log"; + import { loadState, saveState, type RunState } from "./state.js"; import { resolveSessionLabel } from "./session-label.js"; import { projectRootFor, projectSessionsRoot } from "./project-key.js"; +import { LOG_NAMESPACE_ROOT } from "../branding.js"; + +const log = getLogger([LOG_NAMESPACE_ROOT, "session"]); + +function isENOENT(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "ENOENT" + ); +} // --------------------------------------------------------------------------- // UUIDv7 generator (no external dependencies) @@ -110,6 +124,7 @@ function realpathSafe(path: string): string { try { return realpathSync(path); } catch { + // Path does not exist yet; compare lexically. return path; } } @@ -174,7 +189,13 @@ export async function initSessionDir( await mkdir(dirname(linkPath), { recursive: true }); // Remove existing symlink first, then create new one. - await unlink(linkPath).catch(() => undefined); + await unlink(linkPath).catch((err: unknown) => { + if (isENOENT(err)) return; + log.debug("failed to replace latest symlink at {path}: {error}", { + path: linkPath, + error: err instanceof Error ? err.message : String(err), + }); + }); await symlink(sessionId, linkPath); return dir; @@ -198,8 +219,7 @@ export async function resolveLatestSession( contextDir: sessionContextDir(cwd, sessionId, home), }; } catch { - // Fall back: legacy latest under cwd, then under the git project root - // (nested cwd may not have its own .agent-state/latest). + // No canonical latest symlink; try legacy in-repo links. for (const legacyLink of legacyLatestCandidates(cwd)) { try { const sessionId = await readlink(legacyLink); @@ -210,7 +230,7 @@ export async function resolveLatestSession( contextDir: sessionContextDir(cwd, sessionId, home), }; } catch { - // try next candidate + // This legacy latest link is missing; try the next candidate. } } return null; @@ -242,6 +262,7 @@ async function collectSessionIds(cwd: string, home: string): Promise { try { entries = await readdir(base); } catch { + // Session root not created yet for this project. continue; } for (const entry of entries) { @@ -259,9 +280,11 @@ async function sessionUpdatedAt( try { return (await stat(join(dir, "run.json"))).mtimeMs; } catch { + // No run.json yet; use the session directory mtime. try { return (await stat(dir)).mtimeMs; } catch { + // Session dir gone between list and stat; keep the in-memory fallback. return fallbackMs; } } diff --git a/src/tui/runner/commands.ts b/src/tui/runner/commands.ts index e28db591b..bcc72be30 100644 --- a/src/tui/runner/commands.ts +++ b/src/tui/runner/commands.ts @@ -120,7 +120,10 @@ export function createCommandLayer( "Yolo flipped for this session, but the default did not stick.", ); } - } catch { + } catch (err: unknown) { + tuiLogger.debug("skip-permissions persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); state.systemNotice?.( "Yolo flipped for this session, but the default did not stick.", ); diff --git a/src/tui/runner/exit.ts b/src/tui/runner/exit.ts index c39ffef63..7a461a6bf 100644 --- a/src/tui/runner/exit.ts +++ b/src/tui/runner/exit.ts @@ -774,8 +774,10 @@ export async function finalizeTUIRun( try { await state.streamPromise; - } catch { - // ignore + } catch (err: unknown) { + tuiLogger.debug("stream promise rejected during exit: {error}", { + error: err instanceof Error ? err.message : String(err), + }); } return resolveExitCode({ diff --git a/src/tui/runner/settings.ts b/src/tui/runner/settings.ts index 5143d3ad2..1d6c3cbef 100644 --- a/src/tui/runner/settings.ts +++ b/src/tui/runner/settings.ts @@ -69,6 +69,7 @@ export async function loadLocalSettingsWriteBase( try { return (await load(path)) ?? {}; } catch { + // Unreadable or invalid local settings — caller must skip the write. return null; } } @@ -196,8 +197,10 @@ export async function wireSettings( if (state.telemetryFirstRun) { void services.globalSettingsWriter .enqueue(() => markTelemetryNoticeShown(trueGlobalSettingsPath)) - .catch(() => { - // Best-effort: worst case the notice shows again next launch. + .catch((err: unknown) => { + tuiLogger.debug("telemetry notice watermark persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); }); } @@ -218,8 +221,10 @@ export async function wireSettings( .enqueue(() => markLastChangelogVersion(trueGlobalSettingsPath, stampVersion), ) - .catch(() => { - // Best-effort watermark. + .catch((err: unknown) => { + tuiLogger.debug("changelog watermark persist failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); }); } diff --git a/src/tui/runner/wiring.ts b/src/tui/runner/wiring.ts index 18de53712..9a972d0aa 100644 --- a/src/tui/runner/wiring.ts +++ b/src/tui/runner/wiring.ts @@ -348,7 +348,11 @@ export function wirePostStartup( // Recall spans the whole session, including what was sent before a resume. void loadSentMessages(state.config.cwd, state.sessionId) .then((sent) => setSentMessageHistory(hostOf(state).shell, sent)) - .catch(() => undefined); + .catch((err: unknown) => { + tuiLogger.debug("sent-message history load failed: {error}", { + error: err instanceof Error ? err.message : String(err), + }); + }); if (!state.resumeSkipInitialTask && state.config.task.trim().length > 0) { // The operator's initial task, typed as a CLI argument before launch —