Add first version of dev sandbox
This commit is contained in:
parent
883b0df070
commit
7974e725d0
10 changed files with 989 additions and 0 deletions
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
.env
|
||||||
|
aws-config
|
||||||
|
.pi/agent/auth.json
|
||||||
|
.pi/agent/sessions/
|
||||||
|
bin/pi
|
||||||
|
|
||||||
340
.pi/agent/extensions/fetch-url.ts
Normal file
340
.pi/agent/extensions/fetch-url.ts
Normal file
|
|
@ -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(/<script[\s\S]*?<\/script>/gi, " ")
|
||||||
|
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||||
|
.replace(/<noscript[\s\S]*?<\/noscript>/gi, " ")
|
||||||
|
.replace(/<!--[\s\S]*?-->/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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
162
.pi/agent/extensions/web-search.ts
Normal file
162
.pi/agent/extensions/web-search.ts
Normal file
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
5
.pi/agent/settings.json
Normal file
5
.pi/agent/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"lastChangelogVersion": "0.74.0",
|
||||||
|
"theme": "zen_dark",
|
||||||
|
"steeringMode": "all"
|
||||||
|
}
|
||||||
75
.pi/agent/skills/planning/SKILL.md
Normal file
75
.pi/agent/skills/planning/SKILL.md
Normal file
|
|
@ -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: <short title of the overall goal>
|
||||||
|
|
||||||
|
<1–3 sentence summary of the goal and any important constraints.>
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [ ] 1. <First concrete, verifiable task>
|
||||||
|
- [ ] 2. <Next task>
|
||||||
|
- [ ] 2a. <Sub-step, if the task naturally decomposes>
|
||||||
|
- [ ] 2b. <Sub-step>
|
||||||
|
- [ ] 3. <...>
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- <Open questions, assumptions, decisions, links, or context worth preserving.>
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
18
.pi/agent/skills/planning/example-plan.md
Normal file
18
.pi/agent/skills/planning/example-plan.md
Normal file
|
|
@ -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.
|
||||||
94
.pi/agent/themes/zen_dark.json
Normal file
94
.pi/agent/themes/zen_dark.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
94
.pi/agent/themes/zen_light.json
Normal file
94
.pi/agent/themes/zen_light.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
Dockerfile
Normal file
165
Dockerfile
Normal file
|
|
@ -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"]
|
||||||
30
entrypoint.sh
Normal file
30
entrypoint.sh
Normal file
|
|
@ -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 "$@"
|
||||||
Loading…
Add table
Add a link
Reference in a new issue