Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/conformance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,5 @@ jobs:
run: bash run.sh
- name: Build CLI
run: bash cli/cli-build.sh
- name: Run CLI tests (unit + integration)
run: node --test "tests/*.test.js"
17 changes: 17 additions & 0 deletions cli/args.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Pure CLI-argument parsing, extracted from cli.js for testability.
// Behavior is IDENTICAL to the original inline logic — do not "improve" it here.

export function parseArgs(argv, env, providerDefaults) {
const args = argv.filter((a) => !a.startsWith("--"));
const resumeIndex = argv.indexOf("--resume");
const resumeId = resumeIndex >= 0 ? argv[resumeIndex + 1] : undefined;
const providerIndex = argv.indexOf("--provider");
const providerOverride = providerIndex >= 0 ? argv[providerIndex + 1] : undefined;
const listSessions = argv.includes("--sessions");
const provider =
providerOverride ??
env.DSH_PROVIDER ??
(env.DEEPSEEK_API_KEY || !env.GEMINI_API_KEY ? "deepseek-official" : "google");
const model = args[0] ?? providerDefaults[provider]?.model ?? "deepseek-v4-flash";
return { model, provider, resumeId, listSessions };
}
119 changes: 76 additions & 43 deletions cli/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
// pi's shell (the real @earendil-works/pi-tui framework) + DSH's engine AND
// state (AgentLoop, ToolRuntime, event-sourced sessions, JSONL persistence).
import "../polyfills.js";
import { FakeLlm } from "../fake-llm.js";
import { parseArgs } from "./args.js";
import { loadEnvFiles, persistCredential } from "./env.js";
import { Context } from "@deepseek-ai/cordis";
import { AgentRegistry } from "@deepseek-ai/dsh-agent";
import { SessionStore } from "@deepseek-ai/dsh-session";
import { ToolRuntime } from "@deepseek-ai/dsh-tools";
import { SystemPrompt } from "@deepseek-ai/dsh-system-prompt";
import { AgentLoop } from "@deepseek-ai/dsh-agent-loop";
import { LlmRuntime, createUserMessage } from "@deepseek-ai/dsh-llm";
import { LlmRuntime, LlmAdapter, createUserMessage } from "@deepseek-ai/dsh-llm";
import * as fsTools from "@deepseek-ai/dsh-tool-fs";
import * as todoTools from "@deepseek-ai/dsh-tool-todo";
import * as persistenceJsonl from "@deepseek-ai/dsh-session-persistence-jsonl";
Expand Down Expand Up @@ -44,7 +47,7 @@ import { renderBanner } from "./banner.js";
import { defineBashTool, bashGuidanceSection } from "./bash-tool.js";
import * as readline from "node:readline";
import { join } from "node:path";
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { spawn } from "node:child_process";
import { dirname } from "node:path";
import { zstdCompress } from "node:zlib";
Expand All @@ -65,12 +68,6 @@ const TTY = !!process.stdout.isTTY && !!process.stdin.isTTY && !process.env.DSH_
const USE_CC_TUI = TTY && process.env.DSH_TUI !== "basic" && !process.env.DSH_PLAIN;

// CLI args: node cli.mjs [model] [--provider <id>] [--resume <id>] [--sessions]
const ARGS = process.argv.slice(2).filter((a) => !a.startsWith("--"));
const RESUME_INDEX = process.argv.indexOf("--resume");
const RESUME_ID = RESUME_INDEX >= 0 ? process.argv[RESUME_INDEX + 1] : undefined;
const PROVIDER_INDEX = process.argv.indexOf("--provider");
const PROVIDER_OVERRIDE = PROVIDER_INDEX >= 0 ? process.argv[PROVIDER_INDEX + 1] : undefined;
const LIST_SESSIONS = process.argv.includes("--sessions");
// DeepSeek is the default provider (this is dsh, after all): the DSH-native
// dsh-llm-deepseek adapter owns the "deepseek-official" route.
const PROVIDER_DEFAULTS = {
Expand All @@ -82,9 +79,11 @@ const PROVIDER_DEFAULTS = {
anthropic: { model: "claude-sonnet-4-5", keyEnv: "ANTHROPIC_API_KEY" },
openrouter: { model: "openai/gpt-4o-mini", keyEnv: "OPENROUTER_API_KEY" },
};
const PROVIDER = PROVIDER_OVERRIDE ?? process.env.DSH_PROVIDER ?? (process.env.DEEPSEEK_API_KEY || !process.env.GEMINI_API_KEY ? "deepseek-official" : "google");
const MODEL = ARGS[0] ?? PROVIDER_DEFAULTS[PROVIDER]?.model ?? "deepseek-v4-flash";

const { model: MODEL, provider: PROVIDER, resumeId: RESUME_ID, listSessions: LIST_SESSIONS } = parseArgs(
process.argv.slice(2),
process.env,
PROVIDER_DEFAULTS,
);
const PERSONA = [
"You are dsh-mini, a compact interactive coding agent CLI built on the DeepSeek Harness core.",
"You help the user with coding tasks inside the current workspace directory.",
Expand All @@ -99,41 +98,63 @@ process.on("unhandledRejection", (r) => console.error("[proc] unhandledRejection

// Minimal env loader: ~/.dsh-mini/env then ./.env (gitignored), KEY=VALUE
// lines, never overriding the real environment.
for (const envFile of [join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")]) {
try {
if (!existsSync(envFile)) continue;
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match && process.env[match[1]] === undefined) process.env[match[1]] = match[2].trim();
}
} catch {
// unreadable env file is not fatal
}
}
loadEnvFiles([join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")], process.env);

// Persist an interactively entered key: user config dir first, cwd .env
// fallback (both gitignored; never touch the repo's tracked files).
function persistCredential(provider, key) {
const env = PROVIDER_DEFAULTS[provider].keyEnv;
for (const target of [join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")]) {
try {
mkdirSync(dirname(target), { recursive: true });
const previous = existsSync(target) ? readFileSync(target, "utf8").replace(new RegExp(`^${env}=.*$`, "m"), "").trimEnd() : "";
writeFileSync(target, `${previous}${previous ? "\n" : ""}${env}=${key}\n`);
console.log(`(saved ${env} to ${target})`);
return;
} catch {
// try the next target
}
}
console.error(`[warn] could not persist ${env}; it is set for this session only`);
function persistKey(provider, key) {
const envName = PROVIDER_DEFAULTS[provider].keyEnv;
const saved = persistCredential(
[join(homedir(), ".dsh-mini", "env"), join(CWD, ".env")],
process.env,
envName,
key,
);
if (saved) console.log(`(saved ${envName} to ${saved})`);
else console.error(`[warn] could not persist ${envName}; it is set for this session only`);
}

const AGENTS_MD_CAP = 30 * 1024; // keep injected instructions bounded

// Fake scripted adapter for tests/demos (DSH_FAKE_LLM=1 + DSH_PROVIDER=fake):
// same stream contract as the conformance fake, but a full LlmAdapter so it
// can be registered alongside the real LlmRuntime. No network, no key.
class FakeAdapter extends LlmAdapter {
providerInfo(provider) {
return { id: provider, name: "Fake (scripted)" };
}
// LlmRuntime adapter contract: stream is a method on the adapter that
// returns an async iterable of harness-vocabulary chunks.
stream() {
const chunks = [
{ type: "block-start", index: 0, blockType: "text" },
{ type: "text-delta", index: 0, text: "(default reply)" },
{ type: "block-end", index: 0, block: { type: "text", text: "(default reply)" } },
{ type: "finish", reason: { kind: "stop" } },
];
let i = 0;
// Manual async iterator (no async generators: keeps the CLI bundle
// portable without relying on generator lowering).
return {
[Symbol.asyncIterator]() {
return {
async next() {
if (i < chunks.length) return { value: chunks[i++], done: false };
return { done: true };
},
};
},
};
}
prepareCall(config) {
return { config, stream: (request) => this.stream(request) };
}
}

const boot = async (ctx) => {
if (TTY && !process.env.DSH_NO_BANNER) process.stdout.write(renderBanner());
if (GEMINI_KEY) ctx.llm.registerAdapter(["google"], new GeminiAdapter(GEMINI_KEY));
if (process.env.DSH_FAKE_LLM) ctx.llm.registerAdapter(["fake"], new FakeAdapter());
// /new: available in every renderer. In the community TUI it restarts the
// process with a fresh session id; plain mode handles it in handleLine.
ctx.commands.register({
Expand Down Expand Up @@ -235,11 +256,13 @@ const boot = async (ctx) => {
}
});
// Exit on stdin EOF only when idle: a closing pipe must not kill a
// turn that is still streaming.
// turn that is still streaming, nor drop lines still queued from a
// chunk that arrived before the REPL was armed (piped stdin delivers
// whole chunks at once; boot takes hundreds of ms to mount).
plainRl.on("close", () => {
if (!plainInputActive) return;
stdinClosed = true;
if (!busy) gracefulExit();
if (!busy && lineQueue.length === 0) gracefulExit();
});
}
const askUser = (question) => {
Expand All @@ -252,7 +275,7 @@ const boot = async (ctx) => {

let currentProvider = PROVIDER;
let currentModel = MODEL;
if (!process.env[PROVIDER_DEFAULTS[currentProvider]?.keyEnv]) {
if (!process.env[PROVIDER_DEFAULTS[currentProvider]?.keyEnv] && !process.env.DSH_FAKE_LLM) {
const hasAnyKey = Object.values(PROVIDER_DEFAULTS).some((def) => process.env[def.keyEnv]);
if (hasAnyKey) {
console.error(`[warn] ${PROVIDER_DEFAULTS[currentProvider].keyEnv} is not set: ${currentProvider} calls will fail with MISSING_CREDENTIAL`);
Expand All @@ -272,7 +295,7 @@ const boot = async (ctx) => {
process.exit(1);
}
process.env[PROVIDER_DEFAULTS[answer].keyEnv] = key;
persistCredential(answer, key);
persistKey(answer, key);
}
}

Expand Down Expand Up @@ -378,20 +401,25 @@ const boot = async (ctx) => {

// Exit with a persistence flush grace: the JSONL backend writes in
// 200ms batches; an immediate process.exit() kills the pending write.
const gracefulExit = () => {
// Await the flush itself (async) instead of guessing a fixed delay.
const gracefulExit = async () => {
try {
if (agent) ctx.emit("session/flush", agent.session);
if (agent) {
ctx.emit("session/flush", agent.session);
await ctx.sessionPersistence.flush(agent.session);
}
} catch {
// flush is best-effort on the way out
}
setTimeout(() => process.exit(0), 500);
setTimeout(() => process.exit(0), 100);
};

async function handleLine(line) {
const trimmed = line.trim();
try {
if (/^(\/)?(exit|quit|e|q)(\(\))?$/i.test(trimmed)) {
gracefulExit();
return;
}
if (trimmed === "") return;
if (trimmed === "/provider") {
Expand Down Expand Up @@ -600,6 +628,11 @@ const boot = async (ctx) => {
if (!ui) {
const ask = async () => {
for (;;) {
// EOF + drained queue: nothing left to process, exit cleanly.
if (stdinClosed && lineQueue.length === 0) {
gracefulExit();
return;
}
const line = await askUser("you> ");
await handleLine(line);
process.stdout.write("\n");
Expand Down
41 changes: 41 additions & 0 deletions cli/env.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Env-file loading + credential persistence, extracted from cli.js for
// testability. Behavior is IDENTICAL to the original inline logic.

import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname } from "node:path";

// KEY=VALUE lines from env files; never overriding the real environment.
// Unreadable env files are not fatal.
export function loadEnvFiles(paths, env) {
for (const envFile of paths) {
try {
if (!existsSync(envFile)) continue;
for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) {
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match && env[match[1]] === undefined) env[match[1]] = match[2].trim();
}
} catch {
// unreadable env file is not fatal
}
}
}

// Persist an interactively entered key: try targets in order (user config dir
// first, cwd .env fallback — both gitignored). Replaces any previous value of
// the same var (idempotent). Returns the target path on success, null if all
// targets failed.
export function persistCredential(targets, env, envName, key) {
for (const target of targets) {
try {
mkdirSync(dirname(target), { recursive: true });
const previous = existsSync(target)
? readFileSync(target, "utf8").replace(new RegExp(`^${envName}=.*$`, "m"), "").trimEnd()
: "";
writeFileSync(target, `${previous}${previous ? "\n" : ""}${envName}=${key}\n`);
return target;
} catch {
// try the next target
}
}
return null;
}
4 changes: 2 additions & 2 deletions cli/skill-scanner.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { join, basename } from "node:path";

const KEBAB_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;

function parseFrontmatter(text) {
export function parseFrontmatter(text) {
const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!match) return { meta: {}, body: text };
const meta = {};
Expand All @@ -19,7 +19,7 @@ function parseFrontmatter(text) {
return { meta, body: match[2] };
}

function discover(roots) {
export function discover(roots) {
const found = new Map();
for (const root of roots) {
if (!existsSync(root)) continue;
Expand Down
49 changes: 49 additions & 0 deletions tests/args.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { parseArgs } from "../cli/args.js";

const DEFAULTS = {
"deepseek-official": { model: "deepseek-v4-flash", keyEnv: "DEEPSEEK_API_KEY" },
google: { model: "gemini-flash-latest", keyEnv: "GEMINI_API_KEY" },
};

test("默认 provider 是 deepseek-official(无任何 key)", () => {
const r = parseArgs([], {}, DEFAULTS);
assert.equal(r.provider, "deepseek-official");
assert.equal(r.model, "deepseek-v4-flash");
});

test("有 GEMINI key 无 DeepSeek key 时默认 google", () => {
const r = parseArgs([], { GEMINI_API_KEY: "x" }, DEFAULTS);
assert.equal(r.provider, "google");
});

test("--resume 缺席时 resumeId 为 undefined(不是 argv[0],防 SKILL.md L135 坑)", () => {
const r = parseArgs(["some-model"], {}, DEFAULTS);
assert.equal(r.resumeId, undefined);
});

test("--resume 带 id 时正确解析", () => {
const r = parseArgs(["--resume", "abc123"], {}, DEFAULTS);
assert.equal(r.resumeId, "abc123");
});

test("--provider 覆盖默认", () => {
const r = parseArgs(["--provider", "google"], {}, DEFAULTS);
assert.equal(r.provider, "google");
});

test("--sessions 标志解析", () => {
assert.equal(parseArgs(["--sessions"], {}, DEFAULTS).listSessions, true);
assert.equal(parseArgs([], {}, DEFAULTS).listSessions, false);
});

test("位置参数第一个是 model", () => {
const r = parseArgs(["my-model"], { DEEPSEEK_API_KEY: "x" }, DEFAULTS);
assert.equal(r.model, "my-model");
});

test("DSH_PROVIDER 环境变量优先", () => {
const r = parseArgs([], { DSH_PROVIDER: "google" }, DEFAULTS);
assert.equal(r.provider, "google");
});
Loading