diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e83fbd --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +aws-config +.pi/agent/auth.json +.pi/agent/sessions/ +bin/pi + diff --git a/.pi/agent/extensions/fetch-url.ts b/.pi/agent/extensions/fetch-url.ts new file mode 100644 index 0000000..f4cddd5 --- /dev/null +++ b/.pi/agent/extensions/fetch-url.ts @@ -0,0 +1,340 @@ +/** + * fetch_url — curl-first page fetcher, Tavily Extract fallback. + * + * Default path: spawn `curl` via pi.exec (fast, free, no API). + * Fallback: POST https://api.tavily.com/extract (handles JS walls, + * Cloudflare challenges, SPA shells, etc.) when the curl + * result looks bad. + * + * Pairs with web-search.ts. Requires TAVILY_API_KEY only for the fallback. + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; + +const DEFAULT_UA = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36"; + +const BAD_SENTINELS = [ + "just a moment", + "checking your browser", + "enable javascript", + "please enable js", + "attention required | cloudflare", + "access denied", + "captcha", + "are you a robot", +]; + +type FetchSource = "curl" | "tavily-extract"; + +type FetchOutcome = { + source: FetchSource; + status?: number; + contentType?: string; + url: string; + finalUrl?: string; + text: string; + truncated: boolean; + reasonForFallback?: string; +}; + +function stripHtml(html: string): string { + // Cheap visible-text extraction. Good enough for the "is this page junk?" + // heuristic; we don't try to replace a real readability pipeline. + return html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//g, " ") + .replace(/<[^>]+>/g, " ") + .replace(/ /g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +function looksBad( + status: number | undefined, + contentType: string | undefined, + body: string, +): string | null { + if (status === undefined) return "no HTTP status"; + if (status >= 400) return `HTTP ${status}`; + + const ct = (contentType ?? "").toLowerCase(); + const isHtml = ct.includes("html") || ct === "" || ct.includes("text/plain"); + if (!isHtml && !ct.startsWith("application/json") && !ct.startsWith("application/xml")) { + return `non-text content-type: ${contentType}`; + } + + const visible = isHtml ? stripHtml(body) : body; + if (visible.length < 500) return `only ${visible.length} chars of visible text`; + + const lower = visible.slice(0, 4000).toLowerCase(); + for (const needle of BAD_SENTINELS) { + if (lower.includes(needle)) return `sentinel match: "${needle}"`; + } + + return null; +} + +function truncate(text: string, maxChars: number): { text: string; truncated: boolean } { + if (text.length <= maxChars) return { text, truncated: false }; + return { text: text.slice(0, maxChars), truncated: true }; +} + +async function fetchWithCurl( + pi: ExtensionAPI, + url: string, + signal: AbortSignal | undefined, + timeoutMs: number, +): Promise<{ + status?: number; + contentType?: string; + body: string; + finalUrl?: string; + error?: string; +}> { + // `-w` writes a trailer we parse off the end so we get status + content-type + // + effective URL without a second request. + const TRAILER = "\n---PI_CURL_META---\n"; + const result = await pi.exec( + "curl", + [ + "-sSL", + "--compressed", + "--max-time", + String(Math.ceil(timeoutMs / 1000)), + "-A", + DEFAULT_UA, + "-H", + "Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "-H", + "Accept-Language: en-US,en;q=0.9", + "-w", + `${TRAILER}%{http_code} %{content_type} %{url_effective}`, + url, + ], + { timeout: timeoutMs + 2_000, signal }, + ); + + if (result.code !== 0 && !result.stdout) { + return { body: "", error: `curl exit ${result.code}: ${result.stderr.trim()}` }; + } + + const idx = result.stdout.lastIndexOf(TRAILER); + if (idx === -1) { + return { body: result.stdout, error: "missing curl meta trailer" }; + } + const body = result.stdout.slice(0, idx); + const meta = result.stdout.slice(idx + TRAILER.length).trim(); + const [codeStr, contentType, finalUrl] = meta.split(" "); + const status = Number.parseInt(codeStr ?? "", 10); + return { + status: Number.isFinite(status) ? status : undefined, + contentType, + body, + finalUrl, + }; +} + +async function fetchWithTavilyExtract( + url: string, + signal: AbortSignal | undefined, + format: "markdown" | "text", +): Promise<{ text: string; error?: string }> { + const apiKey = process.env.TAVILY_API_KEY; + if (!apiKey) { + return { + text: "", + error: + "TAVILY_API_KEY is not set; cannot fall back to Tavily Extract. " + + "Get a key at https://tavily.com/ and export it before launching pi.", + }; + } + + let resp: Response; + try { + resp = await fetch("https://api.tavily.com/extract", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + urls: [url], + extract_depth: "advanced", + format, // "markdown" or "text" + }), + signal, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { text: "", error: `Tavily Extract network error: ${msg}` }; + } + + if (!resp.ok) { + const body = await resp.text().catch(() => ""); + return { text: "", error: `Tavily Extract ${resp.status}: ${body.slice(0, 500)}` }; + } + + const data = (await resp.json()) as { + results?: Array<{ url: string; raw_content?: string }>; + failed_results?: Array<{ url: string; error?: string }>; + }; + const first = data.results?.[0]; + if (!first?.raw_content) { + const failure = data.failed_results?.[0]?.error ?? "no content returned"; + return { text: "", error: `Tavily Extract returned no content: ${failure}` }; + } + return { text: first.raw_content }; +} + +export default function fetchUrlExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "fetch_url", + label: "Fetch URL", + description: + "Fetch a web page as text. Tries curl first; if the page looks bot-walled, " + + "JS-only, or errors out, falls back to Tavily Extract (requires TAVILY_API_KEY).", + promptSnippet: + "Fetch a URL as text (curl first, Tavily Extract fallback for JS/bot walls)", + promptGuidelines: [ + "Use fetch_url after web_search when the snippet/answer is not enough and you need the page body.", + "Prefer fetch_url over `curl` in bash for web pages: it handles JS walls and anti-bot pages transparently.", + ], + parameters: Type.Object({ + url: Type.String({ description: "Absolute URL to fetch (http/https)" }), + max_chars: Type.Optional( + Type.Integer({ + description: "Max characters of extracted text to return (default 20000)", + minimum: 500, + maximum: 200_000, + }), + ), + format: Type.Optional( + StringEnum(["markdown", "text"] as const, { + description: + "Output format for the Tavily Extract fallback. Ignored by curl path. Default 'markdown'.", + }), + ), + force: Type.Optional( + StringEnum(["auto", "curl", "tavily"] as const, { + description: + "'auto' (default) = curl then Tavily fallback. 'curl' = curl only. 'tavily' = skip curl.", + }), + ), + timeout_ms: Type.Optional( + Type.Integer({ + description: "curl timeout in ms (default 15000)", + minimum: 1000, + maximum: 60_000, + }), + ), + }), + async execute(_toolCallId, params, signal) { + const url = params.url; + const maxChars = params.max_chars ?? 20_000; + const format = params.format ?? "markdown"; + const force = params.force ?? "auto"; + const timeoutMs = params.timeout_ms ?? 15_000; + + if (!/^https?:\/\//i.test(url)) { + return { + content: [{ type: "text", text: `Refusing to fetch non-http(s) URL: ${url}` }], + isError: true, + details: { url }, + }; + } + + let outcome: FetchOutcome | null = null; + let curlError: string | undefined; + let fallbackReason: string | undefined; + + // --- curl path --- + if (force !== "tavily") { + const curl = await fetchWithCurl(pi, url, signal, timeoutMs); + if (curl.error) { + curlError = curl.error; + fallbackReason = curl.error; + } else { + const bad = looksBad(curl.status, curl.contentType, curl.body); + if (!bad || force === "curl") { + const isHtml = (curl.contentType ?? "").toLowerCase().includes("html"); + const text = isHtml ? stripHtml(curl.body) : curl.body; + const { text: clipped, truncated } = truncate(text, maxChars); + outcome = { + source: "curl", + status: curl.status, + contentType: curl.contentType, + url, + finalUrl: curl.finalUrl, + text: clipped, + truncated, + }; + } else { + fallbackReason = bad; + } + } + } + + // --- Tavily Extract fallback --- + if (!outcome && force !== "curl") { + const tv = await fetchWithTavilyExtract(url, signal, format); + if (tv.error) { + const msg = [ + curlError && `curl: ${curlError}`, + fallbackReason && `fallback trigger: ${fallbackReason}`, + `tavily: ${tv.error}`, + ] + .filter(Boolean) + .join("\n"); + return { + content: [{ type: "text", text: `Failed to fetch ${url}\n${msg}` }], + isError: true, + details: { url, curlError, fallbackReason, tavilyError: tv.error }, + }; + } + const { text: clipped, truncated } = truncate(tv.text, maxChars); + outcome = { + source: "tavily-extract", + url, + text: clipped, + truncated, + reasonForFallback: fallbackReason, + }; + } + + if (!outcome) { + return { + content: [ + { + type: "text", + text: `Failed to fetch ${url}: ${curlError ?? "unknown error"}`, + }, + ], + isError: true, + details: { url, curlError, fallbackReason }, + }; + } + + const header = [ + `URL: ${outcome.finalUrl ?? outcome.url}`, + `Source: ${outcome.source}${ + outcome.reasonForFallback ? ` (fallback: ${outcome.reasonForFallback})` : "" + }`, + outcome.status !== undefined ? `HTTP: ${outcome.status}` : undefined, + outcome.contentType ? `Content-Type: ${outcome.contentType}` : undefined, + outcome.truncated ? `(truncated to ${maxChars} chars)` : undefined, + ] + .filter(Boolean) + .join("\n"); + + return { + content: [{ type: "text", text: `${header}\n\n${outcome.text}` }], + details: outcome, + }; + }, + }); +} diff --git a/.pi/agent/extensions/web-search.ts b/.pi/agent/extensions/web-search.ts new file mode 100644 index 0000000..b670e62 --- /dev/null +++ b/.pi/agent/extensions/web-search.ts @@ -0,0 +1,162 @@ +/** + * Web Search Extension (Tavily API) + * + * Registers a `web_search` tool the LLM can call for current/up-to-date info. + * + * Setup: + * 1. Get an API key at https://tavily.com/ (email signup, free 1k queries/month) + * 2. export TAVILY_API_KEY=tvly-... + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Type } from "typebox"; + +type TavilyResult = { + title: string; + url: string; + content: string; + score?: number; + published_date?: string; +}; + +type TavilyResponse = { + query: string; + answer?: string; + results: TavilyResult[]; +}; + +export default function webSearchExtension(pi: ExtensionAPI) { + pi.registerTool({ + name: "web_search", + label: "Web Search", + description: + "Search the web via Tavily and return a synthesized answer plus result titles, URLs, and snippets. " + + "Use for current events, docs lookups, or any fact that may be newer than training data.", + promptSnippet: "Search the web (Tavily) for up-to-date information", + promptGuidelines: [ + "Use web_search when the user asks about current events, recent releases, or facts that may be outdated in training data.", + "After web_search returns results, call fetch_url on the most relevant URLs if the snippets are insufficient. Prefer fetch_url over raw curl/wget in bash — it handles JS walls and anti-bot pages automatically.", + ], + parameters: Type.Object({ + query: Type.String({ description: "Search query" }), + max_results: Type.Optional( + Type.Integer({ + description: "Max results to return (1-20, default 5)", + minimum: 1, + maximum: 20, + }), + ), + search_depth: Type.Optional( + StringEnum(["basic", "advanced"] as const, { + description: + "'basic' is faster/cheaper; 'advanced' does deeper crawling for harder queries. Default 'basic'.", + }), + ), + topic: Type.Optional( + StringEnum(["general", "news"] as const, { + description: "Use 'news' for recent-events queries. Default 'general'.", + }), + ), + include_answer: Type.Optional( + Type.Boolean({ + description: "Ask Tavily to return a short synthesized answer. Default true.", + }), + ), + }), + async execute(_toolCallId, params, signal) { + const apiKey = process.env.TAVILY_API_KEY; + if (!apiKey) { + return { + content: [ + { + type: "text", + text: + "TAVILY_API_KEY is not set. Get a key at https://tavily.com/ " + + "and `export TAVILY_API_KEY=...` before launching pi.", + }, + ], + isError: true, + details: {}, + }; + } + + const body = { + query: params.query, + max_results: params.max_results ?? 5, + search_depth: params.search_depth ?? "basic", + topic: params.topic ?? "general", + include_answer: params.include_answer ?? true, + }; + + let resp: Response; + try { + resp = await fetch("https://api.tavily.com/search", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(body), + signal, + }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return { + content: [{ type: "text", text: `Network error calling Tavily: ${msg}` }], + isError: true, + details: {}, + }; + } + + if (!resp.ok) { + const errBody = await resp.text().catch(() => ""); + return { + content: [ + { + type: "text", + text: `Tavily search failed: ${resp.status} ${resp.statusText}\n${errBody}`, + }, + ], + isError: true, + details: { status: resp.status }, + }; + } + + const data = (await resp.json()) as TavilyResponse; + const results = data.results ?? []; + + if (results.length === 0 && !data.answer) { + return { + content: [{ type: "text", text: `No results for: ${params.query}` }], + details: { query: params.query, results: [] }, + }; + } + + const parts: string[] = []; + if (data.answer) { + parts.push(`Answer: ${data.answer}`); + } + if (results.length > 0) { + const formatted = results + .map( + (r, i) => + `${i + 1}. ${r.title}\n ${r.url}\n ${r.content}${ + r.published_date ? ` (${r.published_date})` : "" + }`, + ) + .join("\n\n"); + parts.push(`Results:\n${formatted}`); + } + + return { + content: [{ type: "text", text: parts.join("\n\n") }], + details: { + query: params.query, + answer: data.answer, + results, + }, + }; + }, + }); +} diff --git a/.pi/agent/settings.json b/.pi/agent/settings.json new file mode 100644 index 0000000..03c5865 --- /dev/null +++ b/.pi/agent/settings.json @@ -0,0 +1,5 @@ +{ + "lastChangelogVersion": "0.74.0", + "theme": "zen_dark", + "steeringMode": "all" +} \ No newline at end of file diff --git a/.pi/agent/skills/planning/SKILL.md b/.pi/agent/skills/planning/SKILL.md new file mode 100644 index 0000000..f11ab91 --- /dev/null +++ b/.pi/agent/skills/planning/SKILL.md @@ -0,0 +1,75 @@ +--- +name: planning +description: Creates and maintains a structured plan document for multi-step tasks. The plan lives at PLAN.md and contains an ordered, checkbox-driven task list the agent keeps in sync as work progresses. Use when the user asks for a plan, when a task has multiple non-trivial steps, or when you need to track progress across a longer session. +--- + +# Planning + +Track a multi-step project using a markdown checklist. + +## When to use + +Use this skill when any of the following is true: +- The user explicitly asks for a plan, todo list, or breakdown. +- Work will continue across several turns and progress needs to be tracked. +- You need to confirm scope with the user before executing. + +For trivial one-shot edits, do not create a plan. + +## Plan location + +- Default path: `./PLAN.md` in the current working directory. +- If a plan path is specified by the user, use that instead. +- If `PLAN.md` already exists, read it first and update it in place rather than overwriting unrelated content. + +## Plan document format + +The plan is plain Markdown. Every task is a GitHub-style checkbox list item. Keep it scannable. + +```markdown +# Plan: + +<1–3 sentence summary of the goal and any important constraints.> + +## Tasks + +- [ ] 1. +- [ ] 2. + - [ ] 2a. + - [ ] 2b. +- [ ] 3. <...> + +## Notes +- +``` + +Rules for tasks: +- One action per checkbox. If a step has "and" in it, consider splitting. +- Each task must be concrete and verifiable ("Add `--json` flag to `cli.ts` and update `--help` output"), not vague ("improve CLI"). +- Number tasks so they can be referenced in conversation ("done with 2, starting 3"). +- Use nested checkboxes for sub-steps only when it adds clarity. +- Prefer 3–12 top-level tasks. If you have more, group them under `##` section headings. + +Checkbox states: +- `[ ]` not started +- `[~]` in progress (optional; use when a task spans multiple turns) +- `[x]` done +- `[-]` skipped or no longer applicable (add a short reason in the same line or in Notes) + +## Workflow + +1. **Draft.** On first use, read any existing `PLAN.md`, then write a complete plan with all tasks as `[ ]`. Keep the plan focused on the user's current goal. +2. **Confirm (when useful).** If scope is ambiguous or the work is large, show the plan to the user and ask for confirmation before executing. For clear requests, proceed. +3. **Execute one task at a time.** Before starting a task, mark it `[~]` (or announce which task you're starting). After finishing, mark it `[x]` and update `Last updated` and `Current step`. +4. **Keep the plan in sync.** If you discover new work, add new checkboxes. If a task becomes unnecessary, mark it `[-]` with a brief reason rather than deleting it, so the history stays readable. +5. **Summarize at the end.** When every task is `[x]` or `[-]`, add a short `## Summary` section describing what was done and any follow-ups. + +## Editing the plan + +- Use the `edit` tool to flip individual checkboxes and update the `Status` block — avoid rewriting the whole file. +- Only use `write` when creating the plan for the first time or doing a full restructure. +- Never silently drop tasks. Either complete them, skip them with `[-]` and a reason, or move them to a `## Deferred` section. + +## Example + +See [example-plan.md](example-plan.md) for a fully worked example of a small feature broken into tasks. diff --git a/.pi/agent/skills/planning/example-plan.md b/.pi/agent/skills/planning/example-plan.md new file mode 100644 index 0000000..86b8164 --- /dev/null +++ b/.pi/agent/skills/planning/example-plan.md @@ -0,0 +1,18 @@ +# Plan: Add `--json` output mode to the `report` CLI + +Add a `--json` flag to the existing `report` command so it can emit machine-readable output in addition to the current human-readable table. Must not change default behavior. + +## Tasks + +- [x] 1. Read `src/cli/report.ts` and identify where output is currently rendered. +- [x] 2. Define a `ReportJson` type in `src/cli/report-types.ts` matching the existing table columns. +- [~] 3. Add `--json` flag parsing to `src/cli/report.ts` and thread it through to the renderer. + - [x] 3a. Register the flag with the arg parser. + - [ ] 3b. Branch on the flag in `renderReport()` to call a new `renderJson()` helper. +- [ ] 4. Implement `renderJson()` that prints `JSON.stringify(data, null, 2)` and exits 0. +- [ ] 5. Update `--help` text and `README.md` usage section to document `--json`. +- [ ] 6. Add a test in `test/cli/report.test.ts` covering `--json` output shape. + +## Notes +- Keep default (non-`--json`) output byte-identical to current behavior — existing snapshot tests must still pass. +- Open question: should errors also be JSON when `--json` is set? Assuming yes; will confirm with user before step 4. diff --git a/.pi/agent/themes/zen_dark.json b/.pi/agent/themes/zen_dark.json new file mode 100644 index 0000000..e63e886 --- /dev/null +++ b/.pi/agent/themes/zen_dark.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "zen_dark", + "vars": { + "bg": "#191919", + "fg": "#BBBBBB", + "black": "#191919", + "red": "#DE6E7C", + "green": "#819B69", + "yellow": "#B77E64", + "blue": "#6099C0", + "magenta": "#B279A7", + "cyan": "#66A5AD", + "white": "#BBBBBB", + "brBlack": "#3d3839", + "brRed": "#E8838F", + "brGreen": "#8BAE68", + "brYellow": "#D68C67", + "brBlue": "#61ABDA", + "brMagenta": "#CF86C1", + "brCyan": "#65B8C1", + "brWhite": "#8e8e8e", + "selectedBg": "#2a2a2a", + "userMsgBg": "#222222", + "customMsgBg": "#2a2430", + "toolPendingBg": "#1f1f1f", + "toolSuccessBg": "#1e2a1e", + "toolErrorBg": "#2e1e20" + }, + "colors": { + "accent": "cyan", + "border": "blue", + "borderAccent": "brCyan", + "borderMuted": "brBlack", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "brWhite", + "dim": "brBlack", + "text": "", + "thinkingText": "brWhite", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "magenta", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "", + "toolOutput": "brWhite", + + "mdHeading": "brYellow", + "mdLink": "brBlue", + "mdLinkUrl": "brWhite", + "mdCode": "cyan", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "brBlack", + "mdQuote": "brWhite", + "mdQuoteBorder": "brBlack", + "mdHr": "brBlack", + "mdListBullet": "cyan", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "brWhite", + + "syntaxComment": "brBlack", + "syntaxKeyword": "magenta", + "syntaxFunction": "brBlue", + "syntaxVariable": "fg", + "syntaxString": "green", + "syntaxNumber": "yellow", + "syntaxType": "cyan", + "syntaxOperator": "brWhite", + "syntaxPunctuation": "brWhite", + + "thinkingOff": "brBlack", + "thinkingMinimal": "brWhite", + "thinkingLow": "blue", + "thinkingMedium": "brBlue", + "thinkingHigh": "magenta", + "thinkingXhigh": "brMagenta", + + "bashMode": "green" + }, + "export": { + "pageBg": "#191919", + "cardBg": "#222222", + "infoBg": "#3d3839" + } +} diff --git a/.pi/agent/themes/zen_light.json b/.pi/agent/themes/zen_light.json new file mode 100644 index 0000000..c51f145 --- /dev/null +++ b/.pi/agent/themes/zen_light.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://raw.githubusercontent.com/earendil-works/pi-mono/main/packages/coding-agent/src/modes/interactive/theme/theme-schema.json", + "name": "zen_light", + "vars": { + "bg": "#F0EDEC", + "fg": "#4F5E68", + "black": "#F0EDEC", + "red": "#A8334C", + "green": "#4F6C31", + "yellow": "#944927", + "blue": "#286486", + "magenta": "#88507D", + "cyan": "#3B8992", + "white": "#2C363C", + "brBlack": "#CFC1BA", + "brRed": "#94253E", + "brGreen": "#3F5A22", + "brYellow": "#803D1C", + "brBlue": "#1D5573", + "brMagenta": "#7B3B70", + "brCyan": "#2B747C", + "brWhite": "#4F5E68", + "selectedBg": "#E2DCD8", + "userMsgBg": "#E6E1DE", + "customMsgBg": "#E8DEE6", + "toolPendingBg": "#E6E1DE", + "toolSuccessBg": "#DDE4D2", + "toolErrorBg": "#EAD6D8" + }, + "colors": { + "accent": "cyan", + "border": "blue", + "borderAccent": "brCyan", + "borderMuted": "brBlack", + "success": "green", + "error": "red", + "warning": "yellow", + "muted": "brWhite", + "dim": "brBlack", + "text": "", + "thinkingText": "brWhite", + + "selectedBg": "selectedBg", + "userMessageBg": "userMsgBg", + "userMessageText": "", + "customMessageBg": "customMsgBg", + "customMessageText": "", + "customMessageLabel": "magenta", + "toolPendingBg": "toolPendingBg", + "toolSuccessBg": "toolSuccessBg", + "toolErrorBg": "toolErrorBg", + "toolTitle": "", + "toolOutput": "brWhite", + + "mdHeading": "brYellow", + "mdLink": "blue", + "mdLinkUrl": "brBlack", + "mdCode": "cyan", + "mdCodeBlock": "green", + "mdCodeBlockBorder": "brBlack", + "mdQuote": "brWhite", + "mdQuoteBorder": "brBlack", + "mdHr": "brBlack", + "mdListBullet": "cyan", + + "toolDiffAdded": "green", + "toolDiffRemoved": "red", + "toolDiffContext": "brWhite", + + "syntaxComment": "brBlack", + "syntaxKeyword": "magenta", + "syntaxFunction": "blue", + "syntaxVariable": "fg", + "syntaxString": "green", + "syntaxNumber": "yellow", + "syntaxType": "cyan", + "syntaxOperator": "brWhite", + "syntaxPunctuation": "brWhite", + + "thinkingOff": "brBlack", + "thinkingMinimal": "brWhite", + "thinkingLow": "blue", + "thinkingMedium": "brBlue", + "thinkingHigh": "magenta", + "thinkingXhigh": "brMagenta", + + "bashMode": "green" + }, + "export": { + "pageBg": "#F0EDEC", + "cardBg": "#E6E1DE", + "infoBg": "#CFC1BA" + } +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..488456a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,165 @@ +FROM debian:bookworm-slim + +ARG PYTHON_VERSION=3.11 +ARG NODE_MAJOR=22 +ARG JAVA_VERSION=17 +ARG GO_VERSION=1.22.4 +ARG GRADLE_VERSION=8.7 +ARG TERRAFORM_VERSION=1.8.5 + +ENV DEBIAN_FRONTEND=noninteractive +ENV LANG=C.UTF-8 + +# Base system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + wget \ + git \ + ssh \ + make \ + build-essential \ + unzip \ + jq \ + gnupg \ + lsb-release \ + software-properties-common \ + libxml2-dev \ + libxmlsec1-dev \ + libffi-dev \ + libpq-dev \ + libssl-dev \ + zlib1g-dev \ + libbz2-dev \ + libreadline-dev \ + libsqlite3-dev \ + zsh \ + fd-find \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* \ + && ln -sf /usr/bin/fdfind /usr/local/bin/fd + +# Set zsh as default shell +SHELL ["/bin/zsh", "-c"] +RUN chsh -s /bin/zsh root + +# Python 3.11 via deadsnakes-style build (bookworm ships 3.11) +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + python3-venv \ + python3-dev \ + && ln -sf /usr/bin/python3 /usr/bin/python \ + && rm -rf /var/lib/apt/lists/* + +# Poetry (official installer avoids PyPI timeout issues) +ENV POETRY_HOME="/opt/poetry" +ENV PATH="${POETRY_HOME}/bin:${PATH}" +RUN curl -sSL https://install.python-poetry.org | python3 - && \ + poetry config virtualenvs.in-project true + +# Node.js 22 +RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* + +# Java 17 (Eclipse Temurin) +RUN curl -fsSL https://packages.adoptium.net/artifactory/api/gpg/key/public | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg && \ + echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" > /etc/apt/sources.list.d/adoptium.list && \ + apt-get update && apt-get install -y --no-install-recommends temurin-${JAVA_VERSION}-jdk && \ + rm -rf /var/lib/apt/lists/* + +# Gradle +RUN curl -fsSL https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip -o /tmp/gradle.zip && \ + unzip -d /opt /tmp/gradle.zip && \ + ln -s /opt/gradle-${GRADLE_VERSION}/bin/gradle /usr/local/bin/gradle && \ + rm /tmp/gradle.zip + +# Go +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL https://go.dev/dl/go${GO_VERSION}.linux-${ARCH}.tar.gz | tar -C /usr/local -xz +ENV PATH="/usr/local/go/bin:/root/go/bin:${PATH}" + +# Docker CLI (client only, expects host docker socket mount) +RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list && \ + apt-get update && apt-get install -y --no-install-recommends docker-ce-cli && \ + rm -rf /var/lib/apt/lists/* + +# kubectl +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL https://dl.k8s.io/release/$(curl -sL https://dl.k8s.io/release/stable.txt)/bin/linux/${ARCH}/kubectl -o /usr/local/bin/kubectl && \ + chmod +x /usr/local/bin/kubectl + +# Helm +RUN curl -fsSL https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash + +# AWS CLI v2 +RUN ARCH=$(uname -m) && \ + curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \ + unzip -q /tmp/awscli.zip -d /tmp && \ + /tmp/aws/install && \ + rm -rf /tmp/aws /tmp/awscli.zip + +# Terraform +RUN ARCH=$(dpkg --print-architecture) && \ + curl -fsSL "https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_${ARCH}.zip" -o /tmp/terraform.zip && \ + unzip /tmp/terraform.zip -d /usr/local/bin && \ + rm /tmp/terraform.zip + +# Atlassian CLI (acli) +RUN mkdir -p -m 755 /etc/apt/keyrings && \ + wget -nv -O- https://acli.atlassian.com/gpg/public-key.asc | gpg --dearmor -o /etc/apt/keyrings/acli-archive-keyring.gpg && \ + chmod go+r /etc/apt/keyrings/acli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/acli-archive-keyring.gpg] https://acli.atlassian.com/linux/deb stable main" > /etc/apt/sources.list.d/acli.list && \ + apt-get update && apt-get install -y acli && \ + rm -rf /var/lib/apt/lists/* + +# Rust +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y +ENV PATH="/root/.cargo/bin:${PATH}" + +# Starship prompt +RUN curl -sS https://starship.rs/install.sh | sh -s -- -y + +# Pi coding agent +RUN npm install -g @earendil-works/pi-coding-agent + +# SelimovDE dotfiles +RUN git clone https://forge.alexselimov.com/aselimov/SelimovDE.git /root/SelimovDE && \ + cd /root/SelimovDE && \ + git submodule init && \ + git submodule update && \ + ./deploy.sh + +# AWS config (optional, only applied if aws-config exists in build context) +COPY Dockerfile aws-config* /tmp/build-context/ +RUN if [ -f /tmp/build-context/aws-config ]; then \ + mkdir -p /root/.aws && mv /tmp/build-context/aws-config /root/.aws/config; \ + fi && rm -rf /tmp/build-context + +# Pi wrapper script (optional, only applied if bin/pi exists in build context) +COPY Dockerfile bin/p* /tmp/build-context/ +RUN if [ -f /tmp/build-context/pi ]; then \ + mv /tmp/build-context/pi /usr/local/bin/pi && chmod +x /usr/local/bin/pi; \ + fi && rm -rf /tmp/build-context + +# Workspace directory (mount point for ~/repos) +RUN mkdir -p /workspace +WORKDIR /workspace + +# Pi state directory lives on a named Docker volume mounted at /root/.pi +# (see docker run: -v pi-state:/root/.pi). This keeps pi's hot runtime state +# (sessions, logs, caches) on the Linux VM's native ext4 instead of the +# macOS bind mount (virtiofs), which is a major perf win for the agent loop. +# +# Config you want to persist/commit (settings, skills, themes, extensions, +# etc.) lives in the workspace at /workspace/dev_sandbox/.pi/agent and is +# symlinked into /root/.pi/agent at runtime by entrypoint.sh. +RUN mkdir -p /root/.pi + +# Entrypoint script for runtime auth +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["/bin/zsh"] diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..a4c0199 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/zsh + +# Wire up Pi config from the workspace bind mount. +# +# Why: ~/.pi holds both config (settings, skills, themes, extensions) AND hot +# runtime state (sessions, logs). We want to persist/commit the config via the +# workspace, but keep sessions on the container's native FS so agent turns +# aren't bottlenecked by virtiofs. +# +# Strategy: keep /root/.pi on native FS, and symlink only the config entries +# out to /workspace/dev_sandbox/.pi/agent/*. +PI_SRC="/workspace/dev_sandbox/.pi/agent" +PI_DST="/root/.pi/agent" +if [[ -d "$PI_SRC" ]]; then + mkdir -p "$PI_DST" "$PI_DST/sessions" + # Entries to persist from the workspace (add more here as needed). + # Note: auth.json is intentionally omitted — credentials stay on the + # named volume only, not in the committed workspace. + for entry in settings.json bin extensions skills themes prompt-templates agents.toml config.toml; do + src="$PI_SRC/$entry" + dst="$PI_DST/$entry" + if [[ -e "$src" ]]; then + # Replace whatever is at dst with a symlink to the workspace copy. + rm -rf "$dst" + ln -sf "$src" "$dst" + fi + done +fi + +exec "$@"