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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user