/** * Compact startup sections for the banner: Context, Skills, Themes. * * pi's own startup listing has no per-section switch — hiding the noisy * [Extensions] block (20 internal module names) means `quietStartup: true`, * which hides everything. So when quiet startup is on, the banner shows its * own compact versions of the sections that ARE useful. pi's resourceLoader * is not exposed to extensions, so these are re-derived the same way our * other extensions derive them (claude-compat's skill dirs, pi's git-root * context walk); pi-only extras such as the agent dir's skills/ are included * for parity (the caller passes the live agent dir, which honours * PI_CODING_AGENT_DIR isolation). */ import { existsSync, readdirSync, readFileSync } from "node:fs"; import { basename, dirname, join, relative } from "node:path"; import { findGitRoot } from "../lib/git.ts"; import { defaultDiscoverRoots, discoverPlugins } from "../lib/plugins.ts"; import { claudeUserDir } from "../lib/paths.ts"; export interface StartupSection { label: string; items: string[]; } /** * The project context files One Code actually gathers, from cwd up to the git * root (or just cwd outside a repo), for the startup banner. Mirrors what the * blocks send: a directory's CLAUDE.md, or its AGENTS.md when it has no CLAUDE.md * (CLAUDE.md > AGENTS.md), plus CLAUDE.local.md and ONECODE.md when present — so * AGENTS.md is listed only when it's really in play, not whenever it exists. */ export function contextFileNames(cwd: string): string[] { const stop = findGitRoot(cwd) ?? cwd; const found: string[] = []; let dir = cwd; while (true) { const rel = (name: string) => relative(cwd, join(dir, name)) || name; const present = (name: string) => existsSync(join(dir, name)); // CLAUDE.md is primary; AGENTS.md stands in only when there is no CLAUDE.md. if (present("CLAUDE.md")) found.push(rel("CLAUDE.md")); else if (present("AGENTS.md")) found.push(rel("AGENTS.md")); if (present("CLAUDE.local.md")) found.push(rel("CLAUDE.local.md")); for (const name of ["ONECODE.md", "onecode.md", "One Code.md"]) { if (present(name)) { found.push(rel(name)); break; } } if (dir === stop) break; const parent = dirname(dir); if (parent === dir) break; dir = parent; } return found; } /** * Skills across the same sources our extensions feed to pi: project/user * Claude Code dirs, pi's own user dir, and installed plugins. An entry counts * when //SKILL.md exists — existsSync follows symlinked skill * directories, which readdir's isDirectory() would miss. */ export function skillNames(cwd: string, home: string, agentDir: string): string[] { const dirs = [ join(cwd, ".claude", "skills"), join(claudeUserDir(home), "skills"), join(agentDir, "skills"), ]; const names = new Set(); for (const dir of dirs) { if (!existsSync(dir)) continue; try { for (const entry of readdirSync(dir)) { if (existsSync(join(dir, entry, "SKILL.md"))) names.add(entry); } } catch { // Unreadable dir: skip, same as pi would. } } for (const skill of discoverPlugins(defaultDiscoverRoots(agentDir, cwd, home)).skills) { names.add(skill.name); } return [...names].sort((a, b) => a.localeCompare(b)); } /** Saved workflow names from the Claude Code layout dirs (project shadows user). */ export function workflowNames(cwd: string, home: string): string[] { const names = new Set(); for (const dir of [join(cwd, ".claude", "workflows"), join(claudeUserDir(home), "workflows")]) { if (!existsSync(dir)) continue; try { for (const entry of readdirSync(dir)) { if (entry.endsWith(".js") || entry.endsWith(".mjs")) names.add(entry.replace(/\.(js|mjs)$/, "")); } } catch { // Unreadable dir: skip, same as pi would. } } return [...names].sort((a, b) => a.localeCompare(b)); } /** Theme names bundled with this package. */ export function themeNames(packageThemesDir: string): string[] { try { return readdirSync(packageThemesDir) .filter((f) => f.endsWith(".json")) .map((f) => basename(f, ".json")) .sort((a, b) => a.localeCompare(b)); } catch { return []; } } /** * Whether One Code should default thinking blocks to collapsed (the Claude Code * look: a one-line label, expanded on demand). Only when the user has never * chosen: a `hideThinkingBlock` key in pi's global settings — written by * ctrl+t, /settings, or a previous run of this default — is their decision and * is never overridden. An unreadable settings file means "don't touch it". */ export function shouldDefaultHideThinking(settingsRaw: string | undefined): boolean { if (settingsRaw === undefined) return true; try { const settings = JSON.parse(settingsRaw); return !(settings && typeof settings === "object" && "hideThinkingBlock" in settings); } catch { return false; } } /** * Whether One Code should default pi's output padding to 0 — flush-left — so the * assistant "●" marker (see `assistant-marker.ts`) lines up with the tool "●" * bullets, which render at column 0. Same rule as the thinking default: only when * the user has never set `outputPad` themselves (via `/settings` or a previous * run of this default). An unreadable settings file means "don't touch it". */ export function shouldDefaultFlushOutputPad(settingsRaw: string | undefined): boolean { if (settingsRaw === undefined) return true; try { const settings = JSON.parse(settingsRaw); return !(settings && typeof settings === "object" && "outputPad" in settings); } catch { return false; } } /** True when pi's own startup listing is silenced, so ours should render instead. */ export function quietStartupEnabled(piSettingsPath: string): boolean { try { const settings = JSON.parse(readFileSync(piSettingsPath, "utf8")); return settings?.quietStartup === true; } catch { return false; } } export function collectStartupSections(cwd: string, home: string, packageThemesDir: string, agentDir: string): StartupSection[] { const sections: StartupSection[] = [ { label: "context", items: contextFileNames(cwd) }, { label: "skills", items: skillNames(cwd, home, agentDir) }, { label: "workflows", items: workflowNames(cwd, home) }, { label: "themes", items: themeNames(packageThemesDir) }, ]; return sections.filter((s) => s.items.length > 0); }