From 756f38fa90a750a67f8e8b10b6002e52bdce804b Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:14:58 +0000 Subject: [PATCH 1/4] Discover skills and context via pi resource loader MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the hand-rolled skill scan in harness-skills.ts with pi's DefaultResourceLoader so installed-package skills load (the loader is a strict superset of the old ~/.agents/skills discovery). Bridge pi's skill file paths back through cua-agent's loadSkills to recover the skill body the harness needs, deduping by file path so a skills root that mixes loose .md and nested SKILL.md never double-loads. Startup stays non-interactive: project settings start untrusted (no trust prompt) and package resolution runs offline so a configured-but- not-installed package is skipped instead of triggering a network install. pi extensions are not loaded — cua drives the lower-level AgentHarness, which cannot bind pi AgentSession extensions. Also load context files (AGENTS.md/CLAUDE.md) and inject their content into the system prompt so the model sees what the [Context] section advertises; thread contextFiles through setupHarnessRuntime and buildCuaHarness. Co-Authored-By: Claude Opus 4.7 --- packages/cli/src/cli-harness.ts | 8 ++- packages/cli/src/harness-skills.ts | 105 +++++++++++++++++++++++++---- packages/cli/src/harness.ts | 23 +++++-- 3 files changed, 116 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/cli-harness.ts b/packages/cli/src/cli-harness.ts index 5182efa..7c7d806 100644 --- a/packages/cli/src/cli-harness.ts +++ b/packages/cli/src/cli-harness.ts @@ -43,7 +43,7 @@ import { readMetadataFromFile, resolveSessionRef, } from "./harness-sessions"; -import { discoverCuaSkills } from "./harness-skills"; +import { type ContextFile, discoverCuaSkills } from "./harness-skills"; import { runPrint } from "./print"; const MODELS_HELP = `cua models — list supported -m/--model values @@ -341,6 +341,7 @@ interface HarnessRuntime { resolved: ResolvedSession | undefined; session: Session; skills: Skill[]; + contextFiles: ContextFile[]; harness: ReturnType; provider: string; modelRef: CuaModelRef; @@ -363,7 +364,7 @@ async function setupHarnessRuntime( const auth = resolveAuth(flags); const cwd = process.cwd(); const env = new NodeExecutionEnv({ cwd }); - const { skills } = await discoverCuaSkills({ + const { skills, contextFiles } = await discoverCuaSkills({ cwd, env, extraPaths: flags.skillPaths, @@ -410,6 +411,7 @@ async function setupHarnessRuntime( session, model: auth.modelRef, skills, + contextFiles, thinkingLevel, modelBaseUrl: baseUrlOverride, }); @@ -419,6 +421,7 @@ async function setupHarnessRuntime( resolved, session, skills, + contextFiles, harness, provider, modelRef: auth.modelRef, @@ -506,6 +509,7 @@ export async function runInteractiveCommand( browserHandle: runtime.handle, session: runtime.session, skills: runtime.skills, + contextFiles: runtime.contextFiles, modelRef: runtime.modelRef, provider: runtime.provider, initialPrompt: initialPrompt || undefined, diff --git a/packages/cli/src/harness-skills.ts b/packages/cli/src/harness-skills.ts index 38d8f41..2d0d44c 100644 --- a/packages/cli/src/harness-skills.ts +++ b/packages/cli/src/harness-skills.ts @@ -1,7 +1,10 @@ import { type ExecutionEnv, loadSkills, type Skill, type SkillDiagnostic } from "@onkernel/cua-agent"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; +import { + DefaultResourceLoader, + getAgentDir, + SettingsManager, +} from "@earendil-works/pi-coding-agent"; +import { dirname } from "node:path"; export interface DiscoverSkillsOptions { cwd: string; @@ -10,30 +13,104 @@ export interface DiscoverSkillsOptions { extraPaths?: string[]; /** Disable all skill discovery. */ disabled?: boolean; + /** pi agent dir to resolve installed packages from. Defaults to `getAgentDir()`. */ + agentDir?: string; +} + +export interface ContextFile { + path: string; + content: string; } export interface DiscoverSkillsResult { skills: Skill[]; + contextFiles: ContextFile[]; sources: string[]; diagnostics: SkillDiagnostic[]; } /** - * Discover skills following the cross-agent `~/.agents/skills/` standard. + * Discover skills and context files via pi's `DefaultResourceLoader`, the same + * loader pi's own TUI uses. This resolves skills from installed pi packages + * (`pi install …` writes them under the agent dir and records them in + * settings.json) in addition to `~/.agents/skills/`, `/.agents/skills/`, + * `~/.pi/agent/skills/`, and explicit `--skill` paths. + * + * Startup never blocks on an interactive prompt: project settings start + * untrusted (no trust prompt), and `PI_OFFLINE` keeps a configured-but-not- + * installed package from triggering a network install — it is skipped instead. * - * Discovery order: explicit `--skill` paths, then `~/.agents/skills/`, - * then `/.agents/skills/`. Missing paths are skipped silently. + * pi extensions are not loaded (`noExtensions`): cua's harness drives the + * lower-level `AgentHarness` directly and cannot bind pi `AgentSession` + * extensions. */ export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise { - if (opts.disabled) return { skills: [], sources: [], diagnostics: [] }; const extras = (opts.extraPaths ?? []).filter((p) => p && p.trim().length > 0); - const userAgentsDir = join(homedir(), ".agents", "skills"); - const projectAgentsDir = join(opts.cwd, ".agents", "skills"); - const candidates = [...extras, userAgentsDir, projectAgentsDir]; - const sources = candidates.filter((p) => existsSync(p)); - if (sources.length === 0) return { skills: [], sources: [], diagnostics: [] }; - const result = await loadSkills(opts.env, sources); - return { skills: result.skills, sources, diagnostics: result.diagnostics }; + const agentDir = opts.agentDir ?? getAgentDir(); + const settingsManager = SettingsManager.create(opts.cwd, agentDir, { projectTrusted: false }); + const loader = new DefaultResourceLoader({ + cwd: opts.cwd, + agentDir, + settingsManager, + additionalSkillPaths: extras, + noSkills: opts.disabled === true, + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + }); + + const restoreOffline = forceOfflinePackageResolution(); + try { + await loader.reload(); + } finally { + restoreOffline(); + } + + const piSkills = loader.getSkills().skills; + const contextFiles = loader.getAgentsFiles().agentsFiles; + + // pi's loader resolves the skill *file paths* — the superset that includes + // package skills — but its skill objects don't carry the file body. cua's + // harness needs the full instructions, so re-read the discovered skills + // through cua-agent's `loadSkills`, which produces the `{ content }` shape + // the harness and `/skill:` expansion consume. Scan each skill's root + // directory, then keep only the files pi actually enumerated (so a skills + // root holding both a loose `.md` and a nested `SKILL.md` doesn't load the + // nested skill twice). + const discoveredPaths = new Set(piSkills.map((s) => s.filePath)); + const skillDirs = [...new Set(piSkills.map((s) => dirname(s.filePath)))]; + if (skillDirs.length === 0) { + return { skills: [], contextFiles, sources: [], diagnostics: [] }; + } + const loaded = await loadSkills(opts.env, skillDirs); + const skills = dedupeByFilePath(loaded.skills.filter((s) => discoveredPaths.has(s.filePath))); + return { skills, contextFiles, sources: skillDirs, diagnostics: loaded.diagnostics }; +} + +function dedupeByFilePath(skills: Skill[]): Skill[] { + const seen = new Set(); + const result: Skill[] = []; + for (const skill of skills) { + if (seen.has(skill.filePath)) continue; + seen.add(skill.filePath); + result.push(skill); + } + return result; +} + +/** + * `DefaultResourceLoader.reload()` resolves packages without an `onMissing` + * callback, which would auto-install a configured-but-missing package over the + * network. `PI_OFFLINE` makes that resolution skip missing packages instead, so + * startup can never hang on an install. Restores any prior value afterward. + */ +function forceOfflinePackageResolution(): () => void { + const previous = process.env.PI_OFFLINE; + if (previous !== undefined) return () => {}; + process.env.PI_OFFLINE = "1"; + return () => { + delete process.env.PI_OFFLINE; + }; } /** diff --git a/packages/cli/src/harness.ts b/packages/cli/src/harness.ts index 815a4d2..f68ced8 100644 --- a/packages/cli/src/harness.ts +++ b/packages/cli/src/harness.ts @@ -18,6 +18,7 @@ import { } from "@onkernel/cua-ai"; import type Kernel from "@onkernel/sdk"; import { createCodingTools } from "@earendil-works/pi-coding-agent"; +import type { ContextFile } from "./harness-skills"; /** Options for {@link buildCuaHarness}. */ export interface BuildCuaHarnessOptions { @@ -27,6 +28,8 @@ export interface BuildCuaHarnessOptions { session: Session; model: CuaModelRef; skills?: Skill[]; + /** Context files (AGENTS.md, CLAUDE.md, …) appended to the system prompt. */ + contextFiles?: ContextFile[]; thinkingLevel?: ThinkingLevel; /** Override the default coding-tools extraTools (bash/read/edit/write/grep/find/ls). */ extraTools?: CuaAgentHarnessOptions["extraTools"]; @@ -45,6 +48,7 @@ export interface BuildCuaHarnessOptions { */ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { const skills = opts.skills ?? []; + const contextFiles = opts.contextFiles ?? []; const extraTools = opts.extraTools ?? createCodingTools(opts.cwd); const model: CuaModelRef | Model = opts.modelBaseUrl ? { ...getCuaModel(opts.model), baseUrl: opts.modelBaseUrl } @@ -60,7 +64,7 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { thinkingLevel: opts.thinkingLevel, systemPrompt: ({ model: activeModel, resources }) => { const runtime = resolveCuaRuntimeSpec(activeModel); - return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? []); + return composeSystemPrompt(runtime.defaultSystemPrompt, resources.skills ?? [], contextFiles); }, getApiKeyAndHeaders: opts.getApiKeyAndHeaders ?? @@ -71,8 +75,19 @@ export function buildCuaHarness(opts: BuildCuaHarnessOptions): CuaAgentHarness { }); } -function composeSystemPrompt(base: string, skills: Skill[]): string { +function composeSystemPrompt(base: string, skills: Skill[], contextFiles: ContextFile[]): string { + const sections = [base.trim()]; const skillBlock = formatSkillsForSystemPrompt(skills).trim(); - if (!skillBlock) return base; - return `${base.trim()}\n\n${skillBlock}\n`; + if (skillBlock) sections.push(skillBlock); + const contextBlock = formatContextFiles(contextFiles); + if (contextBlock) sections.push(contextBlock); + return `${sections.join("\n\n")}\n`; +} + +function formatContextFiles(contextFiles: ContextFile[]): string { + const blocks = contextFiles + .filter((file) => file.content.trim().length > 0) + .map((file) => `## ${file.path}\n\n${file.content.trim()}`); + if (blocks.length === 0) return ""; + return `# Context\n\n${blocks.join("\n\n")}`; } From 4d9045d4508c27f54921ab64cef2d94e6c55f622 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:15:05 +0000 Subject: [PATCH 2/4] Align the interactive TUI styling and preamble with pi Adopt pi's theme system in place of the hand-rolled ANSI: initialize the theme once at TUI startup and route the message list, status line, editor, and section labels through pi's palette (accent, dim, mdHeading, muted, success, error, warning). Rework the startup preamble to match pi: a "cua v" logo (bold accent name + dim version), a dim key-hint row reflecting cua's real bindings, and [Context] / [Skills] sections styled like pi (label in mdHeading, dim body). No [Extensions] section. The version is inlined by a tsdown define so the bundled bin never reads package.json at runtime. Co-Authored-By: Claude Opus 4.7 --- packages/cli/src/tui/main.ts | 58 +++++++++++++++-- packages/cli/src/tui/message-list.ts | 14 ++-- packages/cli/src/tui/status-line.ts | 2 +- packages/cli/src/tui/themes.ts | 96 +++++++++++++--------------- packages/cli/src/tui/version.ts | 11 ++++ packages/cli/tsdown.config.ts | 7 ++ 6 files changed, 122 insertions(+), 66 deletions(-) create mode 100644 packages/cli/src/tui/version.ts diff --git a/packages/cli/src/tui/main.ts b/packages/cli/src/tui/main.ts index a186a69..67111d3 100644 --- a/packages/cli/src/tui/main.ts +++ b/packages/cli/src/tui/main.ts @@ -20,9 +20,12 @@ import { TUI, TUI_KEYBINDINGS, } from "@earendil-works/pi-tui"; +import { initTheme } from "@earendil-works/pi-coding-agent"; +import { homedir } from "node:os"; import type { ImageContent, Model } from "@onkernel/cua-ai"; import { captureScreenshot, type CuaBrowserHandle } from "../harness-browser"; import { resolveCuaModelRef } from "../harness-models"; +import type { ContextFile } from "../harness-skills"; import { openTuiDebugLog } from "./debug-log"; import { applyAndSummarizeImageProtocol } from "./diagnostics"; import { type AssistantBuffer, MessageList } from "./message-list"; @@ -30,7 +33,8 @@ import { ScreenshotWidget } from "./screenshot-widget"; import { buildAutocompleteProvider, parseSlashCommand } from "./slash-commands"; import { StatusLine } from "./status-line"; import { TelemetryFooter } from "./telemetry-footer"; -import { colors, editorTheme } from "./themes"; +import { colors, getEditorTheme } from "./themes"; +import { cuaVersion } from "./version"; export interface InteractiveOptions { cwd: string; @@ -38,6 +42,8 @@ export interface InteractiveOptions { browserHandle: CuaBrowserHandle; session: Session; skills?: Skill[]; + /** Loaded context files (AGENTS.md, …) shown in the `[Context]` section. */ + contextFiles?: ContextFile[]; /** CUA model ref currently active. Used for the status line and `/model` default. */ modelRef: string; provider: string; @@ -61,6 +67,9 @@ export interface InteractiveOptions { * directly via `harness.subscribe()`. */ export async function runInteractive(opts: InteractiveOptions): Promise { + // pi's `theme` singleton throws until initialized; do this before any + // component or theme helper runs. + initTheme(); // Apply image protocol override BEFORE constructing TUI components so // the Image component sees the resolved capabilities on its first render. const { summary: capsSummary, overridden } = applyAndSummarizeImageProtocol(opts.imageProtocol); @@ -94,7 +103,7 @@ export async function runInteractive(opts: InteractiveOptions): Promise const _keybindings = new KeybindingsManager(TUI_KEYBINDINGS); void _keybindings; - const editor = new Editor(tui, editorTheme); + const editor = new Editor(tui, getEditorTheme()); editor.setAutocompleteProvider(buildAutocompleteProvider(opts.cwd, opts.skills ?? [])); const messages = new MessageList(); const screenshot = new ScreenshotWidget(); @@ -113,7 +122,9 @@ export async function runInteractive(opts: InteractiveOptions): Promise }); const header = new Container(); - header.addChild(new Text(colors.bold("cua") + colors.dim(" — kernel-cloud-browser computer-use agent"), 0, 0)); + const logo = colors.bold(colors.accent("cua")) + colors.dim(` v${cuaVersion()}`); + header.addChild(new Text(logo, 0, 0)); + header.addChild(new Text(keyHintRow(), 0, 0)); const capsHint = overridden ? colors.dim(capsSummary) : colors.dim(capsSummary + " · set CUA_IMAGE_PROTOCOL=kitty|iterm2 to force inline images"); @@ -123,8 +134,13 @@ export async function runInteractive(opts: InteractiveOptions): Promise } header.addChild(new Text("", 0, 0)); + const contextSection = buildContextSection(opts.contextFiles ?? []); const skillSection = buildSkillSection(opts.skills ?? []); tui.addChild(header); + if (contextSection) { + tui.addChild(contextSection); + tui.addChild(new Spacer(1)); + } if (skillSection) { tui.addChild(skillSection); tui.addChild(new Spacer(1)); @@ -228,14 +244,14 @@ export async function runInteractive(opts: InteractiveOptions): Promise } | undefined; const isError = !!event.isError; - let summary = isError ? colors.red("error") : colors.green("ok"); + let summary = isError ? colors.error("error") : colors.success("ok"); if (!isError && result?.content) { const imgs = result.content.filter((c) => c?.type === "image"); if (imgs.length > 0) summary += colors.dim(` · ${imgs.length} screenshot${imgs.length > 1 ? "s" : ""}`); const lastImg = imgs[imgs.length - 1]; if (lastImg?.data) screenshot.update(lastImg.data, lastImg.mimeType ?? "image/png"); } - if (isError && result?.details?.error) summary = colors.red(result.details.error); + if (isError && result?.details?.error) summary = colors.error(result.details.error); messages.addToolResult(event.toolName, !isError, summary); debug?.log("tool_execution_end", { toolName: event.toolName, @@ -511,9 +527,39 @@ async function refreshContextTokens(session: Session): Promise { return estimateContextTokens(context.messages).tokens; } +function keyHintRow(): string { + const hint = (keys: string, label: string) => colors.bold(keys) + colors.dim(` ${label}`); + return [ + hint("esc/ctrl+c", "to interrupt"), + hint("ctrl+c/ctrl+d", "to exit"), + hint("/", "for commands"), + ].join(colors.muted(" · ")); +} + +function sectionLabel(name: string): string { + return colors.heading(`[${name}]`); +} + +function buildContextSection(contextFiles: ContextFile[]): Container | undefined { + if (contextFiles.length === 0) return undefined; + const paths = contextFiles.map((file) => displayPath(file.path)).join(", "); + const container = new Container(); + container.addChild(new Text(sectionLabel("Context") + "\n" + colors.dim(` ${paths}`), 0, 0)); + return container; +} + function buildSkillSection(skills: Skill[]): Container | undefined { if (skills.length === 0) return undefined; + const names = skills + .map((s) => s.name) + .sort((a, b) => a.localeCompare(b)) + .join(", "); const container = new Container(); - container.addChild(new Text(colors.blue("[Skills]") + "\n" + skills.map((s) => s.name).join(", "), 0, 0)); + container.addChild(new Text(sectionLabel("Skills") + "\n" + colors.dim(` ${names}`), 0, 0)); return container; } + +function displayPath(path: string): string { + const home = homedir(); + return path.startsWith(home) ? `~${path.slice(home.length)}` : path; +} diff --git a/packages/cli/src/tui/message-list.ts b/packages/cli/src/tui/message-list.ts index f567942..422c84d 100644 --- a/packages/cli/src/tui/message-list.ts +++ b/packages/cli/src/tui/message-list.ts @@ -1,5 +1,5 @@ import { Container, Markdown, Text } from "@earendil-works/pi-tui"; -import { colors, markdownTheme } from "./themes"; +import { colors, getMarkdownTheme } from "./themes"; /** * Append-only chat log of user prompts, assistant text, tool-call summaries, @@ -20,20 +20,20 @@ export class MessageList extends Container { addToolCall(name: string, args: unknown): void { const summary = formatToolCall(name, args); - this.appendBlock([colors.cyan("· ") + colors.dim(name) + " " + summary]); + this.appendBlock([colors.accent("· ") + colors.dim(name) + " " + summary]); } addToolResult(name: string, ok: boolean, summary: string): void { - const icon = ok ? colors.green("✓") : colors.red("✗"); + const icon = ok ? colors.success("✓") : colors.error("✗"); this.appendBlock([` ${icon} ${colors.dim(name)} ${summary}`]); } addNotice(text: string): void { - this.appendBlock([colors.yellow("· ") + colors.dim(text)]); + this.appendBlock([colors.warning("· ") + colors.dim(text)]); } addError(text: string): void { - this.appendBlock([colors.red("error ") + text]); + this.appendBlock([colors.error("error ") + text]); } private appendBlock(lines: string[]): void { @@ -51,8 +51,8 @@ export class AssistantBuffer extends Container { constructor() { super(); - this.addChild(new Text(colors.green("assistant"), 0, 0)); - this.body = new Markdown("", 0, 0, markdownTheme); + this.addChild(new Text(colors.success("assistant"), 0, 0)); + this.body = new Markdown("", 0, 0, getMarkdownTheme()); this.addChild(this.body); } diff --git a/packages/cli/src/tui/status-line.ts b/packages/cli/src/tui/status-line.ts index d7c9720..7867f41 100644 --- a/packages/cli/src/tui/status-line.ts +++ b/packages/cli/src/tui/status-line.ts @@ -36,7 +36,7 @@ export class StatusLine extends Text { if (this.state.currentUrl) parts.push(colors.dim("url ") + truncate(this.state.currentUrl, 50)); if (this.state.tokens !== undefined) parts.push(colors.dim("tokens ") + this.state.tokens.toLocaleString()); if (this.state.cost !== undefined) parts.push(colors.dim("$") + this.state.cost.toFixed(3)); - if (this.state.working) parts.push(colors.yellow(`⏳ ${this.state.working}`)); + if (this.state.working) parts.push(colors.warning(`⏳ ${this.state.working}`)); this.setText(parts.join(sep)); } } diff --git a/packages/cli/src/tui/themes.ts b/packages/cli/src/tui/themes.ts index 23d7c40..1f648bb 100644 --- a/packages/cli/src/tui/themes.ts +++ b/packages/cli/src/tui/themes.ts @@ -1,60 +1,52 @@ -import type { - EditorTheme, - ImageTheme, - MarkdownTheme, - SelectListTheme, -} from "@earendil-works/pi-tui"; +import type { EditorTheme, ImageTheme } from "@earendil-works/pi-tui"; +import { getMarkdownTheme, getSelectListTheme, Theme } from "@earendil-works/pi-coding-agent"; -const RESET = "\x1b[0m"; +/** + * cua's TUI styling rides on pi's theme system so it matches pi's own TUI. + * `initTheme()` must run once at TUI startup (see `tui/main.ts`) before any of + * these helpers are used. + * + * pi exports the `Theme` class and the markdown/select-list theme getters, but + * not the live theme instance behind `theme.fg(...)`. That instance is published + * on a `Symbol.for` global key (pi's cross-realm contract for its own `theme` + * proxy), so we read it back here to colorize text with the active palette. + */ +const THEME_KEY = Symbol.for("@earendil-works/pi-coding-agent:theme"); -const ansi = { - dim: (text: string) => `\x1b[2m${text}${RESET}`, - bold: (text: string) => `\x1b[1m${text}${RESET}`, - italic: (text: string) => `\x1b[3m${text}${RESET}`, - underline: (text: string) => `\x1b[4m${text}${RESET}`, - strikethrough: (text: string) => `\x1b[9m${text}${RESET}`, - cyan: (text: string) => `\x1b[36m${text}${RESET}`, - green: (text: string) => `\x1b[32m${text}${RESET}`, - yellow: (text: string) => `\x1b[33m${text}${RESET}`, - red: (text: string) => `\x1b[31m${text}${RESET}`, - gray: (text: string) => `\x1b[90m${text}${RESET}`, - blue: (text: string) => `\x1b[34m${text}${RESET}`, - lightBlue: (text: string) => `\x1b[38;2;129;162;190m${text}${RESET}`, - magenta: (text: string) => `\x1b[35m${text}${RESET}`, -}; - -export const colors = ansi; +function activeTheme(): Theme { + const instance = (globalThis as Record)[THEME_KEY]; + if (!(instance instanceof Theme)) { + throw new Error("pi theme not initialized; call initTheme() before rendering the TUI"); + } + return instance; +} -export const selectListTheme: SelectListTheme = { - selectedPrefix: (text) => ansi.cyan(text), - selectedText: (text) => ansi.cyan(text), - description: (text) => ansi.dim(text), - scrollInfo: (text) => ansi.dim(text), - noMatch: (text) => ansi.dim(text), +/** + * The small palette cua's components reach for, mapped onto pi theme colors so + * existing call sites keep working while picking up pi's palette. + */ +export const colors = { + dim: (text: string) => activeTheme().fg("dim", text), + bold: (text: string) => activeTheme().bold(text), + accent: (text: string) => activeTheme().fg("accent", text), + muted: (text: string) => activeTheme().fg("muted", text), + heading: (text: string) => activeTheme().fg("mdHeading", text), + success: (text: string) => activeTheme().fg("success", text), + error: (text: string) => activeTheme().fg("error", text), + warning: (text: string) => activeTheme().fg("warning", text), }; -export const editorTheme: EditorTheme = { - borderColor: (text) => ansi.lightBlue(text), - selectList: selectListTheme, -}; +export { getMarkdownTheme }; -export const imageTheme: ImageTheme = { - fallbackColor: (text) => ansi.dim(text), -}; +/** pi has no exported editor theme; compose one from its select-list theme. */ +export function getEditorTheme(): EditorTheme { + return { + borderColor: (text) => activeTheme().fg("borderAccent", text), + selectList: getSelectListTheme(), + }; +} -export const markdownTheme: MarkdownTheme = { - heading: (text) => ansi.bold(text), - link: (text) => ansi.cyan(text), - linkUrl: (text) => ansi.dim(text), - code: (text) => ansi.magenta(text), - codeBlock: (text) => text, - codeBlockBorder: (text) => ansi.dim(text), - quote: (text) => ansi.dim(text), - quoteBorder: (text) => ansi.dim(text), - hr: (text) => ansi.dim(text), - listBullet: (text) => ansi.cyan(text), - bold: (text) => ansi.bold(text), - italic: (text) => ansi.italic(text), - strikethrough: (text) => ansi.strikethrough(text), - underline: (text) => ansi.underline(text), +/** pi has no image theme; only the text fallback color is cua-specific. */ +export const imageTheme: ImageTheme = { + fallbackColor: (text) => activeTheme().fg("dim", text), }; diff --git a/packages/cli/src/tui/version.ts b/packages/cli/src/tui/version.ts new file mode 100644 index 0000000..438af51 --- /dev/null +++ b/packages/cli/src/tui/version.ts @@ -0,0 +1,11 @@ +/** + * cua's version, inlined by tsdown's `define` (see tsdown.config.ts) so the + * bundled bin never reads package.json from disk at runtime. When the source + * runs unbundled (e.g. tests via tsx), the define isn't applied and this falls + * back to "dev". + */ +declare const __CUA_VERSION__: string | undefined; + +export function cuaVersion(): string { + return typeof __CUA_VERSION__ === "string" ? __CUA_VERSION__ : "dev"; +} diff --git a/packages/cli/tsdown.config.ts b/packages/cli/tsdown.config.ts index cb7d4f7..cc69ae1 100644 --- a/packages/cli/tsdown.config.ts +++ b/packages/cli/tsdown.config.ts @@ -1,5 +1,9 @@ +import { createRequire } from "node:module"; import { defineConfig } from "tsdown"; +const require = createRequire(import.meta.url); +const { version } = require("./package.json") as { version: string }; + export default defineConfig({ entry: ["src/cli.ts"], format: ["esm"], @@ -8,4 +12,7 @@ export default defineConfig({ sourcemap: false, clean: true, outExtensions: () => ({ js: ".js" }), + define: { + __CUA_VERSION__: JSON.stringify(version), + }, }); From 8036b2558c68ef172e1fc1d13c57595b08f3c698 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:15:12 +0000 Subject: [PATCH 3/4] Test resource-loader discovery; update skill/context docs Add hermetic tests (isolated HOME, temp agentDir/cwd) that prove a skill bundled in a pi-installed package is discovered, a configured- but-not-installed package is skipped without throwing or hanging, a mixed loose/nested skills root loads each skill once, and AGENTS.md context is injected into the system prompt. Extend the ptywright fixtures to assert the new preamble and the [Context]/[Skills] sections, and that no [Extensions] section renders. Update the --skill help text, README, and architecture.md to say skills and context come from pi's resource loader (including installed packages and the pi agent dir) and that pi extensions are not executed by cua. Co-Authored-By: Claude Opus 4.7 --- docs/architecture.md | 33 +++-- packages/cli/README.md | 23 +++- packages/cli/src/cli.ts | 6 +- .../cli/test/fixtures/tui-fixture-runner.ts | 13 +- .../test/fixtures/tui-fixtures/resources.json | 26 ++++ packages/cli/test/harness-assembly.test.ts | 27 ++++ packages/cli/test/harness-skills.test.ts | 121 ++++++++++++++++++ packages/cli/test/tui.fixture.test.ts | 26 ++++ 8 files changed, 252 insertions(+), 23 deletions(-) create mode 100644 packages/cli/test/fixtures/tui-fixtures/resources.json create mode 100644 packages/cli/test/harness-skills.test.ts diff --git a/docs/architecture.md b/docs/architecture.md index 568ea16..0c7b1fa 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -205,17 +205,22 @@ coding tools, then renders the result to text, JSONL, or pi-tui. `--resume`, `--session `, and the `cua-browser` custom entry. - `harness-named-sessions.ts` — persisted Kernel browser metadata for `cua session start|stop|list|show` and `-s `. -- `harness-skills.ts` — pi `loadSkills` over `~/.agents/skills`, - `/.agents/skills`, and `--skill `; `/skill:` - expansion. +- `harness-skills.ts` — skill and context discovery via pi's + `DefaultResourceLoader` (the loader pi's own TUI uses), so the set + includes pi-installed packages plus `~/.agents/skills`, + `/.agents/skills`, the pi agent dir, and `--skill `. + pi extensions are not loaded — cua drives the lower-level + `AgentHarness`, which cannot bind pi `AgentSession` extensions. + Also handles `/skill:` expansion. - `action/` — constrained one-shot prompts (`open|click|type|press|observe|url|screenshot|do`) and a bounded harness-driven runner. - `print.ts` — single-shot `--print` text output. - `output/harness-jsonl.ts` — JSONL event sink for `-o jsonl`. -- `tui/` — pi-tui 0.79 interactive front-end: `Markdown` message list, - `Image` screenshot widget, status line, telemetry footer, `Editor` - with autocomplete-backed slash commands (`/model`, `/thinking`, - `/compact`, `/skill:`). +- `tui/` — pi-tui 0.79 interactive front-end styled with pi's theme + system: `Markdown` message list, `Image` screenshot widget, status + line, telemetry footer, `Editor` with autocomplete-backed slash + commands (`/model`, `/thinking`, `/compact`, `/skill:`), and a + startup preamble with `[Context]` and `[Skills]` sections. ### `@onkernel/ptywright` @@ -258,16 +263,18 @@ The CLI's `buildCuaHarness` in `packages/cli/src/harness.ts`: `@onkernel/cua-ai`. 2. Wires `JsonlSessionRepo` and a `Session` for transcript persistence and resume. -3. Loads pi skills from `~/.agents/skills`, `/.agents/skills`, and - any `--skill ` flags and exposes them via `resources.skills`. +3. Discovers skills and context files through pi's + `DefaultResourceLoader` (installed packages, `~/.agents/skills`, + `/.agents/skills`, the pi agent dir, and `--skill `) and + exposes the skills via `resources.skills`. 4. Provides `extraTools` from `createCodingTools(cwd)` (`@earendil-works/pi-coding-agent`) for bash/read/edit/write/grep/find/ls. 5. Resolves the API key via `requireCuaEnvApiKeyForModel(ref)` and spreads any `_BASE_URL` env override onto the model object. 6. Composes the `systemPrompt` callback from - `resolveCuaRuntimeSpec(model).defaultSystemPrompt` plus - `formatSkillsForSystemPrompt(resources.skills)` so it stays correct - across `setModel()`. + `resolveCuaRuntimeSpec(model).defaultSystemPrompt`, + `formatSkillsForSystemPrompt(resources.skills)`, and the loaded + context files so it stays correct across `setModel()`. ## Component map @@ -289,7 +296,7 @@ flowchart LR aiPkg --> piAi harness --> coding["bash/read/edit/write/... (pi-coding-agent)"] harness --> sessions["JsonlSessionRepo (re-exported from cua-agent)"] - harness --> skillsMod["loadSkills (re-exported from cua-agent)"] + harness --> skillsMod["DefaultResourceLoader (pi-coding-agent): skills + context"] cli --> tui["cli/src/tui/main.ts (pi-tui)"] cli --> jsonl["cli/src/output/harness-jsonl.ts"] pty["ptywright (dev/test only)"] --> tui diff --git a/packages/cli/README.md b/packages/cli/README.md index cd2f682..084036d 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -135,14 +135,19 @@ For named sessions, the exact transcript path is in [Session transcripts section in the top-level README](../../README.md#session-transcripts) for the JSONL schema and `jq` analysis examples. -## Skills +## Skills and context -`cua` follows the cross-agent -[`~/.agents/skills/`](https://agentskills.io) standard. Discovery -defaults: +`cua` resolves skills and context files through pi's resource loader +(the same loader pi's own TUI uses), so the discovery set matches pi. +Skills load from: -- `~/.agents/skills/` (user-global) +- `~/.agents/skills/` (user-global, the cross-agent + [`~/.agents/skills/`](https://agentskills.io) standard) - `/.agents/skills/` (project-local) +- the pi agent dir (`~/.pi/agent/`) +- pi-installed packages (`pi install …` records the package in pi's + settings and clones it under the agent dir; its bundled skills load + here too) Plus any explicit `--skill ` flags. Disable with `--no-skills` (`-ns`). @@ -152,6 +157,14 @@ the system prompt; the model uses the `read` tool to load a skill's full body when its description matches the task. Use `/skill:` in a prompt to force-load a skill body inline. +Context files (`AGENTS.md` / `CLAUDE.md`) discovered by the resource +loader are appended to the system prompt and listed in the TUI's +`[Context]` section. + +pi *extensions* are not executed by `cua`: extensions bind into pi's +`AgentSession`, and `cua` drives the lower-level `AgentHarness` +directly. Installed-package skills and context still load. + ## Image protocol Force the inline-screenshot protocol with `--image-protocol` or diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index b3fae70..b3a249c 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -58,8 +58,10 @@ Options: --session Resume a specific session: path | partial id | latest --session-dir Override the sessions directory --no-session Don't persist this session to disk - --skill Load a skill file or directory (repeatable). - Defaults: ~/.agents/skills/, /.agents/skills/ + --skill Load an extra skill file or directory (repeatable). + Skills also load from ~/.agents/skills/, + /.agents/skills/, the pi agent dir + (~/.pi/agent/), and pi-installed packages. -ns, --no-skills Disable skill discovery entirely --debug-tui Enable TUI render diagnostics for manual repros -v, --verbose Verbose progress output to stderr diff --git a/packages/cli/test/fixtures/tui-fixture-runner.ts b/packages/cli/test/fixtures/tui-fixture-runner.ts index 50f90ec..f2ced50 100644 --- a/packages/cli/test/fixtures/tui-fixture-runner.ts +++ b/packages/cli/test/fixtures/tui-fixture-runner.ts @@ -5,11 +5,12 @@ * scripted provider, assembles the real {@link buildCuaHarness}, and starts * the interactive TUI. */ -import { InMemorySessionRepo } from "@onkernel/cua-agent"; +import { InMemorySessionRepo, type Skill } from "@onkernel/cua-agent"; import type { CuaModelRef } from "@onkernel/cua-ai"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { buildCuaHarness } from "../../src/harness"; +import type { ContextFile } from "../../src/harness-skills"; import { runInteractive } from "../../src/tui/main"; import { createFakeKernelEnvironment } from "./fake-kernel"; import { registerScriptedProvider, type ScriptedTurn } from "./scripted-provider"; @@ -18,6 +19,8 @@ interface TuiFixture { modelRef?: string; api?: string; turns: ScriptedTurn[]; + skills?: Skill[]; + contextFiles?: ContextFile[]; } const DEFAULT_API_FOR_MODEL: Record = { @@ -42,13 +45,16 @@ async function main(): Promise { const sessionRepo = new InMemorySessionRepo(); const session = await sessionRepo.create(); const cwd = process.cwd(); + const skills = fixture.skills ?? []; + const contextFiles = fixture.contextFiles ?? []; const harness = buildCuaHarness({ cwd, client: kernel.client, browser: kernel.browser, session, model: modelRef as CuaModelRef, - skills: [], + skills, + contextFiles, extraTools: [], getApiKeyAndHeaders: async () => ({ apiKey: "fixture-key" }), }); @@ -62,7 +68,8 @@ async function main(): Promise { async close(): Promise {}, }, session, - skills: [], + skills, + contextFiles, modelRef, provider: modelRef.split(":", 1)[0] ?? "openai", skipInitialScreenshot: true, diff --git a/packages/cli/test/fixtures/tui-fixtures/resources.json b/packages/cli/test/fixtures/tui-fixtures/resources.json new file mode 100644 index 0000000..f12c336 --- /dev/null +++ b/packages/cli/test/fixtures/tui-fixtures/resources.json @@ -0,0 +1,26 @@ +{ + "modelRef": "openai:gpt-5.5", + "api": "openai-responses", + "skills": [ + { + "name": "deploy-skill", + "description": "Ship the build.", + "content": "Run the deploy steps.", + "filePath": "/tmp/skills/deploy-skill/SKILL.md" + }, + { + "name": "review-skill", + "description": "Review a diff.", + "content": "Run the review steps.", + "filePath": "/tmp/skills/review-skill/SKILL.md" + } + ], + "contextFiles": [ + { "path": "/tmp/project/AGENTS.md", "content": "Be concise." } + ], + "turns": [ + { + "steps": [{ "type": "text", "text": "fixture response" }] + } + ] +} diff --git a/packages/cli/test/harness-assembly.test.ts b/packages/cli/test/harness-assembly.test.ts index 00d8528..ff7a8ed 100644 --- a/packages/cli/test/harness-assembly.test.ts +++ b/packages/cli/test/harness-assembly.test.ts @@ -74,6 +74,33 @@ describe("buildCuaHarness", () => { expect(capturedSystemPrompt).toContain(skillBlock); }); + it("injects loaded context files into the system prompt", async () => { + provider = registerScriptedProvider("openai-responses", [ + { steps: [{ type: "text", text: "ok" }] }, + ]); + const cwd = mkdtempSync(join(tmpdir(), "cua-cli-harness-")); + const kernel = createFakeKernelEnvironment(); + const session = await new InMemorySessionRepo().create(); + const harness = buildCuaHarness({ + cwd, + client: kernel.client, + browser: kernel.browser, + session, + model: "openai:gpt-5.5", + contextFiles: [{ path: join(cwd, "AGENTS.md"), content: "Always prefer tabs over spaces." }], + extraTools: [], + getApiKeyAndHeaders: async () => ({ apiKey: "test-key" }), + }); + let capturedSystemPrompt: string | undefined; + harness.on("before_agent_start", (event) => { + capturedSystemPrompt = event.systemPrompt; + return undefined; + }); + await harness.prompt("hi"); + expect(capturedSystemPrompt).toContain("Always prefer tabs over spaces."); + expect(capturedSystemPrompt).toContain(join(cwd, "AGENTS.md")); + }); + it("delivers the first prompt with an image attached via harness.prompt({ images })", async () => { provider = registerScriptedProvider("openai-responses", [ { steps: [{ type: "text", text: "done" }] }, diff --git a/packages/cli/test/harness-skills.test.ts b/packages/cli/test/harness-skills.test.ts new file mode 100644 index 0000000..e3d7e74 --- /dev/null +++ b/packages/cli/test/harness-skills.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { NodeExecutionEnv } from "@onkernel/cua-agent"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { discoverCuaSkills } from "../src/harness-skills"; + +/** + * Skill/context discovery is hermetic: pi's resource loader reads + * `$HOME/.agents/skills` and `/.agents/skills`, so each test isolates + * `HOME` and uses a fresh empty cwd plus an explicit temp `agentDir`. That way + * the only resources in scope are the fixtures the test writes. + */ +let originalHome: string | undefined; +let cwd: string; +let agentDir: string; + +beforeEach(() => { + originalHome = process.env.HOME; + const home = mkdtempSync(join(tmpdir(), "cua-home-")); + process.env.HOME = home; + cwd = mkdtempSync(join(tmpdir(), "cua-cwd-")); + agentDir = mkdtempSync(join(tmpdir(), "cua-agentdir-")); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; +}); + +function writeSkill(dir: string, name: string, description: string, body: string): void { + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), `---\nname: ${name}\ndescription: ${description}\n---\n${body}\n`); +} + +function writeSettings(packages: string[]): void { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ packages }, null, 2)); +} + +describe("discoverCuaSkills", () => { + it("discovers a skill bundled in a pi-installed package", async () => { + // A local package fixture mirrors what `pi install` leaves on disk: the + // package is recorded in settings.json and its skills live under + // /skills//SKILL.md. + const pkgDir = join(agentDir, "weather-pkg"); + writeSkill(join(pkgDir, "skills", "weather"), "weather", "Check the weather forecast.", "Run the weather workflow."); + writeSettings([pkgDir]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const weather = result.skills.find((s) => s.name === "weather"); + expect(weather, "package skill should be discovered").toBeDefined(); + expect(weather?.content).toContain("Run the weather workflow."); + // The skill came from the package, not ~/.agents/skills (which is empty here). + expect(result.skills).toHaveLength(1); + }); + + it("loads each package skill once when a skills root mixes loose and nested skills", async () => { + // A skills root holding both a loose `.md` and a nested `/SKILL.md` + // must yield both skills exactly once (the nested skill's directory and + // the root both surface it). + const pkgDir = join(agentDir, "mixed-pkg"); + const skillsRoot = join(pkgDir, "skills"); + mkdirSync(skillsRoot, { recursive: true }); + writeFileSync(join(skillsRoot, "loose.md"), "---\nname: loose\ndescription: A loose skill.\n---\nLoose body.\n"); + writeSkill(join(skillsRoot, "nested"), "nested", "A nested skill.", "Nested body."); + writeSettings([pkgDir]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + expect(result.skills.map((s) => s.name).sort()).toEqual(["loose", "nested"]); + }); + + it("skips a configured-but-not-installed package without throwing or hanging", async () => { + // An npm package that was never installed. Resolution must not attempt a + // network install or block; the package is skipped and discovery returns + // cleanly with no skills. + writeSettings(["npm:@example/totally-not-installed-package"]); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + expect(result.skills).toHaveLength(0); + }); + + it("loads skills from an explicit --skill path", async () => { + const extraDir = mkdtempSync(join(tmpdir(), "cua-extra-skill-")); + writeSkill(join(extraDir, "deploy"), "deploy", "Ship the build.", "Run the deploy steps."); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir, extraPaths: [extraDir] }); + + expect(result.skills.map((s) => s.name)).toContain("deploy"); + }); + + it("returns no skills when disabled, but still loads context files", async () => { + const pkgDir = join(agentDir, "weather-pkg"); + writeSkill(join(pkgDir, "skills", "weather"), "weather", "Check the weather forecast.", "Run the weather workflow."); + writeSettings([pkgDir]); + writeFileSync(join(cwd, "AGENTS.md"), "# Project context\n\nBe concise.\n"); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir, disabled: true }); + + expect(result.skills).toHaveLength(0); + expect(result.contextFiles.map((f) => f.path)).toContain(join(cwd, "AGENTS.md")); + }); + + it("loads an AGENTS.md context file from the cwd", async () => { + writeFileSync(join(cwd, "AGENTS.md"), "# Project context\n\nUse two-space indentation.\n"); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const agents = result.contextFiles.find((f) => f.path === join(cwd, "AGENTS.md")); + expect(agents, "AGENTS.md should be discovered").toBeDefined(); + expect(agents?.content).toContain("Use two-space indentation."); + }); +}); diff --git a/packages/cli/test/tui.fixture.test.ts b/packages/cli/test/tui.fixture.test.ts index 3120523..622b812 100644 --- a/packages/cli/test/tui.fixture.test.ts +++ b/packages/cli/test/tui.fixture.test.ts @@ -44,6 +44,14 @@ suite("TUI ptywright scenarios", () => { ctx.onTestFinished(() => session.close()); await waitForFixtureReady(session); + + // The pi-styled preamble renders the "cua v" logo and a + // key-hint row reflecting cua's real bindings. + const preamble = session.snapshot(); + assert.match(preamble.visible, /cua v/); + assert.match(preamble.visible, /to interrupt/); + assert.match(preamble.visible, /for commands/); + session.line("say hi"); await session.waitForVisible("fixture response", { timeoutMs: WAIT_MS }); @@ -54,6 +62,24 @@ suite("TUI ptywright scenarios", () => { await exitFixture(session); }); + test("renders [Context] and [Skills] sections and no [Extensions]", async (ctx) => { + const { spawnFixture, exitFixture, waitForFixtureReady } = await loadPtywrightHelpers(); + const session = spawnFixture("resources.json"); + ctx.onTestFinished(() => session.close()); + + await waitForFixtureReady(session); + + const snapshot = session.snapshot(); + assert.match(snapshot.visible, /\[Context\]/); + assert.match(snapshot.visible, /AGENTS\.md/); + assert.match(snapshot.visible, /\[Skills\]/); + assert.match(snapshot.visible, /deploy-skill/); + assert.match(snapshot.visible, /review-skill/); + assert.doesNotMatch(snapshot.visible, /\[Extensions\]/); + + await exitFixture(session); + }); + test("keeps multiline drafts left aligned", async (ctx) => { const { spawnFixture, exitFixture, waitForFixtureReady, KeyEnter } = await loadPtywrightHelpers(); const session = spawnFixture("multiline.json"); From d5491d62f6f4cad4a7a1f57d528b99693686c950 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Sat, 13 Jun 2026 23:26:46 +0000 Subject: [PATCH 4/4] Discover project-local .agents/skills via additionalSkillPaths Forcing projectTrusted:false gated off pi's trusted project scan, which dropped `/.agents/skills` from discovery. Load that directory through additionalSkillPaths instead: it resolves unconditionally and never binds extensions, so project skills work without a trust prompt. Add a hermetic test for the project-local path, drop the unused `sources` return field, and document that --no-skills leaves context files intact. --- packages/cli/README.md | 4 +++- packages/cli/src/harness-skills.ts | 16 +++++++++++----- packages/cli/test/harness-skills.test.ts | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/cli/README.md b/packages/cli/README.md index 084036d..3ae0fe2 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -159,7 +159,9 @@ in a prompt to force-load a skill body inline. Context files (`AGENTS.md` / `CLAUDE.md`) discovered by the resource loader are appended to the system prompt and listed in the TUI's -`[Context]` section. +`[Context]` section. `--no-skills` disables skill discovery only; +context files still load, since they describe the project rather than +add agent capabilities. pi *extensions* are not executed by `cua`: extensions bind into pi's `AgentSession`, and `cua` drives the lower-level `AgentHarness` diff --git a/packages/cli/src/harness-skills.ts b/packages/cli/src/harness-skills.ts index 2d0d44c..531681c 100644 --- a/packages/cli/src/harness-skills.ts +++ b/packages/cli/src/harness-skills.ts @@ -4,7 +4,7 @@ import { getAgentDir, SettingsManager, } from "@earendil-works/pi-coding-agent"; -import { dirname } from "node:path"; +import { dirname, join } from "node:path"; export interface DiscoverSkillsOptions { cwd: string; @@ -25,7 +25,6 @@ export interface ContextFile { export interface DiscoverSkillsResult { skills: Skill[]; contextFiles: ContextFile[]; - sources: string[]; diagnostics: SkillDiagnostic[]; } @@ -48,11 +47,18 @@ export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise p && p.trim().length > 0); const agentDir = opts.agentDir ?? getAgentDir(); const settingsManager = SettingsManager.create(opts.cwd, agentDir, { projectTrusted: false }); + // Project-local `/.agents/skills` is loaded explicitly rather than via + // pi's trusted project scan. That scan only runs when the project is trusted + // (which would mean prompting the user and binding untrusted `.pi/` + // extensions); `additionalSkillPaths` loads the directory unconditionally and + // never binds extensions, so project skills work without a trust prompt. + const projectSkillDir = join(opts.cwd, ".agents", "skills"); + const additionalSkillPaths = [...extras, projectSkillDir]; const loader = new DefaultResourceLoader({ cwd: opts.cwd, agentDir, settingsManager, - additionalSkillPaths: extras, + additionalSkillPaths, noSkills: opts.disabled === true, noExtensions: true, noPromptTemplates: true, @@ -80,11 +86,11 @@ export async function discoverCuaSkills(opts: DiscoverSkillsOptions): Promise s.filePath)); const skillDirs = [...new Set(piSkills.map((s) => dirname(s.filePath)))]; if (skillDirs.length === 0) { - return { skills: [], contextFiles, sources: [], diagnostics: [] }; + return { skills: [], contextFiles, diagnostics: [] }; } const loaded = await loadSkills(opts.env, skillDirs); const skills = dedupeByFilePath(loaded.skills.filter((s) => discoveredPaths.has(s.filePath))); - return { skills, contextFiles, sources: skillDirs, diagnostics: loaded.diagnostics }; + return { skills, contextFiles, diagnostics: loaded.diagnostics }; } function dedupeByFilePath(skills: Skill[]): Skill[] { diff --git a/packages/cli/test/harness-skills.test.ts b/packages/cli/test/harness-skills.test.ts index e3d7e74..3c01e0e 100644 --- a/packages/cli/test/harness-skills.test.ts +++ b/packages/cli/test/harness-skills.test.ts @@ -85,6 +85,20 @@ describe("discoverCuaSkills", () => { expect(result.skills).toHaveLength(0); }); + it("discovers a project-local skill from /.agents/skills", async () => { + // Project settings stay untrusted, so pi's trusted project scan is off. + // The project skills dir must still be discovered (via additionalSkillPaths) + // without enabling untrusted `.pi/` extensions. + writeSkill(join(cwd, ".agents", "skills", "lint"), "lint", "Run the linter.", "Run the lint workflow."); + + const env = new NodeExecutionEnv({ cwd }); + const result = await discoverCuaSkills({ cwd, env, agentDir }); + + const lint = result.skills.find((s) => s.name === "lint"); + expect(lint, "project-local skill should be discovered").toBeDefined(); + expect(lint?.content).toContain("Run the lint workflow."); + }); + it("loads skills from an explicit --skill path", async () => { const extraDir = mkdtempSync(join(tmpdir(), "cua-extra-skill-")); writeSkill(join(extraDir, "deploy"), "deploy", "Ship the build.", "Run the deploy steps.");