From d1a99597f415c26413ff2855ffb3483da22db129 Mon Sep 17 00:00:00 2001 From: willamhou Date: Sun, 24 May 2026 14:41:20 +0800 Subject: [PATCH] Improve first-run config and add 2048 demo recorder --- docs/current-status.md | 13 +- docs/demo/README.md | 32 ++++ docs/demo/record-2048-demo.sh | 269 +++++++++++++++++++++++++++++++++ docs/install.md | 2 + docs/launch/demo-script.md | 20 ++- docs/launch/product-hunt.md | 11 +- docs/public-beta.md | 3 +- scripts/check-secrets.js | 1 + src/cli/app.rs | 152 ++++++++++++++++++- src/cli/commands/config.rs | 102 ++++++++++++- src/cli/commands/quickstart.rs | 12 ++ 11 files changed, 600 insertions(+), 17 deletions(-) create mode 100755 docs/demo/record-2048-demo.sh diff --git a/docs/current-status.md b/docs/current-status.md index 2e2a122..250c619 100644 --- a/docs/current-status.md +++ b/docs/current-status.md @@ -73,8 +73,11 @@ dogfood 证据。 - 入口:`deepseek`、`deepseek chat`、`deepseek run`、`deepseek tui`、`deepseek exec`。 - TUI:Plan / Agent / YOLO 模式、approval modal、command palette、session/thread 视图、 MCP 管理、setup/onboarding、provider/model picker。 -- 首跑:`deepseek quickstart` 以只读方式展示 workspace config、API key env、TTY 状态、 - 下一步命令和 starter tasks;`--json` 可用于安装验证和自动化排障。 +- 首跑:`deepseek quickstart` 以只读方式展示 workspace config、API key env、model/base + URL、TTY 状态、下一步命令和 starter tasks;`deepseek config provider + [show|list| [model]]`、`deepseek config model [show|list|]` 和 + `deepseek config auth [ENV] --stdin` 已支持 provider/model 选择与安全 `.env` 写入; + `--json` 可用于安装验证和自动化排障。 - REPL:raw-mode line editor、history、session list/load completion、SIGINT cancel、 `/save`、`/load`、`/sessions`、custom slash commands。 - Runtime:`.dscode/runtime/` 下持久化 sessions、threads、turns、items、events、 @@ -106,8 +109,10 @@ dogfood 证据。 已手动发布并验证。 3. 在干净 Linux/macOS 机器上安装 systemd/launchd user services,记录 `service-doctor --installed` 和 `service-smoke --installed` 证据。 -4. 补真实 VS Code CLI runner 或 manual GUI fixture 证据。 -5. 持续和 Claude Code CLI / Codex CLI / DeepSeek-TUI 做核心 loop 对照,只保留会影响真实用户使用的差距。 +4. 产出并审核基于 `docs/demo/record-2048-demo.sh` 的 30-60 秒 GIF/MP4, + 展示从空 repo 到可玩 2048 的 model-backed 可视化流程。 +5. 补真实 VS Code CLI runner 或 manual GUI fixture 证据。 +6. 持续和 Claude Code CLI / Codex CLI / DeepSeek-TUI 做核心 loop 对照,只保留会影响真实用户使用的差距。 ## 推荐公开表述 diff --git a/docs/demo/README.md b/docs/demo/README.md index 424e132..2621f8e 100644 --- a/docs/demo/README.md +++ b/docs/demo/README.md @@ -17,6 +17,11 @@ model-backed transcript. It shows the source-evidence loop: failing `cargo test` `deepseek exec`, a one-line Rust patch, and passing `cargo test`. A polished GIF or MP4 can still be added later for launch pages. +`record-2048-demo.sh` captures a more visual launch demo: an empty disposable +web repository, a model-backed `deepseek exec` run that builds a playable 2048 +game with plain HTML/CSS/JS, file validation, `git diff --stat`, and an +optional local preview server for browser gameplay capture. + ## Model-Backed Demo Capture Use `record-model-backed-demo.sh` to capture real model-backed CLI evidence @@ -61,3 +66,30 @@ The verifier can be checked without a model call: docs/demo/verify-model-backed-demo.js --self-test docs/demo/render-model-backed-demo-svg.js --self-test ``` + +## 2048 Launch Demo Capture + +Dry-run the 2048 capture plan without creating a repo or spending model calls: + +```bash +docs/demo/record-2048-demo.sh --dry-run +docs/demo/record-2048-demo.sh --redaction-self-test +``` + +Record a real model-backed transcript: + +```bash +printf '%s\n' '' > /tmp/deepseek-2048.key +chmod 600 /tmp/deepseek-2048.key +DEEPSEEK_2048_KEY_FILE=/tmp/deepseek-2048.key docs/demo/record-2048-demo.sh +``` + +Record with a local preview server for GIF/MP4 capture: + +```bash +DEEPSEEK_2048_KEY_FILE=/tmp/deepseek-2048.key docs/demo/record-2048-demo.sh --serve +``` + +The script prints the disposable demo repo and transcript path. Keep raw +transcripts only after reviewing them for local paths and generated content +quality. Use `--cleanup` only after recording any browser gameplay you need. diff --git a/docs/demo/record-2048-demo.sh b/docs/demo/record-2048-demo.sh new file mode 100755 index 0000000..ec4d947 --- /dev/null +++ b/docs/demo/record-2048-demo.sh @@ -0,0 +1,269 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: docs/demo/record-2048-demo.sh [--dry-run] [--serve] [--cleanup] [--api-key-stdin] [--redaction-self-test] [--help] + +Records a model-backed DeepSeekCode launch demo against an empty disposable +web repo: +empty repo -> deepseek exec builds a playable 2048 game -> validate files -> +git diff --stat -> optional local preview server. + +Environment: + DEEPSEEK_API_KEY Required for model-backed demo evidence. + DEEPSEEK_2048_KEY_FILE Read DEEPSEEK_API_KEY from a first-line key file + outside this repository when DEEPSEEK_API_KEY is unset. + DEEPSEEK_2048_BIN DeepSeekCode binary to run. Defaults to + target/debug/deepseek, then PATH deepseek, then + builds target/debug/deepseek. + DEEPSEEK_2048_BUDGET Agent step budget. Defaults to 16. + DEEPSEEK_2048_OUT Transcript path. Defaults to a timestamped file + in docs/demo/. + DEEPSEEK_2048_WORKDIR Parent directory for the disposable repo. + DEEPSEEK_2048_PROMPT Override the coding task prompt. + +The transcript is source evidence for GIF/MP4 capture. Review generated media +before committing it. Do not publish a run unless it used a real model call. +EOF +} + +dry_run=0 +serve=0 +cleanup=0 +api_key_stdin=0 +redaction_self_test=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) + dry_run=1 + shift + ;; + --serve) + serve=1 + shift + ;; + --cleanup) + cleanup=1 + shift + ;; + --api-key-stdin) + api_key_stdin=1 + shift + ;; + --redaction-self-test) + redaction_self_test=1 + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + echo "unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +script_dir=$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(CDPATH= cd -- "$script_dir/../.." && pwd) +run_id=$(date +%Y%m%d-%H%M%S) +demo_budget=${DEEPSEEK_2048_BUDGET:-16} +demo_out=${DEEPSEEK_2048_OUT:-"$repo_root/docs/demo/deepseek-code-2048-demo-$run_id.log"} +work_parent=${DEEPSEEK_2048_WORKDIR:-"${TMPDIR:-/tmp}"} +demo_repo="$work_parent/deepseek-code-2048-demo-$run_id" +demo_prompt=${DEEPSEEK_2048_PROMPT:-"Build a playable 2048 web game in this empty repository using plain HTML, CSS, and JavaScript. Create index.html, styles.css, and app.js. Requirements: a 4x4 board, keyboard arrow controls, tile merging, score tracking, random new tiles, win/game-over messaging, and a restart button. Keep the UI polished but lightweight. After writing files, run a validation command that verifies index.html, styles.css, and app.js exist and prints their byte sizes."} + +redact_demo_stream() { + awk ' + function redact_all(line, needle, replacement, out, pos) { + if (length(needle) == 0) { + return line + } + out = "" + while ((pos = index(line, needle)) > 0) { + out = out substr(line, 1, pos - 1) replacement + line = substr(line, pos + length(needle)) + } + return out line + } + BEGIN { + names[1] = "DEEPSEEK_API_KEY" + names[2] = "OPENAI_API_KEY" + names[3] = "ANTHROPIC_API_KEY" + names[4] = "DEEPSEEK_2048_KEY_FILE" + for (i = 1; i <= 4; i++) { + value = ENVIRON[names[i]] + if (length(value) > 0) { + count += 1 + secrets[count] = value + replacements[count] = "<" names[i] ":redacted>" + } + } + } + { + line = $0 + for (i = 1; i <= count; i++) { + line = redact_all(line, secrets[i], replacements[i]) + } + print line + } + ' +} + +if [[ "$redaction_self_test" -eq 1 ]]; then + previous_key_set=0 + previous_key_value= + if [[ -n "${DEEPSEEK_API_KEY+x}" ]]; then + previous_key_set=1 + previous_key_value=$DEEPSEEK_API_KEY + fi + export DEEPSEEK_API_KEY="${DEEPSEEK_API_KEY:-demo-secret-for-redaction}" + test_secret=$DEEPSEEK_API_KEY + output=$(printf 'before %s after\n' "$test_secret" | redact_demo_stream) + if [[ "$previous_key_set" -eq 1 ]]; then + DEEPSEEK_API_KEY=$previous_key_value + export DEEPSEEK_API_KEY + else + unset DEEPSEEK_API_KEY + fi + if [[ "$output" == *"$test_secret"* ]]; then + echo "redaction self-test failed: secret remained in output" >&2 + exit 1 + fi + if [[ "$output" != *""* ]]; then + echo "redaction self-test failed: redaction marker missing" >&2 + exit 1 + fi + echo "redaction self-test ok" + exit 0 +fi + +if [[ "$dry_run" -eq 1 ]]; then + echo "DeepSeekCode 2048 demo dry run" + echo "repo_root: $repo_root" + echo "demo_repo: $demo_repo" + echo "transcript: $demo_out" + echo "budget: $demo_budget" + echo "serve: $serve" + echo "prompt: $demo_prompt" + echo "status: dry-run only; no API call, repository creation, or transcript write" + exit 0 +fi + +if [[ -z "${DEEPSEEK_API_KEY:-}" && -n "${DEEPSEEK_2048_KEY_FILE:-}" ]]; then + if [[ ! -f "$DEEPSEEK_2048_KEY_FILE" ]]; then + echo "DEEPSEEK_2048_KEY_FILE does not exist: $DEEPSEEK_2048_KEY_FILE" >&2 + exit 1 + fi + key_file_dir=$(CDPATH= cd -- "$(dirname -- "$DEEPSEEK_2048_KEY_FILE")" && pwd) + key_file_abs="$key_file_dir/$(basename -- "$DEEPSEEK_2048_KEY_FILE")" + case "$key_file_abs" in + "$repo_root"/*) + echo "DEEPSEEK_2048_KEY_FILE must live outside this repository: $key_file_abs" >&2 + exit 1 + ;; + esac + IFS= read -r DEEPSEEK_API_KEY < "$key_file_abs" + export DEEPSEEK_API_KEY +fi + +if [[ -z "${DEEPSEEK_API_KEY:-}" && "$api_key_stdin" -eq 1 ]]; then + IFS= read -r DEEPSEEK_API_KEY + export DEEPSEEK_API_KEY +fi + +if [[ -z "${DEEPSEEK_API_KEY:-}" ]]; then + echo "DEEPSEEK_API_KEY is required for model-backed 2048 demo evidence." >&2 + echo "Use DEEPSEEK_2048_KEY_FILE outside the repo or --api-key-stdin to avoid putting the key in shell history." >&2 + exit 1 +fi + +if [[ -n "${DEEPSEEK_2048_BIN:-}" ]]; then + deepseek_bin=$DEEPSEEK_2048_BIN +elif [[ -x "$repo_root/target/debug/deepseek" ]]; then + deepseek_bin="$repo_root/target/debug/deepseek" +elif command -v deepseek >/dev/null 2>&1; then + deepseek_bin=$(command -v deepseek) +else + echo "target/debug/deepseek not found; building debug binary" >&2 + cargo build --manifest-path "$repo_root/Cargo.toml" --bin deepseek + deepseek_bin="$repo_root/target/debug/deepseek" +fi + +if [[ ! -x "$deepseek_bin" ]]; then + echo "DeepSeekCode binary is not executable: $deepseek_bin" >&2 + exit 1 +fi + +mkdir -p "$demo_repo" +mkdir -p "$(dirname -- "$demo_out")" + +git -C "$demo_repo" init -q +git -C "$demo_repo" config user.email "demo@deepseekcode.local" +git -C "$demo_repo" config user.name "DeepSeekCode Demo" +cat > "$demo_repo/README.md" <<'EOF' +# DeepSeekCode 2048 Demo + +This disposable repository starts empty except for this README. The demo asks +DeepSeekCode to build a playable 2048 web game with plain HTML, CSS, and +JavaScript. +EOF +git -C "$demo_repo" add README.md +git -C "$demo_repo" commit -q -m "Create empty 2048 demo repo" + +run_session() { + cd "$demo_repo" + echo "DeepSeekCode 2048 model-backed demo" + echo "workspace: $demo_repo" + echo + echo "$ find . -maxdepth 2 -type f | sort" + find . -maxdepth 2 -type f | sort + echo + echo "$ DSCODE_AUTO_APPROVE_WRITES=1 DSCODE_AUTO_APPROVE_SHELL=1 $deepseek_bin exec --budget $demo_budget \"<2048 prompt>\"" + DSCODE_AUTO_APPROVE_WRITES=1 \ + DSCODE_AUTO_APPROVE_SHELL=1 \ + "$deepseek_bin" exec --budget "$demo_budget" "$demo_prompt" + echo + echo "$ test -s index.html && test -s styles.css && test -s app.js" + test -s index.html && test -s styles.css && test -s app.js + echo + echo "$ wc -c index.html styles.css app.js" + wc -c index.html styles.css app.js + echo + echo "$ git diff --stat" + git diff --stat + echo + echo "$ git diff -- index.html styles.css app.js | sed -n '1,220p'" + git diff -- index.html styles.css app.js | sed -n '1,220p' + if [[ "$serve" -eq 1 ]]; then + echo + echo "$ python3 -m http.server 4173" + echo "Preview URL: http://127.0.0.1:4173" + echo "Stop the server with Ctrl+C after recording browser gameplay." + python3 -m http.server 4173 + else + echo + echo "Preview command: cd $demo_repo && python3 -m http.server 4173" + echo "Preview URL: http://127.0.0.1:4173" + fi +} + +set +e +run_session 2>&1 | redact_demo_stream | tee "$demo_out" +session_status=${PIPESTATUS[0]} +set -e + +echo +echo "transcript: $demo_out" +echo "demo repo: $demo_repo" + +if [[ "$cleanup" -eq 1 ]]; then + rm -rf "$demo_repo" + echo "demo repo removed" +fi + +exit "$session_status" diff --git a/docs/install.md b/docs/install.md index b849297..a759396 100644 --- a/docs/install.md +++ b/docs/install.md @@ -567,6 +567,8 @@ curl http://127.0.0.1:8765/runtime - `deepseek update release-smoke [--version ... --repo ... --base-url ... --platform ... --out ... --keep-workdir --json]`:下载、校验并执行当前平台 release binary install smoke - `deepseek update publish-status [--dist ... --npm-dist ... --live-evidence-verification ... --strict --json]`:检查 npm/Homebrew 发布所需 token、tap 配置、平台包、release checksum 和 online dogfood evidence - `deepseek pr live-status [--require-write --json]`:只读检查真实 GitHub PR 是否具备 live review/retry fixture 前置条件 +- `deepseek config provider [show|list| [model]]` / `deepseek config model [show|list|]`:查看或切换首跑 provider/model 配置;例如 `deepseek config provider deepseek pro` +- `deepseek config auth [ENV] --stdin`:从 stdin 安全写入 `.env`,避免把 API key 放进 shell argv - `deepseek config network allow|deny `:把网络 host 策略写回项目 `.dscode/config.toml`,用于持久化 web/search/fetch 的允许或拒绝规则 - `deepseek agents run-task `:认领并执行 pending durable runtime task,写回同一 thread 的 turns/items/usage/status - `deepseek agents daemon [--interval-ms 1000] [--budget N]`:本地轮询 `.dscode/runtime`,触发到期 automation、执行 thread-linked pending task,并自动追加 non-destructive compaction summary diff --git a/docs/launch/demo-script.md b/docs/launch/demo-script.md index 4c1a677..dc9d1b9 100644 --- a/docs/launch/demo-script.md +++ b/docs/launch/demo-script.md @@ -1,7 +1,8 @@ # Demo Script Use this to record a 30-60 second launch demo. The goal is to show the product -working, not to explain every feature. +working, not to explain every feature. For the most visual launch asset, start +with the 2048 recorder in [../demo/README.md](../demo/README.md). ## Recording Setup @@ -16,11 +17,11 @@ working, not to explain every feature. 1. Install or verify the CLI. 2. Run first-run checks. -3. Open a small repo. -4. Ask DeepSeekCode to inspect the repo and make a bounded change. +3. Open an empty 2048 demo repo or a small disposable code repo. +4. Ask DeepSeekCode to make a bounded change or generate the playable 2048 app. 5. Approve file/shell actions. -6. Show the diff. -7. Run tests. +6. Show the generated files or diff. +7. Run tests, validation, or local browser preview. 8. Close with the GitHub repo URL. ## Command Flow @@ -47,6 +48,13 @@ git diff --stat git diff ``` +For the 2048 launch asset: + +```bash +DEEPSEEK_2048_KEY_FILE=/tmp/deepseek-2048.key docs/demo/record-2048-demo.sh +DEEPSEEK_2048_KEY_FILE=/tmp/deepseek-2048.key docs/demo/record-2048-demo.sh --serve +``` + If a live model call is too slow for a public recording, use the committed model-backed SVG in the README and record a shorter install/quickstart clip. @@ -59,6 +67,8 @@ model-backed SVG in the README and record a shorter install/quickstart clip. - The agent reads repo context before editing. - File changes are reviewable with `git diff`. - Tests or checks run in the same terminal loop. +- The 2048 demo reaches a visual, playable result rather than only a terminal + transcript. - The project is public beta and asks for real terminal-workflow feedback. ## Social Preview Brief diff --git a/docs/launch/product-hunt.md b/docs/launch/product-hunt.md index 109b73e..4c25c77 100644 --- a/docs/launch/product-hunt.md +++ b/docs/launch/product-hunt.md @@ -11,7 +11,8 @@ Go when: - npm is either published or the page clearly says Homebrew is the primary macOS install path and Linux uses release archives or source install. -- A 30-60 second demo video exists. +- A 30-60 second demo video exists, ideally from the 2048 recorder in + `docs/demo/record-2048-demo.sh`. - At least three clean screenshots or gallery images exist. - README, install docs, and current status match the latest release. - Known limitations are visible and defensible. @@ -58,8 +59,8 @@ It is still early. npm publishing is not live yet, Windows is not the current pu ## Gallery Ideas 1. macOS Homebrew install and quickstart. -2. Full-screen TUI in a local repo. -3. Agent proposing a file change. -4. `git diff` after the agent edit. -5. Passing test/check output. +2. DeepSeekCode generating the playable 2048 app from an empty repo. +3. Browser gameplay of the generated 2048 app. +4. Full-screen TUI in a local repo. +5. `git diff` after the agent edit. 6. Release evidence: GitHub Release, Homebrew Smoke, and GHCR. diff --git a/docs/public-beta.md b/docs/public-beta.md index baf0dab..0bc1276 100644 --- a/docs/public-beta.md +++ b/docs/public-beta.md @@ -108,7 +108,8 @@ Keep these caveats visible when promoting the project: - Hosted IDE evidence and broader Windows service proof are outside the current Linux/macOS local CLI milestone. - Rich GIF/MP4 launch media can improve conversion, but the committed SVG demo - is already enough to show the core loop. + is already enough to show the core loop. The 2048 recorder in + `docs/demo/record-2048-demo.sh` is the recommended next visual launch asset. ## Promotion Checklist diff --git a/scripts/check-secrets.js b/scripts/check-secrets.js index 9f7c3a5..d10a8eb 100755 --- a/scripts/check-secrets.js +++ b/scripts/check-secrets.js @@ -40,6 +40,7 @@ const SKIP_DIRS = new Set([ const SKIP_PATH_PATTERNS = [ /^\.env(?:\.|$)/, /^docs\/demo\/deepseek-code-model-demo-.*\.log$/, + /^docs\/demo\/deepseek-code-2048-demo-.*\.log$/, /^npm\/platforms\/[^/]+\/bin\//, /\.key$/, ]; diff --git a/src/cli/app.rs b/src/cli/app.rs index 1d93a65..9e76b73 100644 --- a/src/cli/app.rs +++ b/src/cli/app.rs @@ -1347,6 +1347,25 @@ pub struct ConfigArgs { pub network_deny: Option, pub auth_env: Option, pub auth_stdin: bool, + pub model_action: Option, + pub provider_action: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigModelAction { + Show, + List, + Set(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigProviderAction { + Show, + List, + Set { + provider: String, + model: Option, + }, } #[derive(Debug, Default)] @@ -2339,6 +2358,8 @@ fn parse_config_args(args: Vec) -> Result { network_deny: None, auth_env: None, auth_stdin: false, + model_action: None, + provider_action: None, }; let mut index = 0; @@ -2387,9 +2408,25 @@ fn parse_config_args(args: Vec) -> Result { } } } + "model" => { + if parsed.model_action.is_some() { + return Err("config model can only be specified once".to_string()); + } + let rest = &args[index + 1..]; + parsed.model_action = Some(parse_config_model_action(rest)?); + index = args.len(); + } + "provider" => { + if parsed.provider_action.is_some() { + return Err("config provider can only be specified once".to_string()); + } + let rest = &args[index + 1..]; + parsed.provider_action = Some(parse_config_provider_action(rest)?); + index = args.len(); + } other => { return Err(format!( - "unknown config argument `{other}`; expected init|auth [ENV] --stdin|network allow|network deny|--force|--print-default" + "unknown config argument `{other}`; expected init|auth [ENV] --stdin|model [show|list|MODEL]|provider [show|list|NAME [MODEL]]|network allow|network deny|--force|--print-default" )); } } @@ -2398,6 +2435,11 @@ fn parse_config_args(args: Vec) -> Result { let network_mutations = usize::from(parsed.network_allow.is_some()) + usize::from(parsed.network_deny.is_some()); let auth_mutation = parsed.auth_env.is_some() || parsed.auth_stdin; + let config_action_count = network_mutations + + usize::from(auth_mutation) + + usize::from(parsed.init) + + usize::from(parsed.model_action.is_some()) + + usize::from(parsed.provider_action.is_some()); if network_mutations > 1 { return Err("config accepts only one network allow/deny mutation at a time".to_string()); } @@ -2424,10 +2466,60 @@ fn parse_config_args(args: Vec) -> Result { if parsed.force && !parsed.init { return Err("config --force requires init".to_string()); } + if config_action_count > 1 { + return Err( + "config accepts only one action at a time: init, auth, model, provider, or network" + .to_string(), + ); + } + if (parsed.model_action.is_some() || parsed.provider_action.is_some()) + && (parsed.print_default || parsed.force) + { + return Err( + "config model/provider cannot be combined with --force or --print-default".to_string(), + ); + } Ok(parsed) } +fn parse_config_model_action(args: &[String]) -> Result { + match args { + [] => Ok(ConfigModelAction::Show), + [value] if matches!(value.as_str(), "show" | "status") => Ok(ConfigModelAction::Show), + [value] if matches!(value.as_str(), "list" | "ls") => Ok(ConfigModelAction::List), + [value] if !value.starts_with('-') => Ok(ConfigModelAction::Set(value.clone())), + [value] => Err(format!( + "unknown config model argument `{value}`; expected show|list|MODEL" + )), + _ => Err("config model accepts at most one argument: show|list|MODEL".to_string()), + } +} + +fn parse_config_provider_action(args: &[String]) -> Result { + match args { + [] => Ok(ConfigProviderAction::Show), + [value] if matches!(value.as_str(), "show" | "status") => Ok(ConfigProviderAction::Show), + [value] if matches!(value.as_str(), "list" | "ls") => Ok(ConfigProviderAction::List), + [provider] if !provider.starts_with('-') => Ok(ConfigProviderAction::Set { + provider: provider.clone(), + model: None, + }), + [provider, model] if !provider.starts_with('-') && !model.starts_with('-') => { + Ok(ConfigProviderAction::Set { + provider: provider.clone(), + model: Some(model.clone()), + }) + } + [value] => Err(format!( + "unknown config provider argument `{value}`; expected show|list|NAME [MODEL]" + )), + _ => { + Err("config provider accepts at most two arguments: show|list|NAME [MODEL]".to_string()) + } + } +} + fn parse_tui_args(args: Vec) -> Result { let mut parsed = TuiArgs::default(); let mut iter = args.into_iter(); @@ -8271,6 +8363,64 @@ mod tests { assert!(error.contains("config auth requires --stdin")); } + #[test] + fn cli_from_argv_routes_config_provider_set() { + let cli = Cli::from_argv(vec![ + "config".to_string(), + "provider".to_string(), + "openrouter".to_string(), + "flash".to_string(), + ]) + .expect("parse should succeed"); + + match cli.command { + Some(Command::Config(args)) => { + assert_eq!( + args.provider_action, + Some(ConfigProviderAction::Set { + provider: "openrouter".to_string(), + model: Some("flash".to_string()), + }) + ); + assert!(!args.init); + assert!(!args.print_default); + } + other => panic!("expected Command::Config, got {other:?}"), + } + } + + #[test] + fn cli_from_argv_routes_config_model_list() { + let cli = Cli::from_argv(vec![ + "config".to_string(), + "model".to_string(), + "list".to_string(), + ]) + .expect("parse should succeed"); + + match cli.command { + Some(Command::Config(args)) => { + assert_eq!(args.model_action, Some(ConfigModelAction::List)); + assert!(!args.init); + assert!(!args.print_default); + } + other => panic!("expected Command::Config, got {other:?}"), + } + } + + #[test] + fn cli_from_argv_rejects_multiple_config_actions() { + let error = Cli::from_argv(vec![ + "config".to_string(), + "init".to_string(), + "provider".to_string(), + "deepseek".to_string(), + ]) + .unwrap_err(); + + assert!(error.contains("config accepts only one action")); + } + #[test] fn cli_from_argv_routes_doctor_json() { let cli = Cli::from_argv(vec!["doctor".to_string(), "--json".to_string()]) diff --git a/src/cli/commands/config.rs b/src/cli/commands/config.rs index fbd8dff..6b39a87 100644 --- a/src/cli/commands/config.rs +++ b/src/cli/commands/config.rs @@ -1,4 +1,4 @@ -use crate::cli::app::ConfigArgs; +use crate::cli::app::{ConfigArgs, ConfigModelAction, ConfigProviderAction}; use crate::config::load::{config_assignments, load_or_default, parse_dotenv_assignment}; use crate::config::types::AppConfig; use crate::core::network_policy::{decide, normalize_host, NetworkDecision}; @@ -7,6 +7,14 @@ use crate::error::AppResult; use std::io::Read as _; pub fn run(args: ConfigArgs) -> AppResult<()> { + if let Some(action) = args.model_action.clone() { + run_model_action(action)?; + return Ok(()); + } + if let Some(action) = args.provider_action.clone() { + run_provider_action(action)?; + return Ok(()); + } if let Some(host) = args.network_allow { let result = persist_network_rule_at(&std::env::current_dir()?, &host, NetworkRuleTarget::Allow)?; @@ -56,6 +64,59 @@ pub fn run(args: ConfigArgs) -> AppResult<()> { Ok(()) } +fn run_model_action(action: ConfigModelAction) -> AppResult<()> { + let cwd = std::env::current_dir()?; + match action { + ConfigModelAction::Show => { + let summary = model_config_summary_at(&cwd)?; + print_model_summary(&summary); + } + ConfigModelAction::List => { + let summary = provider_config_summary_at(&cwd)?; + print_model_list(&summary); + } + ConfigModelAction::Set(model) => { + let result = set_model_at(&cwd, &model)?; + if result.changed { + println!("model: {} -> {}", result.previous, result.model); + } else { + println!("model: {} already selected", result.model); + } + println!("config: {}", result.path.display()); + } + } + Ok(()) +} + +fn run_provider_action(action: ConfigProviderAction) -> AppResult<()> { + let cwd = std::env::current_dir()?; + match action { + ConfigProviderAction::Show => { + let summary = provider_config_summary_at(&cwd)?; + print_provider_summary(&summary); + } + ConfigProviderAction::List => print_provider_list(), + ConfigProviderAction::Set { provider, model } => { + let result = set_provider_at(&cwd, &provider, model.as_deref())?; + if result.changed { + println!( + "provider: {} -> {} ({})", + result.previous_provider, result.provider, result.label + ); + } else { + println!( + "provider: {} already selected ({})", + result.provider, result.label + ); + } + println!("model: {}", result.model); + println!("api key env: {}", result.api_key_env); + println!("config: {}", result.path.display()); + } + } + Ok(()) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum NetworkRuleTarget { Allow, @@ -214,6 +275,45 @@ fn print_network_rule_result(result: &NetworkRuleResult) { println!("config: {}", result.path.display()); } +fn print_model_summary(summary: &ModelConfigSummary) { + println!("model: {}", summary.model); + println!("base_url: {}", summary.base_url); + println!("api_key_env: {}", summary.api_key_env); + println!("reasoning_effort: {}", summary.reasoning_effort); + println!("config: {}", summary.path.display()); +} + +fn print_provider_summary(summary: &ProviderConfigSummary) { + println!("provider: {} ({})", summary.provider, summary.label); + println!("base_url: {}", summary.base_url); + println!("model: {}", summary.model); + println!("api_key_env: {}", summary.api_key_env); + println!("reasoning_effort: {}", summary.reasoning_effort); + println!("config: {}", summary.path.display()); +} + +fn print_provider_list() { + println!("Supported providers"); + for preset in provider_presets() { + println!( + "- {} ({}) model={} api_key_env={}", + preset.name, preset.label, preset.default_model, preset.api_key_env + ); + } +} + +fn print_model_list(summary: &ProviderConfigSummary) { + println!( + "Models for provider {} ({})", + summary.provider, summary.label + ); + for model in provider_model_completion_values(&summary.provider) { + println!("- {model}"); + } + println!("current: {}", summary.model); + println!("config: {}", summary.path.display()); +} + pub(crate) fn network_policy_summary_at(root: &std::path::Path) -> AppResult { let path = network_config_path_at(root); if !path.exists() { diff --git a/src/cli/commands/quickstart.rs b/src/cli/commands/quickstart.rs index b03c3bf..4aec06a 100644 --- a/src/cli/commands/quickstart.rs +++ b/src/cli/commands/quickstart.rs @@ -31,6 +31,8 @@ struct QuickstartReport { cwd: String, config_path: String, config_present: bool, + base_url: String, + model: String, api_key_env: String, api_key_present: bool, terminal_tty: bool, @@ -75,6 +77,8 @@ fn build_quickstart_report_from_state( cwd, config_path, config_present, + base_url: config.model.base_url.clone(), + model: config.model.model.clone(), api_key_env: config.model.api_key_env.clone(), api_key_present, terminal_tty, @@ -96,6 +100,10 @@ fn quickstart_next_commands( if !config_present { commands.push("deepseek config init".to_string()); } + commands.push("deepseek config provider show".to_string()); + if !config_present || !api_key_present { + commands.push("deepseek config provider list".to_string()); + } if !api_key_present { commands.push(format!( "printf '%s\\n' '' | deepseek config auth {api_key_env} --stdin" @@ -143,6 +151,8 @@ fn render_text_report(report: &QuickstartReport) -> String { report.api_key_env ) .expect("write to string"); + writeln!(&mut out, "- model: {}", report.model).expect("write to string"); + writeln!(&mut out, "- base URL: {}", report.base_url).expect("write to string"); writeln!( &mut out, "- terminal: {}", @@ -206,6 +216,8 @@ fn render_json_report(report: &QuickstartReport) -> String { ("cwd", JsonValue::String(report.cwd.clone())), ("config_path", JsonValue::String(report.config_path.clone())), ("config_present", JsonValue::Bool(report.config_present)), + ("base_url", JsonValue::String(report.base_url.clone())), + ("model", JsonValue::String(report.model.clone())), ("api_key_env", JsonValue::String(report.api_key_env.clone())), ("api_key_present", JsonValue::Bool(report.api_key_present)), ("terminal_tty", JsonValue::Bool(report.terminal_tty)),