Initialize repository with harness code and assets

Initial import of all source code, config, and README assets: the
packages workspace (cli, core, server, web, docs, landing, skills),
build scripts, tooling config, and CI workflows.

Includes the data-layout revision made on this branch: the local data
root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the
installer keeps its binaries in ~/.penguin), and every Agent lives
under <project>/agents/<agent>/ — path helpers, the three
agent-enumeration scans, the system prompt, built-in Skills, tests
and docs all follow the new layout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
Yaowei Zheng
2026-07-19 14:06:53 +08:00
committed by GitHub
parent 056bed7aeb
commit 45bfae6e94
543 changed files with 92949 additions and 0 deletions
+369
View File
@@ -0,0 +1,369 @@
/**
* `penguin chat` — interactive REPL.
*
* penguin chat [--model-id <id>] [--provider <group>] [--project-id <id>] [--agent-id <id>]
* [--workspace <path>] [--approve <allow-all|deny-all|read-only|always-ask>]
*
* Each line of input starts one conversation turn; `/compact` proactively compacts the
* context (reason=manual); `/exit` or `/quit` exits.
* Uses the current directory when no Workspace is specified.
*
* Multi-line input: trailing `\` continues the line; when the terminal supports bracketed
* paste, a multi-line paste is treated as a single message (sent on Enter).
*
* Ctrl-C behavior (state-dependent): buffer has content -> clear it;
* awaiting approval -> deny; running -> abort the current turn and return to input;
* empty buffer -> show a y/N exit confirmation.
*
* Implementation notes: on a TTY, stdin is put into raw mode with bracketed paste enabled;
* stdin is piped through PasteFilter into a readline created with `terminal: true` — Ctrl-C
* is captured in-process by readline as 'SIGINT' (it never escapes as an OS signal killing
* the process group), and pasted content is held whole by PasteFilter (not split into
* multiple submits by embedded newlines).
* Docs: /docs/cli § "penguin chat".
*/
import { createInterface, type Interface } from "node:readline";
import type { Command } from "commander";
import { createAgent, userText } from "@prismshadow/penguin-core";
import type { ApprovalDecision, OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core";
import { StreamRenderer, dim, renderHistory } from "../render.js";
import { runTask } from "../task-loop.js";
import { parseApprovalAnswer, resolveApprovalMode } from "../approval.js";
import { LineComposer, PasteFilter } from "../input.js";
import type { Messages } from "../i18n.js";
export type ChatState = "idle" | "running" | "approving" | "confirming-exit";
export type SigintAction = "deny" | "abort" | "clear" | "confirm-exit" | "exit";
/** Pure decision: current state + whether the input buffer is non-empty -> the action Ctrl-C should perform. */
export function decideSigint(state: ChatState, hasBufferedInput: boolean): SigintAction {
if (state === "approving") return "deny";
if (state === "running") return "abort";
if (state === "confirming-exit") return "exit";
return hasBufferedInput ? "clear" : "confirm-exit";
}
interface RlInternals {
line: string;
cursor: number;
_refreshLine?: () => void;
}
const MAIN_PROMPT = "> ";
const CONT_PROMPT = "… ";
export function registerChatCommand(program: Command, t: Messages): void {
program
.command("chat")
.description(t.chat.desc)
.option("--model-id <id>", t.common.modelId)
.option("--provider <group>", t.common.provider)
.option("--project-id <id>", t.common.projectId)
.option("--agent-id <id>", t.common.agentId)
.option("--workspace <path>", t.common.workspace)
.option("--approve <mode>", t.common.approve)
.option("--resume [sessionId]", t.chat.resume)
.action(async (opts) => {
const mode = resolveApprovalMode(opts.approve, t);
const out = process.stdout;
const agent = await createAgent({
...(opts.agentId ? { agentId: opts.agentId } : {}),
...(opts.projectId ? { projectId: opts.projectId } : {}),
});
// --resume: resumes an existing Session. Workspace and
// Model follow the original Session and cannot be overridden; when omitted, resumes
// the current Agent's most recent Session.
let session;
if (opts.resume !== undefined) {
if (opts.workspace || opts.modelId || opts.provider) {
out.write(`${t.error(t.resumeNoOverride())}\n`);
process.exitCode = 1;
return;
}
const sessionId =
typeof opts.resume === "string" ? opts.resume : await agent.latestSessionId();
if (!sessionId) {
out.write(`${t.error(t.resumeNoSession())}\n`);
process.exitCode = 1;
return;
}
session = await agent.resumeSession({ sessionId });
} else {
session = await agent.createSession({
workspaceDir: opts.workspace ?? process.cwd(),
...(opts.modelId ? { modelId: opts.modelId } : {}),
...(opts.provider ? { provider: opts.provider } : {}),
});
}
const renderer = new StreamRenderer(out, t);
out.write(
`${t.header("chat", agent.state.agentId, session.workspaceDir, session.modelId)}\n` +
`${t.chatHints()}\n`,
);
// On resume, first render the history messages of the current context per Trace
// (full messages, including interrupted turns and their markers), then proceed to
// regular input.
if (session.resumedHistory) {
out.write(`${t.resumedBanner(session.sessionId, session.resumedHistory.length)}\n`);
renderHistory(session.resumedHistory, out);
}
// TTY: raw mode + bracketed paste + PasteFilter; non-TTY (pipe/test): read stdin directly.
const isTTY = Boolean(process.stdin.isTTY);
let pasteFilter: PasteFilter | null = null;
let inputStream: NodeJS.ReadableStream = process.stdin;
if (isTTY) {
process.stdin.setRawMode(true);
out.write("\x1b[?2004h");
pasteFilter = new PasteFilter();
process.stdin.pipe(pasteFilter);
inputStream = pasteFilter;
}
const rl = createInterface({
input: inputStream,
output: out,
terminal: isTTY,
});
const rli = rl as unknown as RlInternals;
const composer = new LineComposer();
let state: ChatState = "idle";
let closed = false;
let taskAbort: AbortController | null = null;
let pendingLine: ((line: string | null) => void) | null = null;
let pendingApproval: ((decision: ApprovalDecision) => void) | null = null;
const cleanup = () => {
if (!isTTY) return;
try {
out.write("\x1b[?2004l");
} catch {
/* ignore */
}
try {
process.stdin.setRawMode(false);
} catch {
/* ignore */
}
try {
if (pasteFilter) process.stdin.unpipe(pasteFilter);
} catch {
/* ignore */
}
try {
process.stdin.pause();
} catch {
/* ignore */
}
};
process.once("exit", cleanup);
if (pasteFilter) {
pasteFilter.on("paste", (text: string) => {
if (state !== "idle") return; // ignore paste while running
const { lineCount, normalized } = composer.pushPaste(text);
if (lineCount === 0) return;
out.write(`${normalized}\n`);
rl.setPrompt(CONT_PROMPT);
rl.prompt();
});
}
rl.on("line", (line) => {
if (state === "confirming-exit") {
if (parseApprovalAnswer(line) === "allow") {
rl.close();
} else {
state = "idle";
composer.reset();
out.write("\n");
rl.setPrompt(MAIN_PROMPT);
rl.prompt();
}
return;
}
if (state === "idle" && pendingLine) {
const { message } = composer.pushTypedLine(line);
if (message === undefined) {
// Continuation: show the continuation prompt and keep waiting.
rl.setPrompt(CONT_PROMPT);
rl.prompt();
} else {
const resolve = pendingLine;
pendingLine = null;
resolve(message);
}
return;
}
if (state === "approving" && pendingApproval) {
const resolve = pendingApproval;
pendingApproval = null;
// Tool approval defaults to allow: pressing Enter (empty input) is treated as allow.
resolve(parseApprovalAnswer(line, "allow"));
}
// running: ignore any line typed at this moment.
});
rl.on("SIGINT", () => {
const hasBuffer = rli.line.length > 0 || composer.hasPending();
const action = decideSigint(state, hasBuffer);
if (action === "deny") {
if (pendingApproval) {
const resolve = pendingApproval;
pendingApproval = null;
out.write("\n");
resolve("deny");
}
} else if (action === "abort") {
if (taskAbort && !taskAbort.signal.aborted) {
out.write(`\n${t.taskInterrupted()}\n`);
taskAbort.abort();
}
} else if (action === "clear") {
composer.reset();
rl.setPrompt(MAIN_PROMPT);
clearCurrentLine(rl, rli, out);
} else if (action === "confirm-exit") {
state = "confirming-exit";
rli.line = "";
rli.cursor = 0;
out.write("\n");
rl.setPrompt(t.confirmExit());
rl.prompt();
} else {
out.write("\n");
rl.close();
}
});
rl.on("close", () => {
closed = true;
if (pendingLine) {
const resolve = pendingLine;
pendingLine = null;
resolve(null);
}
});
const askLine = (): Promise<string | null> =>
new Promise((resolve) => {
if (closed) {
resolve(null);
return;
}
state = "idle";
pendingLine = resolve;
composer.reset();
rli.line = "";
rli.cursor = 0;
out.write("\n");
rl.setPrompt(MAIN_PROMPT);
rl.prompt();
});
// Interactive approval prompt: reuses the persistent readline, prompt text is
// localized; the tool call is already rendered above via streaming, so it is not
// re-rendered here.
const interactivePrompt = (_tc: OmniMessage<ToolCallPayload>): Promise<ApprovalDecision> =>
new Promise((resolve) => {
state = "approving";
pendingApproval = (decision) => {
state = "running";
resolve(decision);
};
rl.setPrompt(t.approvePrompt());
rl.prompt();
});
// Whether this Session already has a resumable Trace record: a resumed Session
// naturally has one; a new Session gets one starting from its first Task / compact
// (session_meta is written along with it). This decides whether to print the resume
// command example on exit.
let resumable = opts.resume !== undefined;
try {
for (;;) {
const line = await askLine();
if (line === null) break;
const text = line.trim();
if (text === "/exit" || text === "/quit") break;
if (text.length === 0) continue;
state = "running";
taskAbort = new AbortController();
try {
if (text === "/compact") {
// Proactive context compaction (Task boundary, reason=manual): the renderer
// prints compaction progress; Ctrl-C aborts the compaction via signal
// (preserving the original context). When there's nothing to compact (session
// just started / two consecutive /compact calls), the engine silently returns
// and we add one line of feedback here. Afterwards, settle the renderer's
// counters (endCompact) — compaction usage is already shown on the completion
// line and must not be counted again toward the next task's stats delta.
const startedAt = Date.now();
let sawMessage = false;
try {
for await (const msg of session.compact({
signal: taskAbort.signal,
})) {
sawMessage = true;
resumable = true;
renderer.handle(msg);
}
} finally {
renderer.endCompact(Date.now() - startedAt);
}
if (!sawMessage) out.write(`${t.compactNothing()}\n`);
} else {
resumable = true;
await runTask(session, [userText(text)], {
mode,
signal: taskAbort.signal,
renderer,
interactivePrompt,
t,
});
}
} catch (err) {
out.write(`\n${t.error(err instanceof Error ? err.message : String(err))}\n`);
} finally {
taskAbort = null;
state = "idle";
}
}
} finally {
rl.close();
cleanup();
session.dispose(); // tear down managed long-running command sessions to avoid leaking background processes
process.removeListener("exit", cleanup);
// On exit, print a dimmed resume command example: includes this
// session's Project / Agent options so the command can be copy-pasted directly;
// skipped when the Session has no Trace record yet (nothing to resume).
if (resumable) {
const command =
`penguin chat --resume ${session.sessionId}` +
(opts.projectId ? ` --project-id ${opts.projectId}` : "") +
(opts.agentId ? ` --agent-id ${opts.agentId}` : "");
out.write(`${dim(t.resumeHint(command))}\n`);
}
}
});
}
/** Clear the current input line and redraw the prompt (Ctrl-C clears the buffer when it has content). */
function clearCurrentLine(rl: Interface, rli: RlInternals, out: NodeJS.WritableStream): void {
rli.line = "";
rli.cursor = 0;
if (typeof rli._refreshLine === "function") {
rli._refreshLine();
} else {
out.write("\r\x1b[K");
rl.prompt(true);
}
}
+368
View File
@@ -0,0 +1,368 @@
/**
* `penguin config` — manages a Project's model credentials, default model, model list,
* Agent-level vault environment variables, and UI language.
*
* penguin config model add --model-id <upstream id> [--provider <group>] [--api-key <key>] [--context-window <n>] [--set-default] [--root <dir>]
* penguin config model default --model-id <upstream id> --provider <group> [--root <dir>]
* penguin config model vision --model-id <upstream id> --provider <group> [--root <dir>]
* penguin config model list [--root <dir>]
* penguin config vault set --key <name> --value <value> [--agent-id <id>] [--root <dir>]
* penguin config vault list [--agent-id <id>] [--root <dir>]
* penguin config vault remove --key <name> [--agent-id <id>] [--root <dir>]
* penguin config lang <en|zh>
*
* `--model-id` always takes the **upstream id** (the request id sent to AgentHub verbatim),
* which together with `--provider` forms a `(provider, model_id)` paired reference —
* **no string concatenation is ever performed**. For `model add`, --provider defaults to
* an inference from the built-in catalog (falling back to custom when inference fails);
* a new entry's client_type defaults according to the group's semantics (not set for
* first-party vendors; openai for custom / self-hosted groups / gateways, with the
* gateway's endpoint base URL pre-filled). For `model default` / `model vision`,
* --provider is **required**; core validation raises an error when the reference is not
* found in models. `--root` specifies the data root directory (priority: option >
* PENGUIN_HOME > ~/.penguin/data). The UI language is controlled by the PENGUIN_LANG
* environment variable; `config lang` writes it into the shell startup file and restarts
* the shell to take effect.
* Docs: /docs/cli § "penguin config".
*/
import { homedir } from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import type { Command } from "commander";
import {
DEFAULT_AGENT_ID,
DEFAULT_PROJECT_ID,
type ModelPricing,
type ModelRef,
type ProjectConfig,
addModel,
catalogEntryFor,
formatModelRef,
getModel,
inferProviderForUpstream,
loadAgentVault,
loadProjectConfig,
providerInfo,
removeVaultEntry,
resolveRoot,
setDefaultModel,
setVaultEntry,
setVisionModel,
} from "@prismshadow/penguin-core";
import { parseApprovalAnswer } from "../approval.js";
import { getMessages, maskApiKey, type Messages } from "../i18n.js";
import { applyLanguageToRc, restartShell } from "../lang-config.js";
/** Data root directory: the `--root` option takes priority (relative paths resolved against cwd), then PENGUIN_HOME / ~/.penguin/data. */
function resolveRootOption(root: string | undefined): string {
return root !== undefined ? path.resolve(root) : resolveRoot();
}
/**
* Renders the model list as column-aligned lines (the default model is marked with `*`;
* fully empty columns are omitted automatically). `provider` and `model_id` each occupy
* their own column (stored fields, never split apart); `vision` reflects the effective
* semantics (the TOML `vision` annotation takes priority, falling back to the catalog
* annotation — matched by the (provider, model_id) pair — and recorded as Y under
* "default = supported" when neither is present). Exported for unit tests.
*/
export function formatModelRows(cfg: ProjectConfig): string[] {
const cells = cfg.models.map((entry) => {
const cat = catalogEntryFor(entry.provider, entry.model_id);
const vision = entry.vision ?? cat?.supportsVision ?? true;
const isDefault =
cfg.default_model?.provider === entry.provider &&
cfg.default_model?.model_id === entry.model_id;
return {
provider: `${isDefault ? "* " : " "}${entry.provider}`,
model: entry.model_id,
vision: `vision=${vision ? "Y" : "-"}`,
context_window:
entry.context_window !== undefined ? `context_window=${entry.context_window}` : "",
client_type: entry.client_type ? `client_type=${entry.client_type}` : "",
pricing: entry.pricing
? `price=${entry.pricing.cache_read}/${entry.pricing.cache_write}/${entry.pricing.output}`
: "",
api_key: `api_key=${maskApiKey(entry.api_key)}`,
base_url: entry.base_url ? `base_url=${entry.base_url}` : "",
};
});
const columns = [
"provider",
"model",
"vision",
"context_window",
"client_type",
"pricing",
"api_key",
"base_url",
] as const;
const widths = columns.map((c) => Math.max(...cells.map((cell) => cell[c].length)));
const active = columns
.map((c, i) => ({ key: c, width: widths[i]! }))
.filter((col) => col.width > 0);
return cells.map((cell) =>
active
.map((col, i) => (i === active.length - 1 ? cell[col.key] : cell[col.key].padEnd(col.width)))
.join(" ")
.trimEnd(),
);
}
export function registerConfigCommand(program: Command, t: Messages): void {
const config = program.command("config").description(t.config.desc);
const model = config.command("model").description(t.config.modelDesc);
model
.command("add")
.description(t.config.addDesc)
.requiredOption("--model-id <id>", t.config.addModelId)
.option("--provider <group>", t.config.addProvider)
.option("--api-key <key>", t.config.addApiKey)
.option("--base-url <url>", t.config.addBaseUrl)
.option("--context-window <n>", t.config.addContextWindow, parseIntArg)
.option("--client-type <type>", t.config.addClientType)
// Tri-state: --vision marks it supported / --no-vision marks it unsupported / neither given keeps the existing value (defaults to supported).
.option("--vision", t.config.addVision)
.option("--no-vision", t.config.addNoVision)
.option("--price-cache-read <n>", t.config.addPriceCacheRead, parseFloatArg)
.option("--price-cache-write <n>", t.config.addPriceCacheWrite, parseFloatArg)
.option("--price-output <n>", t.config.addPriceOutput, parseFloatArg)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--set-default", t.config.addSetDefault, false)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
// --model-id takes the upstream id, paired with --provider as a reference
// (--provider defaults to catalog-based inference, falling back to custom); no
// concatenation is performed.
const modelId: string = opts.modelId;
const provider: string = opts.provider ?? inferProviderForUpstream(modelId);
const ref: ModelRef = { provider, model_id: modelId };
const before = await loadProjectConfig(root, opts.projectId);
const existed = getModel(before, ref) !== undefined;
// client_type default rule, only injected for new entries (updating an
// existing entry never overrides an explicit config): not set for first-party
// vendor groups (AgentHub auto-routes by upstream id, with env fallback keyed on
// id); defaults to openai for custom / self-hosted / gateway groups, with the
// gateway's endpoint base URL pre-filled as well.
const pInfo = providerInfo(provider);
const openAiDefault =
pInfo === undefined || pInfo.id === "custom" || pInfo.gatewayBaseUrl !== undefined;
const clientType: string | undefined =
opts.clientType ?? (!existed && openAiDefault ? "openai" : undefined);
const baseUrl: string | undefined =
opts.baseUrl ?? (!existed ? pInfo?.gatewayBaseUrl : undefined);
// Only collect explicitly given price fields, letting addModel merge them with the existing pricing per-field.
const pricing: Partial<ModelPricing> = {};
if (opts.priceCacheRead !== undefined) pricing.cache_read = opts.priceCacheRead;
if (opts.priceCacheWrite !== undefined) pricing.cache_write = opts.priceCacheWrite;
if (opts.priceOutput !== undefined) pricing.output = opts.priceOutput;
const cfg = await addModel(
root,
opts.projectId,
{
provider,
model_id: modelId,
...(opts.contextWindow !== undefined ? { context_window: opts.contextWindow } : {}),
...(clientType !== undefined ? { client_type: clientType } : {}),
...(opts.vision !== undefined ? { vision: opts.vision } : {}),
...(Object.keys(pricing).length > 0 ? { pricing } : {}),
...(opts.apiKey !== undefined ? { api_key: opts.apiKey } : {}),
...(baseUrl !== undefined ? { base_url: baseUrl } : {}),
},
{ setDefault: Boolean(opts.setDefault) },
);
const defaultRef = cfg.default_model && formatModelRef(cfg.default_model);
const line = existed
? t.modelUpdated(formatModelRef(ref), defaultRef)
: t.modelAdded(formatModelRef(ref), defaultRef);
process.stdout.write(`${line}\n`);
});
model
.command("default")
.description(t.config.defaultDesc)
.requiredOption("--model-id <id>", t.config.refModelId)
.requiredOption("--provider <group>", t.config.refProvider)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
// --model-id takes the upstream id, paired with the required --provider as a
// reference (no concatenation, no fuzzy matching); setDefaultModel raises an error
// when the reference is not found in models.
const ref: ModelRef = { provider: opts.provider, model_id: opts.modelId };
try {
await setDefaultModel(root, opts.projectId, ref);
} catch (err) {
process.stderr.write(`${t.error(err instanceof Error ? err.message : String(err))}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`${t.defaultModelSet(formatModelRef(ref))}\n`);
});
model
.command("vision")
.description(t.config.visionDesc)
.requiredOption("--model-id <id>", t.config.refModelId)
.requiredOption("--provider <group>", t.config.refProvider)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
// Paired reference semantics match `model default`; existence and vision=false semantics validation is handled by setVisionModel.
const ref: ModelRef = { provider: opts.provider, model_id: opts.modelId };
try {
await setVisionModel(root, opts.projectId, ref);
} catch (err) {
process.stderr.write(`${t.error(err instanceof Error ? err.message : String(err))}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`${t.visionModelSet(formatModelRef(ref))}\n`);
});
model
.command("list")
.description(t.config.listDesc)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
const cfg = await loadProjectConfig(root, opts.projectId);
if (cfg.models.length === 0) {
process.stdout.write(`${t.modelListEmpty()}\n`);
return;
}
process.stdout.write(`${t.modelListTitle()}\n`);
for (const line of formatModelRows(cfg)) {
process.stdout.write(`${line}\n`);
}
});
const vault = config.command("vault").description(t.config.vaultDesc);
vault
.command("set")
.description(t.config.vaultSetDesc)
.requiredOption("--key <name>", t.config.vaultKey)
.requiredOption("--value <value>", t.config.vaultValue)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--agent-id <id>", t.common.agentId, DEFAULT_AGENT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
try {
await setVaultEntry(root, opts.projectId, opts.agentId, opts.key, opts.value);
} catch (err) {
// Validation errors such as an invalid key name: print an explanation and exit with a non-zero code, without throwing a stack trace.
process.stderr.write(`${t.error(err instanceof Error ? err.message : String(err))}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(`${t.vaultSet(opts.key)}\n`);
});
vault
.command("list")
.description(t.config.vaultListDesc)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--agent-id <id>", t.common.agentId, DEFAULT_AGENT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
const entries = Object.entries(await loadAgentVault(root, opts.projectId, opts.agentId));
if (entries.length === 0) {
process.stdout.write(`${t.vaultListEmpty()}\n`);
return;
}
process.stdout.write(`${t.vaultListTitle()}\n`);
const width = Math.max(...entries.map(([key]) => key.length));
for (const [key, value] of entries) {
process.stdout.write(`${key.padEnd(width)} ${maskApiKey(value)}\n`);
}
});
vault
.command("remove")
.description(t.config.vaultRemoveDesc)
.requiredOption("--key <name>", t.config.vaultKey)
.option("--project-id <id>", t.common.projectId, DEFAULT_PROJECT_ID)
.option("--agent-id <id>", t.common.agentId, DEFAULT_AGENT_ID)
.option("--root <dir>", t.common.root)
.action(async (opts) => {
const root = resolveRootOption(opts.root);
const vaultEntries = await loadAgentVault(root, opts.projectId, opts.agentId);
if (vaultEntries[opts.key] === undefined) {
process.stderr.write(`${t.vaultKeyMissing(opts.key)}\n`);
process.exitCode = 1;
return;
}
await removeVaultEntry(root, opts.projectId, opts.agentId, opts.key);
process.stdout.write(`${t.vaultRemoved(opts.key)}\n`);
});
config
.command("lang")
.description(t.config.langDesc)
.argument("<language>", t.config.langArg)
.action(async (language: string) => {
const lang = String(language).trim().toLowerCase();
if (lang !== "zh" && lang !== "en") {
process.stderr.write(`${t.langInvalid(String(language))}\n`);
process.exitCode = 1;
return;
}
const { rcPath } = await applyLanguageToRc(lang, {
shell: process.env.SHELL,
home: homedir(),
});
// The confirmation message is shown in the target language; the user must confirm before the shell restarts.
const m = getMessages(lang);
process.stdout.write(`${m.langSet(lang, rcPath)}\n`);
const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
if (interactive && (await confirmYes(m.langRestartConfirm()))) {
process.stdout.write(`${m.langRestart()}\n`);
restartShell(lang);
} else {
process.stdout.write(`${m.langRestartHint(rcPath)}\n`);
}
});
}
/** Interactive y/N confirmation; Ctrl-C (SIGINT) or input stream EOF/close are both treated as no, to avoid hanging. */
function confirmYes(prompt: string): Promise<boolean> {
const rl = createInterface({ input: process.stdin, output: process.stdout });
return new Promise<boolean>((resolve) => {
let done = false;
const finish = (value: boolean) => {
if (done) return;
done = true;
process.off("SIGINT", onSigint);
rl.close();
resolve(value);
};
const onSigint = () => finish(false);
process.once("SIGINT", onSigint);
rl.on("close", () => finish(false));
rl.question(prompt, (answer) => finish(parseApprovalAnswer(answer) === "allow"));
});
}
function parseIntArg(value: string): number {
const n = Number.parseInt(value, 10);
if (Number.isNaN(n)) {
throw new Error(`无效的整数:${value}`);
}
return n;
}
function parseFloatArg(value: string): number {
const n = Number.parseFloat(value);
if (Number.isNaN(n)) {
throw new Error(`无效的数值:${value}`);
}
return n;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* `penguin run` — send a single Task in one shot.
*
* penguin run -m <msg> [--model-id <id>] [--provider <group>] [--workspace <path>]
* [--project-id <id>] [--agent-id <id>]
* [--approve <allow-all|deny-all|read-only|always-ask>]
*
* Uses the current directory when Workspace is unspecified; uses the Project's default model
* when model is unspecified. `--provider` is optional: when omitted, `--model-id` is resolved
* via resolveModelRef semantics (only matches when the exact value is globally unique in the
* config; ambiguity is an error). Defaults to interactive per-call approval; `--approve`
* selects the permission mode.
* Docs: /docs/cli § "penguin run".
*/
import type { Command } from "commander";
import { createAgent, userText } from "@prismshadow/penguin-core";
import { StreamRenderer } from "../render.js";
import { runTask } from "../task-loop.js";
import { denyActivePrompt, resolveApprovalMode } from "../approval.js";
import type { Messages } from "../i18n.js";
export function registerRunCommand(program: Command, t: Messages): void {
program
.command("run")
.description(t.run.desc)
.requiredOption("-m, --message <message>", t.run.message)
.option("--model-id <id>", t.common.modelId)
.option("--provider <group>", t.common.provider)
.option("--project-id <id>", t.common.projectId)
.option("--agent-id <id>", t.common.agentId)
.option("--workspace <path>", t.common.workspace)
.option("--approve <mode>", t.common.approve)
.action(async (opts) => {
const mode = resolveApprovalMode(opts.approve, t);
const agent = await createAgent({
...(opts.agentId ? { agentId: opts.agentId } : {}),
...(opts.projectId ? { projectId: opts.projectId } : {}),
});
const session = await agent.createSession({
workspaceDir: opts.workspace ?? process.cwd(),
...(opts.modelId ? { modelId: opts.modelId } : {}),
...(opts.provider ? { provider: opts.provider } : {}),
});
const out = process.stdout;
out.write(`${t.header("run", agent.state.agentId, session.workspaceDir, session.modelId)}\n`);
const controller = new AbortController();
const onSigint = () => {
// Single SIGINT handler: Ctrl-C during approval collapses to "deny this tool" (see
// approval.ts); at all other times it interrupts the whole turn.
if (denyActivePrompt()) return;
controller.abort();
};
process.on("SIGINT", onSigint);
const renderer = new StreamRenderer(out, t);
try {
const result = await runTask(session, [userText(opts.message)], {
mode,
signal: controller.signal,
renderer,
t,
});
// Task ended with an abort (LLM failure/reconnect exhausted/user interrupt): non-zero
// exit code, for scripts/CI to check.
if (result.aborted) process.exitCode = 1;
} finally {
process.off("SIGINT", onSigint);
session.dispose(); // Tear down managed long-running command sessions to avoid leaking background processes
}
out.write("\n");
});
}
+133
View File
@@ -0,0 +1,133 @@
/**
* `penguin server` / `penguin web` — starts the Web service.
*
* penguin server [--port <port>] [--host <host>]
* penguin web [--port <port>] [--host <host>] [--no-open]
*
* Both are entry points into the same service process: after setting PORT / HOST, it
* dynamically imports `@prismshadow/penguin-server` (whose entry point handles dotenv
* loading and graceful shutdown on its own), so the two never listen on separate ports
* in parallel. Port/host priority: command-line option > existing environment variable
* (including .env) > default 7364 / 127.0.0.1. `penguin web` additionally polls until the
* service is ready, prints the URL, and opens a browser per-platform (`--no-open`
* disables this).
* Docs: /docs/cli § "penguin server / penguin web".
*/
import { spawn } from "node:child_process";
import type { Command } from "commander";
import type { Messages } from "../i18n.js";
/** Default service port (deliberately avoids common defaults like 3000/8080). */
export const DEFAULT_PORT = 7364;
/** Default service listen host. */
export const DEFAULT_HOST = "127.0.0.1";
/**
* Resolves the listen port: command-line option takes priority, then the PORT
* environment variable, defaulting to 7364; throws if not an integer or out of the
* 0-65535 range. Exported for unit tests.
*/
export function resolvePort(option: string | undefined, env: string | undefined): number {
const raw = option ?? env;
if (raw === undefined || raw === "") return DEFAULT_PORT;
const port = Number(raw);
if (!Number.isInteger(port) || port < 0 || port > 65535) {
throw new Error(`Invalid port "${raw}". Use an integer between 0 and 65535.`);
}
return port;
}
/**
* Picks the command to open a browser per-platform. On win32, `start` treats the first
* quoted argument as the window title, so an extra empty title placeholder is passed.
* Exported for unit tests.
*/
export function browserCommand(platform: string, url: string): { command: string; args: string[] } {
if (platform === "darwin") return { command: "open", args: [url] };
if (platform === "win32") return { command: "cmd", args: ["/c", "start", "", url] };
return { command: "xdg-open", args: [url] };
}
/** URL used for the readiness probe and browser access: when listening on a wildcard address (0.0.0.0 / ::), access via 127.0.0.1 instead. Exported for unit tests. */
export function browserUrl(host: string, port: number): string {
const target = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
return `http://${target}:${port}/`;
}
/**
* Sets PORT / HOST then starts the service: the server entry point only reads
* process.env, and its dotenv loading never overrides existing environment variables,
* so the values written here are the ones that take effect (options take priority over
* .env and any pre-existing env vars).
*/
async function startServer(opts: {
port?: string;
host?: string;
}): Promise<{ host: string; port: number }> {
const port = resolvePort(opts.port, process.env.PORT);
const host = opts.host ?? process.env.HOST ?? DEFAULT_HOST;
process.env.PORT = String(port);
process.env.HOST = host;
await import("@prismshadow/penguin-server");
return { host, port };
}
/** Polls the service root path until it responds (any HTTP response counts as ready); keeps waiting on connection failure, returns false on timeout. */
async function waitForReady(url: string, timeoutMs = 15_000, intervalMs = 300): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
// Each probe is capped at 1s: if the port is held by a non-HTTP program, the
// connection can succeed while the response hangs forever; without a timeout this
// would block the whole polling loop (the deadline check below would never run).
const res = await fetch(url, { signal: AbortSignal.timeout(1000) });
void res.body?.cancel();
return true;
} catch {
// The service isn't listening yet (or this probe timed out): keep polling.
}
if (Date.now() >= deadline) return false;
await new Promise((resolve) => setTimeout(resolve, intervalMs));
}
}
/** Opens the browser: spawn detached with output ignored; any failure is silently swallowed (failing to open doesn't affect the running service). */
function openBrowser(url: string): void {
const { command, args } = browserCommand(process.platform, url);
try {
const child = spawn(command, args, { detached: true, stdio: "ignore" });
child.on("error", () => {});
child.unref();
} catch {
// e.g. the browser command doesn't exist: ignore, the user can open it manually.
}
}
export function registerServeCommands(program: Command, t: Messages): void {
program
.command("server")
.description(t.serve.serverDesc)
.option("--port <port>", t.serve.port)
.option("--host <host>", t.serve.host)
.action(async (opts: { port?: string; host?: string }) => {
await startServer(opts);
});
program
.command("web")
.description(t.serve.webDesc)
.option("--port <port>", t.serve.port)
.option("--host <host>", t.serve.host)
.option("--no-open", t.serve.noOpen)
.action(async (opts: { port?: string; host?: string; open: boolean }) => {
const { host, port } = await startServer(opts);
const url = browserUrl(host, port);
const ready = await waitForReady(url);
if (!ready) {
process.stdout.write(`${t.webTimeout(url)}\n`);
return;
}
process.stdout.write(`${t.webReady(url)}\n`);
if (opts.open) openBrowser(url);
});
}