Compare commits

..

1 Commits

Author SHA1 Message Date
CodeWhale Bot 518db2b37e debug: probe python availability on Windows runner 2026-08-11 22:05:33 -07:00
918 changed files with 50714 additions and 155955 deletions
+1 -16
View File
@@ -34,15 +34,6 @@
cargo fmt --all -- --check
cargo check --workspace --all-targets --locked
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
# Hermetic HOME so libtest's shared process cannot see a populated
# ~/.codewhale/config.toml (#5355 config-fixture family). Tests stay in
# the suite; isolation is scheduling, not deletion.
hermetic_home="${TMPDIR:-/tmp}/cw-cnb-hermetic-home-$$"
mkdir -p "${hermetic_home}/.codewhale"
export HOME="${hermetic_home}"
export USERPROFILE="${hermetic_home}"
export CODEWHALE_HOME="${hermetic_home}/.codewhale"
unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME || true
RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked
# Parity gates as first-class steps so drift surfaces as a named failure,
# not a buried workspace-test entry. Mirrors release.yml's parity job.
@@ -156,13 +147,6 @@ $:
./scripts/release/check-versions.sh
./scripts/release/check-ohos-deps.sh
checkout_sha="$(git rev-parse 'HEAD^{commit}')"
commit_sha="${CNB_COMMIT:-${checkout_sha}}"
if [ "$commit_sha" != "$checkout_sha" ]; then
echo "ERROR: CNB_COMMIT ${commit_sha} does not match checkout ${checkout_sha}" >&2
exit 1
fi
export CODEWHALE_BUILD_SHA="$commit_sha"
cargo build --jobs 2 --release --locked \
--target x86_64-unknown-linux-musl \
-p codewhale-cli # single binary
@@ -197,6 +181,7 @@ $:
echo "ERROR: tag ${tag_name} does not match Cargo.toml version ${cargo_version}" >&2
exit 1
fi
commit_sha="${CNB_COMMIT:-$(git rev-parse HEAD)}"
{
echo "# ${tag_name:-CNB release}"
echo
-60
View File
@@ -1,60 +0,0 @@
# cargo-nextest profile for contributors (`cargo nextest run --workspace`).
#
# `cargo test --workspace --all-features --locked` remains the authoritative
# release gate. nextest is a faster local loop: it runs each test in its own
# process, so the 10k-test codewhale-tui unit suite finishes in well under
# half the wall-clock time of libtest on a multi-core machine, and slow or
# hanging tests are named instead of stalling the whole binary.
#
# Because tests no longer share a process, integration suites that serialize
# on in-process locks must be bounded here when they contend for mock-server
# ports or wall-clock timing budgets.
[profile.default]
# Name tests that take longer than this so contributors see where the time goes.
slow-timeout = { period = "30s" }
# Keep failures visible in the final summary and never retry silently:
# a flaky test is a bug report, not something to paper over.
retries = 0
fail-fast = false
final-status-level = "slow"
# RUST_MIN_STACK is not a nextest.toml key. CI and scripts/dev-test.sh
# export 16 MiB so local nextest matches the product thread stack.
[test-groups]
# Integration tests that spawn the real `codewhale` binary and wait on
# service start-up deadlines; bounded so a fully parallel run on a busy
# machine cannot starve them past their 30 s budgets.
spawns-binaries = { max-threads = 3 }
# Telemetry contract tests also spawn the real binary, and their fixture's
# in-process mutex cannot serialize nextest's one-process-per-test workers.
# Keep one telemetry process alongside the other three integration workers.
telemetry-contract = { max-threads = 1 }
# Persistent-service exec tests wait on a pid file from a real child. Under
# parallel load the file never appears (#5355). Serialize them; do not drop
# the tests.
exec-persistent-service = { max-threads = 1 }
# First matching test-group override wins. Keep these more-specific
# integration filters before binary(integration) so they are not stolen
# by the three-thread group.
[[profile.default.overrides]]
filter = 'binary(integration) & test(/^telemetry_contract::/)'
test-group = 'telemetry-contract'
[[profile.default.overrides]]
filter = 'binary(integration) & test(/^exec_persistent_service::/)'
test-group = 'exec-persistent-service'
slow-timeout = { period = "120s" }
[[profile.default.overrides]]
filter = 'binary(integration)'
test-group = 'spawns-binaries'
# CI (profiles inherit from default): the final summary lists every failure and
# slow test in full so the run log is enough to diagnose without rerunning.
[profile.ci]
retries = 0
fail-fast = false
final-status-level = "slow"
failure-output = "immediate-final"
-3
View File
@@ -18,9 +18,6 @@ crates/*/assets/**/*.json text eol=lf
crates/*/assets/**/*.md text eol=lf
crates/*/locales/*.json text eol=lf
workflows/*.js text eol=lf
# The dsh bundle scene is include_str!() into the generated client.js and
# hashed for stale detection; CRLF would change both across platforms.
crates/tui/src/integrations/dsh/*.js text eol=lf
# Rustfmt writes LF; keep Rust sources stable across Windows/Linux/macOS.
*.rs text eol=lf
-11
View File
@@ -52,7 +52,6 @@ angziii = angziii <177907677+angziii@users.noreply.github.com>
aboimpinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
Paulo Aboim Pinto = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
aboimpinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
paulo.aboim.pinto@gmail.com = aboimpinto <1231687+aboimpinto@users.noreply.github.com>
encyc = encyc <62669951+encyc@users.noreply.github.com>
Duducoco = Duducoco <69681789+Duducoco@users.noreply.github.com>
cyq1017 = cyq1017 <61975706+cyq1017@users.noreply.github.com>
@@ -66,8 +65,6 @@ LeoLin990405 = LeoLin990405 <101193087+LeoLin990405@users.noreply.github.com>
THINKER-ONLY = THINKER-ONLY <181556007+THINKER-ONLY@users.noreply.github.com>
nightt5879 = nightt5879 <87569709+nightt5879@users.noreply.github.com>
LmeSzinc = LmeSzinc <37934724+LmeSzinc@users.noreply.github.com>
Lstarsky0 = Lstarsky0 <59827030+Lstarsky0@users.noreply.github.com>
RepentStar = RepentStar <87593085+RepentStar@users.noreply.github.com>
CCChisato = Fushimi Rio <158128433+CCChisato@users.noreply.github.com>
aznikline = aznikline <27564626+aznikline@users.noreply.github.com>
Aznable = aznikline <27564626+aznikline@users.noreply.github.com>
@@ -186,7 +183,6 @@ heloanc = heloanc <61081755+heloanc@users.noreply.github.com>
heloanc@users.noreply.github.com = heloanc <61081755+heloanc@users.noreply.github.com>
bistack = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
zhenyuan.sun@163.com = Sun Zhenyuan <9128763+bistack@users.noreply.github.com>
OctoBored = OctoBored <212877535+OctoBored@users.noreply.github.com>
skyzhao1223 = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
zhaotian1 = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
zhaotian1@wps.cn = SKY ZHAO <15373810+skyzhao1223@users.noreply.github.com>
@@ -218,10 +214,3 @@ XhesicaFrost = XhesicaFrost <142909332+XhesicaFrost@users.noreply.github.com>
ffaacceelee = ffaacceelee <11267580+ffaacceelee@users.noreply.github.com>
mky = mky <817223+mky@users.noreply.github.com>
cacdcaecawae = cacdcaecawae <109055297+cacdcaecawae@users.noreply.github.com>
XiaoHuo888-hue = XiaoHuo888-hue <315183888+XiaoHuo888-hue@users.noreply.github.com>
sjh00112233@outlook.com = XiaoHuo888-hue <315183888+XiaoHuo888-hue@users.noreply.github.com>
wuisabel-gif = Isabel Wu <231155141+wuisabel-gif@users.noreply.github.com>
redstar = Kai Nacke <827859+redstar@users.noreply.github.com>
kai@redstar.de = Kai Nacke <827859+redstar@users.noreply.github.com>
Shizuku = Sh1Zuku <125943630+SparkofSpike@users.noreply.github.com>
2163018547@qq.com = Sh1Zuku <125943630+SparkofSpike@users.noreply.github.com>
+11 -201
View File
@@ -34,7 +34,6 @@ const nightly = read(".github/workflows/nightly.yml");
const candidate = read(".github/workflows/release-candidate.yml");
const artifacts = read(".github/workflows/release-artifacts.yml");
const release = read(".github/workflows/release.yml");
const republish = read(".github/workflows/release-republish.yml");
const releaseDockerfile = read("packaging/docker/Dockerfile.release");
const cnb = read(".cnb.yml");
const bundles = read("scripts/release/create-release-bundles.sh");
@@ -52,6 +51,13 @@ for (const output of ["heavy", "workflow", "mobile", "actions"]) {
}
assert.match(manualForceBlock[1], /#EXPECTED_SHA.*-ne 40/s);
assert.match(manualForceBlock[1], /actual.*EXPECTED_SHA/s);
assert.match(
ci,
/run: cargo test -p codewhale-tui --test pty qa_pty::skills_opens_manager_owned_then_compatible -- --ignored --exact/,
"CI must run the isolated Skills Manager acceptance from the consolidated PTY target",
);
assert.doesNotMatch(ci, /--test qa_pty\b/, "CI must not name the removed qa_pty target");
const expectedNightlyTargets = [
"x86_64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
@@ -113,13 +119,7 @@ assert.doesNotMatch(candidate, /^ (push|pull_request|schedule):/m);
assert.match(candidate, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
assert.match(candidate, /source_sha: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.match(candidate, /^ web:\n/m);
assert.doesNotMatch(
candidate,
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"candidate jobs must checkout GITHUB_SHA, not interpolate the dispatch SHA into ref",
);
assert.match(candidate, /cache-dependency-path: web\/package-lock\.json/);
assert.match(candidate, /package-manager-cache: false/);
assert.match(candidate, /ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/);
assert.match(candidate, /working-directory: web/);
for (const command of [
"npm ci",
@@ -159,7 +159,6 @@ for (const [label, workflow] of [
["release candidate", candidate],
["shared artifact", artifacts],
["public release", release],
["release republish", republish],
]) {
const remoteActions = [...workflow.matchAll(/^\s+(?:-\s+)?uses:\s+([^@\s]+)@([^#\s]+)/gm)]
.map((match) => ({ action: match[1], ref: match[2] }))
@@ -174,25 +173,6 @@ for (const [label, workflow] of [
}
}
const republishHomebrewJob = republish.match(/\n homebrew:\n([\s\S]*)$/);
assert.ok(republishHomebrewJob, "republish must retain a Homebrew recovery job");
const republishHomebrewCheckout = namedStep(
republishHomebrewJob[0],
"Checkout release infrastructure",
);
assert.match(
republishHomebrewCheckout,
/ref: \$\{\{ github\.event\.repository\.default_branch \}\}/,
"Homebrew recovery must use the repaired default-branch infrastructure",
);
assert.doesNotMatch(
republishHomebrewCheckout,
/needs\.resolve\.outputs\.sha/,
"Homebrew recovery must not resurrect release-tag infrastructure",
);
assert.match(republishHomebrewJob[0], /gh release download "\$\{\{ needs\.resolve\.outputs\.tag \}\}"/);
assert.match(republishHomebrewJob[0], /MANIFEST: \/tmp\/codewhale-artifacts-sha256\.txt/);
assert.match(artifacts, /^ workflow_call:/m);
assert.match(artifacts, /^permissions:\n contents: read$/m);
const expectedTargets = [
@@ -230,7 +210,7 @@ assert.match(releaseStaticSmoke, /"\$\{bin_path\}" --version/);
const builtAssetNames = [
...valuesForKey(artifacts, "cli_artifact"),
...valuesForKey(artifacts, "shim_artifact"),
...valuesForKey(artifacts, "compat_tui_artifact"),
...valuesForKey(artifacts, "tui_artifact"),
];
assert.equal(builtAssetNames.length, 21);
assert.deepEqual(
@@ -242,7 +222,7 @@ assert.deepEqual(
);
assert.match(
artifacts,
/stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.compat_tui_artifact \}\}"/,
/stage_binary "\$\{\{ matrix\.cli_binary \}\}" "\$\{\{ matrix\.tui_artifact \}\}"/,
"legacy TUI bridge assets must be staged from the one compiled codewhale binary",
);
const bundleInvocations = [...bundles.matchAll(
@@ -258,51 +238,14 @@ assert.match(artifacts, /codew-windows-arm64\.exe/);
assert.match(artifacts, /CodeWhaleSetup\.exe/);
assert.match(artifacts, /assemble-release-assets\.js --verify release-assets/);
assert.match(artifacts, /CODEWHALE_SMOKE_ASSETS_DIR/);
assert.match(artifacts, /^ pin:\n/m);
assert.match(artifacts, /Require source_sha equals github\.sha/);
assert.doesNotMatch(
artifacts,
/ref: \$\{\{ inputs\.source_sha \}\}/,
"artifact jobs must checkout GITHUB_SHA, not interpolate the caller SHA into ref",
);
assert.match(artifacts, /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
assert.equal(
(artifacts.match(/package-manager-cache: false/g) || []).length,
2,
"assemble and smoke must disable setup-node's implicit npm cache",
);
const bundleStep = namedStep(artifacts, "Create and checksum platform archives");
assert.match(bundleStep, /SOURCE_SHA: \$\{\{ github\.sha \}\}/);
assert.match(bundleStep, /git show -s --format=%ct "\$\{SOURCE_SHA\}"/);
assert.match(bundleStep, /git show -s --format=%ct "\$\{\{ inputs\.source_sha \}\}"/);
assert.match(
bundleStep,
/SOURCE_DATE_EPOCH="\$\{source_date_epoch\}"[\s\\]+bash scripts\/release\/create-release-bundles\.sh artifacts bundles/,
);
assert.doesNotMatch(bundleStep, /inputs\.source_sha/);
assert.doesNotMatch(bundleStep, /\bdate\b/, "bundle timestamps must come from the pinned source commit, not wall-clock time");
const rustCacheBlocks = [...artifacts.matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.ok(rustCacheBlocks.length >= 1, "shared artifact workflow must pin rust-cache");
for (const block of rustCacheBlocks) {
assert.doesNotMatch(block, /github\.(event|ref|sha)|inputs\./);
}
const parity = release.match(/\n parity:\n([\s\S]*?)\n artifacts:\n/);
assert.ok(parity, "public release must retain a parity job");
assert.doesNotMatch(
parity[1],
/ref: \$\{\{ needs\.resolve\.outputs\.sha \}\}/,
"parity must checkout GITHUB_SHA after resolve, not interpolate the tag SHA into ref",
);
assert.match(parity[1], /prefix-key: v1-\$\{\{ runner\.os \}\}-\$\{\{ runner\.arch \}\}-stable/);
const parityRustCache = [...parity[1].matchAll(/uses: Swatinem\/rust-cache@[\s\S]*?(?=\n - )/g)].map(
(match) => match[0],
);
assert.equal(parityRustCache.length, 1, "parity must pin exactly one rust-cache");
assert.doesNotMatch(parityRustCache[0], /github\.(event|ref|sha)|inputs\./);
assert.equal(allReleaseAssetNames().length, 34);
assert.match(release, /^ artifacts:\n/m);
assert.match(release, /uses: \.\/\.github\/workflows\/release-artifacts\.yml/);
@@ -377,7 +320,6 @@ assert.match(npmTagGate, /verify-remote-tag\.sh/);
assert.match(npmAssetGate, /verify-release-assets\.sh/);
assert.match(npmAssetGate, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(npmPublish, /working-directory: npm\/codewhale/);
assert.match(npmPublish, /GH_TOKEN: \$\{\{ github\.token \}\}/);
assert.match(npmPublish, /npm publish --access public/);
assert.doesNotMatch(npmJob[1], /NPM_TOKEN|NODE_AUTH_TOKEN|secrets\./);
assert.ok(
@@ -403,8 +345,6 @@ assert.match(runbook, /expected_sha/);
assert.match(runbook, /34/);
assert.match(runbook, /does not create a tag/i);
assert.match(runbook, /explicit.*approval/i);
assert.match(runbook, /last[- ]useful[- ]log/i, "runbook must document the last-useful-log rule (#5496)");
assert.match(runbook, /404 logs/i, "runbook must document the 404-log cancellation rule (#5496)");
const cnbRustGates = cnb.match(
/\.rust_workspace_gates_stage: &rust_workspace_gates_stage([\s\S]*?)\n\.linux_rust_gates:/,
@@ -415,30 +355,6 @@ assert.match(
/timeout: 45m[\s\S]*export CARGO_BUILD_JOBS=1[\s\S]*export CARGO_PROFILE_TEST_DEBUG=0[\s\S]*cargo check --workspace --all-targets --locked[\s\S]*cargo clippy --workspace --all-targets --all-features --locked -- -D warnings[\s\S]*RUST_MIN_STACK=16777216 cargo test --workspace --all-features --locked/,
"CNB must serialize the memory-heavy Rust gate and preserve the workspace test stack contract",
);
assert.match(
cnbRustGates[1],
/export HOME="\$\{hermetic_home\}"[\s\S]*export CODEWHALE_HOME="\$\{hermetic_home\}\/\.codewhale"[\s\S]*unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME/,
"CNB workspace tests must not read a populated runner ~/.codewhale (#5355)",
);
const nextest = read(".config/nextest.toml");
const integrationGroup = nextest.search(/^filter = 'binary\(integration\)'$/m);
const telemetryGroup = nextest.indexOf(
"filter = 'binary(integration) & test(/^telemetry_contract::/)'",
);
const execGroup = nextest.indexOf(
"filter = 'binary(integration) & test(/^exec_persistent_service::/)'",
);
assert.ok(integrationGroup >= 0, "nextest must bound the integration binary");
assert.ok(
telemetryGroup >= 0 && telemetryGroup < integrationGroup,
"telemetry-contract override must precede binary(integration); first matching group wins",
);
assert.ok(
execGroup >= 0 && execGroup < integrationGroup,
"exec_persistent_service override must precede binary(integration); first matching group wins",
);
assert.match(nextest, /exec-persistent-service = \{ max-threads = 1 \}/);
assert.equal(
(cnb.match(/^\s+- \*rust_workspace_gates_stage$/gm) || []).length,
2,
@@ -460,20 +376,6 @@ assert.ok(cnbBuild >= 0, "CNB release preflight must build the consolidated runt
assert.ok(cnbAlias > cnbBuild, "CNB release preflight must materialize codew after the build");
assert.ok(cnbSmoke > cnbAlias, "CNB release preflight must materialize codew before smoke");
const cnbTagRelease = cnb.match(/\$:\n tag_push:\n([\s\S]*)$/);
assert.ok(cnbTagRelease, "CNB must retain a tag release pipeline");
const cnbTagStamp = cnbTagRelease[1].indexOf(
'export CODEWHALE_BUILD_SHA="$commit_sha"',
);
const cnbTagBuild = cnbTagRelease[1].indexOf(
"cargo build --jobs 2 --release --locked \\",
);
assert.match(cnbTagRelease[1], /checkout_sha="\$\(git rev-parse 'HEAD\^\{commit\}'\)"/);
assert.match(cnbTagRelease[1], /commit_sha="\$\{CNB_COMMIT:-\$\{checkout_sha\}\}"/);
assert.match(cnbTagRelease[1], /CNB_COMMIT[\s\S]*does not match checkout[\s\S]*exit 1/);
assert.ok(cnbTagStamp >= 0, "CNB tag releases must stamp the consolidated runtime");
assert.ok(cnbTagBuild > cnbTagStamp, "CNB tag releases must stamp before compiling");
assert.doesNotMatch(
archiveInstaller,
/cargo install codewhale --locked/,
@@ -495,98 +397,6 @@ assert.doesNotMatch(
"the CLI dispatcher must leave auto routing to the provider-aware runtime",
);
// #5496: every release-lane job carries an explicit `timeout-minutes`.
//
// GitHub's default is 360 minutes, so an assigned-but-dead runner sits for six
// hours before anything reclaims it — observed on the v0.9.9 train as a job
// stuck `in_progress` with 404 logs. Timeouts are containment, not recovery:
// the runbook keeps the 404-log cancel/rerun rule for infrastructure failures.
//
// A job that calls a reusable workflow (`uses:`) cannot carry the key at all —
// GitHub rejects it — so the callee owns its own caps. That is why the artifact
// bounds live in release-artifacts.yml rather than in its callers.
function jobsWithoutTimeout(source) {
const lines = source.split("\n");
const jobsAt = lines.findIndex((line) => /^jobs:\s*$/.test(line));
assert.notEqual(jobsAt, -1, "workflow must declare jobs");
const offenders = [];
for (let i = jobsAt + 1; i < lines.length; i += 1) {
const header = lines[i].match(/^ ([A-Za-z0-9_-]+):\s*$/);
if (!header) continue;
let reusable = false;
let capped = false;
for (let j = i + 1; j < lines.length; j += 1) {
if (/^ [A-Za-z0-9_-]+:\s*$/.test(lines[j])) break;
if (/^ uses:/.test(lines[j])) reusable = true;
if (/^ timeout-minutes:\s*\d+\s*$/.test(lines[j])) capped = true;
}
if (!reusable && !capped) offenders.push(header[1]);
}
return offenders;
}
assert.deepEqual(
jobsWithoutTimeout("jobs:\n uncapped:\n runs-on: ubuntu-latest\n"),
["uncapped"],
"jobsWithoutTimeout must detect an uncapped job",
);
assert.deepEqual(
jobsWithoutTimeout("jobs:\n reusable:\n uses: ./.github/workflows/reusable.yml\n"),
[],
"jobsWithoutTimeout must skip reusable workflow callers",
);
assert.deepEqual(
jobsWithoutTimeout("jobs:\n capped:\n runs-on: ubuntu-latest\n timeout-minutes: 15\n"),
[],
"jobsWithoutTimeout must accept a capped job",
);
for (const [name, source] of [
["release-candidate.yml", candidate],
["release-artifacts.yml", artifacts],
["release.yml", release],
["release-republish.yml", republish],
["ci.yml", ci],
["nightly.yml", nightly],
]) {
assert.deepEqual(
jobsWithoutTimeout(source),
[],
`${name}: every job must set timeout-minutes (#5496)`,
);
}
// The Windows artifact build historically runs 40-45 minutes, so its cap has to
// keep real margin — a tight bound here fails healthy releases.
const buildTimeout = artifacts.match(/^ build:\n(?:.*\n)*? timeout-minutes: (\d+)$/m);
assert.ok(buildTimeout, "release-artifacts build job must be capped");
assert.ok(
Number(buildTimeout[1]) >= 60,
`artifact build cap ${buildTimeout[1]}m leaves no margin over a healthy 40-45m Windows build`,
);
function jobTimeout(source, job) {
const match = source.match(
new RegExp(`^ ${job}:\\n(?:.*\\n)*? timeout-minutes: (\\d+)$`, "m"),
);
assert.ok(match, `${job} must declare timeout-minutes`);
return Number(match[1]);
}
// Pin the measured release-lane budget: fast setup and packaging fail quickly,
// while cross-platform compilation keeps real margin over the 40-45m Windows
// build observed on the release train.
assert.equal(jobTimeout(candidate, "resolve"), 10);
assert.equal(jobTimeout(candidate, "web"), 15);
assert.equal(jobTimeout(artifacts, "pin"), 10);
assert.equal(jobTimeout(artifacts, "build"), 90);
for (const job of ["bundle", "windows-installer", "assemble", "smoke"]) {
assert.equal(jobTimeout(artifacts, job), 15, `${job} must keep the 15m packaging cap`);
}
assert.equal(jobTimeout(nightly, "build"), 90);
assert.equal(jobTimeout(release, "resolve"), 10);
assert.equal(jobTimeout(release, "parity"), 20);
console.log(
"Workflow contracts OK: 6-target/12-asset single-runtime nightly and exact-head 7-target/34-asset release candidate.",
);
+9 -26
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env bash
# Update the Homebrew tap after a release.
#
# The tap GitHub repo is still Hmbown/homebrew-deepseek-tui until Hunter
# renames it. The formula users type is `codewhale`. The legacy
# `deepseek-tui` formula stays as a deprecated alias for one overlap release.
# Update the Homebrew tap at Hmbown/homebrew-deepseek-tui after a release.
#
# Expected environment:
# TAG git tag, e.g. "v0.8.31"
@@ -11,7 +7,6 @@
# TAP_REPO owner/repo of the Homebrew tap
# TOKEN PAT with contents:write on TAP_REPO (optional; skips if unset)
# FORMULA_OUTPUT optional local render path used by contract tests
# FORMULA_LEGACY_OUTPUT optional local render path for the alias formula
set -euo pipefail
@@ -57,22 +52,20 @@ readonly SHA_COD_LINUX_X64 SHA_CODEW_LINUX_X64
# --- temp dirs --------------------------------------------------------
FORMULA_FILE="$(mktemp)"
LEGACY_FILE="$(mktemp)"
TAP_DIR="$(mktemp -d)"
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}" "${LEGACY_FILE}"' EXIT
trap 'rm -rf "${TAP_DIR}" "${FORMULA_FILE}"' EXIT
# --- generate formula --------------------------------------------------
readonly BASE_URL="https://github.com/Hmbown/CodeWhale/releases/download/${TAG}"
render_formula() {
local class_name="${1:?}"
local extra_header="${2:-}"
cat << EOF
class ${class_name} < Formula
cat > "${FORMULA_FILE}" << EOF
class DeepseekTui < Formula
desc "Agentic terminal for open-source and open-weight coding models"
homepage "https://github.com/Hmbown/CodeWhale"
version "${VERSION}"
license "MIT"
${extra_header}
on_macos do
if Hardware::CPU.arm?
url "${BASE_URL}/codewhale-macos-arm64", using: :nounzip
@@ -120,19 +113,10 @@ ${extra_header}
end
end
EOF
}
render_formula "Codewhale" "" > "${FORMULA_FILE}"
render_formula "DeepseekTui" " deprecate! date: \"2026-08-14\", because: \"renamed to codewhale\"
" > "${LEGACY_FILE}"
if [ -n "${FORMULA_OUTPUT:-}" ]; then
cp "${FORMULA_FILE}" "${FORMULA_OUTPUT}"
echo "Rendered Homebrew formula to ${FORMULA_OUTPUT}"
if [ -n "${FORMULA_LEGACY_OUTPUT:-}" ]; then
cp "${LEGACY_FILE}" "${FORMULA_LEGACY_OUTPUT}"
echo "Rendered legacy Homebrew formula to ${FORMULA_LEGACY_OUTPUT}"
fi
exit 0
fi
@@ -144,14 +128,13 @@ TAP_URL="https://x-access-token:${ENCODED_TOKEN}@github.com/${TAP_REPO}.git"
git clone --depth 1 "${TAP_URL}" "${TAP_DIR}"
mkdir -p "${TAP_DIR}/Formula"
cp "${FORMULA_FILE}" "${TAP_DIR}/Formula/codewhale.rb"
cp "${LEGACY_FILE}" "${TAP_DIR}/Formula/deepseek-tui.rb"
cp "${FORMULA_FILE}" "${TAP_DIR}/Formula/deepseek-tui.rb"
cd "${TAP_DIR}"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Formula/codewhale.rb Formula/deepseek-tui.rb
git add Formula/deepseek-tui.rb
if git diff --cached --quiet; then
echo "Formula unchanged (already at ${VERSION}); nothing to push."
+1 -11
View File
@@ -6,8 +6,7 @@ tmp_dir="$(mktemp -d)"
trap 'rm -rf "${tmp_dir}"' EXIT
manifest="${tmp_dir}/codewhale-artifacts-sha256.txt"
formula="${tmp_dir}/codewhale.rb"
legacy="${tmp_dir}/deepseek-tui.rb"
formula="${tmp_dir}/deepseek-tui.rb"
assets=(
codewhale-macos-arm64
@@ -28,14 +27,9 @@ TAG=v1.2.3 \
MANIFEST="${manifest}" \
TAP_REPO=Hmbown/homebrew-deepseek-tui \
FORMULA_OUTPUT="${formula}" \
FORMULA_LEGACY_OUTPUT="${legacy}" \
bash "${repo_root}/.github/scripts/update-homebrew-tap.sh"
ruby -c "${formula}" >/dev/null
ruby -c "${legacy}" >/dev/null
grep -Fq 'class Codewhale < Formula' "${formula}"
grep -Fq 'class DeepseekTui < Formula' "${legacy}"
grep -Fq 'deprecate! date: "2026-08-14", because: "renamed to codewhale"' "${legacy}"
grep -Fq 'desc "Agentic terminal for open-source and open-weight coding models"' "${formula}"
test "$(grep -Fc 'resource "codew" do' "${formula}")" -eq 4
grep -Fq 'bin.install Dir["*"].first => "codew"' "${formula}"
@@ -44,9 +38,5 @@ if grep -Fq 'codewhale-tui' "${formula}"; then
echo "Homebrew formula must not install the legacy TUI compatibility asset" >&2
exit 1
fi
if grep -Fq 'class DeepseekTui' "${formula}"; then
echo "Primary Homebrew formula must be Codewhale, not DeepseekTui" >&2
exit 1
fi
echo "update-homebrew-tap tests passed"
+33 -154
View File
@@ -18,29 +18,17 @@ permissions:
contents: read
concurrency:
# PRs still share one group so a new push cancels the superseded head.
# Push/schedule/dispatch on main must be keyed by SHA: with cancel-in-progress
# false, GitHub still cancels a *pending* run in the same group when a new
# one queues. That is how 31 of the last 40 main CI runs vanished without a
# verdict (test bankruptcy, 2026-08-19). Each SHA gets its own group so
# every commit on main actually finishes.
group: ${{ github.event_name == 'pull_request' && format('ci-pr-{0}', github.event.pull_request.number) || format('ci-{0}-{1}', github.workflow, github.sha) }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
group: ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
# Test threads share a process and tokio/async frames run deep; the default
# 2 MiB stack overflowed sporadically in runtime_api::tests::start_turn_*
# under load and aborted the whole lib suite (signal 6). 8 MiB is the
# measured-safe floor; nextest's per-process runs are unaffected either way.
RUST_MIN_STACK: 8388608
jobs:
changes:
name: Change detection
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
heavy: ${{ steps.detect.outputs.heavy }}
@@ -118,13 +106,13 @@ jobs:
# arm (fail-safe default-heavy). Light-classified scripts below
# are exercised by ALWAYS-on jobs/steps that run regardless of
# `heavy` (check-versions.sh / check-ohos-deps.sh via Version
# drift, check-coauthor-trailers.py via Lint, dev-cache/dev-test
# self-checks via Version drift), so no coverage is lost.
# drift, check-coauthor-trailers.py via Lint), so no coverage is
# lost.
case "${path}" in
scripts/release/npm-wrapper-smoke.js|scripts/mobile-smoke.sh|scripts/check-provider-registry.py)
heavy=true
;;
docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/check-coauthor-trailers.py|scripts/dev-cache.sh|scripts/dev-cache.test.sh|scripts/dev-cargo.sh|scripts/dev-test.sh)
docs/*|*.md|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|.github/scripts/agent-task-metadata.test.sh|.github/workflows/agent-task-labels.yml|.github/workflows/auto-tag.yml|.github/workflows/stale.yml|.github/workflows/triage.yml|scripts/release/check-versions.sh|scripts/release/check-ohos-deps.sh|scripts/release/install-dogfood.sh|scripts/release/install-dogfood.test.sh|scripts/release/prepare-release.sh|scripts/release/prepare-release.test.sh|scripts/check-coauthor-trailers.py)
;;
*)
heavy=true
@@ -135,7 +123,7 @@ jobs:
workflow=true
;;
esac
# Mobile runtime surface: the `codewhale serve --mobile`
# Mobile runtime surface: the `codewhale-tui serve --mobile`
# HTTP/SSE stack that scripts/mobile-smoke.sh exercises. Pull
# requests run the smoke only when one of these changes; every
# push to main still runs it unconditionally as the pre-release
@@ -166,12 +154,9 @@ jobs:
versions:
name: Version drift
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: dtolnay/rust-toolchain@stable
- uses: actions/setup-node@v7
with:
@@ -183,14 +168,12 @@ jobs:
- name: Check release helper contracts
run: |
bash .github/scripts/agent-task-metadata.test.sh
bash scripts/release/check-feature-release-notes.test.sh
bash scripts/release/generate-release-body.test.sh
bash scripts/release/install-dogfood.test.sh
bash scripts/release/prepare-release.test.sh
bash scripts/release/require-release-tag-checkout.test.sh
bash scripts/release/validate-crate-publish-order.test.sh
bash scripts/release/verify-remote-tag.test.sh
sh scripts/dev-cache.test.sh
bash .github/scripts/update-homebrew-tap.test.sh
node .github/scripts/release-workflows.test.js
node --test scripts/release/assemble-release-assets.test.js
@@ -202,7 +185,6 @@ jobs:
integrations:
name: Integrations
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -220,55 +202,9 @@ jobs:
(cd "integrations/${bridge}" && npm test)
done
safety-gate:
name: Safety gate
needs: changes
if: needs.changes.outputs.heavy == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@master
with:
toolchain: stable
- uses: mozilla-actions/sccache-action@v0.0.11
id: sccache
continue-on-error: true
- name: Enable sccache
if: steps.sccache.outcome == 'success'
shell: bash
run: |
echo "SCCACHE_GHA_ENABLED=true" >> "${GITHUB_ENV}"
echo "RUSTC_WRAPPER=sccache" >> "${GITHUB_ENV}"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1" >> "${GITHUB_ENV}"
- name: Install Linux system dependencies
run: |
for i in 1 2 3 4 5; do
sudo apt-get update && break
echo "apt-get update failed (attempt $i); retrying in 15s"
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- name: Hermetic safety and authorization tests
env:
HOME: ${{ runner.temp }}/cw-hermetic-home
USERPROFILE: ${{ runner.temp }}/cw-hermetic-home
CODEWHALE_HOME: ${{ runner.temp }}/cw-hermetic-home/.codewhale
RUST_MIN_STACK: "8388608"
run: |
mkdir -p "${HOME}" "${CODEWHALE_HOME}"
unset CODEWHALE_CONFIG_PATH DEEPSEEK_CONFIG_PATH DEEPSEEK_HOME || true
cargo test -p codewhale-tui --lib --locked -- command_safety auto_review authority sandbox
cargo test -p codewhale-execpolicy --locked
lint:
name: Lint
needs: changes
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -279,7 +215,7 @@ jobs:
with:
toolchain: stable
components: rustfmt, clippy
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
# Cache bootstrap failures (e.g. GitHub 504s fetching the sccache
# binary) degrade to an uncached build instead of failing product CI.
@@ -312,24 +248,15 @@ jobs:
if: needs.changes.outputs.heavy == 'true'
run: cargo fmt --all -- --check
- name: Run clippy
# --all-targets, because without it CI never lints test code at all.
# That gap is not theoretical: the v0.9.10 release gate opened with
# four clippy failures sitting on a green main, and every one of them
# was in a test target. crates/tui/AGENTS.md already documents the
# all-targets command as the release gate; this makes CI run the gate
# it points contributors at instead of a weaker subset.
#
# collapsible_if and assertions_on_constants are no longer allowed for
# the same reason — they were three of those four, so the allowances
# were hiding exactly the class of problem that reached the gate. The
# three that remain are deliberate project style, not oversights.
if: needs.changes.outputs.heavy == 'true'
run: |
cargo clippy --workspace --all-targets --all-features --locked -- \
cargo clippy --workspace --all-features --locked -- \
-D warnings \
-A clippy::uninlined_format_args \
-A clippy::too_many_arguments \
-A clippy::unnecessary_map_or
-A clippy::unnecessary_map_or \
-A clippy::collapsible_if \
-A clippy::assertions_on_constants
- name: sccache stats
if: needs.changes.outputs.heavy == 'true' && steps.sccache.outcome == 'success'
continue-on-error: true
@@ -338,25 +265,6 @@ jobs:
- name: Check provider registry drift
if: needs.changes.outputs.heavy == 'true'
run: python3 scripts/check-provider-registry.py
- name: Check command-contract prototype boundary
if: needs.changes.outputs.heavy == 'true'
run: |
python3 scripts/test_check_command_crate_boundaries.py
python3 scripts/check-command-crate-boundaries.py
- name: Check command migration manifest
if: needs.changes.outputs.heavy == 'true'
env:
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
PUSH_BEFORE_SHA: ${{ github.event.before }}
run: |
python3 scripts/test_check_command_migration_manifest.py
baseline="${PR_BASE_SHA:-${PUSH_BEFORE_SHA:-}}"
if [[ -n "${baseline}" && ! "${baseline}" =~ ^0+$ ]]; then
git fetch --no-tags origin "${baseline}"
python3 scripts/check-command-migration-manifest.py --baseline-ref "${baseline}"
else
python3 scripts/check-command-migration-manifest.py
fi
# Clippy above runs without `--all-targets`, so it cannot see dead code
# that only tests keep alive. This ratchet covers that blind spot by
# refusing to let the `#[allow(dead_code)]` total rise (#4785).
@@ -395,6 +303,12 @@ jobs:
- name: Check persistence-backlog budget
if: needs.changes.outputs.heavy == 'true'
run: python3 scripts/check-persistence-backlog-budget.py
# Source-only ownership ratchet. Deletion and line-neutral consolidation
# pass; new packages/binaries/thousand-line module paths, a larger maximum
# module, or aggregate owned Rust growth require reviewed updates.
- name: Check source-structure budget
if: needs.changes.outputs.heavy == 'true'
run: python3 scripts/check-source-structure-budget.py
- name: Check README translations stay in sync
if: github.event_name != 'schedule'
run: python3 scripts/check-readme-translations.py
@@ -412,9 +326,8 @@ jobs:
shell: bash
run: |
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
BASE_SHA="${{ github.event.pull_request.base.sha }}"
git fetch --no-tags origin "${BASE_SHA}"
RANGE="${BASE_SHA}..HEAD"
git fetch --no-tags origin "${{ github.base_ref }}"
RANGE="origin/${{ github.base_ref }}..HEAD"
elif [[ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]]; then
RANGE="${{ github.event.before }}..${{ github.sha }}"
else
@@ -435,12 +348,11 @@ jobs:
name: Workflow RLM cache
needs: changes
if: needs.changes.outputs.workflow == 'true'
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
@@ -467,7 +379,6 @@ jobs:
# macOS/Windows runners. Heavy changes use the real matrix OS as before.
# The ternary is safe: matrix.os is always a non-empty literal, so
# runs-on can never evaluate to empty.
timeout-minutes: 90
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
# A failure on one desktop platform must not erase evidence from the
@@ -490,37 +401,14 @@ jobs:
- name: Install NSIS for Windows installer regression
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
shell: pwsh
# Bounded retry, not a weaker check (#5403). Every observed failure here
# was Chocolatey's feed, not the code: a 504 from the V2 API, and
# "package was not found with the source(s) listed". A single attempt
# made `Test (windows-latest)` — a required check on every PR — report
# on community.chocolatey.org's availability instead of on the tree.
# NSIS must still install for the regression below to run; this only
# survives a transient outage.
run: |
$ErrorActionPreference = 'Continue'
$delays = @(0, 20, 45)
for ($attempt = 0; $attempt -lt $delays.Count; $attempt++) {
if ($delays[$attempt] -gt 0) {
Write-Host "NSIS install attempt $($attempt + 1) after $($delays[$attempt])s backoff"
Start-Sleep -Seconds $delays[$attempt]
}
choco install nsis -y --no-progress
if ($LASTEXITCODE -eq 0) {
Write-Host "NSIS installed on attempt $($attempt + 1)"
exit 0
}
Write-Host "::warning::choco install nsis failed (exit $LASTEXITCODE)"
}
Write-Host "::error::NSIS could not be provisioned from Chocolatey after $($delays.Count) attempts"
exit 1
run: choco install nsis -y --no-progress
- name: Test Windows installer PATH regression
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'windows-latest'
shell: pwsh
run: ./scripts/installer/installer-path-regression.tests.ps1 -AllowUserPathMutation
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
@@ -545,17 +433,9 @@ jobs:
with:
cache-bin: false
save-if: ${{ github.ref == 'refs/heads/main' }}
- uses: taiki-e/install-action@nextest
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
- name: Run tests
# Same test binaries as `cargo test`, run by cargo-nextest: one
# process per test, all runner cores busy, slow tests named instead
# of stalling the binary. `.config/nextest.toml` serializes the PTY
# binary and bounds the integration binary that spawns the real
# executable; retries are off, so a flake is a red run, not a hidden
# one. nextest does not run doctests — the next step keeps them.
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
run: cargo nextest run --workspace --all-features --locked --profile ci
run: cargo test --workspace --all-features --locked
env:
# Give test threads the stack the product gives itself. main.rs runs
# the owner thread and every tokio worker at
@@ -570,17 +450,20 @@ jobs:
# this for any thread spawned without an explicit size, which covers
# both libtest's per-test threads and tokio's workers.
RUST_MIN_STACK: '16777216'
- name: Run doctests
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
run: cargo test --workspace --all-features --locked --doc
env:
RUST_MIN_STACK: '16777216'
# The Ubuntu lint lane validates non-RSS backlog fields. Run the same
# source-bound measurement on macOS so loss or growth of RSS evidence
# fails closed instead of becoming an unsupported-field skip.
- name: Check persistence-backlog RSS budget
if: needs.changes.outputs.heavy == 'true' && matrix.os == 'macos-latest'
run: python3 scripts/check-persistence-backlog-budget.py
- name: Run isolated Skills Manager PTY acceptance
# This real-PTY scenario is deterministic in a fresh process (10/10
# locally) but can inherit event starvation after the full qa_pty
# module suite on loaded Linux runners. Keep the assertion intact and
# run it separately on Unix after the workspace suite has released its
# PTYs.
if: needs.changes.outputs.heavy == 'true' && matrix.os != 'windows-latest' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
run: cargo test -p codewhale-tui --test pty qa_pty::skills_opens_manager_owned_then_compatible -- --ignored --exact
- name: Lockfile drift guard
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
run: git diff --exit-code -- Cargo.lock
@@ -608,7 +491,6 @@ jobs:
# them off macOS/Windows runners. On pull_request the matrix is
# ubuntu-only, so the required "npm wrapper smoke (ubuntu-latest)"
# context is unaffected.
timeout-minutes: 30
runs-on: ${{ needs.changes.outputs.heavy == 'true' && matrix.os || 'ubuntu-latest' }}
strategy:
matrix:
@@ -621,7 +503,7 @@ jobs:
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
- uses: dtolnay/rust-toolchain@stable
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
if: needs.changes.outputs.heavy == 'true' && (matrix.os != 'ubuntu-latest' || github.event_name == 'workflow_dispatch')
@@ -682,12 +564,11 @@ jobs:
github.event_name != 'schedule' &&
needs.changes.outputs.heavy == 'true' &&
(github.event_name != 'pull_request' || needs.changes.outputs.mobile == 'true')
timeout-minutes: 30
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: dtolnay/rust-toolchain@stable
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
@@ -727,7 +608,6 @@ jobs:
name: Workflow lint
needs: changes
if: needs.changes.outputs.actions == 'true'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
@@ -744,7 +624,6 @@ jobs:
docs:
name: Documentation
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
+29
View File
@@ -0,0 +1,29 @@
name: Debug windows python
on:
workflow_dispatch:
permissions:
contents: read
jobs:
debug:
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- name: Probe python availability
shell: pwsh
run: |
python --version
python3 --version
where.exe python
where.exe python3
py -3 --version
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
with:
cache-bin: false
- name: Run failing test with output
shell: pwsh
run: cargo test -p codewhale-tui --lib --all-features --locked -- --nocapture full_access_auto_approves_non_bypassable_registered_tools
env:
RUST_MIN_STACK: '16777216'
+5 -23
View File
@@ -21,12 +21,11 @@ env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
CODEWHALE_BUILD_SHA: ${{ github.sha }}
DEEPSEEK_BUILD_SHA: ${{ github.sha }}
jobs:
build:
name: Build ${{ matrix.platform }}
timeout-minutes: 90
strategy:
fail-fast: false
matrix:
@@ -74,7 +73,7 @@ jobs:
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@v0.0.11
- uses: mozilla-actions/sccache-action@v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
@@ -108,28 +107,11 @@ jobs:
- name: Build
shell: bash
# Nightly artifacts are disposable smoke binaries (14-day retention),
# so skip fat LTO; tagged releases keep the full optimized profile via
# the Release workflow. (`codegen-units` is not overridden here --
# [profile.release] already sets 16, so restating it changed nothing.)
#
# RUST_MIN_STACK sizes the stack of the LLVM worker threads rustc
# spawns to run `optimize module <crate>-cgu.N`. With `lto=off` the
# per-CGU optimization pipeline runs while the *library* is compiled
# rather than being deferred to the ThinLTO stage, and one crates/tui
# codegen unit needs more stack than a 1 MiB thread provides. Measured
# on aarch64 by holding the crate and every flag fixed and varying only
# this value: 1 MiB crashes rustc, 2 MiB and 4 MiB succeed. Unix std
# defaults to 2 MiB and passes; windows-11-arm sat under the
# requirement and failed every nightly from 2026-08-16 with
# `thread 'optimize module codewhale_tui...-cgu.13' has overflowed its
# stack`, deterministically on the same codegen unit across all three
# build attempts. This is a property of the crate's size, not of
# Windows, so it is set for every target: at 788k lines in crates/tui
# the other platforms are one refactor away from crossing 2 MiB too.
# The value is reserved address space, not committed memory.
# so skip fat LTO + codegen-units=1; tagged releases keep the full
# optimized profile via the Release workflow.
env:
CARGO_PROFILE_RELEASE_LTO: 'off'
RUST_MIN_STACK: '16777216'
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: '16'
run: |
for attempt in 1 2 3; do
if cargo build --release --locked --target ${{ matrix.target }} -p codewhale-cli; then
+1 -2
View File
@@ -30,8 +30,7 @@ permissions:
concurrency:
group: ohos-${{ github.event.pull_request.number || github.ref }}
# Same as ci.yml: cancel superseded PR heads only; never cancel main pushes.
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
+28 -57
View File
@@ -24,35 +24,11 @@ env:
CARGO_TERM_COLOR: always
CARGO_INCREMENTAL: 0
RUSTFLAGS: -Dwarnings
# Build identity is the trusted workflow SHA. Callers pass source_sha only
# so `pin` can refuse a mismatch; it must not retarget checkout or caches.
CODEWHALE_BUILD_SHA: ${{ github.sha }}
DEEPSEEK_BUILD_SHA: ${{ inputs.source_sha }}
jobs:
pin:
name: Pin caller SHA to this run
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- name: Require source_sha equals github.sha
env:
SOURCE_SHA: ${{ inputs.source_sha }}
run: |
set -euo pipefail
if [[ "${#SOURCE_SHA}" -ne 40 || "${SOURCE_SHA}" =~ [^0-9a-fA-F] ]]; then
echo "::error::source_sha must be a full 40-character commit SHA." >&2
exit 1
fi
expected="$(printf '%s' "${SOURCE_SHA}" | tr '[:upper:]' '[:lower:]')"
actual="$(printf '%s' "${GITHUB_SHA}" | tr '[:upper:]' '[:lower:]')"
if [[ "${actual}" != "${expected}" ]]; then
echo "::error::Reusable workflow SHA ${actual} does not match source_sha ${SOURCE_SHA}." >&2
exit 1
fi
build:
name: Build ${{ matrix.platform }}
timeout-minutes: 90
# FreeBSD is a source-build target validated via `cargo check --target x86_64-unknown-freebsd -p codewhale-cli --locked`
# (see packaging/freebsd/README.md and docs/INSTALL.md#freebsd). The 7×1 prebuilt matrix stays 7 targets;
# FreeBSD has no prebuilt asset, no npm binary, and no matrix bloat — it builds from source.
@@ -67,7 +43,7 @@ jobs:
shim_binary: codew
cli_artifact: codewhale-linux-x64
shim_artifact: codew-linux-x64
compat_tui_artifact: codewhale-tui-linux-x64
tui_artifact: codewhale-tui-linux-x64
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
platform: linux-arm64
@@ -75,7 +51,7 @@ jobs:
shim_binary: codew
cli_artifact: codewhale-linux-arm64
shim_artifact: codew-linux-arm64
compat_tui_artifact: codewhale-tui-linux-arm64
tui_artifact: codewhale-tui-linux-arm64
- os: ubuntu-latest
target: aarch64-linux-android
platform: android-arm64
@@ -83,7 +59,7 @@ jobs:
shim_binary: codew
cli_artifact: codewhale-android-arm64
shim_artifact: codew-android-arm64
compat_tui_artifact: codewhale-tui-android-arm64
tui_artifact: codewhale-tui-android-arm64
- os: macos-latest
target: x86_64-apple-darwin
platform: macos-x64
@@ -91,7 +67,7 @@ jobs:
shim_binary: codew
cli_artifact: codewhale-macos-x64
shim_artifact: codew-macos-x64
compat_tui_artifact: codewhale-tui-macos-x64
tui_artifact: codewhale-tui-macos-x64
- os: macos-latest
target: aarch64-apple-darwin
platform: macos-arm64
@@ -99,7 +75,7 @@ jobs:
shim_binary: codew
cli_artifact: codewhale-macos-arm64
shim_artifact: codew-macos-arm64
compat_tui_artifact: codewhale-tui-macos-arm64
tui_artifact: codewhale-tui-macos-arm64
- os: windows-latest
target: x86_64-pc-windows-msvc
platform: windows-x64
@@ -107,7 +83,7 @@ jobs:
shim_binary: codew.exe
cli_artifact: codewhale-windows-x64.exe
shim_artifact: codew-windows-x64.exe
compat_tui_artifact: codewhale-tui-windows-x64.exe
tui_artifact: codewhale-tui-windows-x64.exe
- os: windows-11-arm
target: aarch64-pc-windows-msvc
platform: windows-arm64
@@ -115,18 +91,17 @@ jobs:
shim_binary: codew.exe
cli_artifact: codewhale-windows-arm64.exe
shim_artifact: codew-windows-arm64.exe
compat_tui_artifact: codewhale-tui-windows-arm64.exe
tui_artifact: codewhale-tui-windows-arm64.exe
runs-on: ${{ matrix.os }}
needs: pin
steps:
# No ref: — GITHUB_SHA only. CodeQL treats workflow_call checkout-with-ref
# and any ref named *sha* as an untrusted checkout (cache-poisoning).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
with:
ref: ${{ inputs.source_sha }}
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-07-18
with:
toolchain: stable
targets: ${{ matrix.target }}
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
@@ -138,13 +113,9 @@ jobs:
echo "RUSTC_WRAPPER=sccache"
echo "SCCACHE_IGNORE_SERVER_IO_ERROR=1"
} >> "${GITHUB_ENV}"
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- name: Build static Linux binaries (musl)
if: endsWith(matrix.target, '-unknown-linux-musl')
shell: bash
@@ -253,10 +224,10 @@ jobs:
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.cli_artifact }}"
stage_binary "${{ matrix.shim_binary }}" "${{ matrix.shim_artifact }}"
# Compatibility bridge for v0.9.4's hard-coded release
# One-release compatibility bridge for v0.9.4's hard-coded release
# completeness/updater contract. This is the same runtime, not a
# separately compiled or installed TUI command.
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.compat_tui_artifact }}"
# separately compiled TUI binary.
stage_binary "${{ matrix.cli_binary }}" "${{ matrix.tui_artifact }}"
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.cli_artifact }}
@@ -273,32 +244,31 @@ jobs:
overwrite: true
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ${{ matrix.compat_tui_artifact }}
path: ${{ matrix.compat_tui_artifact }}
name: ${{ matrix.tui_artifact }}
path: ${{ matrix.tui_artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention_days }}
overwrite: true
bundle:
timeout-minutes: 15
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
pattern: '*'
- name: Create and checksum platform archives
shell: bash
env:
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
source_date_epoch="$(git show -s --format=%ct "${SOURCE_SHA}")"
source_date_epoch="$(git show -s --format=%ct "${{ inputs.source_sha }}")"
if [[ ! "${source_date_epoch}" =~ ^[0-9]+$ ]]; then
echo "Could not read a Unix timestamp for source commit ${SOURCE_SHA}" >&2
echo "Could not read a Unix timestamp for source commit ${{ inputs.source_sha }}" >&2
exit 1
fi
SOURCE_DATE_EPOCH="${source_date_epoch}" \
@@ -315,12 +285,13 @@ jobs:
overwrite: true
windows-installer:
timeout-minutes: 15
needs: build
if: ${{ !cancelled() && needs.build.result == 'success' }}
runs-on: windows-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: artifacts
@@ -356,16 +327,16 @@ jobs:
overwrite: true
assemble:
timeout-minutes: 15
needs: [bundle, windows-installer]
if: ${{ !cancelled() && needs.bundle.result == 'success' && needs.windows-installer.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
path: intermediate-artifacts
@@ -382,16 +353,16 @@ jobs:
overwrite: true
smoke:
timeout-minutes: 15
needs: assemble
if: ${{ !cancelled() && needs.assemble.result == 'success' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ inputs.source_sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: codewhale-release-assets
@@ -412,7 +383,7 @@ jobs:
{
echo "### Release artifact candidate"
echo ""
echo "- Source: \`${{ github.sha }}\`"
echo "- Source: \`${{ inputs.source_sha }}\`"
echo "- Version metadata: \`${{ inputs.version }}\`"
echo "- Inventory: 7 targets / 34 files (single binary; 7 legacy alias assets)"
echo "- Publication: none (Actions artifact \`codewhale-release-assets\` only)"
+3 -6
View File
@@ -20,7 +20,6 @@ concurrency:
jobs:
resolve:
name: Resolve exact candidate source
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.source.outputs.sha }}
@@ -32,8 +31,7 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # stable 2026-07-18
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # stable 2026-07-18
with:
toolchain: stable
- name: Match dispatch to the requested commit
@@ -77,7 +75,6 @@ jobs:
web:
name: Verify exact candidate web surface
timeout-minutes: 15
needs: resolve
if: ${{ !cancelled() && needs.resolve.result == 'success' }}
runs-on: ubuntu-latest
@@ -85,9 +82,9 @@ jobs:
run:
working-directory: web
steps:
# resolve already proved expected_sha equals GITHUB_SHA. Do not
# interpolate that SHA into checkout or the npm cache key.
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
+4 -11
View File
@@ -39,7 +39,6 @@ permissions:
jobs:
resolve:
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
@@ -101,7 +100,6 @@ jobs:
"${EXPECTED_SHA}"
docker:
timeout-minutes: 30
needs: resolve
if: ${{ contains(inputs.channels, 'docker') }}
runs-on: ubuntu-latest
@@ -124,7 +122,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -153,7 +151,7 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
build-args: |
CODEWHALE_BUILD_SHA=${{ needs.resolve.outputs.sha }}
DEEPSEEK_BUILD_SHA=${{ needs.resolve.outputs.sha }}
tags: |
${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.tag }}
${{ steps.image.outputs.name }}:${{ needs.resolve.outputs.version }}
@@ -171,7 +169,6 @@ jobs:
docker run --rm --entrypoint codew "${IMAGE}" --version
homebrew:
timeout-minutes: 20
needs: resolve
if: ${{ contains(inputs.channels, 'homebrew') }}
runs-on: ubuntu-latest
@@ -187,13 +184,9 @@ jobs:
echo "::error::No Homebrew tap token configured; cannot republish the tap." >&2
exit 1
fi
# Recovery logic must come from the current protected default branch.
# The released bytes remain pinned by the tag and checksum manifest;
# checking out the old tag here would also restore the bug being repaired.
- name: Checkout release infrastructure
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
ref: ${{ github.event.repository.default_branch }}
ref: ${{ needs.resolve.outputs.sha }}
- name: Download checksum manifest
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+6 -25
View File
@@ -24,7 +24,6 @@ env:
jobs:
resolve:
timeout-minutes: 10
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.release.outputs.tag }}
@@ -114,20 +113,17 @@ jobs:
run: node scripts/release/ensure-release-assets-absent.js "${GITHUB_REPOSITORY}" "${TAG}"
parity:
timeout-minutes: 20
needs: resolve
runs-on: ubuntu-latest
steps:
# resolve already proved GITHUB_SHA equals the tag commit. Do not
# interpolate needs.resolve.outputs.sha into checkout or cache keys —
# CodeQL treats a *sha* ref as an untrusted checkout on workflow_dispatch
# (default-branch cache write).
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
- uses: dtolnay/rust-toolchain@6c977a6ca4077a0ceb28ffbe03f59d46e9ac8772 # master 2026-07-18
with:
ref: ${{ needs.resolve.outputs.sha }}
- uses: dtolnay/rust-toolchain@2c7215f132e9ebf062739d9130488b56d53c060c # master 2026-07-18
with:
toolchain: stable
components: clippy, rustfmt
- uses: mozilla-actions/sccache-action@fc920bf0ec8de6ee65d409111f7ec508035751ba # v0.0.11
- uses: mozilla-actions/sccache-action@9e7fa8a12102821edf02ca5dbea1acd0f89a2696 # v0.0.10
id: sccache
continue-on-error: true
- name: Enable sccache
@@ -147,13 +143,9 @@ jobs:
sleep 15
done
sudo apt-get install -y libdbus-1-dev pkg-config
# Restore after the trusted lockfile is on disk. Key is OS + arch +
# explicit stable toolchain + rust-cache's Cargo.lock / rust-toolchain
# hash. Never interpolate github.event, github.ref, github.sha, or inputs.
- uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2
with:
cache-bin: false
prefix-key: v1-${{ runner.os }}-${{ runner.arch }}-stable
- name: Format check
run: cargo fmt --all -- --check
- name: Compile check
@@ -197,7 +189,6 @@ jobs:
needs: [artifacts, resolve]
if: ${{ !cancelled() && needs.artifacts.result == 'success' }}
name: Docker ${{ matrix.platform }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@@ -248,7 +239,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -319,7 +310,6 @@ jobs:
overwrite: true
docker:
timeout-minutes: 30
needs: [docker-build, resolve]
if: ${{ !cancelled() && needs.docker-build.result == 'success' }}
runs-on: ubuntu-latest
@@ -341,7 +331,7 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4
uses: docker/login-action@371161bbe7024a29a25c5e19bfcbc0804fe9ad2c # v4
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -424,7 +414,6 @@ jobs:
docker run --rm --entrypoint codew "${IMAGE}" --version
release:
timeout-minutes: 30
needs: [artifacts, docker, resolve]
if: ${{ !cancelled() && needs.artifacts.result == 'success' && needs.docker.result == 'success' }}
runs-on: ubuntu-latest
@@ -438,7 +427,6 @@ jobs:
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 20
package-manager-cache: false
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
name: codewhale-release-assets
@@ -474,7 +462,6 @@ jobs:
fail_on_unmatched_files: true
npm:
timeout-minutes: 20
needs: [release, resolve]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
@@ -511,15 +498,9 @@ jobs:
run: npm test
- name: Publish npm wrapper with trusted publishing
working-directory: npm/codewhale
env:
# npm runs prepublishOnly in this step; that guard revalidates the
# public GitHub Release and therefore needs the same read token as
# the explicit asset gate above.
GH_TOKEN: ${{ github.token }}
run: npm publish --access public
homebrew:
timeout-minutes: 20
needs: [release, resolve]
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
+23 -38
View File
@@ -1,10 +1,10 @@
# Hmbown.CodeWhale — winget singleton manifest for CodeWhale (single binary)
# PackageIdentifier follows the publisher convention used by the Homebrew formula (codewhale).
# Canonical maintenance instructions live in packaging/winget/README.md; the generator mirrors these exact bytes to .winget/.
# Winget installs only `codewhale` + `codew`; compatibility release filenames are not installed commands.
# This is a mirror of packaging/winget/Hmbown.CodeWhale.yaml for tooling that expects .winget/.
# Keep both in sync; the canonical source is packaging/winget/Hmbown.CodeWhale.yaml.
# See packaging/winget/README.md for update instructions.
PackageIdentifier: Hmbown.CodeWhale
PackageVersion: 0.9.6
PackageVersion: 0.9.5
DefaultLocale: en-US
ManifestType: singleton
ManifestVersion: 1.6.0
@@ -19,10 +19,10 @@ LicenseUrl: https://github.com/Hmbown/CodeWhale/blob/main/LICENSE
Copyright: Copyright (c) Hmbown
ShortDescription: Terminal coding agent for supported hosted and local models
Description: |
CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5+ single-binary
CodeWhale is a terminal coding agent that runs on your machine. The v0.9.5 single-binary
release ships one `codewhale` binary per target (plus the `codew` shim) across Linux x64 (musl),
Linux arm64, Android arm64, macOS x64/arm64, and Windows x64/arm64. See https://github.com/Hmbown/CodeWhale
for provider setup, Fleet workflows, and the full install guide (docs/INSTALL.md).
for provider setup, Fleet workflows, and the full install guide.
Author: Hmbown
Moniker: codewhale
Tags:
@@ -35,78 +35,63 @@ Tags:
- coding-agent
- rust
MinimumOSVersion: 10.0.0.0
ReleaseNotes: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.6
ReleaseNotesUrl: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.6
InstallationNotes: |
The winget package installs the Windows binaries. For the NSIS installer (CodeWhaleSetup.exe)
the installer adds %LOCALAPPDATA%\Programs\CodeWhale\bin to the user PATH. For the portable
ZIP, winget extracts codewhale.exe and codew.exe side-by-side and adds the install location to PATH.
Verify checksums with codewhale-artifacts-sha256.txt from the same GitHub Release.
Documentations:
- DocumentLabel: Install guide
DocumentUrl: https://github.com/Hmbown/CodeWhale/blob/main/docs/INSTALL.md
- DocumentLabel: Releases
DocumentUrl: https://github.com/Hmbown/CodeWhale/releases
ReleaseNotes: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5
ReleaseNotesUrl: https://github.com/Hmbown/CodeWhale/releases/tag/v0.9.5
Installers:
# Preferred: NSIS installer for Windows x64 (per-user, no elevation, adds user PATH).
- Architecture: x64
InstallerType: nullsoft
Scope: user
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.6/CodeWhaleSetup.exe
InstallerSha256: 040afa01a70ddae4ea7783e563bacd962a512b4d288ee5c5c5111ef2760755a7
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/CodeWhaleSetup.exe
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
ProductCode: CodeWhale
UpgradeBehavior: install
FileExtensions:
- toml
ReleaseDate: 2026-08-12
# Portable ZIP fallback — same single binary (codewhale.exe + codew.exe). Used when NSIS is blocked
# by policy or for winget's portable install flow. NestedInstallerType is portable (no installer).
ReleaseDate: 2026-08-07
- Architecture: x64
InstallerType: zip
Scope: user
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.6/codewhale-windows-x64.zip
InstallerSha256: a3e0ed9b5810904ca4f0d76074aca749dfd13b71126de17bb12357d3506587b1
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-x64.zip
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: codewhale-windows-x64/codewhale.exe
PortableCommandAlias: codewhale
- RelativeFilePath: codewhale-windows-x64/codew.exe
PortableCommandAlias: codew
ReleaseDate: 2026-08-12
ReleaseDate: 2026-08-07
- Architecture: x64
InstallerType: zip
Scope: user
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.6/codewhale-windows-x64-portable.zip
InstallerSha256: f803fba6f174ccd710e81b4e986daedcbe7a62fda0744f8c64342aa7751d1206
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-x64-portable.zip
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: codewhale-windows-x64-portable/codewhale.exe
PortableCommandAlias: codewhale
- RelativeFilePath: codewhale-windows-x64-portable/codew.exe
PortableCommandAlias: codew
ReleaseDate: 2026-08-12
ReleaseDate: 2026-08-07
- Architecture: arm64
InstallerType: zip
Scope: user
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.6/codewhale-windows-arm64.zip
InstallerSha256: c1ed644838550922c563fca7b00646482d3a3bb781aa92affc9059d14ca6d942
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-arm64.zip
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: codewhale-windows-arm64/codewhale.exe
PortableCommandAlias: codewhale
- RelativeFilePath: codewhale-windows-arm64/codew.exe
PortableCommandAlias: codew
ReleaseDate: 2026-08-12
ReleaseDate: 2026-08-07
- Architecture: arm64
InstallerType: zip
Scope: user
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.6/codewhale-windows-arm64-portable.zip
InstallerSha256: ac2acd52090c88fd0babf94a30951b97c4eb1f45f522ca70e0fee12d2b728102
InstallerUrl: https://github.com/Hmbown/CodeWhale/releases/download/v0.9.5/codewhale-windows-arm64-portable.zip
InstallerSha256: 0000000000000000000000000000000000000000000000000000000000000000
NestedInstallerType: portable
NestedInstallerFiles:
- RelativeFilePath: codewhale-windows-arm64-portable/codewhale.exe
PortableCommandAlias: codewhale
- RelativeFilePath: codewhale-windows-arm64-portable/codew.exe
PortableCommandAlias: codew
ReleaseDate: 2026-08-12
ReleaseDate: 2026-08-07
+84 -87
View File
@@ -1,101 +1,98 @@
# Codewhale agent guidance
# Repository Agent Guidance
Keep this file durable. Derive changing release, provider, branch, and flake
state from the repository, tests, CI, and current issue tracker rather than from
instructions or memory. The nearest scoped `AGENTS.md` adds path-specific rules.
Durable rules only. Perishable lane state — branch, milestone, known flakes,
closed investigations — lives in the private `codewhale-ops` repo, not here.
Read it there; don't trust memory of it.
## Working rules
## Intent is the artifact
- Inspect status and existing consumers before editing. Preserve unrelated,
dirty, and untracked work.
- Prefer the simplest implementation that preserves observable contracts. A
rewrite is acceptable when justified by product intent and observed behavior,
not as a shortcut around understanding existing code.
- Search for behavior and symbols before reviving work from an old branch. If a
lane is obsolete, preserve its intent and evidence rather than merging stale
code mechanically.
- A small coherent change may be committed directly to `main` when that checkout
is current, clean, and owns the affected files. A worktree remains the right
safety boundary for conflicting, dirty, stale, or independent work. Local
commit permission never implies push, merge, tag, release, or deploy permission.
- When the task is local-only, stay fully offline: no browsing, GitHub or remote
Git operations, downloads, dependency installation, provider calls, or
source/diff transmission. Record the missing external receipt and keep working
locally.
- Public name is **Codewhale**. Compatibility identifiers such as `CodeWhale`,
`codew`, protocol names, and storage keys change only through an explicit
migration.
- Keep providers and models first-class and provider-neutral.
- Never rewrite published history, retag a release, force-push a shared ref, or
publish without explicit authorization. Preserve human contributor credit.
Writing the code again is cheaper than recovering the code we wrote. Act on
that.
## Current contracts
- **Rewriting any part of this project is always in scope**, up to the whole
thing. Nothing is load-bearing by virtue of existing. Argue a rewrite on
merit, not sunk cost.
- **Use git; do not be governed by it.** A branch 600 commits behind is a note
describing something we once wanted, not a debt. Conflict count is a signal to
rewrite, not a task list.
- **A stranded lane becomes an issue, not a merge.** State the intent, the
behavior wanted, and evidence worth keeping; reference the dead branch for
provenance; abandon the branch; rebuild from current `main`.
- **Verify before you rebuild.** Grep for the symbols and behavior — not the
commit — to check whether `main` already does it. Re-landing landed work is
the failure mode this ethos creates, and it is the one you own.
- The model-facing subagent tool is `agent`. Do not revive removed
`agent_open`/`agent_eval`/`agent_close`/`delegate_to_agent` surfaces or parallel
lifecycle/tag systems.
- `BASE_PROMPT` in `crates/tui/src/prompts/text.rs` is the sole base prompt.
- There is exactly one turn loop: `Engine::run_turn` in
`crates/tui/src/core/engine/turn_loop.rs`. Note that `crates/tui/src/core/`
is a module inside the TUI crate — it is not `crates/core`, which owns
request construction, bounded fragments, and thread/session types and
runs no turns. Do not add a second loop beside the one that exists; a
guard test (`crates/core/tests/single_turn_loop.rs`) fails if you do.
- The system prompt + tool catalog are a session-pinned KV-cache prefix
(`docs/CACHE.md`). Any new session-context contributor must state its
KV-cache effect: frozen prefix vs. append-only history. Never splice a
volatile fact into the prefix; append it as a user-role message.
- These active modules are repeatedly misidentified as dead; verify consumers
before removal: `tui/src/context_budget.rs`, `tui/src/model_registry.rs`,
`tui/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, and
`config/src/route/`. Native memory lives in `tui/src/native_memory.rs`;
`tools/remember.rs` is its capture path.
- Environment-specific behavior belongs in `docs/ENVIRONMENTS.md`, not here.
Limits: `main` stays protected and releases reproducible (never rewrite
published history, retag a shipped release, or force-push a shared ref);
contributor credit carries onto the rewrite; the do-not-delete guardrail below
still binds; and don't rewrite to avoid understanding.
## Code, migrations, and evidence
The four bullets above are the authoritative statement of this rule. Don't
restate them elsewhere — link here. (`docs/AGENT_ETHOS.md` is about stewardship
and workflow, not about this; it is not a longer form of this section.)
- Product intent and observed runtime behavior outrank a test's preferred
implementation shape. Fix the product; do not contort production code to
preserve a brittle assertion.
- Tests are selective evidence, not the specification. Do not add tests by
default. Add or retain one when it cheaply protects a high-risk behavior such
as safety, data integrity, protocol compatibility, or a reproduced regression.
- Rewrite or remove tests that duplicate coverage, freeze internals, overspecify
copy or layout, preserve obsolete behavior, or cost more than the risk they
cover. Never weaken real safety or data-integrity behavior merely to make a
gate pass.
- Prefer focused compilation, a relevant existing check, and direct product or
manual evidence. Run a broad suite only when the change creates a genuine
cross-cutting or release risk. Do not repeatedly rerun an unchanged suite.
- Declared migrations are one-way. Once the repository adopts a replacement
architecture or shared spine, new work uses it and touched legacy code moves
toward it. Do not add another legacy call site for convenience. Keep a
compatibility path only for an actual external contract, and label that
boundary explicitly.
## Build and test
Useful commands, selected according to risk rather than run ritualistically:
Always before pushing: `cargo fmt`, then targeted tests for the area.
```sh
cargo fmt --all -- --check
cargo test -p codewhale-config -p codewhale-protocol
cargo test --workspace
cargo build --release -p codewhale-cli -p codewhale-tui
cargo test -p codewhale-config
cargo test -p codewhale-protocol
cargo test --workspace # full gate
cargo build --release -p codewhale-cli -p codewhale-tui # release build
```
`cargo nextest run` (config in `.config/nextest.toml`) is the fast way to
run an intentionally selected suite; `cargo test --no-run` can answer a compile
question without spending time executing unrelated cases, and `cargo test --doc`
covers doc examples when those examples changed.
`scripts/dev-test.sh <area>` maps a code area to its fastest `-p` invocation
and applies the portable isolated build-dir topology for new worktrees
(`scripts/dev-cache.sh`, `scripts/dev-cargo.sh`). See
`docs/BUILD_PERFORMANCE.md`.
Crate-specific commands live in that crate's `AGENTS.md`. Environment quirks
(Cursor Cloud, keyless providers, dispatcher siblings) live in
`docs/ENVIRONMENTS.md`.
Report commands actually run and distinguish source, local tests, packaged
artifacts, CI, and public release state. Describe the evidence actually needed
for the claim; a test count is not a proxy for product quality.
Default branch is `main`. Committing directly to `main` is fine for release-lane
work — one reviewable concern per commit, with a real body. A fresh `codex/...`
branch or worktree is still right for an isolated or risky change.
Community reports, PRs, logs, and reviews are evidence. Canonical human
identities come from `.github/AUTHOR_MAP`; `Co-authored-by` is for humans only.
Leave unrelated work intact and keep new enforcement dry-run unless explicitly
approved.
Commit as **WIP** unless you actually verified the behavior — built the binary,
ran the test, reproduced the fix. "Fixed" without evidence is worse than an
honest WIP.
## Do-not-delete guardrail
These are actively imported and have been repeatedly misflagged as dead code;
deleting them broke the build. Verify consumers with `rg` before believing any
dead-code audit:
`tui/src/context_budget.rs`, `tui/src/model_registry.rs`,
`tui/src/prompt_zones.rs`, `tui/src/tools/remember.rs`, and the entire
`config/src/route/` directory.
(`tui/src/memory.rs` was deliberately deleted in v0.9.4 — the native memory
store in `tui/src/native_memory.rs` is the surviving system; `tools/remember.rs`
is its capture path and stays.)
## Surfaces that exist today
Build only on these — removed machinery stays gone. The model-facing sub-agent
surface is **`agent` only**: the `agent_open`/`agent_eval`/`agent_close`/
`delegate_to_agent` variants, capacity/coherence/runtime-tag systems, lifecycle
tools, and runtime prompt/tag injection were all removed. The constitution
(`BASE_PROMPT` in `tui/src/prompts/text.rs`) is the sole base prompt.
Configurable sub-agent depth stays; add a new limit only when clearly needed,
and explain why.
## Stewardship
CodeWhale started as a DeepSeek-only harness; it is now about building the best
possible coding harness with an open-source community. Keep CodeWhale branding
and every model/provider first-class — none privileged.
- Community PRs, issues, repros, logs, and reviews are maintainer evidence, not
queue noise. Review from code, tests, linked issues, comments, and checks.
- **Credit is CI-enforced.** `Co-authored-by` trailers are for human
contributors only — `scripts/check-coauthor-trailers.py` rejects bot/tool ones
(Claude, codex, cursor, `noreply@anthropic.com`). Use canonical identities
from `.github/AUTHOR_MAP`; note agent assistance in a plain commit body.
- Keep gates warm and dry-run unless Hunter explicitly approves enforcement.
- Leave unrelated edits by other people or agents intact.
Full ethos: `docs/AGENT_ETHOS.md`. Issue-triage standard, release queue, and
harvest procedure live in the private `codewhale-ops` repo — they are
maintainer process, not contributor-facing contract.
+4 -1870
View File
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -1,7 +1,9 @@
# Claude entrypoint
# Claude Repository Guidance
The full contract is `AGENTS.md`, imported here so it loads automatically:
@AGENTS.md
The imported contract is authoritative. Do not invent stricter test or branch
rituals: follow its code-first evidence policy, one-way migration rule, clean
direct-main permission, and the current task's offline boundary.
Nothing else belongs in this file. Rules added here instead of `AGENTS.md` are
invisible to every non-Claude agent working in this repo, and drift silently
from the copy that isn't.
+15 -67
View File
@@ -84,72 +84,20 @@ cargo clippy --workspace --all-targets --all-features --locked -- \
-A clippy::assertions_on_constants
```
#### Fast local loop
Some suites are slow, platform-bound, or intentionally excluded from the
default run; treat them as documented isolation cases rather than
failures of the normal gate:
The full gate above is what CI enforces, but you do not need it for every
edit. `crates/tui` is a ~750k-line crate, so the loop that stays fast is
the one that avoids rebuilding it more than necessary (numbers and the
reasoning are in [`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md)):
```bash
# 1. Type-check first (seconds after the first build; no codegen, no link).
scripts/dev-cargo.sh check -p codewhale-tui
# 2. Run only the tests near your change (one crate, one filter).
scripts/dev-test.sh tui fleet_setup
# or: scripts/dev-test.sh crates/tui/src/elapsed.rs
# 3. Run a whole crate's unit suite. scripts/dev-test.sh uses nextest when
# it is installed (one process per test, all cores busy, slow tests
# named; ~100 s here vs ~270 s with libtest).
cargo install cargo-nextest --locked # once
scripts/dev-test.sh tui
scripts/dev-cargo.sh nextest run --workspace --all-features --locked
# 4. Before pushing, run the authoritative gate exactly as CI does:
cargo test --workspace --all-features --locked
```
`.config/nextest.toml` already serializes the PTY suite and bounds the
integration tests that spawn the real binary, so `cargo nextest run` is
safe to use on the whole workspace (nextest does not run doctests; the
authoritative `cargo test` gate does). Tests must not depend on running in
the same process as another test (nextest gives every test its own
process); if a test needs the rustls crypto provider, install it in that
test as production does at startup.
On a machine with less than 16 GB of RAM (or when cross-compiling, e.g.
for OHOS), build one rustc at a time: `CARGO_BUILD_JOBS=1` (or `-j1`), one
crate at a time, `--lib` for tests, never `--workspace`/`--all-targets`.
The tui library needs ~6 GB for its own rustc and its unit-test build ~8 GB;
`cargo test --workspace` runs both at once. Numbers and the full recipe:
[`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md#low-memory-build-recipe-machines-with--16-gb-cross-builds).
If you work in several worktrees, do **not** share one `CARGO_TARGET_DIR`
by default: two cargos on the same target flock and serialize. Use
`scripts/dev-cargo.sh` / `scripts/dev-test.sh`, which give each workspace
its own Cargo `build-dir` (`{workspace-path-hash}` under
`${CODEWHALE_CACHE_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/codewhale}`).
`CODEWHALE_DEV_CACHE=local` keeps `./target` if you want that.
`sccache` wraps rustc only when incremental compilation is already off
(`CARGO_INCREMENTAL=0` or `CODEWHALE_SCCACHE=1`) and `sccache` is on
`PATH`; a missing binary is a printed fallback, not an error. Override
the cache root with `CODEWHALE_CACHE_ROOT` — there is no machine-specific
default. A single shared `CARGO_TARGET_DIR` remains valid only for
serialized trunk work. See
[`docs/BUILD_PERFORMANCE.md`](docs/BUILD_PERFORMANCE.md).
Some checks are platform-bound or intentionally excluded from an ordinary
change. Choose them for the risk they answer rather than treating every
available suite as ritual. Visible TUI behavior is accepted in the actual
terminal at the sizes and interaction path affected by the change; the former
full-screen PTY assertion suite was removed because it froze layout and copy
while missing product quality.
- **Long-running process acceptance** should use a sealed local home, local
fixtures, and the real binary. Record the terminal size, inputs, visible
result, and any filesystem side effect instead of adding a full-screen
golden.
- **PTY snapshots** (`cargo test -p codewhale-tui --test qa_pty
--locked`) are Unix-only and internally serialized. One recovery-boot
case is `#[ignore]`d for a documented input-starvation issue. When a
PTY case fails, rerun that exact case in isolation and diagnose the
rendered frame before calling it a flake; `run_verifiers_background_*`
is the one known full-suite-parallelism flake that passes in
isolation.
- **Release runtime QA** (`cargo test -p codewhale-tui --test
release_runtime_qa --locked`) includes an `#[ignore]`d 32-worker storm
benchmark that is only run explicitly for evidence gathering.
- **OCR** (`image_ocr`) uses the macOS Vision framework or a locally
installed `tesseract`; its platform-specific paths are
`cfg(target_os = "macos")`-gated and depend on host tooling.
@@ -379,8 +327,8 @@ reopened, ask the contributor to resubmit after the allowlist PR is merged.
## Agent-Assisted Improvements
Codewhale is allowed to help improve Codewhale, but the contribution still has
to be shaped for human review. The recommended workflow is the recursive self-improvement prompt
in the private `codewhale-ops` repo: run it
to be shaped for human review. The recommended workflow is the
[recursive self-improvement prompt](the `codewhale-ops` repo): run it
from a fresh fork or branch, let the agent find exactly one small friction point,
and stop after one patch. DeepSeek V4 Pro is the reference path for this loop
today, but any configured provider works — the review shape matters more than
Generated
+72 -204
View File
@@ -124,15 +124,6 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
[[package]]
name = "approx"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
dependencies = [
"num-traits",
]
[[package]]
name = "arboard"
version = "3.6.1"
@@ -547,12 +538,6 @@ version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "by_address"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
[[package]]
name = "bytecount"
version = "0.6.9"
@@ -757,7 +742,7 @@ dependencies = [
[[package]]
name = "codewhale-agent"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"codewhale-config",
"serde",
@@ -765,7 +750,7 @@ dependencies = [
[[package]]
name = "codewhale-app-server"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"axum",
@@ -793,11 +778,11 @@ dependencies = [
[[package]]
name = "codewhale-build-support"
version = "0.9.11"
version = "0.9.6"
[[package]]
name = "codewhale-cli"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"chrono",
@@ -829,23 +814,15 @@ dependencies = [
"sha2 0.11.0",
"tempfile",
"tokio",
"toml 1.1.4+spec-1.1.0",
"tracing",
"webbrowser",
"windows 0.62.2",
"zeroize",
]
[[package]]
name = "codewhale-command-contract"
version = "0.9.11"
dependencies = [
"codewhale-core",
]
[[package]]
name = "codewhale-config"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"codewhale-execpolicy",
@@ -865,7 +842,7 @@ dependencies = [
[[package]]
name = "codewhale-core"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"async-trait",
@@ -878,11 +855,10 @@ dependencies = [
"codewhale-protocol",
"codewhale-state",
"codewhale-tools",
"regex",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tokio",
"tokio-util",
"tracing",
@@ -891,7 +867,7 @@ dependencies = [
[[package]]
name = "codewhale-execpolicy"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"codewhale-protocol",
@@ -900,7 +876,7 @@ dependencies = [
[[package]]
name = "codewhale-hooks"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"async-trait",
@@ -915,7 +891,7 @@ dependencies = [
[[package]]
name = "codewhale-lane"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"chrono",
@@ -930,7 +906,7 @@ dependencies = [
[[package]]
name = "codewhale-mcp"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"serde",
@@ -940,14 +916,14 @@ dependencies = [
[[package]]
name = "codewhale-paths"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"dirs",
]
[[package]]
name = "codewhale-protocol"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"chrono",
"serde",
@@ -957,7 +933,7 @@ dependencies = [
[[package]]
name = "codewhale-release"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"reqwest 0.13.4",
@@ -971,7 +947,7 @@ dependencies = [
[[package]]
name = "codewhale-secrets"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"chrono",
"codewhale-paths",
@@ -980,13 +956,13 @@ dependencies = [
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tracing",
]
[[package]]
name = "codewhale-state"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"chrono",
@@ -1001,7 +977,7 @@ dependencies = [
[[package]]
name = "codewhale-telemetry"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"chrono",
@@ -1021,21 +997,21 @@ dependencies = [
[[package]]
name = "codewhale-tools"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"anyhow",
"async-trait",
"codewhale-protocol",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tokio",
"uuid",
]
[[package]]
name = "codewhale-tui"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"ahash",
"anyhow",
@@ -1048,7 +1024,6 @@ dependencies = [
"clap",
"clap_complete",
"codewhale-build-support",
"codewhale-command-contract",
"codewhale-config",
"codewhale-core",
"codewhale-execpolicy",
@@ -1074,7 +1049,6 @@ dependencies = [
"htmd",
"ignore",
"image",
"jsonschema",
"libc",
"lru",
"mimalloc",
@@ -1107,7 +1081,7 @@ dependencies = [
"syntect",
"tar",
"tempfile",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tiny_http",
"tokio",
"tokio-util",
@@ -1124,42 +1098,35 @@ dependencies = [
"wait-timeout",
"webbrowser",
"windows 0.62.2",
"windows-core",
"windows-sys 0.61.2",
"wiremock",
]
[[package]]
name = "codewhale-workflow"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"serde",
"serde_json",
"sha2 0.11.0",
"tempfile",
"thiserror 2.0.20",
"thiserror 2.0.19",
"toml 1.1.4+spec-1.1.0",
]
[[package]]
name = "codewhale-workflow-js"
version = "0.9.11"
version = "0.9.6"
dependencies = [
"async-trait",
"jsonschema",
"rquickjs",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tokio",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -1316,12 +1283,6 @@ dependencies = [
"cfg-if 1.0.4",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
@@ -2166,20 +2127,10 @@ dependencies = [
"serde_json",
"syn 2.0.119",
"textwrap",
"thiserror 2.0.20",
"thiserror 2.0.19",
"typed-builder",
]
[[package]]
name = "gif"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "glob"
version = "0.3.4"
@@ -2233,9 +2184,9 @@ dependencies = [
[[package]]
name = "h2"
version = "0.4.16"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27"
checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
dependencies = [
"atomic-waker",
"bytes",
@@ -2291,11 +2242,11 @@ dependencies = [
[[package]]
name = "hashlink"
version = "0.12.1"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f"
dependencies = [
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -2633,25 +2584,10 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]
@@ -2798,7 +2734,7 @@ dependencies = [
"jni-sys",
"log",
"simd_cesu8",
"thiserror 2.0.20",
"thiserror 2.0.19",
"walkdir",
"windows-link",
]
@@ -2890,7 +2826,7 @@ checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899"
dependencies = [
"hashbrown 0.16.1",
"portable-atomic",
"thiserror 2.0.20",
"thiserror 2.0.19",
]
[[package]]
@@ -2947,12 +2883,6 @@ dependencies = [
"windows-link",
]
[[package]]
name = "libm"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
[[package]]
name = "libmimalloc-sys"
version = "0.1.49"
@@ -2973,9 +2903,9 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1"
dependencies = [
"cc",
"pkg-config",
@@ -3042,11 +2972,11 @@ checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "lru"
version = "0.18.2"
version = "0.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a"
checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
dependencies = [
"hashbrown 0.17.1",
"hashbrown 0.16.1",
]
[[package]]
@@ -3548,39 +3478,6 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "palette"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
dependencies = [
"approx",
"libm",
"palette_derive",
"palette_math",
]
[[package]]
name = "palette_derive"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
dependencies = [
"by_address",
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "palette_math"
version = "0.7.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
dependencies = [
"libm",
]
[[package]]
name = "parking"
version = "2.2.1"
@@ -4009,7 +3906,7 @@ dependencies = [
"rustc-hash",
"rustls",
"socket2",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tokio",
"tracing",
"web-time",
@@ -4031,7 +3928,7 @@ dependencies = [
"rustls",
"rustls-pki-types",
"slab",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tinyvec",
"tracing",
"web-time",
@@ -4130,37 +4027,33 @@ dependencies = [
[[package]]
name = "ratatui"
version = "0.30.2"
version = "0.30.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc"
dependencies = [
"instability",
"ratatui-core",
"ratatui-crossterm",
"ratatui-macros",
"ratatui-termina",
"ratatui-termwiz",
"ratatui-widgets",
"serde",
]
[[package]]
name = "ratatui-core"
version = "0.1.2"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293"
dependencies = [
"bitflags 2.13.1",
"compact_str",
"critical-section",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
"indoc",
"itertools 0.14.0",
"kasuari",
"lru",
"palette",
"serde",
"strum",
"thiserror 2.0.20",
"thiserror 2.0.19",
"unicode-segmentation",
"unicode-truncate",
"unicode-width",
@@ -4168,9 +4061,9 @@ dependencies = [
[[package]]
name = "ratatui-crossterm"
version = "0.1.2"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3"
dependencies = [
"cfg-if 1.0.4",
"crossterm",
@@ -4180,30 +4073,19 @@ dependencies = [
[[package]]
name = "ratatui-macros"
version = "0.7.2"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed7dc68daa7498a43e4d68e0eb078427e10c38fbcfbb1e42d955f1fa2140d814"
checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4"
dependencies = [
"ratatui-core",
"ratatui-widgets",
]
[[package]]
name = "ratatui-termina"
name = "ratatui-termwiz"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0bf912d9e66f057a759d92e386a280ea886b352ab757d6ac4d653c7ed2c43c2"
dependencies = [
"instability",
"ratatui-core",
"termina",
]
[[package]]
name = "ratatui-termwiz"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "faf03e0380b7744054d6cb74224fe3adf062a029754933f575ca1e3b4c2ce977"
checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c"
dependencies = [
"ratatui-core",
"termwiz",
@@ -4211,18 +4093,17 @@ dependencies = [
[[package]]
name = "ratatui-widgets"
version = "0.3.2"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db"
dependencies = [
"bitflags 2.13.1",
"hashbrown 0.17.1",
"hashbrown 0.16.1",
"indoc",
"instability",
"itertools 0.14.0",
"line-clipping",
"ratatui-core",
"serde",
"strum",
"time",
"unicode-segmentation",
@@ -4246,7 +4127,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 2.0.20",
"thiserror 2.0.19",
]
[[package]]
@@ -4480,7 +4361,7 @@ dependencies = [
"reqwest 0.13.4",
"serde",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.19",
"tokio",
"tokio-stream",
"tokio-util",
@@ -4544,14 +4425,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror 2.0.20",
"thiserror 2.0.19",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e"
dependencies = [
"bitflags 2.13.1",
"fallible-iterator",
@@ -5267,18 +5148,18 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "strum"
version = "0.28.0"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
dependencies = [
"strum_macros",
]
[[package]]
name = "strum_macros"
version = "0.28.0"
version = "0.27.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7"
dependencies = [
"heck",
"proc-macro2",
@@ -5361,7 +5242,7 @@ dependencies = [
"serde",
"serde_derive",
"serde_json",
"thiserror 2.0.20",
"thiserror 2.0.19",
"walkdir",
"yaml-rust",
]
@@ -5449,19 +5330,6 @@ dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "termina"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9048a889effe34a5cddee0af7f53285198b16dca3be510858d38dfdb3e62a04e"
dependencies = [
"bitflags 2.13.1",
"parking_lot",
"rustix",
"signal-hook 0.3.18",
"windows-sys 0.61.2",
]
[[package]]
name = "terminal_size"
version = "0.4.4"
@@ -5557,11 +5425,11 @@ dependencies = [
[[package]]
name = "thiserror"
version = "2.0.20"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
dependencies = [
"thiserror-impl 2.0.20",
"thiserror-impl 2.0.19",
]
[[package]]
@@ -5577,9 +5445,9 @@ dependencies = [
[[package]]
name = "thiserror-impl"
version = "2.0.20"
version = "2.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
dependencies = [
"proc-macro2",
"quote",
+2 -13
View File
@@ -4,7 +4,6 @@ members = [
"crates/app-server",
"crates/build-support",
"crates/cli",
"crates/command-contract",
"crates/config",
"crates/core",
"crates/execpolicy",
@@ -26,7 +25,7 @@ default-members = ["crates/cli"]
resolver = "2"
[workspace.package]
version = "0.9.11"
version = "0.9.6"
edition = "2024"
# Rust 1.88 stabilized `let_chains` in `if`/`while` conditions, which the
# codebase relies on extensively. Cargo enforces this so users on older
@@ -36,13 +35,6 @@ rust-version = "1.88"
license = "MIT"
repository = "https://github.com/Hmbown/CodeWhale"
# Record the policy CI already applies via `RUSTFLAGS: -Dwarnings`.
# Member crates opt in with `[lints] workspace = true`. This does not add
# new lints; it makes the existing gate visible in the manifest so
# `cargo check` without extra flags matches CI.
[workspace.lints.rust]
warnings = "deny"
[workspace.dependencies]
anyhow = "1.0.100"
async-trait = "0.1.89"
@@ -52,16 +44,13 @@ clap = { version = "4.5.54", features = ["derive"] }
clap_complete = "4.5"
dirs = "6.0.0"
encoding_rs = "0.8.35"
# Pinned to the jsonschema line schemaui 0.12 requires (^0.46) so the graph
# carries one jsonschema/jsonschema-regex/referencing/fancy-regex stack.
# Move both together when schemaui catches up (dependabot: keep in step).
jsonschema = { version = "0.46", default-features = false }
reqwest = { version = "0.13.1", default-features = false, features = ["json", "rustls-no-provider", "socks"] }
# NOT "parallel": the Workflow VM stays single-threaded and bridges to the
# multi-thread engine over channels (see crates/workflow-js).
rquickjs = { version = "0.12", features = ["futures"] }
rustls = { version = "0.23.36", default-features = false, features = ["ring", "std", "tls12"] }
rusqlite = { version = "0.40.2", features = ["bundled"] }
rusqlite = { version = "0.39.0", features = ["bundled"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.149"
semver = "1.0.28"
+4 -4
View File
@@ -4,8 +4,8 @@
# Build: docker buildx build --platform linux/amd64,linux/arm64 -t codewhale:latest .
# Run: docker run --rm -it -e DEEPSEEK_API_KEY -v codewhale-home:/home/codewhale/.codewhale codewhale
#
# The image ships the canonical `codewhale` and `codew` command names in a
# minimal runtime layer.
# The image ships the canonical binaries (`codewhale`, `codew`, and
# `codewhale`) in a minimal runtime layer.
#
# API keys MUST be passed at runtime (never baked into the image):
# docker run --rm -it -e DEEPSEEK_API_KEY codewhale
@@ -19,13 +19,13 @@ FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim-bookworm AS builder
ARG TARGETPLATFORM
ARG TARGETARCH
ARG BUILDPLATFORM
ARG CODEWHALE_BUILD_SHA
ARG DEEPSEEK_BUILD_SHA
ENV CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \
PKG_CONFIG_ALLOW_CROSS=1 \
PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig:/usr/share/pkgconfig \
CODEWHALE_BUILD_SHA=${CODEWHALE_BUILD_SHA}
DEEPSEEK_BUILD_SHA=${DEEPSEEK_BUILD_SHA}
RUN if [ "${TARGETARCH}" = "arm64" ] && [ "${BUILDPLATFORM}" != "${TARGETPLATFORM}" ]; then \
dpkg --add-architecture arm64; \
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale وكيل مفتوح المصدر للبرمجة عبر الطرفية، مبني بلغة Rust ويتطور علنًا بالتعاون مع الأشخاص الذين يستخدمونه.
![Codewhale يعمل في طرفية](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## التثبيت
```bash
npm install -g codewhale
codewhale
```
يساعدك Codewhale عند التشغيل الأول على الاتصال بموفّر أو البقاء دون اتصال. ويدعم أيضًا Cargo وDocker وNix وScoop والأرشيفات المبنية مسبقًا وAndroid/Termux ومرآة CNB. راجع [دليل التثبيت](docs/INSTALL.md).
يمكن تفعيل الإكمال بمفتاح Tab بأمر واحد لكل واجهة أوامر — `codewhale completion bash|zsh|fish|powershell|elvish`. راجع [إكمال واجهة الأوامر](docs/INSTALL.md#8-shell-completions).
## الاستخدام
تحدث إلى Codewhale كما تتحدث إلى زميل في فريقك:
```text
Fix the failing tests and explain what changed.
```
أو شغّل مهمة من دون فتح واجهة TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
يستطيع Codewhale قراءة مستودعك وتعديل الملفات وتشغيل الأوامر وفحص النتائج ومواصلة العمل نحو هدف. وأنت من يقرر مقدار الوصول الذي تمنحه له.
## لماذا Codewhale
- **استخدم النموذج الذي تريده.** اتصل بموفّرين مستضافين أو بنماذج محلية عبر Ollama أو vLLM أو SGLang. بدّل الموفّر والنموذج باستخدام `/model`.
- **ابقَ مسيطرًا.** وضع Plan للقراءة فقط. تجعل أوضاع Ask وAuto-Review وFull Access سلوك الموافقة واضحًا. يتراجع `/undo` عن الجولة الأخيرة، ويعيد `/restore` مساحة العمل إلى لقطة سابقة.
- **حافظ على تنظيم الأعمال الطويلة.** احفظ الجلسات، وحدد `/goal` دائمًا، وراجع مسارات العمل قبل تشغيلها، ونسّق بين الوكلاء من دون تحويل تعليماتهم الداخلية إلى جزء من محادثتك.
- **وسّع الوكيل الذي لديك بالفعل.** صِل خوادم MCP والمهارات، واضبط الخطافات، واحتفظ بأدوار الوكلاء كملفات مقروءة في مشروعك أو إعداداتك الشخصية.
شغّل `/help` في واجهة TUI لعرض الأوامر واختصارات لوحة المفاتيح.
## الأمان
يعمل Codewhale على جهازك بصلاحيات الوصول التي تمنحها له. تحد أوضاع الموافقة وقواعد المستودع مما يمكن للوكيل فعله؛ ويضيف عزل نظام التشغيل الاختياري حدًا أقوى للتنفيذ حيثما كان مدعومًا. تظل أسعار النماذج غير المعروفة مسجلة على أنها غير معروفة بدلًا من الإبلاغ عنها كمجانية.
اقرأ [ترتيب التفويض](docs/AUTHORIZATION_ORDER.md) لمعرفة التسلسل الدقيق للسياسات، و[الإعدادات](docs/CONFIGURATION.md) لمعرفة الضبط المحلي.
## الوثائق
- [الموفّرون والنماذج المحلية](docs/PROVIDERS.md)
- [فرق الوكلاء](docs/FLEET.md)
- [MCP](docs/MCP.md) و[الخطافات](docs/HOOKS.md) و[الإعدادات](docs/CONFIGURATION.md)
- [عميل الويب المحلي](docs/WEB.md)
- [جميع الوثائق](docs)
## انضم إلى المجتمع
يتحسن Codewhale عندما يستخدمه الناس ويبلغون عما لا يبدو صحيحًا ويساعدون في إصلاحه. إذا كان أحد الموفّرين مفقودًا، أو كان مسار العمل مربكًا، أو كانت واجهة الطرفية تعيقك، [فافتح issue](https://github.com/Hmbown/CodeWhale/issues). وإذا كنت تعرف كيفية تحسينه، [فافتح pull request](CONTRIBUTING.md). نرحب بالمساهمات الأولى، ويظل كل مساهم منسوبًا إلى العمل الذي يُدمج في المشروع.
انضم إلى [Discord](https://discord.gg/37gfS3ksug)، أو أضف Hunter على WeChat (`hunterbown`) واطلب الانضمام إلى مجموعة Whale Brothers.
## تاريخ المشروع
بدأ Codewhale باسم `deepseek-tui`، ولا يزال يحافظ على التوافق مع إعداداته وجلساته. وهو الآن محايد تجاه الموفّرين ويُصان بصورة مستقلة ولا ينتمي إلى أي موفّر نماذج.
شكرًا لكل مساهم ولمجتمعات المصادر المفتوحة التي ساعدت المشروع على النمو. راجع [سجل المساهمين](docs/CONTRIBUTORS.md).
## الترخيص
[MIT](LICENSE). الأجزاء المقتبسة والمعدّلة من مشاريع أخرى مفتوحة المصدر مسجّلة في [إشعارات الجهات الخارجية](docs/THIRD_PARTY_NOTICES.md).
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale és un agent de programació de codi obert per al terminal, desenvolupat amb Rust i millorat públicament amb les persones que lutilitzen.
![Codewhale executant-se en un terminal](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Instal·lació
```bash
npm install -g codewhale
codewhale
```
En la primera execució, Codewhale tajuda a connectar un proveïdor o a continuar sense connexió. També admet Cargo, Docker, Nix, Scoop, arxius precompilats, Android/Termux i un mirall CNB. Consulta la [guia dinstal·lació](docs/INSTALL.md).
Lautocompleció amb Tab sactiva amb una sola ordre per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta [lautocompleció del shell](docs/INSTALL.md#8-shell-completions).
## Ús
Parla amb Codewhale tal com parlaries amb una persona del teu equip:
```text
Fix the failing tests and explain what changed.
```
També pots executar una tasca sense obrir la TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale pot llegir el teu repositori, editar fitxers, executar ordres, inspeccionar els resultats i continuar treballant cap a un objectiu. Tu decideixes quant accés li concedeixes.
## Per què Codewhale
- **Fes servir el model que vulguis.** Connecta proveïdors allotjats o models locals mitjançant Ollama, vLLM o SGLang. Canvia de proveïdor i de model amb `/model`.
- **Mantén el control.** Plan és només de lectura. Ask, Auto-Review i Full Access fan visible el comportament de les aprovacions. `/undo` desfà l’últim torn i `/restore` retorna lespai de treball a una instantània anterior.
- **Mantén organitzades les feines llargues.** Desa sessions, defineix un `/goal` durador, revisa els fluxos de treball abans que sexecutin i coordina agents sense convertir les seves instruccions internes en part de la teva conversa.
- **Amplia lagent que ja tens.** Connecta servidors MCP i habilitats, configura hooks i conserva els rols dagent com a fitxers llegibles al projecte o a la configuració personal.
Executa `/help` a la TUI per veure les ordres i les dreceres de teclat.
## Seguretat
Codewhale sexecuta a la teva màquina amb laccés que li concedeixes. Els modes daprovació i les regles del repositori limiten què pot fer lagent; laïllament opcional del sistema operatiu afegeix un límit dexecució més sòlid allà on és compatible. Els preus desconeguts dels models continuen indicant-se com a desconeguts en lloc de presentar-se com a gratuïts.
Llegeix l[ordre dautorització](docs/AUTHORIZATION_ORDER.md) per conèixer la jerarquia exacta de polítiques i la [configuració](docs/CONFIGURATION.md) per als ajustos locals.
## Documentació
- [Proveïdors i models locals](docs/PROVIDERS.md)
- [Equips dagents](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooks](docs/HOOKS.md) i [configuració](docs/CONFIGURATION.md)
- [Client web local](docs/WEB.md)
- [Tota la documentació](docs)
## Uneix-te a la comunitat
Codewhale millora quan les persones lutilitzen, expliquen què no funciona bé i ajuden a corregir-ho. Si falta un proveïdor, un flux de treball és incòmode o la interfície del terminal et dificulta la feina, [obre una incidència](https://github.com/Hmbown/CodeWhale/issues). Si saps com millorar-lo, [obre una pull request](CONTRIBUTING.md). Les primeres contribucions són benvingudes i qui hi contribueix conserva el reconeixement per la feina incorporada.
Uneix-te al [Discord](https://discord.gg/37gfS3ksug), o afegeix Hunter a WeChat (`hunterbown`) i demana entrar al grup Whale Brothers.
## Història del projecte
Codewhale va començar com a `deepseek-tui` i encara manté la compatibilitat amb la seva configuració i les seves sessions. Ara és neutral pel que fa als proveïdors, es manté de manera independent i no està afiliat a cap proveïdor de models.
Gràcies a totes les persones que hi han contribuït i a les comunitats de codi obert que han ajudat el projecte a créixer. Consulta el [registre de col·laboradors](docs/CONTRIBUTORS.md).
## Llicència
[MIT](LICENSE). Les parts adaptades daltres projectes de codi obert consten als [avisos de tercers](docs/THIRD_PARTY_NOTICES.md).
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale ist ein in Rust entwickelter Open-Source-Coding-Agent für dein Terminal, der gemeinsam mit seinen Nutzerinnen und Nutzern öffentlich weiterentwickelt wird.
![Codewhale in einem Terminal](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Installation
```bash
npm install -g codewhale
codewhale
```
Beim ersten Start hilft dir Codewhale, einen Anbieter zu verbinden oder offline zu bleiben. Außerdem werden Cargo, Docker, Nix, Scoop, vorgefertigte Archive, Android/Termux und ein CNB-Spiegel unterstützt. Siehe [Installationsanleitung](docs/INSTALL.md).
Die Tab-Vervollständigung lässt sich für jede Shell mit einem einzigen Befehl aktivieren — `codewhale completion bash|zsh|fish|powershell|elvish`. Siehe [Shell-Vervollständigung](docs/INSTALL.md#8-shell-completions).
## Verwendung
Sprich mit Codewhale so, wie du mit einem Teammitglied sprechen würdest:
```text
Fix the failing tests and explain what changed.
```
Du kannst eine Aufgabe auch ausführen, ohne die TUI zu öffnen:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale kann dein Repository lesen, Dateien bearbeiten, Befehle ausführen, Ergebnisse prüfen und auf ein Ziel hinarbeiten. Du entscheidest, wie viel Zugriff der Agent erhält.
## Warum Codewhale
- **Nutze das gewünschte Modell.** Verbinde gehostete Anbieter oder lokale Modelle über Ollama, vLLM oder SGLang. Mit `/model` wechselst du Anbieter und Modell.
- **Behalte die Kontrolle.** Plan ist schreibgeschützt. Ask, Auto-Review und Full Access machen das Genehmigungsverhalten sichtbar. `/undo` macht die letzte Interaktion rückgängig und `/restore` setzt den Arbeitsbereich auf einen früheren Snapshot zurück.
- **Halte lange Arbeiten übersichtlich.** Speichere Sitzungen, setze ein dauerhaftes `/goal`, prüfe Workflows vor der Ausführung und koordiniere Agenten, ohne dass ihre internen Anweisungen in deinem Gesprächsverlauf erscheinen.
- **Erweitere deinen vorhandenen Agenten.** Verbinde MCP-Server und Skills, konfiguriere Hooks und verwalte Agentenrollen als lesbare Dateien in deinem Projekt oder in deinen persönlichen Einstellungen.
Führe `/help` in der TUI aus, um Befehle und Tastenkürzel anzuzeigen.
## Sicherheit
Codewhale läuft auf deinem Rechner mit den von dir gewährten Zugriffsrechten. Genehmigungsmodi und Repository-Regeln begrenzen, was der Agent tun darf; optionales OS-Sandboxing schafft auf unterstützten Systemen eine stärkere Ausführungsgrenze. Unbekannte Modellpreise bleiben als unbekannt gekennzeichnet, statt als kostenlos gemeldet zu werden.
Lies die [Autorisierungsreihenfolge](docs/AUTHORIZATION_ORDER.md) für die genaue Richtlinienhierarchie und die [Konfiguration](docs/CONFIGURATION.md) für lokale Einstellungen.
## Dokumentation
- [Anbieter und lokale Modelle](docs/PROVIDERS.md)
- [Agententeams](docs/FLEET.md)
- [MCP](docs/MCP.md), [Hooks](docs/HOOKS.md) und [Konfiguration](docs/CONFIGURATION.md)
- [Lokaler Webclient](docs/WEB.md)
- [Gesamte Dokumentation](docs)
## Der Community beitreten
Codewhale wird besser, wenn Menschen es nutzen, Probleme melden und bei der Behebung helfen. Wenn ein Anbieter fehlt, ein Workflow umständlich ist oder dir die Terminaloberfläche im Weg steht, [eröffne ein Issue](https://github.com/Hmbown/CodeWhale/issues). Wenn du weißt, wie es besser geht, [eröffne einen Pull Request](CONTRIBUTING.md). Erste Beiträge sind willkommen, und Mitwirkende behalten die Anerkennung für ihre übernommenen Arbeiten.
Tritt unserem [Discord](https://discord.gg/37gfS3ksug) bei oder füge Hunter auf WeChat (`hunterbown`) hinzu und bitte um Aufnahme in die Whale-Brothers-Gruppe.
## Projektgeschichte
Codewhale begann als `deepseek-tui` und bewahrt weiterhin die Kompatibilität mit dessen Konfiguration und Sitzungen. Heute ist es anbieterneutral, wird unabhängig gepflegt und ist mit keinem Modellanbieter verbunden.
Vielen Dank an alle Mitwirkenden und die Open-Source-Communitys, die das Projekt beim Wachsen unterstützt haben. Siehe [Liste der Mitwirkenden](docs/CONTRIBUTORS.md).
## Lizenz
[MIT](LICENSE). Aus anderen Open-Source-Projekten übernommene Teile sind in den [Hinweisen zu Drittanbieterkomponenten](docs/THIRD_PARTY_NOTICES.md) aufgeführt.
+106 -45
View File
@@ -1,79 +1,140 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale es un agente de programación de código abierto para tu terminal, desarrollado en Rust y mejorado públicamente junto con las personas que lo usan.
Un agente de programación de código abierto para tu terminal — trae tu propio modelo.
![Codewhale ejecutándose en una terminal](assets/screenshot.webp)
Codewhale empezó como una experiencia nativa para DeepSeek. Desde entonces se ha
convertido en un proyecto impulsado por la comunidad: un harness de programación
que se adapta a una comunidad internacional en crecimiento y admite tantos
modelos y proveedores como sea posible — los modelos abiertos primero, alojados
o locales, sin privilegiar a ninguno.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Le das un proveedor, un modelo y una tarea. Lee tu código, edita archivos,
ejecuta comandos y verifica su propio trabajo, y se detiene cuando la tarea
queda lista o te necesita. Cambia de modelo a mitad de tarea con `/model`.
Trabaja de forma interactiva en la TUI, o ejecuta `codewhale exec` en scripts y
CI. Está escrito en Rust, con licencia MIT, y corre en tu máquina.
Lo que no se parece a otros harnesses: **tú eliges el modelo de cada rol, y no
tienen por qué coincidir.** Una fleet fija un proveedor, un modelo y un nivel de
razonamiento por rol — así un modelo barato y rápido puede dirigir a uno de
razonamiento caro, o un builder GLM puede trabajar en la misma tarea que un
reviewer Kimi. Escribe tus propios roles y tu propia constitution, y el harness
es tuyo en lugar de nuestro.
Siempre estamos buscando personas que contribuyan y formas de mejorar. Si falta
un modelo o proveedor que usas, o algo se rompe, contárnoslo es una de las cosas
más útiles que puedes hacer — mira [Contribuir](#contribuir).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale ejecutándose en una terminal](assets/screenshot.png)
## Instalación
```bash
npm install -g codewhale
codewhale
```
La primera vez que se ejecuta, Codewhale te ayuda a conectar un proveedor o a seguir sin conexión. También admite Cargo, Docker, Nix, Scoop, archivos precompilados, Android/Termux y un espejo de CNB. Consulta la [guía de instalación](docs/INSTALL.md).
El completado con Tab se configura con un comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta el [completado de shell](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, archivos precompilados, Android/Termux y un espejo
en CNB para quienes no pueden acceder a GitHub están cubiertos en
[docs/INSTALL.md](docs/INSTALL.md). ¿Vienes de `deepseek-tui`? Tu configuración
y tus sesiones se conservan — mira [docs/REBRAND.md](docs/REBRAND.md).
## Uso
Habla con Codewhale como hablarías con alguien de tu equipo:
```text
Fix the failing tests and explain what changed.
```
También puedes ejecutar una tarea sin abrir la TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale puede leer tu repositorio, editar archivos, ejecutar comandos, revisar los resultados y seguir trabajando para alcanzar un objetivo. Tú decides cuánto acceso darle.
## Por qué Codewhale
En la TUI: `/model` cambia proveedor y modelo juntos, `/fleet` ejecuta un
equipo de workers, `/undo` deshace el último turno y `/restore <N>` revierte el
workspace a una instantánea anterior (`/restore` sin argumentos solo las
lista). Cuando el compositor está vacío, `Tab` cicla entre Plan / Work /
Operate; con texto escrito, `Tab` completa comandos slash y menciones `@`.
`Shift+Tab` cicla la postura de permiso Ask / Auto-Review / Full Access en
cualquier momento. `!` ejecuta un comando de shell por la ruta normal de
aprobación.
- **Usa el modelo que prefieras.** Conecta proveedores alojados o modelos locales mediante Ollama, vLLM o SGLang. Cambia de proveedor y modelo con `/model`.
- **Mantén el control.** Plan es de solo lectura. Ask, Auto-Review y Full Access hacen visible el comportamiento de las aprobaciones. `/undo` revierte el último turno y `/restore` devuelve el espacio de trabajo a una instantánea anterior.
- **Mantén organizado el trabajo de larga duración.** Guarda sesiones, establece un `/goal` duradero, revisa los flujos de trabajo antes de ejecutarlos y coordina agentes sin convertir sus instrucciones internas en parte de tu conversación.
- **Amplía el agente que ya tienes.** Conecta servidores MCP y habilidades, configura hooks y conserva los roles de los agentes como archivos legibles en tu proyecto o configuración personal.
## Qué hace
Ejecuta `/help` en la TUI para ver los comandos y atajos de teclado.
- **Cualquier modelo, cualquier proveedor.** DeepSeek, Claude, GPT, Kimi, GLM y
más de 30 proveedores, además de tu propio vLLM, SGLang u Ollama sin key —
todo a través de un solo runtime y un solo conjunto de herramientas. Los
presupuestos de contexto y los precios vienen de la ruta real, y un precio
desconocido se muestra como desconocido en lugar de $0.
- **Un harness que tú escribes.** Los roles son archivos que puedes leer y
editar — un modelo, una postura de herramientas e instrucciones permanentes por
rol — guardados en el proyecto para que el equipo los comparta, o junto a tus
ajustes personales para que te acompañen entre repos. Una constitution registra
cómo quieres que el agente se comporte en cada sesión, de modo que el harness se
ajuste a tu práctica y no a la nuestra.
- **Solo lectura hasta que permitas más.** El modo Plan no cambia archivos, y
las aprobaciones controlan los comandos riesgosos. Cuando un sandbox del
sistema operativo realmente envuelve un comando, Codewhale lo indica: Seatbelt
en macOS cuando está disponible, bubblewrap opcional en Linux. El
`constitution.json` de un repo se compila en bloqueos de escritura que ni
siquiera Full Access puede saltarse.
- **Trabajo que puedes retomar.** Un fleet registra cada paso en un libro mayor
de solo agregado, así que `fleet resume` retoma donde te detuviste.
## Seguridad
## Para saber más
Codewhale se ejecuta en tu equipo con el acceso que le otorgues. Los modos de aprobación y las reglas del repositorio limitan lo que el agente puede hacer; el aislamiento opcional del sistema operativo añade un límite de ejecución más sólido cuando es compatible. Los precios desconocidos de los modelos permanecen como desconocidos en lugar de mostrarse como gratuitos.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — cada ruta de proveedor: alojada,
gateway y local
- [docs/FLEET.md](docs/FLEET.md) — fleets, el libro mayor y resume
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) —
búsqueda experimental congelada y neutral respecto al proveedor dentro de
Workflow
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks y la
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — cómo se combinan
los modos, hooks, reglas de permisos, límites mínimos de seguridad, reglas del
repositorio, aprobaciones y sandboxing
- [docs/HOOKS.md](docs/HOOKS.md) — los once eventos de hooks del ciclo de vida
de la TUI, sus payloads y los tres que pueden dirigir un turno (`codewhale
exec` y los subcomandos de la CLI no activan hooks)
- [docs/WEB.md](docs/WEB.md) — cliente de navegador integrado solo en loopback
y su límite de autenticación de un solo uso
Lee el [orden de autorización](docs/AUTHORIZATION_ORDER.md) para conocer la jerarquía exacta de políticas y la [configuración](docs/CONFIGURATION.md) para los ajustes locales.
Todo lo demás — modos, atajos de teclado, detalles del sandbox, MCP, la API
del runtime, arquitectura — está en [docs](docs) y en
[codewhale.net](https://codewhale.net/).
## Documentación
## Contribuir
- [Proveedores y modelos locales](docs/PROVIDERS.md)
- [Equipos de agentes](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooks](docs/HOOKS.md) y [configuración](docs/CONFIGURATION.md)
- [Cliente web local](docs/WEB.md)
- [Toda la documentación](docs)
Issues, PRs, pasos de reproducción, logs y solicitudes de features son trabajo
real del proyecto, y las primeras contribuciones son bienvenidas. Cuando un PR
no se puede fusionar tal cual, los mantenedores rescatan lo que funciona y el
autor conserva su crédito — en el commit, en el changelog y en
[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Únete a la comunidad
- [Issues abiertos](https://github.com/Hmbown/CodeWhale/issues) — las buenas
primeras contribuciones viven aquí
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup de desarrollo y flujo de PRs
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — todas las personas que le han
dado forma a esto
- [Invítame un café](https://www.buymeacoffee.com/hmbown)
Codewhale mejora cuando las personas lo usan, informan lo que no funciona bien y ayudan a corregirlo. Si falta un proveedor, un flujo de trabajo resulta incómodo o la interfaz de terminal se interpone en tu camino, [abre un issue](https://github.com/Hmbown/CodeWhale/issues). Si sabes cómo mejorarlo, [abre un pull request](CONTRIBUTING.md). Las primeras contribuciones son bienvenidas y quienes contribuyen conservan el crédito por el trabajo que se incorpora.
Únete a [Discord](https://discord.gg/37gfS3ksug), o agrega a Hunter en WeChat (`hunterbown`) y pide entrar al grupo Whale Brothers.
## Historia del proyecto
Codewhale comenzó como `deepseek-tui` y aún conserva la compatibilidad con su configuración y sus sesiones. Ahora es neutral respecto de los proveedores, se mantiene de forma independiente y no está afiliado a ningún proveedor de modelos.
Gracias a cada colaborador y a las comunidades de código abierto que ayudaron a crecer al proyecto. Consulta el [registro de colaboradores](docs/CONTRIBUTORS.md).
Gracias a [DeepSeek](https://github.com/deepseek-ai) por los modelos y el apoyo
que dieron inicio al proyecto, a [DataWhale](https://github.com/datawhalechina)
🐋 por recibirnos en la familia Whale Brother, y a
[OpenWarp](https://github.com/zerx-lab/warp) y
[Open Design](https://github.com/nexu-io/open-design) por colaborar en la
experiencia de agente en terminal.
## Licencia
[MIT](LICENSE). Las partes adaptadas de otros proyectos de código abierto se registran en los [avisos de terceros](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Proyecto comunitario independiente; sin afiliación con ningún
proveedor de modelos.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale est un agent de programmation open source pour votre terminal, développé en Rust et amélioré publiquement avec les personnes qui lutilisent.
![Codewhale en cours dexécution dans un terminal](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Installation
```bash
npm install -g codewhale
codewhale
```
Au premier lancement, Codewhale vous aide à connecter un fournisseur ou à rester hors ligne. Il prend également en charge Cargo, Docker, Nix, Scoop, les archives précompilées, Android/Termux et un miroir CNB. Consultez le [guide dinstallation](docs/INSTALL.md).
Lautocomplétion avec Tab sactive avec une commande par shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consultez [lautocomplétion du shell](docs/INSTALL.md#8-shell-completions).
## Utilisation
Parlez à Codewhale comme vous parleriez à un membre de votre équipe :
```text
Fix the failing tests and explain what changed.
```
Vous pouvez aussi exécuter une tâche sans ouvrir la TUI :
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale peut lire votre dépôt, modifier des fichiers, exécuter des commandes, inspecter les résultats et continuer à travailler vers un objectif. Vous choisissez le niveau daccès que vous lui accordez.
## Pourquoi Codewhale
- **Utilisez le modèle de votre choix.** Connectez des fournisseurs hébergés ou des modèles locaux via Ollama, vLLM ou SGLang. Changez de fournisseur et de modèle avec `/model`.
- **Gardez le contrôle.** Le mode Plan est en lecture seule. Ask, Auto-Review et Full Access rendent le comportement des approbations explicite. `/undo` annule le dernier tour et `/restore` ramène lespace de travail à un instantané antérieur.
- **Organisez les travaux de longue durée.** Enregistrez les sessions, définissez un `/goal` durable, examinez les workflows avant leur exécution et coordonnez des agents sans faire apparaître leurs instructions internes dans votre conversation.
- **Étendez lagent que vous possédez déjà.** Connectez des serveurs MCP et des compétences, configurez des hooks et conservez les rôles dagent sous forme de fichiers lisibles dans votre projet ou vos paramètres personnels.
Exécutez `/help` dans la TUI pour afficher les commandes et les raccourcis clavier.
## Sécurité
Codewhale sexécute sur votre machine avec les accès que vous lui accordez. Les modes dapprobation et les règles du dépôt limitent les actions de lagent ; un bac à sable facultatif du système dexploitation renforce la limite dexécution lorsquil est pris en charge. Le prix dun modèle inconnu reste indiqué comme tel au lieu d’être présenté comme gratuit.
Consultez l[ordre dautorisation](docs/AUTHORIZATION_ORDER.md) pour connaître la hiérarchie exacte des politiques et la [configuration](docs/CONFIGURATION.md) pour les paramètres locaux.
## Documentation
- [Fournisseurs et modèles locaux](docs/PROVIDERS.md)
- [Équipes dagents](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooks](docs/HOOKS.md) et [configuration](docs/CONFIGURATION.md)
- [Client web local](docs/WEB.md)
- [Toute la documentation](docs)
## Rejoindre la communauté
Codewhale progresse lorsque les gens lutilisent, signalent ce qui ne va pas et contribuent aux correctifs. Sil manque un fournisseur, si un workflow est peu pratique ou si linterface du terminal vous gêne, [ouvrez une issue](https://github.com/Hmbown/CodeWhale/issues). Si vous savez comment laméliorer, [ouvrez une pull request](CONTRIBUTING.md). Les premières contributions sont les bienvenues, et les personnes qui contribuent restent créditées pour le travail intégré.
Rejoignez le [Discord](https://discord.gg/37gfS3ksug), ou ajoutez Hunter sur WeChat (`hunterbown`) et demandez à rejoindre le groupe Whale Brothers.
## Historique du projet
Codewhale a commencé sous le nom de `deepseek-tui` et conserve la compatibilité avec sa configuration et ses sessions. Il est désormais indépendant de tout fournisseur, maintenu de manière autonome et nest affilié à aucun fournisseur de modèles.
Merci à toutes les personnes qui contribuent et aux communautés open source qui ont aidé le projet à grandir. Consultez le [registre des contributeurs](docs/CONTRIBUTORS.md).
## Licence
[MIT](LICENSE). Les parties adaptées dautres projets open source sont répertoriées dans les [mentions relatives aux logiciels tiers](docs/THIRD_PARTY_NOTICES.md).
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale आपके टर्मिनल के लिए Rust में बना एक ओपन सोर्स कोडिंग एजेंट है, जिसे इसके उपयोगकर्ताओं के साथ सार्वजनिक रूप से बेहतर बनाया जाता है।
![टर्मिनल में चलता Codewhale](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## इंस्टॉल करें
```bash
npm install -g codewhale
codewhale
```
पहली बार चलाने पर Codewhale आपको किसी प्रोवाइडर से जुड़ने या ऑफ़लाइन बने रहने में मदद करता है। यह Cargo, Docker, Nix, Scoop, पहले से बने आर्काइव, Android/Termux और CNB मिरर का भी समर्थन करता है। [इंस्टॉलेशन गाइड](docs/INSTALL.md) देखें।
हर शेल में Tab completion के लिए केवल एक कमांड चाहिए — `codewhale completion bash|zsh|fish|powershell|elvish`। [शेल कंप्लीशन](docs/INSTALL.md#8-shell-completions) देखें।
## उपयोग
Codewhale से वैसे ही बात करें जैसे आप अपनी टीम के किसी सदस्य से करेंगे:
```text
Fix the failing tests and explain what changed.
```
या TUI खोले बिना कोई कार्य चलाएँ:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale आपकी रिपॉज़िटरी पढ़ सकता है, फ़ाइलें संपादित कर सकता है, कमांड चला सकता है, परिणामों की जाँच कर सकता है और लक्ष्य की ओर काम जारी रख सकता है। उसे कितना एक्सेस देना है, यह आप तय करते हैं।
## Codewhale क्यों
- **अपनी पसंद का मॉडल इस्तेमाल करें।** होस्ट किए गए प्रोवाइडर या Ollama, vLLM अथवा SGLang के माध्यम से लोकल मॉडल जोड़ें। `/model` से प्रोवाइडर और मॉडल बदलें।
- **नियंत्रण अपने पास रखें।** Plan केवल पढ़ने के लिए है। Ask, Auto-Review और Full Access अनुमोदन के व्यवहार को स्पष्ट बनाते हैं। `/undo` पिछला टर्न वापस करता है और `/restore` वर्कस्पेस को पहले के स्नैपशॉट पर लौटाता है।
- **लंबे काम को व्यवस्थित रखें।** सेशन सहेजें, स्थायी `/goal` तय करें, वर्कफ़्लो चलने से पहले उनकी समीक्षा करें और एजेंटों के आंतरिक निर्देशों को अपनी बातचीत में जोड़े बिना उनका समन्वय करें।
- **अपने मौजूदा एजेंट को विस्तृत करें।** MCP सर्वर और स्किल जोड़ें, हुक कॉन्फ़िगर करें और एजेंट की भूमिकाओं को अपने प्रोजेक्ट या निजी सेटिंग में पढ़ने योग्य फ़ाइलों के रूप में रखें।
कमांड और कीबोर्ड शॉर्टकट देखने के लिए TUI में `/help` चलाएँ।
## सुरक्षा
Codewhale आपकी मशीन पर उतने ही एक्सेस के साथ चलता है जितना आप उसे देते हैं। अनुमोदन मोड और रिपॉज़िटरी के नियम एजेंट की गतिविधियों को सीमित करते हैं; समर्थित सिस्टम पर वैकल्पिक OS सैंडबॉक्सिंग अधिक मज़बूत निष्पादन सीमा जोड़ती है। जिन मॉडलों की कीमत ज्ञात नहीं है, उन्हें मुफ़्त बताने के बजाय अज्ञात ही दिखाया जाता है।
नीतियों का सटीक क्रम जानने के लिए [अधिकार क्रम](docs/AUTHORIZATION_ORDER.md) और लोकल सेटिंग के लिए [कॉन्फ़िगरेशन](docs/CONFIGURATION.md) पढ़ें।
## दस्तावेज़
- [प्रोवाइडर और लोकल मॉडल](docs/PROVIDERS.md)
- [एजेंट टीमें](docs/FLEET.md)
- [MCP](docs/MCP.md), [हुक](docs/HOOKS.md) और [कॉन्फ़िगरेशन](docs/CONFIGURATION.md)
- [लोकल वेब क्लाइंट](docs/WEB.md)
- [सभी दस्तावेज़](docs)
## समुदाय से जुड़ें
जब लोग Codewhale का उपयोग करते हैं, असुविधाओं की जानकारी देते हैं और उन्हें ठीक करने में मदद करते हैं, तब यह बेहतर बनता है। यदि कोई प्रोवाइडर उपलब्ध नहीं है, कोई वर्कफ़्लो असहज है या टर्मिनल UI आपके काम में बाधा डालता है, तो [issue खोलें](https://github.com/Hmbown/CodeWhale/issues)। यदि आप इसे बेहतर बनाने का तरीका जानते हैं, तो [pull request खोलें](CONTRIBUTING.md)। पहले योगदान का स्वागत है और स्वीकार किए गए काम का श्रेय योगदानकर्ताओं के पास रहता है।
[Discord](https://discord.gg/37gfS3ksug) से जुड़ें, या WeChat पर Hunter (`hunterbown`) को जोड़कर Whale Brothers समूह में शामिल होने के लिए कहें।
## प्रोजेक्ट का इतिहास
Codewhale की शुरुआत `deepseek-tui` के रूप में हुई थी और यह आज भी उसके कॉन्फ़िगरेशन तथा सेशन के साथ संगतता बनाए रखता है। अब यह किसी प्रोवाइडर पर निर्भर नहीं है, स्वतंत्र रूप से अनुरक्षित है और किसी भी मॉडल प्रोवाइडर से संबद्ध नहीं है।
हर योगदानकर्ता और प्रोजेक्ट को आगे बढ़ाने वाले ओपन सोर्स समुदायों का धन्यवाद। [योगदानकर्ताओं का रिकॉर्ड](docs/CONTRIBUTORS.md) देखें।
## लाइसेंस
[MIT](LICENSE)। अन्य ओपन सोर्स प्रोजेक्ट से लिए और अनुकूलित किए गए हिस्से [थर्ड-पार्टी नोटिस](docs/THIRD_PARTY_NOTICES.md) में दर्ज हैं।
+44 -45
View File
@@ -1,79 +1,78 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale adalah agen pemrograman sumber terbuka untuk terminal Anda, dibuat dengan Rust dan dikembangkan secara terbuka bersama orang-orang yang menggunakannya.
Sebuah coding agent sumber terbuka untuk terminal Anda — bawa model pilihan Anda sendiri.
![Codewhale berjalan di terminal](assets/screenshot.webp)
Codewhale berawal sebagai pengalaman asli (native) untuk DeepSeek. Sejak saat itu, proyek ini berkembang menjadi proyek yang didorong oleh komunitas: satu coding harness yang memenuhi kebutuhan komunitas internasional yang terus berkembang serta mendukung sebanyak mungkin model dan penyedia (provider) — mengutamakan model terbuka, baik yang di-host maupun lokal, tanpa membeda-bedakan satu sama lain.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Berikan penyedia, model, dan tugas: Codewhale akan membaca kode Anda, mengedit berkas, menjalankan perintah, serta memeriksa hasil kerjanya sendiri, lalu berhenti setelah pekerjaan selesai atau ketika membutuhkan arahan Anda. Ganti model di tengah tugas dengan `/model`. Bekerja secara interaktif di TUI, atau jalankan `codewhale exec` dalam skrip dan CI. Dibuat menggunakan Rust, berlisensi MIT, dan berjalan langsung di mesin Anda sendiri.
Yang membedakannya dari harness lain: **Anda memilih model untuk setiap peran, dan model-model itu tidak harus sama.** Sebuah fleet menyematkan penyedia, model, dan tingkat penalaran per peran — sehingga model yang murah dan cepat bisa mengarahkan model penalaran yang mahal, atau seorang builder GLM bisa mengerjakan tugas yang sama dengan seorang reviewer Kimi. Tulis peran Anda sendiri, constitution Anda sendiri, dan harness itu menjadi milik Anda, bukan milik kami.
Kami selalu membuka kesempatan bagi para kontributor dan cara untuk terus berkembang. Jika model atau penyedia yang Anda gunakan belum tersedia, atau ada hal yang tidak berjalan semestinya, memberi tahu kami adalah salah satu kontribusi paling berharga yang bisa Anda lakukan — lihat [Kontribusi](#kontribusi).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale running in a terminal](assets/screenshot.png)
## Instalasi
```bash
npm install -g codewhale
codewhale
```
Saat pertama dijalankan, Codewhale membantu Anda menghubungkan penyedia atau tetap bekerja secara luring. Codewhale juga mendukung Cargo, Docker, Nix, Scoop, arsip siap pakai, Android/Termux, dan mirror CNB. Lihat [panduan instalasi](docs/INSTALL.md).
Penyelesaian Tab cukup diaktifkan dengan satu perintah per shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Lihat [penyelesaian shell](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, arsip biner pra-kemas, Android/Termux, serta mirror CNB bagi siapa pun yang memiliki keterbatasan akses ke GitHub dibahas secara lengkap di [docs/INSTALL.id.md](docs/INSTALL.id.md) ([English](docs/INSTALL.md)). Bermigrasi dari `deepseek-tui`? Konfigurasi dan sesi Anda akan tetap dipertahankan — lihat [docs/REBRAND.id.md](docs/REBRAND.id.md) ([English](docs/REBRAND.md)).
## Penggunaan
Bicaralah dengan Codewhale seperti Anda berbicara dengan rekan satu tim:
```text
Fix the failing tests and explain what changed.
```
Atau jalankan tugas tanpa membuka TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale dapat membaca repositori Anda, mengedit berkas, menjalankan perintah, memeriksa hasil, dan terus bekerja menuju tujuan. Anda menentukan seberapa besar akses yang dimilikinya.
## Mengapa Codewhale
Di dalam TUI: `/model` mengganti penyedia dan model sekaligus, `/fleet` menjalankan tim pekerja (workers), `/undo` membatalkan langkah (turn) terakhir, dan `/restore <N>` mengembalikan workspace ke snapshot sebelumnya (`/restore` tanpa argumen hanya menampilkan daftarnya). Saat composer kosong, `Tab` beralih antar mode Plan / Work / Operate; bila composer berisi teks, `Tab` justru melengkapi perintah slash dan sebutan `@`. `Shift+Tab` beralih antar postur izin Ask / Auto-Review / Full Access kapan saja. `!` menjalankan perintah shell melalui alur persetujuan normal.
- **Gunakan model yang Anda inginkan.** Hubungkan penyedia terkelola atau model lokal melalui Ollama, vLLM, atau SGLang. Ganti penyedia dan model dengan `/model`.
- **Tetap memegang kendali.** Plan hanya dapat membaca. Ask, Auto-Review, dan Full Access menampilkan perilaku persetujuan dengan jelas. `/undo` membatalkan giliran terakhir dan `/restore` mengembalikan ruang kerja ke snapshot sebelumnya.
- **Jaga agar pekerjaan panjang tetap teratur.** Simpan sesi, tetapkan `/goal` yang bertahan lama, tinjau alur kerja sebelum dijalankan, dan koordinasikan agen tanpa memasukkan instruksi internal mereka ke transkrip Anda.
- **Perluas agen yang sudah Anda miliki.** Hubungkan server MCP dan keterampilan, konfigurasikan hook, dan simpan peran agen sebagai berkas yang mudah dibaca di proyek atau pengaturan pribadi Anda.
## Fitur & Kapabilitas
Jalankan `/help` di TUI untuk melihat perintah dan pintasan papan ketik.
- **Model mana saja, penyedia apa saja.** DeepSeek, Claude, GPT, Kimi, GLM, dan 30+ penyedia lainnya, ditambah vLLM, SGLang, atau Ollama milik Anda sendiri tanpa memerlukan API key — semuanya melalui satu runtime dan satu kumpulan alat. Batas konteks dan harga diambil dari rute sebenarnya, dan harga yang tidak diketahui ditampilkan sebagai *unknown* daripada $0.
- **Harness yang Anda tulis sendiri.** Peran adalah berkas yang bisa Anda baca dan sunting — satu model, satu sikap perkakas, dan instruksi tetap untuk tiap peran — disimpan di dalam proyek agar tim berbagi, atau di samping pengaturan pribadi Anda agar ikut berpindah antar repo. Constitution mencatat bagaimana Anda ingin agen berperilaku di setiap sesi, sehingga harness mengikuti cara kerja Anda, bukan cara kami.
- **Read-only sampai Anda memberi izin lebih.** Mode Plan tidak dapat mengubah berkas, dan gerbang persetujuan memproteksi perintah berisiko. Ketika sandbox OS membungkus perintah, Codewhale akan menginformasikannya: Seatbelt pada macOS (jika tersedia), serta opsi bubblewrap di Linux. Berkas `constitution.json` repositori dikompilasi menjadi pembatas penulisan yang bahkan tidak dapat dilewati oleh mode Full Access.
- **Pekerjaan yang dapat dilanjutkan.** Fleet mencatat setiap langkah ke ledger bertipe append-only, sehingga `fleet resume` dapat melanjutkan pekerjaan tepat di mana Anda meninggalkannya.
## Keamanan
## Pelajari Lebih Lanjut
Codewhale berjalan di mesin Anda dengan akses yang Anda berikan. Mode persetujuan dan aturan repositori membatasi tindakan agen; sandbox OS opsional menambahkan batas eksekusi yang lebih kuat jika didukung. Harga model yang belum diketahui tetap ditampilkan sebagai tidak diketahui, bukan dilaporkan gratis.
- [docs/PROVIDERS.id.md](docs/PROVIDERS.id.md) ([English](docs/PROVIDERS.md)) — setiap rute penyedia: hosted, gateway, dan lokal
- [docs/FLEET.id.md](docs/FLEET.id.md) ([English](docs/FLEET.md)) — fleet, ledger, dan kelanjutan sesi (resume)
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — pencarian eksperimental yang dibekukan dan netral terhadap penyedia di dalam Workflow
- [docs/CONFIGURATION.id.md](docs/CONFIGURATION.id.md) ([English](docs/CONFIGURATION.md)) — `config.toml`, hooks, dan konstitusi
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — bagaimana mode, hooks, aturan izin, batas keamanan, hukum repositori, persetujuan, dan sandbox saling menyusun
- [docs/HOOKS.md](docs/HOOKS.md) — sebelas event hook siklus hidup TUI, payload-nya, dan tiga di antaranya yang dapat mengarahkan sebuah turn (`codewhale exec` dan subperintah CLI tidak memicu hooks)
- [docs/WEB.id.md](docs/WEB.id.md) ([English](docs/WEB.md)) — klien browser berbasis loopback-only dan batas autentikasi sekali pakainya
- [docs/LOCALIZATION.id.md](docs/LOCALIZATION.id.md) ([English](docs/LOCALIZATION.md)) — matriks lokalisasi & panduan terjemahan
Baca [urutan otorisasi](docs/AUTHORIZATION_ORDER.md) untuk susunan kebijakan yang tepat dan [konfigurasi](docs/CONFIGURATION.md) untuk pengaturan lokal.
Topik lainnya — [mode eksekusi](docs/MODES.id.md) ([English](docs/MODES.md)), [pintasan tombol](docs/KEYBINDINGS.id.md) ([English](docs/KEYBINDINGS.md)), detail sandbox, [MCP](docs/MCP.id.md) ([English](docs/MCP.md)), runtime API, dan arsitektur — tersedia di dalam direktori [docs](docs) serta di [codewhale.net](https://codewhale.net/).
## Dokumentasi
## Kontribusi
- [Penyedia dan model lokal](docs/PROVIDERS.md)
- [Tim agen](docs/FLEET.md)
- [MCP](docs/MCP.md), [hook](docs/HOOKS.md), dan [konfigurasi](docs/CONFIGURATION.md)
- [Klien web lokal](docs/WEB.md)
- [Semua dokumentasi](docs)
Issue, PR, langkah reproduksi masalah, log, dan permintaan fitur semuanya merupakan kontribusi nyata pada proyek ini, dan kami sangat menyambut kontribusi pertama Anda. Jika sebuah PR tidak dapat di-merge secara langsung, maintainer akan memetik bagian yang berfungsi dan tetap memberikan kredit kepada pembuatnya — dalam commit, changelog, dan [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Bergabung dengan komunitas
- [Open issues](https://github.com/Hmbown/CodeWhale/issues) — tempat awal yang baik untuk kontribusi pertama
- [CONTRIBUTING.id.md](CONTRIBUTING.id.md) ([English](CONTRIBUTING.md)) — alur pengembangan dan prosedur PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — setiap orang yang telah membentuk proyek ini
- [Dukung proyek ini](https://www.buymeacoffee.com/hmbown)
Codewhale menjadi lebih baik ketika orang menggunakannya, melaporkan hal yang terasa kurang tepat, dan membantu memperbaikinya. Jika penyedia belum tersedia, alur kerja terasa janggal, atau UI terminal menghambat Anda, [buat issue](https://github.com/Hmbown/CodeWhale/issues). Jika Anda tahu cara memperbaikinya, [buat pull request](CONTRIBUTING.md). Kontribusi pertama sangat disambut, dan kontributor tetap menerima kredit untuk pekerjaan yang digabungkan.
Bergabunglah di [Discord](https://discord.gg/37gfS3ksug), atau tambahkan Hunter di WeChat (`hunterbown`) dan mintalah untuk bergabung dengan grup Whale Brothers.
## Riwayat proyek
Codewhale bermula sebagai `deepseek-tui` dan tetap mempertahankan kompatibilitas konfigurasi serta sesinya. Kini Codewhale netral terhadap penyedia, dikelola secara independen, dan tidak berafiliasi dengan penyedia model mana pun.
Terima kasih kepada setiap kontributor dan komunitas sumber terbuka yang membantu proyek ini tumbuh. Lihat [catatan kontributor](docs/CONTRIBUTORS.md).
Terima kasih kepada [DeepSeek](https://github.com/deepseek-ai) untuk model dan dukungan yang mengawali proyek ini, [DataWhale](https://github.com/datawhalechina) 🐋 atas sambutan hangat ke dalam keluarga Whale Brother, serta [OpenWarp](https://github.com/zerx-lab/warp) dan [Open Design](https://github.com/nexu-io/open-design) atas kolaborasi dalam menghadirkan pengalaman terminal-agent yang lebih baik.
## Lisensi
[MIT](LICENSE). Bagian yang diadaptasi dari proyek sumber terbuka lain dicatat dalam [pemberitahuan pihak ketiga](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Sebuah proyek komunitas independen, tidak terafiliasi dengan penyedia model mana pun.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale è un agente di programmazione open source per il terminale, sviluppato in Rust e migliorato pubblicamente insieme alle persone che lo utilizzano.
![Codewhale in esecuzione in un terminale](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Installazione
```bash
npm install -g codewhale
codewhale
```
Al primo avvio, Codewhale ti aiuta a collegare un provider oppure a rimanere offline. Supporta inoltre Cargo, Docker, Nix, Scoop, archivi precompilati, Android/Termux e un mirror CNB. Consulta la [guida allinstallazione](docs/INSTALL.md).
Il completamento con Tab si attiva con un solo comando per ogni shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulta il [completamento della shell](docs/INSTALL.md#8-shell-completions).
## Utilizzo
Parla con Codewhale come parleresti con un membro del tuo team:
```text
Fix the failing tests and explain what changed.
```
Oppure esegui unattività senza aprire la TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale può leggere il tuo repository, modificare file, eseguire comandi, controllare i risultati e continuare a lavorare verso un obiettivo. Sei tu a decidere quanto accesso concedergli.
## Perché Codewhale
- **Usa il modello che preferisci.** Collega provider gestiti oppure modelli locali tramite Ollama, vLLM o SGLang. Cambia provider e modello con `/model`.
- **Mantieni il controllo.** Plan è in sola lettura. Ask, Auto-Review e Full Access rendono visibile il comportamento delle approvazioni. `/undo` annulla lultimo turno e `/restore` riporta larea di lavoro a uno snapshot precedente.
- **Mantieni organizzati i lavori lunghi.** Salva le sessioni, imposta un `/goal` duraturo, rivedi i workflow prima dellesecuzione e coordina gli agenti senza trasformare le loro istruzioni interne in parte della tua conversazione.
- **Estendi lagente che hai già.** Collega server MCP e skill, configura gli hook e conserva i ruoli degli agenti come file leggibili nel progetto o nelle impostazioni personali.
Esegui `/help` nella TUI per vedere i comandi e le scorciatoie da tastiera.
## Sicurezza
Codewhale viene eseguito sul tuo computer con laccesso che gli concedi. Le modalità di approvazione e le regole del repository limitano ciò che lagente può fare; il sandboxing facoltativo del sistema operativo aggiunge un confine di esecuzione più solido dove supportato. I prezzi sconosciuti dei modelli restano indicati come sconosciuti anziché essere segnalati come gratuiti.
Leggi l[ordine di autorizzazione](docs/AUTHORIZATION_ORDER.md) per conoscere lesatta gerarchia delle regole e la [configurazione](docs/CONFIGURATION.md) per le impostazioni locali.
## Documentazione
- [Provider e modelli locali](docs/PROVIDERS.md)
- [Team di agenti](docs/FLEET.md)
- [MCP](docs/MCP.md), [hook](docs/HOOKS.md) e [configurazione](docs/CONFIGURATION.md)
- [Client web locale](docs/WEB.md)
- [Tutta la documentazione](docs)
## Unisciti alla comunità
Codewhale migliora quando le persone lo usano, segnalano ciò che non funziona e aiutano a correggerlo. Se manca un provider, un workflow risulta scomodo o linterfaccia del terminale ti ostacola, [apri una issue](https://github.com/Hmbown/CodeWhale/issues). Se sai come migliorarlo, [apri una pull request](CONTRIBUTING.md). I primi contributi sono benvenuti e chi contribuisce mantiene il riconoscimento per il lavoro integrato.
Unisciti a [Discord](https://discord.gg/37gfS3ksug), oppure aggiungi Hunter su WeChat (`hunterbown`) e chiedi di entrare nel gruppo Whale Brothers.
## Storia del progetto
Codewhale è nato come `deepseek-tui` e conserva ancora la compatibilità con la sua configurazione e le sue sessioni. Ora è indipendente dai provider, viene mantenuto in modo autonomo e non è affiliato ad alcun provider di modelli.
Grazie a ogni persona che ha contribuito e alle comunità open source che hanno aiutato il progetto a crescere. Consulta il [registro dei contributori](docs/CONTRIBUTORS.md).
## Licenza
[MIT](LICENSE). Le parti adattate da altri progetti open source sono indicate nelle [note sui componenti di terze parti](docs/THIRD_PARTY_NOTICES.md).
+51 -45
View File
@@ -1,79 +1,85 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale は Rust で構築された、ターミナル向けのオープンソースコーディングエージェントです。利用者とともに、公開の場で改善を続けています
ターミナルで動くオープンソースコーディングエージェント — モデルはあなたが持ち込む
![ターミナルで動作する Codewhale](assets/screenshot.webp)
Codewhale は DeepSeek のためのネイティブ体験として始まりました。そこから、コミュニティ主導のプロジェクトへと成長しています。広がり続ける国際的なコミュニティに合い、できるだけ多くのモデルとプロバイダに対応する、ひとつのコーディングハーネスです — オープンモデルを最優先に、ホスト型でもローカルでも、どれかを特別扱いすることはありません。
[English](README.md) · [简体中文](README.zh-CN.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
プロバイダ、モデル、タスクを渡すと、コードを読み、ファイルを編集し、コマンドを実行し、自分の作業を確認して、タスクが完了するかあなたの手が必要になった時点で止まります。タスクの途中でも `/model` でモデルを切り替えられます。対話的な作業には TUI を、スクリプトと CI には `codewhale exec` を。Rust 製、MIT ライセンスで、あなたのマシン上で動きます。
他のハーネスと違うのはここです。**役割ごとにどのモデルを使うかはあなたが決められ、しかも揃える必要がありません。** Fleet は役割ごとにプロバイダ・モデル・推論ティアを個別に固定します。だから速くて安いモデルが高価な推論モデルを指揮することも、GLM の builder と Kimi の reviewer が同じ仕事に取り組むこともできます。自分の役割と自分の constitution を書けば、そのハーネスは私たちのものではなく、あなたのものになります。
私たちは常にコントリビューターと改善の方法を探しています。使っているモデルやプロバイダが見当たらないとき、あるいは何かが壊れたときは、それを知らせてもらえることが最も役に立つことのひとつです — [コントリビューション](#コントリビューション)を見てください。
[English](README.md) · [简体中文](README.zh-CN.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![ターミナルで動作する Codewhale](assets/screenshot.png)
## インストール
```bash
npm install -g codewhale
codewhale
```
初回起動時にプロバイダーへの接続を案内しますが、オフラインのまま使うこともできます。Codewhale は Cargo、Docker、Nix、Scoop、ビルド済みアーカイブ、Android/Termux、CNB ミラーにも対応しています。詳しくは[インストールガイド](docs/INSTALL.md)をご覧ください。
各シェルの Tab 補完はコマンド一つで設定できます — `codewhale completion bash|zsh|fish|powershell|elvish`。詳しくは[シェル補完](docs/INSTALL.md#8-shell-completions)をご覧ください。
Cargo、Docker、Nix、Scoop、ビルド済みアーカイブ、Android/Termux、そして GitHub に到達できないユーザー向けの CNB ミラーについては [docs/INSTALL.md](docs/INSTALL.md) で扱っています。`deepseek-tui` からの移行なら、設定とセッションはそのまま引き継がれます — [docs/REBRAND.md](docs/REBRAND.md) を参照してください。
## 使い方
チームメイトに話しかけるのと同じように、Codewhale に依頼します:
```text
Fix the failing tests and explain what changed.
```
TUI を開かずにタスクを実行することもできます:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale はリポジトリを読み、ファイルを編集し、コマンドを実行して結果を確認しながら、目標に向かって作業を続けます。どこまでアクセスを許可するかは、あなたが決められます。
## Codewhale を選ぶ理由
TUI では、`/model` がプロバイダとモデルをまとめて切り替え、`/fleet` がチームを組み立てて走らせ(一度にひとつの役割、それぞれが自分のモデルを持ちます)、`/undo` が直前のターンを取り消し、`/restore <N>` がワークスペースを以前のスナップショットへ巻き戻します(引数なしの `/restore` は一覧を表示するだけです)。入力欄が空のとき、`Tab` は Plan / Work / Operate を順に切り替えます。入力欄に文字があるときの `Tab` はスラッシュコマンドと `@` メンションの補完になります。`Shift+Tab` はいつでも Ask / Auto-Review / Full Access の権限スタンスを順に切り替えます。`!` は Shell コマンドを通常の承認経路で実行します。
- **使いたいモデルを選べます。** ホスト型プロバイダーに接続するほか、Ollama、vLLM、SGLang 経由でローカルモデルも利用できます。`/model` でプロバイダーとモデルを切り替えられます。
- **主導権を保てます。** Plan は読み取り専用です。Ask、Auto-Review、Full Access により、承認の挙動が明確になります。`/undo` は直前のターンを取り消し、`/restore` はワークスペースを以前のスナップショットへ戻します。
- **長い作業も整理できます。** セッションを保存し、永続的な `/goal` を設定し、ワークフローを実行前に確認できます。さらに、エージェントの内部指示を会話履歴に混ぜることなく、複数のエージェントを連携させられます。
- **今あるエージェントを拡張できます。** MCP サーバーやスキルを接続し、フックを設定し、エージェントの役割をプロジェクトまたは個人設定内の読みやすいファイルとして管理できます。
## できること
コマンドとキーボードショートカットは、TUI で `/help` を実行して確認できます。
- **どのモデルでも、どのプロバイダでも、そしてどんな組み合わせでも。** DeepSeek、Claude、GPT、Kimi、GLM をはじめ 30 以上のプロバイダ、そしてキー不要のあなた自身の vLLM・SGLang・Ollama が、すべてひとつのランタイムとひとつのツール群を通って動きます。保存された役割は `provider``model`・推論ティアを明示的に記録するので、ひとつの実行の中で Fleet が複数のベンダーにまたがることができ、役割のルートはそのとき有効なプロバイダに左右されません。コンテキスト予算と価格は実際のルートに由来し、不明な価格は $0 ではなく不明と表示されます。
- **あなたが書くハーネス。** 役割は読んで編集できるファイルです。役割ごとにモデル、ツールの姿勢、常設の指示を持ち、チームで共有するならプロジェクトに、リポジトリをまたいで持ち歩くなら個人設定の隣に置きます。constitution はすべてのセッションを通じてエージェントにどう振る舞ってほしいかを記録し、ハーネスを私たちのやり方ではなくあなたのやり方に合わせます。
- **許可するまでは読み取り専用。** Plan モードはファイルを変更せず、リスクのあるコマンドは承認でゲートされます。OS サンドボックスが実際にコマンドをラップするとき、Codewhale はそれを明示します。macOS では利用可能な Seatbelt、Linux ではオプトインの bubblewrap です。リポジトリの `constitution.json` は書き込みホールドへとコンパイルされ、Full Access でもスキップできません。
- **再開できる作業。** Fleet はすべてのステップを追記専用の台帳に記録するので、`fleet resume` で止めたところから再開できます。
## 安全性
## さらに詳しく
Codewhale は、あなたが許可した範囲のアクセス権で、あなたのマシン上で動作します。承認モードとリポジトリのルールがエージェントの操作を制限し、対応環境では任意の OS サンドボックスがさらに強固な実行境界を加えます。不明なモデル料金は、無料と表示せず不明のまま扱います。
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — ホスト型・ゲートウェイ・ローカル
まで、すべてのプロバイダルート
- [docs/FLEET.md](docs/FLEET.md) — Fleet、台帳、再開
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — Workflow 内の凍結済み・プロバイダ中立の実験的検索
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`、フック、
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — モード、フック、
権限ルール、安全フロア、リポジトリルール、承認、サンドボックスの組み合わせ方
- [docs/HOOKS.md](docs/HOOKS.md) — 11 個の TUI ライフサイクルフックイベント、
そのペイロード、ターンを誘導できる 3 イベント(`codewhale exec` と CLI
サブコマンドではフックは発火しません)
- [docs/WEB.md](docs/WEB.md) — ループバック専用の組み込みブラウザクライアントと
ワンタイム認証境界
正確なポリシーの適用順序は[認可の順序](docs/AUTHORIZATION_ORDER.md)、ローカル設定は[設定ガイド](docs/CONFIGURATION.md)をご覧ください。
その他 — モード、キーバインド、サンドボックスの詳細、MCP、ランタイム API、
アーキテクチャ — は [docs](docs) と [codewhale.net](https://codewhale.net/)
にあります。
## ドキュメント
## コントリビューション
- [プロバイダーとローカルモデル](docs/PROVIDERS.md)
- [エージェントチーム](docs/FLEET.md)
- [MCP](docs/MCP.md)、[フック](docs/HOOKS.md)、[設定](docs/CONFIGURATION.md)
- [ローカル Web クライアント](docs/WEB.md)
- [すべてのドキュメント](docs)
Issue、PR、再現手順、ログ、機能要望は、どれもここでは本物のプロジェクト作業です。初めてのコントリビューションも歓迎します。PR がそのままマージできない場合、メンテナは使える部分を harvest し、作者のクレジットは残ります — コミットにも、changelog にも、[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) にも。
## コミュニティに参加
- [Open issues](https://github.com/Hmbown/CodeWhale/issues) — 最初のコントリビューションに向くものはここにあります
- [CONTRIBUTING.md](CONTRIBUTING.md) — 開発環境のセットアップと PR の流れ
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — このプロジェクトを形づくってきた全員
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Codewhale は、実際に使い、違和感を報告し、修正を手伝ってくださる皆さんとともに成長します。必要なプロバイダーがない、ワークフローが使いづらい、ターミナル UI が作業を妨げるといった場合は、[issue を作成](https://github.com/Hmbown/CodeWhale/issues)してください。改善方法をご存じなら、[pull request を作成](CONTRIBUTING.md)してください。初めてのコントリビューションも歓迎し、採用された成果にはコントリビューターのクレジットを残します。
[Discord](https://discord.gg/37gfS3ksug) に参加するか、WeChat で Hunter`hunterbown`)を追加して Whale Brothers グループへの参加を依頼してください。
## プロジェクトの沿革
Codewhale は `deepseek-tui` として始まり、その設定とセッションとの互換性を現在も維持しています。今ではプロバイダーに依存せず、独立して保守されており、いかなるモデルプロバイダーとも提携していません。
すべてのコントリビューターと、プロジェクトの成長を支えたオープンソースコミュニティに感謝します。[コントリビューターの記録](docs/CONTRIBUTORS.md)もご覧ください。
プロジェクトの出発点となったモデルとサポートを提供してくれた [DeepSeek](https://github.com/deepseek-ai)、「鯨兄弟」ファミリーに迎え入れてくれた [DataWhale](https://github.com/datawhalechina) 🐋、そしてターミナルエージェント体験で協力してくれている [OpenWarp](https://github.com/zerx-lab/warp) と [Open Design](https://github.com/nexu-io/open-design) に感謝します。
## ライセンス
[MIT](LICENSE)。他のオープンソースプロジェクトを基にした部分は[サードパーティー通知](docs/THIRD_PARTY_NOTICES.md)に記載していま
[MIT](LICENSE)。独立したコミュニティプロジェクトであり、いかなるモデルプロバイダとも提携していません
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+54 -46
View File
@@ -1,79 +1,87 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale은 Rust로 만든 터미널용 오픈 소스 코딩 에이전트로, 사용자들과 함께 공개적으로 개선해 나갑니다.
터미널에서 쓰는 오픈소스 코딩 에이전트 — 모델은 당신이 가져옵니다.
![터미널에서 실행 중인 Codewhale](assets/screenshot.webp)
Codewhale은 DeepSeek을 위한 네이티브 경험으로 시작했습니다. 이후 커뮤니티가 이끄는 프로젝트로 성장했습니다. 점점 커지는 국제 커뮤니티에 맞고, 가능한 한 많은 모델과 프로바이더를 지원하는 하나의 코딩 하네스입니다 — 오픈 모델을 가장 먼저, 호스팅이든 로컬이든, 어느 하나를 특별 대우하지 않습니다.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
프로바이더, 모델, 작업을 지정하면 코드를 읽고, 파일을 편집하고, 명령을 실행하고, 스스로 작업을 확인하며, 작업이 끝나거나 사용자의 판단이 필요해지면 멈춥니다. 작업 도중에도 `/model`로 모델을 바꿀 수 있습니다. 대화형 작업에는 TUI를, 스크립트와 CI에는 `codewhale exec`를 사용합니다. Rust로 작성했고, MIT 라이선스이며, 당신의 컴퓨터에서 실행됩니다.
다른 하네스와 다른 점은 이것입니다. **역할마다 어떤 모델을 쓸지 당신이 고르고, 서로 같을 필요가 없습니다.** Fleet은 역할별로 프로바이더, 모델, 추론 등급을 각각 고정합니다. 그래서 빠르고 저렴한 모델이 값비싼 추론 모델을 지휘할 수도 있고, GLM builder와 Kimi reviewer가 같은 작업을 함께 처리할 수도 있습니다. 자신의 역할과 자신의 constitution을 쓰면, 그 하네스는 우리 것이 아니라 당신 것이 됩니다.
우리는 항상 기여자와 개선할 방법을 찾고 있습니다. 사용하는 모델이나 프로바이더가 빠져 있거나 무언가가 깨진다면, 그것을 알려 주는 일이 할 수 있는 가장 유용한 일 중 하나입니다 — [기여](#기여)를 참고하세요.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![터미널에서 실행 중인 Codewhale](assets/screenshot.png)
## 설치
```bash
npm install -g codewhale
codewhale
```
처음 실행하면 공급자 연결 과정을 안내하며, 오프라인 상태로 계속 사용할 수도 있습니다. Codewhale은 Cargo, Docker, Nix, Scoop, 사전 빌드 아카이브, Android/Termux, CNB 미러도 지원합니다. [설치 안내서](docs/INSTALL.md)를 참하세요.
Cargo, Docker, Nix, Scoop, 사전 빌드 아카이브, Android/Termux, 그리고 GitHub에 접근할 수 없는 사용자를 위한 CNB 미러는 [docs/INSTALL.md](docs/INSTALL.md)에서 다룹니다. `deepseek-tui`에서 넘어오나요? 설정과 세션은 그대로 이어집니다 — [docs/REBRAND.md](docs/REBRAND.md)를 참하세요.
각 셸에서 Tab 자동 완성은 명령 한 줄로 설정할 수 있습니다 — `codewhale completion bash|zsh|fish|powershell|elvish`. [셸 자동 완성](docs/INSTALL.md#8-shell-completions)을 참조하세요.
## 사용법
팀원에게 말하듯 Codewhale에 요청하세요:
```text
Fix the failing tests and explain what changed.
```
TUI를 열지 않고 작업을 실행할 수도 있습니다:
## 사용
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale은 저장소를 읽고, 파일을 편집하고, 명령을 실행하고, 결과를 확인하며 목표를 향해 계속 작업할 수 있습니다. 어느 정도의 접근 권한을 줄지는 사용자가 결정합니다.
## Codewhale을 선택하는 이유
TUI 안에서: `/model`은 프로바이더와 모델을 함께 전환하고, `/fleet`은 팀을 구성하고 실행하며(한 번에 한 역할씩, 각자 자기 모델을 가집니다), `/undo`는 직전 턴을 되돌리고, `/restore <N>`은 워크스페이스를 이전 스냅샷으로 되돌립니다(인자 없는 `/restore`는 스냅샷 목록만 보여줍니다). 입력창이 비어 있을 때 `Tab`은 Plan / Work / Operate 모드를 순환하고, 입력창에 내용이 있으면 `Tab`은 슬래시 명령과 `@` 멘션을 자동 완성합니다. `Shift+Tab`은 언제든지 Ask / Auto-Review / Full Access 권한 태세를 순환합니다. `!`는 일반 승인 경로를 거쳐 셸 명령을 실행합니다.
- **원하는 모델을 사용하세요.** 호스팅 공급자에 연결하거나 Ollama, vLLM, SGLang을 통해 로컬 모델을 사용할 수 있습니다. `/model`로 공급자와 모델을 전환하세요.
- **계속 주도권을 가지세요.** Plan은 읽기 전용입니다. Ask, Auto-Review, Full Access는 승인 동작을 명확하게 보여 줍니다. `/undo`는 마지막 턴을 되돌리고 `/restore`는 작업 공간을 이전 스냅샷으로 복원합니다.
- **긴 작업도 체계적으로 관리하세요.** 세션을 저장하고, 지속되는 `/goal`을 설정하고, 워크플로 실행 전에 검토하며, 에이전트의 내부 지시가 대화 기록에 섞이지 않도록 여러 에이전트를 조율할 수 있습니다.
- **이미 사용 중인 에이전트를 확장하세요.** MCP 서버와 스킬을 연결하고, 훅을 구성하고, 에이전트 역할을 프로젝트나 개인 설정에 읽기 쉬운 파일로 보관할 수 있습니다.
## 기능
명령과 키보드 단축키를 보려면 TUI에서 `/help`를 실행하세요.
- **어떤 모델이든, 어떤 프로바이더든, 그리고 어떤 조합이든.** DeepSeek, Claude, GPT, Kimi, GLM 등 30개 이상의 프로바이더와 키 없이 쓰는 자체 vLLM, SGLang, Ollama가 모두 하나의 런타임과 하나의 도구 세트를 통해 동작합니다. 저장된 역할은 `provider`, `model`, 추론 등급을 명시적으로 기록하므로 하나의 실행 안에서 Fleet이 여러 벤더에 걸칠 수 있고, 역할의 라우트는 그때 활성화된 프로바이더에 좌우되지 않습니다. 컨텍스트 예산과 가격은 실제 라우트에서 가져오며, 알 수 없는 가격은 $0이 아니라 알 수 없음으로 표시됩니다.
- **당신이 직접 쓰는 하네스.** 역할은 읽고 수정할 수 있는 파일입니다. 역할마다 모델, 도구 태세, 상시 지시를 담아 팀과 공유하려면 프로젝트에, 저장소를 옮겨 다니며 쓰려면 개인 설정 옆에 둡니다. constitution은 모든 세션에서 에이전트가 어떻게 행동하기를 바라는지 기록해, 하네스가 우리 방식이 아니라 당신의 방식에 맞도록 합니다.
- **허용하기 전까지는 읽기 전용.** Plan 모드는 파일을 바꾸지 않고, 위험한 명령은 승인을 거칩니다. OS 샌드박스가 실제로 명령을 래핑할 때 Codewhale은 이를 그대로 표시합니다. macOS에서는 사용 가능한 Seatbelt, Linux에서는 옵트인 bubblewrap입니다. 저장소의 `constitution.json`은 Full Access조차 건너뛸 수 없는 쓰기 홀드로 컴파일됩니다.
- **이어서 할 수 있는 작업.** Fleet은 모든 단계를 추가 전용 원장에 기록하므로, `fleet resume`으로 멈춘 지점부터 이어갈 수 있습니다.
## 안전
## 더 알아보기
Codewhale은 사용자가 허용한 접근 권한으로 사용자의 컴퓨터에서 실행됩니다. 승인 모드와 저장소 규칙은 에이전트가 할 수 있는 일을 제한하며, 지원되는 환경에서는 선택적 OS 샌드박싱으로 더 강력한 실행 경계를 추가할 수 있습니다. 가격이 알려지지 않은 모델은 무료로 표시하지 않고 미확인 상태로 둡니다.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — 호스팅·게이트웨이·로컬까지 모든
프로바이더 라우트
- [docs/FLEET.md](docs/FLEET.md) — Fleet, 원장, 재개
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — Workflow
안의 동결된, 프로바이더 중립 실험 검색
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, 훅,
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — 모드, 훅, 권한
규칙, 안전 기준선, 저장소 규칙, 승인, 샌드박스가 함께 적용되는 방식
- [docs/HOOKS.md](docs/HOOKS.md) — 11개의 TUI 수명 주기 훅 이벤트, 해당
페이로드, 턴을 조정할 수 있는 3개 이벤트 (`codewhale exec`와 CLI 하위
명령은 훅을 실행하지 않음)
- [docs/WEB.md](docs/WEB.md) — 루프백 전용 내장 브라우저 클라이언트와 일회성
인증 경계
정확한 정책 적용 순서는 [권한 부여 순서](docs/AUTHORIZATION_ORDER.md)에서, 로컬 설정은 [구성](docs/CONFIGURATION.md)에서 확인하세요.
나머지 — 모드, 키 바인딩, 샌드박스 세부 사항, MCP, 런타임 API, 아키텍처 —
는 [docs](docs)와 [codewhale.net](https://codewhale.net/)에 있습니다.
## 문서
## 기여
- [공급자와 로컬 모델](docs/PROVIDERS.md)
- [에이전트 팀](docs/FLEET.md)
- [MCP](docs/MCP.md), [](docs/HOOKS.md), [구성](docs/CONFIGURATION.md)
- [로컬 웹 클라이언트](docs/WEB.md)
- [전체 문서](docs)
이슈, PR, 재현 절차, 로그, 기능 요청은 모두 이곳에서 실제 프로젝트 작업이며, 첫 기여도 환영합니다. PR을 그대로 병합할 수 없을 때는 메인테이너가 작동하는 부분을 거두어 반영하고, 작성자의 크레딧은 커밋, 변경 로그, [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md)에 그대로 남습니다.
## 커뮤니티 참여
- [열려 있는 이슈](https://github.com/Hmbown/CodeWhale/issues) — 처음
기여하기 좋은 작업이 여기에 있습니다
- [CONTRIBUTING.md](CONTRIBUTING.md) — 개발 환경 설정과 PR 흐름
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — 이 프로젝트를 빚어 온
모든 사람
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
사람들이 Codewhale을 사용하고, 불편한 점을 알리고, 수정에 힘을 보탤 때 Codewhale은 더 좋아집니다. 필요한 공급자가 없거나 워크플로가 불편하거나 터미널 UI가 작업을 방해한다면 [issue를 등록](https://github.com/Hmbown/CodeWhale/issues)해 주세요. 개선 방법을 알고 있다면 [pull request를 등록](CONTRIBUTING.md)해 주세요. 첫 기여도 환영하며, 반영된 작업에는 기여자의 이름을 남깁니다.
[Discord](https://discord.gg/37gfS3ksug)에 참여하거나 WeChat에서 Hunter(`hunterbown`)를 추가한 뒤 Whale Brothers 그룹 참여를 요청하세요.
## 프로젝트 역사
Codewhale은 `deepseek-tui`로 시작했으며 해당 구성 및 세션과의 호환성을 계속 유지합니다. 현재는 특정 공급자에 종속되지 않고 독립적으로 관리되며, 어떤 모델 공급자와도 제휴하지 않습니다.
모든 기여자와 프로젝트의 성장을 도운 오픈 소스 커뮤니티에 감사드립니다. [기여자 기록](docs/CONTRIBUTORS.md)을 확인하세요.
프로젝트를 시작하게 해 준 모델과 지원을 제공한 [DeepSeek](https://github.com/deepseek-ai), Whale Brother family로 맞이해 준 [DataWhale](https://github.com/datawhalechina) 🐋, 그리고 터미널 에이전트 경험에 함께 협력해 준 [OpenWarp](https://github.com/zerx-lab/warp)와 [Open Design](https://github.com/nexu-io/open-design)에 감사드립니다.
## 라이선스
[MIT](LICENSE). 다른 오픈 소스 프로젝트를 바탕으로 수정한 부분은 [타사 고지](docs/THIRD_PARTY_NOTICES.md)에 기록되어 있습니다.
[MIT](LICENSE). 독립 커뮤니티 프로젝트이며, 어떤 모델 프로바이더와도 제휴 관계가 없습니다.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+100 -71
View File
@@ -1,104 +1,133 @@
# Codewhale
Codewhale is an open source coding agent for your terminal, built in Rust and
improved in public with the people who use it.
An open source coding agent for your terminal — bring your own model.
![Codewhale running in a terminal](assets/screenshot.webp)
Codewhale started as a native experience for DeepSeek. It has since grown into a
community-driven project: one coding harness that fits a growing international
community and supports as many models and providers as possible — open models
first, hosted or local, none privileged over the rest.
[简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Give it a provider, a model, and a task. It reads your code, edits files, runs
commands, and checks its own work, then stops when the job is done or it needs
you. Switch models mid-task with `/model`. Work interactively in the TUI, or run
`codewhale exec` in scripts and CI. It's written in Rust, licensed MIT, and runs
on your machine.
The part that isn't like other harnesses: **you pick the model for each role,
and they don't have to match.** A fleet pins a provider, a model, and a
reasoning tier per role — so a cheap fast model can direct an expensive
reasoning one, or a GLM builder can work the same job as a Kimi reviewer.
Write your own roles, your own constitution, and the harness is yours rather
than ours.
We're always looking for contributors and ways to improve. If a model or
provider you use is missing, or something breaks, telling us is one of the most
useful things you can do — see [Contributing](#contributing).
[简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale running in a terminal](assets/screenshot.png)
## Install
```bash
npm install -g codewhale
codewhale
```
The first run helps you connect a provider or stay offline. Codewhale also
supports Cargo, Docker, Nix, Scoop, prebuilt archives, Android/Termux, and a CNB
mirror. See [the installation guide](docs/INSTALL.md).
Tab completion is one command per shell — `codewhale completion bash|zsh|fish|powershell|elvish`.
See [shell completions](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, prebuilt archives, Android/Termux, and a CNB mirror
for anyone who can't reach GitHub are covered in
[docs/INSTALL.md](docs/INSTALL.md). Coming from `deepseek-tui`? Your config and
sessions carry over — see [docs/REBRAND.md](docs/REBRAND.md).
## Use
Talk to Codewhale the same way you would talk to a teammate:
```text
Fix the failing tests and explain what changed.
```
Or run a task without opening the TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale can read your repository, edit files, run commands, inspect results,
and keep working toward a goal. You decide how much access it has.
In the TUI: `/model` switches provider and model together, `/fleet` builds and
runs the team — one role at a time, each with its own model — `/undo` reverts
the last turn, and `/restore <N>` rolls the workspace back to an earlier
snapshot (bare `/restore` lists them). `Tab`
cycles Plan / Work / Operate when the composer is empty — with text in it, `Tab`
completes slash commands and `@` mentions instead. `Shift+Tab` cycles the
Ask / Auto-Review / Full Access permission posture at any time. `!` runs a
shell command through the normal approval path.
## Why Codewhale
## What it does
- **Use the model you want.** Connect hosted providers or local models through
Ollama, vLLM, or SGLang. Switch provider and model with `/model`.
- **Stay in control.** Plan is read-only. Ask, Auto-Review, and Full Access make
approval behavior visible. `/undo` reverts the last turn and `/restore`
returns the workspace to an earlier snapshot.
- **Keep long work organized.** Save sessions, set a durable `/goal`, review
workflows before they run, and coordinate agents without turning their
internal instructions into your transcript.
- **Extend the agent you already have.** Connect MCP servers and skills,
configure hooks, and keep agent roles as readable files in your project or
personal settings.
- **Any model, any provider — and any mix of them.** DeepSeek, Claude, GPT,
Kimi, GLM, and 30+ providers, plus your own vLLM, SGLang, or Ollama with no
key, all through one runtime and one toolset. A saved role records its
`provider`, `model`, and reasoning tier explicitly, so a fleet can span
vendors in a single run and a role's route never depends on whichever
provider happens to be active. Context limits and prices come from the real
route, and an unknown price shows as unknown rather than $0.
- **A harness you author.** Roles are files you can read and edit — a model, a
tool posture, and standing instructions per role — kept in the project so the
team shares them, or beside your other personal settings so they follow you
between repos. A constitution records how you want the agent to behave across
every session, so the harness matches your practice instead of ours.
- **Read-only until you allow more.** Plan mode can't change files, and
approvals gate risky commands. When an OS sandbox actually wraps a command,
Codewhale says so: Seatbelt on macOS where available, opt-in bubblewrap on
Linux. A repo's `constitution.json` compiles into write holds that even Full
Access can't skip.
- **Work you can resume.** A fleet records every step to an append-only ledger,
so `fleet resume` picks up where you left off.
Run `/help` in the TUI for commands and keyboard shortcuts.
## Learn more
## Safety
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — every provider route: hosted,
gateway, and local
- [docs/FLEET.md](docs/FLEET.md) — fleets, the ledger, and resume
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — frozen, provider-neutral experimental search within Workflow
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks, and
the constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — how modes,
hooks, permission rules, safety floors, repo law, approvals, and sandboxing
compose
- [docs/HOOKS.md](docs/HOOKS.md) — the eleven TUI lifecycle hook events, their
payloads, and which three of them can steer a turn (`codewhale exec` and the
CLI subcommands do not fire hooks)
- [docs/WEB.md](docs/WEB.md) — the loopback-only browser client and its one-time
authentication boundary
Codewhale runs on your machine with the access you grant it. Approval modes and
repository rules limit what the agent may do; optional OS sandboxing adds a
stronger execution boundary where supported. Unknown model prices stay unknown
instead of being reported as free.
Everything else — modes, keybindings, sandbox details, MCP, the runtime API,
and architecture — lives in [docs](docs) and on
[codewhale.net](https://codewhale.net/).
Read [authorization order](docs/AUTHORIZATION_ORDER.md) for the exact policy
stack and [configuration](docs/CONFIGURATION.md) for local settings.
## Contributing
## Documentation
Issues, PRs, repro steps, logs, and feature requests are all real project work,
and first contributions are welcome. When a PR can't merge as-is, maintainers
harvest what works and keep the author credited — in the commit, the changelog,
and [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
- [Providers and local models](docs/PROVIDERS.md)
- [Agent teams](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooks](docs/HOOKS.md), and [configuration](docs/CONFIGURATION.md)
- [Local web client](docs/WEB.md)
- [All documentation](docs)
- [Open issues](https://github.com/Hmbown/CodeWhale/issues) — good first
contributions live here
- [CONTRIBUTING.md](CONTRIBUTING.md) — dev setup and PR flow
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — everyone who has shaped this
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
## Join the community
Codewhale gets better when people use it, report what feels wrong, and help fix
it. If a provider is missing, a workflow is awkward, or the terminal UI gets in
your way, [open an issue](https://github.com/Hmbown/CodeWhale/issues). If you
know how to improve it, [open a pull request](CONTRIBUTING.md). First
contributions are welcome, and contributors keep credit for the work that
lands.
Join the [Discord](https://discord.gg/37gfS3ksug), or add Hunter on WeChat
(`hunterbown`) and ask to join the Whale Brothers group.
## Project history
Codewhale began as `deepseek-tui` and still preserves that configuration and
session compatibility. It is now provider-neutral and independently maintained;
it is not affiliated with any model provider.
Thanks to every contributor and to the open source communities that helped the
project grow. See [the contributor record](docs/CONTRIBUTORS.md).
Thanks to [DeepSeek](https://github.com/deepseek-ai) for the models and support
that started the project, [DataWhale](https://github.com/datawhalechina) 🐋 for
welcoming us into the Whale Brother family, and
[OpenWarp](https://github.com/zerx-lab/warp) and
[Open Design](https://github.com/nexu-io/open-design) for collaborating on the
terminal-agent experience.
## License
[MIT](LICENSE). Portions adapted from other open-source projects are recorded
in [third-party notices](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). An independent community project, not affiliated with any model
provider.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale to agent programistyczny o otwartym kodzie źródłowym do terminala, napisany w Rust i rozwijany publicznie wspólnie z osobami, które go używają.
![Codewhale działający w terminalu](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Instalacja
```bash
npm install -g codewhale
codewhale
```
Przy pierwszym uruchomieniu Codewhale pomaga połączyć się z dostawcą lub pozostać w trybie offline. Obsługuje też Cargo, Docker, Nix, Scoop, gotowe archiwa, Android/Termux oraz serwer lustrzany CNB. Zobacz [instrukcję instalacji](docs/INSTALL.md).
Uzupełnianie klawiszem Tab można włączyć jednym poleceniem dla każdej powłoki — `codewhale completion bash|zsh|fish|powershell|elvish`. Zobacz [uzupełnianie powłoki](docs/INSTALL.md#8-shell-completions).
## Użycie
Rozmawiaj z Codewhale tak, jak z osobą ze swojego zespołu:
```text
Fix the failing tests and explain what changed.
```
Możesz też uruchomić zadanie bez otwierania TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale może czytać Twoje repozytorium, edytować pliki, wykonywać polecenia, sprawdzać wyniki i kontynuować pracę nad celem. Ty decydujesz, jaki poziom dostępu mu przyznasz.
## Dlaczego Codewhale
- **Używaj wybranego modelu.** Połącz się z hostowanymi dostawcami lub lokalnymi modelami przez Ollama, vLLM albo SGLang. Dostawcę i model zmienisz za pomocą `/model`.
- **Zachowaj kontrolę.** Tryb Plan jest tylko do odczytu. Ask, Auto-Review i Full Access jasno pokazują sposób zatwierdzania działań. `/undo` cofa ostatnią turę, a `/restore` przywraca przestrzeń roboczą do wcześniejszej migawki.
- **Utrzymuj porządek w długich zadaniach.** Zapisuj sesje, ustawiaj trwały `/goal`, sprawdzaj przepływy pracy przed uruchomieniem i koordynuj agentów bez umieszczania ich wewnętrznych instrukcji w zapisie Twojej rozmowy.
- **Rozszerzaj agenta, którego już masz.** Podłączaj serwery MCP i umiejętności, konfiguruj hooki oraz przechowuj role agentów jako czytelne pliki w projekcie lub ustawieniach osobistych.
Uruchom `/help` w TUI, aby zobaczyć polecenia i skróty klawiaturowe.
## Bezpieczeństwo
Codewhale działa na Twoim komputerze z dostępem, który mu przyznasz. Tryby zatwierdzania i reguły repozytorium ograniczają działania agenta; opcjonalny sandbox systemu operacyjnego zapewnia mocniejszą granicę wykonywania tam, gdzie jest obsługiwany. Nieznane ceny modeli pozostają oznaczone jako nieznane, zamiast być przedstawiane jako bezpłatne.
Przeczytaj o [kolejności autoryzacji](docs/AUTHORIZATION_ORDER.md), aby poznać dokładną hierarchię zasad, oraz o [konfiguracji](docs/CONFIGURATION.md), aby poznać ustawienia lokalne.
## Dokumentacja
- [Dostawcy i modele lokalne](docs/PROVIDERS.md)
- [Zespoły agentów](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooki](docs/HOOKS.md) i [konfiguracja](docs/CONFIGURATION.md)
- [Lokalny klient webowy](docs/WEB.md)
- [Cała dokumentacja](docs)
## Dołącz do społeczności
Codewhale staje się lepszy, gdy ludzie go używają, zgłaszają niedogodności i pomagają je naprawiać. Jeśli brakuje dostawcy, przepływ pracy jest niewygodny albo interfejs terminala przeszkadza Ci w pracy, [otwórz issue](https://github.com/Hmbown/CodeWhale/issues). Jeśli wiesz, jak coś ulepszyć, [otwórz pull request](CONTRIBUTING.md). Pierwsze wkłady są mile widziane, a autorzy zachowują uznanie za pracę przyjętą do projektu.
Dołącz do [Discorda](https://discord.gg/37gfS3ksug) albo dodaj Huntera na WeChat (`hunterbown`) i poproś o dołączenie do grupy Whale Brothers.
## Historia projektu
Codewhale rozpoczął się jako `deepseek-tui` i nadal zachowuje zgodność z jego konfiguracją oraz sesjami. Obecnie jest niezależny od dostawców, utrzymywany samodzielnie i nie jest powiązany z żadnym dostawcą modeli.
Dziękujemy wszystkim współtwórcom oraz społecznościom open source, które pomogły projektowi się rozwijać. Zobacz [rejestr współtwórców](docs/CONTRIBUTORS.md).
## Licencja
[MIT](LICENSE). Części zaadaptowane z innych projektów open source są wymienione w [informacjach o komponentach zewnętrznych](docs/THIRD_PARTY_NOTICES.md).
+106 -45
View File
@@ -1,79 +1,140 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale é um agente de programação de código aberto para o seu terminal, desenvolvido em Rust e aprimorado publicamente com as pessoas que o utilizam.
Um agente de programação de código aberto para o seu terminal — traga o seu próprio modelo.
![Codewhale em execução em um terminal](assets/screenshot.webp)
O Codewhale começou como uma experiência nativa para o DeepSeek. Desde então,
virou um projeto guiado pela comunidade: um harness de programação que se
encaixa em uma comunidade internacional em crescimento e suporta o máximo de
modelos e provedores possível — modelos abertos primeiro, hospedados ou locais,
sem privilegiar nenhum.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Você informa um provedor, um modelo e uma tarefa. Ele lê seu código, edita
arquivos, executa comandos e verifica o próprio trabalho, e para quando a tarefa
termina ou quando precisa de você. Troque de modelo no meio da tarefa com
`/model`. Trabalhe de forma interativa na TUI, ou rode `codewhale exec` em
scripts e CI. É escrito em Rust, licenciado sob MIT, e roda na sua máquina.
O que não se parece com outros harnesses: **você escolhe o modelo de cada
papel, e eles não precisam ser iguais.** Uma fleet fixa um provedor, um modelo e
um nível de raciocínio por papel — então um modelo barato e rápido pode dirigir
um modelo de raciocínio caro, ou um builder GLM pode trabalhar na mesma tarefa
que um reviewer Kimi. Escreva seus próprios papéis e sua própria constitution, e
o harness passa a ser seu, não nosso.
Estamos sempre em busca de pessoas que contribuam e de formas de melhorar. Se um
modelo ou provedor que você usa está faltando, ou se algo quebra, nos contar é
uma das coisas mais úteis que você pode fazer — veja [Contribuindo](#contribuindo).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale rodando em um terminal](assets/screenshot.png)
## Instalação
```bash
npm install -g codewhale
codewhale
```
Na primeira execução, o Codewhale ajuda você a conectar um provedor ou a continuar offline. Ele também oferece suporte a Cargo, Docker, Nix, Scoop, arquivos pré-compilados, Android/Termux e um espelho CNB. Consulte o [guia de instalação](docs/INSTALL.md).
O preenchimento automático com Tab é ativado com um comando por shell — `codewhale completion bash|zsh|fish|powershell|elvish`. Consulte o [preenchimento automático do shell](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, arquivos pré-compilados, Android/Termux e um
espelho CNB para quem não consegue acessar o GitHub estão cobertos em
[docs/INSTALL.md](docs/INSTALL.md). Vindo do `deepseek-tui`? Sua configuração
e suas sessões são preservadas — veja [docs/REBRAND.md](docs/REBRAND.md).
## Uso
Converse com o Codewhale como você conversaria com alguém da sua equipe:
```text
Fix the failing tests and explain what changed.
```
Ou execute uma tarefa sem abrir a TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
O Codewhale pode ler seu repositório, editar arquivos, executar comandos, verificar resultados e continuar trabalhando em direção a um objetivo. Você decide quanto acesso ele terá.
## Por que usar o Codewhale
Na TUI: `/model` troca provedor e modelo juntos, `/fleet` executa uma equipe
de workers, `/undo` desfaz o último turno e `/restore <N>` reverte o workspace
para um snapshot anterior (`/restore` sem argumento apenas os lista). Quando o
compositor está vazio, `Tab` cicla entre Plan / Work / Operate; com texto
digitado, `Tab` completa comandos slash e menções `@`. `Shift+Tab` cicla a
postura de permissão Ask / Auto-Review / Full Access a qualquer momento. `!`
executa um comando de shell pelo caminho normal de aprovação.
- **Use o modelo que quiser.** Conecte provedores hospedados ou modelos locais por meio do Ollama, vLLM ou SGLang. Alterne o provedor e o modelo com `/model`.
- **Mantenha o controle.** O modo Plan é somente leitura. Ask, Auto-Review e Full Access tornam visível o comportamento das aprovações. `/undo` desfaz o último turno e `/restore` retorna o espaço de trabalho a um snapshot anterior.
- **Mantenha trabalhos longos organizados.** Salve sessões, defina um `/goal` duradouro, revise os fluxos de trabalho antes da execução e coordene agentes sem transformar as instruções internas deles em parte da sua conversa.
- **Amplie o agente que você já tem.** Conecte servidores MCP e habilidades, configure hooks e mantenha as funções dos agentes como arquivos legíveis no projeto ou nas suas configurações pessoais.
## O que faz
Execute `/help` na TUI para ver comandos e atalhos de teclado.
- **Qualquer modelo, qualquer provedor.** DeepSeek, Claude, GPT, Kimi, GLM e
mais de 30 provedores, além do seu próprio vLLM, SGLang ou Ollama sem key —
tudo por um único runtime e um único conjunto de ferramentas. Orçamentos de
contexto e preços vêm da rota real, e um preço desconhecido aparece como
desconhecido em vez de $0.
- **Um harness escrito por você.** Papéis são arquivos que você pode ler e
editar — um modelo, uma postura de ferramentas e instruções permanentes por
papel — guardados no projeto para o time compartilhar, ou ao lado das suas
configurações pessoais para acompanharem você entre repositórios. Uma
constitution registra como você quer que o agente se comporte em cada sessão,
para que o harness siga a sua prática, e não a nossa.
- **Somente leitura até você permitir mais.** O modo Plan não altera arquivos,
e as aprovações controlam os comandos arriscados. Quando um sandbox do
sistema operacional realmente envolve um comando, o Codewhale avisa: Seatbelt
no macOS quando disponível, bubblewrap opcional no Linux. O
`constitution.json` de um repositório é compilado em bloqueios de escrita
que nem o Full Access consegue pular.
- **Trabalho que você pode retomar.** Um fleet registra cada passo em um
livro-razão de apenas inclusão, então `fleet resume` retoma de onde você
parou.
## Segurança
## Saiba mais
O Codewhale é executado na sua máquina com o acesso que você conceder. Os modos de aprovação e as regras do repositório limitam o que o agente pode fazer; o sandbox opcional do sistema operacional adiciona um limite de execução mais forte quando disponível. Preços de modelos desconhecidos continuam sendo mostrados como desconhecidos, em vez de serem informados como gratuitos.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — cada rota de provedor: hospedada,
gateway e local
- [docs/FLEET.md](docs/FLEET.md) — fleets, o livro-razão e resume
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) —
busca experimental congelada e neutra em relação a provedores dentro do
Workflow
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hooks e a
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — como modos, hooks,
regras de permissão, limites mínimos de segurança, regras do repositório,
aprovações e sandboxing se combinam
- [docs/HOOKS.md](docs/HOOKS.md) — os onze eventos de hook do ciclo de vida da
TUI, seus payloads e os três que podem direcionar um turno (`codewhale exec`
e os subcomandos da CLI não disparam hooks)
- [docs/WEB.md](docs/WEB.md) — cliente de navegador incorporado apenas em
loopback e sua fronteira de autenticação de uso único
Leia a [ordem de autorização](docs/AUTHORIZATION_ORDER.md) para conhecer a hierarquia exata das políticas e a [configuração](docs/CONFIGURATION.md) para os ajustes locais.
Todo o resto — modos, atalhos de teclado, detalhes do sandbox, MCP, a API do
runtime, arquitetura — está em [docs](docs) e em
[codewhale.net](https://codewhale.net/).
## Documentação
## Contribuindo
- [Provedores e modelos locais](docs/PROVIDERS.md)
- [Equipes de agentes](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooks](docs/HOOKS.md) e [configuração](docs/CONFIGURATION.md)
- [Cliente web local](docs/WEB.md)
- [Toda a documentação](docs)
Issues, PRs, passos de reprodução, logs e pedidos de funcionalidade são trabalho
real do projeto, e primeiras contribuições são bem-vindas. Quando um PR não pode
ser mesclado como está, os mantenedores aproveitam o que funciona e o autor
continua creditado — no commit, no changelog e em
[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Participe da comunidade
- [Issues abertas](https://github.com/Hmbown/CodeWhale/issues) — boas
primeiras contribuições moram aqui
- [CONTRIBUTING.md](CONTRIBUTING.md) — setup de desenvolvimento e fluxo de PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — todo mundo que ajudou a
moldar o projeto
- [Me pague um café](https://www.buymeacoffee.com/hmbown)
O Codewhale melhora quando as pessoas o utilizam, relatam o que parece errado e ajudam a corrigir. Se estiver faltando um provedor, se um fluxo de trabalho for inconveniente ou se a interface do terminal atrapalhar, [abra uma issue](https://github.com/Hmbown/CodeWhale/issues). Se souber como melhorar, [abra um pull request](CONTRIBUTING.md). Primeiras contribuições são bem-vindas, e os contribuidores mantêm o crédito pelo trabalho incorporado ao projeto.
Participe do [Discord](https://discord.gg/37gfS3ksug), ou adicione Hunter no WeChat (`hunterbown`) e peça para entrar no grupo Whale Brothers.
## História do projeto
O Codewhale começou como `deepseek-tui` e ainda preserva a compatibilidade com as configurações e sessões desse projeto. Hoje ele é neutro em relação a provedores, mantido de forma independente e não tem afiliação com nenhum provedor de modelos.
Agradecemos a cada contribuidor e às comunidades de código aberto que ajudaram o projeto a crescer. Consulte o [registro de contribuidores](docs/CONTRIBUTORS.md).
Obrigado à [DeepSeek](https://github.com/deepseek-ai) pelos modelos e pelo
apoio que deram início ao projeto, à
[DataWhale](https://github.com/datawhalechina) 🐋 por nos receber na família
Whale Brother, e a [OpenWarp](https://github.com/zerx-lab/warp) e
[Open Design](https://github.com/nexu-io/open-design) pela colaboração na
experiência de agente no terminal.
## Licença
[MIT](LICENSE). As partes adaptadas de outros projetos de código aberto estão registradas nos [avisos de terceiros](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Projeto comunitário independente; sem afiliação com nenhum
provedor de modelos.
[![Gráfico de Star History](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+106 -45
View File
@@ -1,79 +1,140 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale — это агент для программирования с открытым исходным кодом, работающий в терминале. Он написан на Rust и открыто развивается вместе со своими пользователями.
Открытый агент для программирования в вашем терминале — модель приносите с собой.
![Codewhale работает в терминале](assets/screenshot.webp)
Codewhale начинался как нативный клиент для DeepSeek. С тех пор он вырос в проект,
которым руководит сообщество: единый каркас для программирования, подходящий
растущему международному сообществу и поддерживающий как можно больше моделей и
провайдеров — открытые модели в первую очередь, облачные или локальные, ни один не
имеет привилегий перед остальными.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Дайте ему провайдера, модель и задачу. Он читает ваш код, правит файлы, запускает
команды и проверяет собственную работу, а затем останавливается, когда задача
выполнена или ему нужны вы. Переключайте модели прямо посреди задачи командой
`/model`. Работайте интерактивно в TUI или запускайте `codewhale exec` в скриптах
и CI. Он написан на Rust, распространяется по лицензии MIT и работает на вашей
машине.
Чем это не похоже на другие harness: **вы сами выбираете модель для каждой
роли, и они не обязаны совпадать.** Fleet закрепляет провайдера, модель и
уровень рассуждений отдельно для каждой роли — поэтому дешёвая и быстрая модель
может руководить дорогой рассуждающей, а builder на GLM может работать над той
же задачей, что и reviewer на Kimi. Опишите свои роли и свою constitution — и
harness станет вашим, а не нашим.
Мы всегда ищем участников и способы стать лучше. Если модели или провайдера,
которым вы пользуетесь, не хватает, или что-то сломалось, сообщить нам об этом —
одно из самых полезных действий с вашей стороны: см.
[Участие в проекте](#участие-в-проекте).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale, запущенный в терминале](assets/screenshot.png)
## Установка
```bash
npm install -g codewhale
codewhale
```
При первом запуске Codewhale поможет подключить провайдера или остаться в автономном режиме. Он также поддерживает Cargo, Docker, Nix, Scoop, готовые архивы, Android/Termux и зеркало CNB. См. [руководство по установке](docs/INSTALL.md).
Для автодополнения по Tab достаточно одной команды для каждой оболочки — `codewhale completion bash|zsh|fish|powershell|elvish`. См. [автодополнение оболочки](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, готовые архивы, Android/Termux и зеркало CNB для тех,
кто не может получить доступ к GitHub, описаны в
[docs/INSTALL.md](docs/INSTALL.md). Переходите с `deepseek-tui`? Ваши настройки и
сессии переносятся автоматически — см. [docs/REBRAND.md](docs/REBRAND.md).
## Использование
Обращайтесь к Codewhale так же, как к коллеге по команде:
```text
Fix the failing tests and explain what changed.
```
Задачу можно запустить и без открытия TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale умеет читать ваш репозиторий, редактировать файлы, выполнять команды, проверять результаты и продолжать работу над целью. Вы сами решаете, какой доступ ему предоставить.
## Почему Codewhale
В TUI: `/model` переключает провайдера и модель одновременно, `/fleet` запускает
команду воркеров, `/undo` отменяет последний ход, а `/restore <N>` откатывает
рабочую копию к более раннему снимку (`/restore` без аргумента только выводит их
список). Когда поле ввода пустое, `Tab` циклически переключает режимы Plan /
Work / Operate; если в поле есть текст, `Tab` дополняет слэш-команды и упоминания
`@`. `Shift+Tab` переключает уровни прав Ask / Auto-Review / Full Access в любой
момент. `!` запускает команду оболочки через обычный путь подтверждения.
- **Используйте нужную вам модель.** Подключайте облачных провайдеров или локальные модели через Ollama, vLLM или SGLang. Переключайте провайдера и модель командой `/model`.
- **Сохраняйте контроль.** Режим Plan доступен только для чтения. Ask, Auto-Review и Full Access наглядно показывают порядок подтверждений. `/undo` отменяет последний ход, а `/restore` возвращает рабочую область к более раннему снимку.
- **Организуйте длительную работу.** Сохраняйте сеансы, задавайте постоянную `/goal`, проверяйте рабочие процессы перед запуском и координируйте агентов так, чтобы их внутренние инструкции не попадали в вашу переписку.
- **Расширяйте уже настроенного агента.** Подключайте серверы MCP и навыки, настраивайте хуки и храните роли агентов в виде понятных файлов в проекте или личных настройках.
## Что он умеет
Выполните `/help` в TUI, чтобы увидеть команды и сочетания клавиш.
- **Любая модель, любой провайдер.** DeepSeek, Claude, GPT, Kimi, GLM и более 30
провайдеров, плюс ваши собственные vLLM, SGLang или Ollama без ключа — всё
через единый рантайм и единый набор инструментов. Лимиты контекста и цены
берутся из реального маршрута, а неизвестная цена отображается как неизвестная,
а не как $0.
- **Harness, который пишете вы.** Роли — это файлы, которые можно прочитать и
изменить: для каждой роли своя модель, своя позиция по инструментам и
постоянные инструкции. Держите их в проекте, чтобы ими пользовалась команда,
или рядом с личными настройками, чтобы они следовали за вами между
репозиториями. Constitution фиксирует, как вы хотите, чтобы агент вёл себя в
каждой сессии, — так harness подстраивается под вашу практику, а не под нашу.
- **Только чтение, пока вы не разрешите больше.** Режим Plan не может изменять
файлы, а рискованные команды требуют подтверждения. Когда команду действительно
оборачивает песочница ОС, Codewhale сообщает об этом: Seatbelt на macOS, где он
доступен, и опциональный bubblewrap на Linux. Файл `constitution.json` в
репозитории компилируется в блокировки записи, которые не может обойти даже
Full Access.
- **Работа, которую можно продолжить.** Флит записывает каждый шаг в журнал,
доступный только на добавление, поэтому `fleet resume` продолжает с того места,
где вы остановились.
## Безопасность
## Узнать больше
Codewhale работает на вашем компьютере с предоставленным вами доступом. Режимы подтверждения и правила репозитория ограничивают действия агента; дополнительная песочница ОС создаёт более строгую границу выполнения там, где она поддерживается. Неизвестная цена модели отображается как неизвестная, а не как нулевая.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — все маршруты провайдеров: облачные,
шлюзы и локальные
- [docs/FLEET.md](docs/FLEET.md) — флиты, журнал и возобновление работы
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) —
замороженный, нейтральный к провайдерам экспериментальный поиск внутри
Workflow
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, хуки и
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — как сочетаются
режимы, хуки, правила разрешений, минимальные требования безопасности, правила
репозитория, подтверждения и песочница
- [docs/HOOKS.md](docs/HOOKS.md) — одиннадцать событий хуков жизненного цикла
TUI, их полезная нагрузка и три из них, способные направлять ход (`codewhale
exec` и подкоманды CLI хуки не запускают)
- [docs/WEB.md](docs/WEB.md) — браузерный клиент, работающий только на loopback,
и его одноразовая граница аутентификации
Точный порядок применения политик описан в разделе [порядок авторизации](docs/AUTHORIZATION_ORDER.md), а локальные настройки — в разделе [конфигурация](docs/CONFIGURATION.md).
Всё остальное — режимы, сочетания клавиш, подробности о песочнице, MCP, API
рантайма и архитектура — находится в [docs](docs) и на
[codewhale.net](https://codewhale.net/).
## Документация
## Участие в проекте
- [Провайдеры и локальные модели](docs/PROVIDERS.md)
- [Команды агентов](docs/FLEET.md)
- [MCP](docs/MCP.md), [хуки](docs/HOOKS.md) и [конфигурация](docs/CONFIGURATION.md)
- [Локальный веб-клиент](docs/WEB.md)
- [Вся документация](docs)
Задачи, PR, шаги воспроизведения, логи и запросы функций — всё это настоящая
работа над проектом, и первые вклады приветствуются. Когда PR нельзя влить как
есть, мейнтейнеры забирают работающие части, сохраняя авторство — в коммите, в
списке изменений и в [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Присоединяйтесь к сообществу
- [Открытые задачи](https://github.com/Hmbown/CodeWhale/issues) — здесь живут
хорошие задачи для первого вклада
- [CONTRIBUTING.md](CONTRIBUTING.md) — настройка среды разработки и процесс PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — все, кто сформировал этот проект
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Codewhale становится лучше, когда люди пользуются им, сообщают о неудобствах и помогают их исправить. Если нужного провайдера нет, рабочий процесс неудобен или интерфейс терминала мешает работе, [создайте issue](https://github.com/Hmbown/CodeWhale/issues). Если вы знаете, как это улучшить, [откройте pull request](CONTRIBUTING.md). Мы рады первым вкладам, а авторство принятой работы сохраняется за участниками.
Присоединяйтесь к [Discord](https://discord.gg/37gfS3ksug) или добавьте Hunter в WeChat (`hunterbown`) и попросите принять вас в группу Whale Brothers.
## История проекта
Codewhale начинался как `deepseek-tui` и по-прежнему сохраняет совместимость с его конфигурацией и сеансами. Теперь он нейтрален к провайдерам, поддерживается независимо и не связан ни с одним поставщиком моделей.
Спасибо всем участникам и сообществам открытого исходного кода, которые помогли проекту вырасти. См. [список участников](docs/CONTRIBUTORS.md).
Благодарим [DeepSeek](https://github.com/deepseek-ai) за модели и поддержку, с
которых начался проект, [DataWhale](https://github.com/datawhalechina) 🐋 за
теплый приём в семью «Whale Brother», а также
[OpenWarp](https://github.com/zerx-lab/warp) и
[Open Design](https://github.com/nexu-io/open-design) за сотрудничество в
создании терминального агента.
## Лицензия
[MIT](LICENSE). Части, адаптированные из других проектов с открытым исходным кодом, указаны в [уведомлениях о сторонних компонентах](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Независимый проект сообщества, не аффилированный ни с одним
провайдером моделей.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale, terminaliniz için Rust ile geliştirilmiş ve kullanıcılarıyla birlikte açık biçimde iyileştirilen açık kaynaklı bir kodlama ajanıdır.
![Terminalde çalışan Codewhale](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## Kurulum
```bash
npm install -g codewhale
codewhale
```
Codewhale ilk çalıştırmada bir sağlayıcıya bağlanmanıza veya çevrimdışı kalmanıza yardımcı olur. Cargo, Docker, Nix, Scoop, önceden derlenmiş arşivler, Android/Termux ve CNB aynasını da destekler. [Kurulum kılavuzuna](docs/INSTALL.md) bakın.
Her kabukta Tab tamamlama tek bir komutla etkinleştirilir — `codewhale completion bash|zsh|fish|powershell|elvish`. [Kabuk tamamlamalarına](docs/INSTALL.md#8-shell-completions) bakın.
## Kullanım
Codewhale ile ekip arkadaşınızla konuşur gibi konuşun:
```text
Fix the failing tests and explain what changed.
```
TUIyi açmadan da bir görev çalıştırabilirsiniz:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale deponuzu okuyabilir, dosyaları düzenleyebilir, komutları çalıştırabilir, sonuçları inceleyebilir ve bir hedefe doğru çalışmayı sürdürebilir. Ne kadar erişime sahip olacağına siz karar verirsiniz.
## Neden Codewhale
- **İstediğiniz modeli kullanın.** Barındırılan sağlayıcılara veya Ollama, vLLM ya da SGLang üzerinden yerel modellere bağlanın. Sağlayıcı ve modeli `/model` ile değiştirin.
- **Kontrolü elinizde tutun.** Plan salt okunurdur. Ask, Auto-Review ve Full Access, onay davranışını görünür kılar. `/undo` son turu geri alır, `/restore` ise çalışma alanını önceki bir anlık görüntüye döndürür.
- **Uzun süren işleri düzenli tutun.** Oturumları kaydedin, kalıcı bir `/goal` belirleyin, iş akışlarını çalışmadan önce gözden geçirin ve ajanların iç talimatlarını konuşmanıza taşımadan onları koordine edin.
- **Elinizdeki ajanı genişletin.** MCP sunucularını ve becerileri bağlayın, hookları yapılandırın ve ajan rollerini projenizde veya kişisel ayarlarınızda okunabilir dosyalar olarak saklayın.
Komutları ve klavye kısayollarını görmek için TUIde `/help` komutunu çalıştırın.
## Güvenlik
Codewhale, verdiğiniz erişimle kendi makinenizde çalışır. Onay modları ve depo kuralları ajanın yapabileceklerini sınırlar; desteklenen ortamlarda isteğe bağlı işletim sistemi sandbox’ı daha güçlü bir yürütme sınırı ekler. Bilinmeyen model fiyatları ücretsiz olarak bildirilmek yerine bilinmeyen olarak kalır.
Politikaların kesin sıralaması için [yetkilendirme sırasını](docs/AUTHORIZATION_ORDER.md), yerel ayarlar için [yapılandırmayı](docs/CONFIGURATION.md) okuyun.
## Belgeler
- [Sağlayıcılar ve yerel modeller](docs/PROVIDERS.md)
- [Ajan ekipleri](docs/FLEET.md)
- [MCP](docs/MCP.md), [hooklar](docs/HOOKS.md) ve [yapılandırma](docs/CONFIGURATION.md)
- [Yerel web istemcisi](docs/WEB.md)
- [Tüm belgeler](docs)
## Topluluğa katılın
İnsanlar Codewhalei kullandıkça, yanlış gelen noktaları bildirdikçe ve düzeltmeye yardımcı oldukça Codewhale daha iyi olur. Bir sağlayıcı eksikse, bir iş akışı kullanışsızsa veya terminal arayüzü işinizi zorlaştırıyorsa [bir issue açın](https://github.com/Hmbown/CodeWhale/issues). Nasıl iyileştirileceğini biliyorsanız [bir pull request açın](CONTRIBUTING.md). İlk katkılar memnuniyetle karşılanır ve katkıda bulunanların projeye alınan çalışmaları üzerindeki emeği kayda geçer.
[Discorda](https://discord.gg/37gfS3ksug) katılın veya WeChatte Hunter’ı (`hunterbown`) ekleyip Whale Brothers grubuna katılmak istediğinizi belirtin.
## Proje geçmişi
Codewhale, `deepseek-tui` olarak başladı ve onun yapılandırması ile oturumlarıyla uyumluluğunu hâlâ koruyor. Artık sağlayıcılardan bağımsızdır, bağımsız olarak sürdürülür ve herhangi bir model sağlayıcısıyla bağlantılı değildir.
Projeyi büyütmeye yardımcı olan tüm katkıcılara ve açık kaynak topluluklarına teşekkürler. [Katkıcı kaydına](docs/CONTRIBUTORS.md) bakın.
## Lisans
[MIT](LICENSE). Diğer açık kaynak projelerinden uyarlanan bölümler [üçüncü taraf bildirimlerinde](docs/THIRD_PARTY_NOTICES.md) kayıtlıdır.
+103 -45
View File
@@ -1,79 +1,137 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale — це агент програмування з відкритим кодом для вашого термінала, створений на Rust і вдосконалюваний публічно разом із людьми, які ним користуються.
Агент для програмування з відкритим кодом у вашому терміналі — модель приносите ви.
![Codewhale працює в терміналі](assets/screenshot.webp)
Codewhale починався як нативний інструмент для DeepSeek. Відтоді він виріс у
проєкт, яким керує спільнота: єдине середовище для програмування, яке підходить
міжнародній спільноті, що зростає, і підтримує якнайбільше моделей та
провайдерів — відкриті моделі насамперед, хмарні чи локальні, жодна не має
переваги перед іншими.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Дайте йому провайдера, модель і завдання. Він читає ваш код, редагує файли,
виконує команди й перевіряє власну роботу, а потім зупиняється, коли робота
завершена або коли йому потрібні ви. Перемикайте моделі в розпалі завдання
командою `/model`. Працюйте інтерактивно в TUI або запускайте `codewhale exec`
у скриптах і CI. Він написаний на Rust, поширюється за ліцензією MIT і працює
на вашому комп'ютері.
Чим це не схоже на інші harness: **ви самі обираєте модель для кожної ролі, і
вони не мусять збігатися.** Fleet закріплює провайдера, модель і рівень
міркувань окремо для кожної ролі — тож дешева і швидка модель може керувати
дорогою міркувальною, а builder на GLM може працювати над тим самим завданням,
що й reviewer на Kimi. Опишіть свої ролі та свою constitution — і harness стане
вашим, а не нашим.
Ми завжди шукаємо учасників і способи стати кращими. Якщо моделі чи
провайдера, якими ви користуєтесь, бракує, або щось ламається, повідомити про
це — одна з найкорисніших речей, які ви можете зробити — див.
[Участь у проєкті](#участь-у-проєкті).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [codewhale.net](https://codewhale.net/) · [Документація](docs) · [Журнал змін](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale працює в терміналі](assets/screenshot.png)
## Встановлення
```bash
npm install -g codewhale
codewhale
```
Під час першого запуску Codewhale допоможе під’єднати провайдера або залишитися в автономному режимі. Він також підтримує Cargo, Docker, Nix, Scoop, готові архіви, Android/Termux і дзеркало CNB. Див. [посібник зі встановлення](docs/INSTALL.md).
Для автодоповнення за Tab достатньо однієї команди для кожної оболонки — `codewhale completion bash|zsh|fish|powershell|elvish`. Див. [автодоповнення оболонки](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, готові архіви, Android/Termux, а також дзеркало CNB
для тих, хто не може отримати доступ до GitHub, описані в
[docs/INSTALL.md](docs/INSTALL.md). Переходите з `deepseek-tui`? Ваші
налаштування й сесії переносяться — див. [docs/REBRAND.md](docs/REBRAND.md).
## Використання
Спілкуйтеся з Codewhale так само, як із колегою по команді:
```text
Fix the failing tests and explain what changed.
```
Також можна запустити завдання, не відкриваючи TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale може читати ваш репозиторій, редагувати файли, виконувати команди, перевіряти результати й продовжувати роботу над метою. Ви самі вирішуєте, який доступ йому надати.
## Чому Codewhale
У TUI: `/model` перемикає провайдера й модель разом, `/fleet` запускає команду
працівників, `/undo` скасовує останній крок, а `/restore <N>` відкочує робочу
копію до давнішого знімка (`/restore` без аргументу лише виводить їхній
список). Коли поле введення порожнє, `Tab` циклічно перемикає Plan / Work /
Operate; якщо в полі є текст, `Tab` доповнює слеш-команди та згадки `@`.
`Shift+Tab` перемикає режими дозволів Ask / Auto-Review / Full Access будь-коли.
`!` виконує команду оболонки через звичайний шлях затвердження.
- **Використовуйте потрібну вам модель.** Під’єднуйте хостингових провайдерів або локальні моделі через Ollama, vLLM чи SGLang. Змінюйте провайдера й модель за допомогою `/model`.
- **Зберігайте контроль.** Режим Plan доступний лише для читання. Ask, Auto-Review і Full Access наочно показують поведінку погоджень. `/undo` скасовує останній хід, а `/restore` повертає робочий простір до попереднього знімка.
- **Упорядковуйте тривалу роботу.** Зберігайте сеанси, установлюйте постійну `/goal`, перевіряйте робочі процеси перед запуском і координуйте агентів так, щоб їхні внутрішні інструкції не потрапляли до вашої розмови.
- **Розширюйте вже наявного агента.** Під’єднуйте сервери MCP і навички, налаштовуйте хуки та зберігайте ролі агентів як зрозумілі файли у своєму проєкті або особистих налаштуваннях.
## Що він уміє
Виконайте `/help` у TUI, щоб переглянути команди й клавіатурні скорочення.
- **Будь-яка модель, будь-який провайдер.** DeepSeek, Claude, GPT, Kimi, GLM та
понад 30 провайдерів, а також власні vLLM, SGLang чи Ollama без жодного
ключа — усе через одне середовище виконання й один набір інструментів. Ліміти
контексту й ціни беруться з реального маршруту, а невідома ціна показується
як невідома, а не як $0.
- **Harness, який пишете ви.** Ролі — це файли, які можна прочитати й змінити:
для кожної ролі своя модель, своя позиція щодо інструментів і постійні
інструкції. Тримайте їх у проєкті, щоб ними користувалася команда, або поруч з
особистими налаштуваннями, щоб вони йшли за вами між репозиторіями.
Constitution фіксує, як ви хочете, щоб агент поводився в кожній сесії, — тож
harness підлаштовується під вашу практику, а не під нашу.
- **Лише читання, доки ви не дозволите більше.** Режим Plan не може змінювати
файли, а ризиковані команди проходять через затвердження. Коли пісочниця ОС
справді обгортає команду, Codewhale каже про це: Seatbelt на macOS, де він
доступний, і bubblewrap за бажанням на Linux. `constitution.json` репозиторію
компілюється у блокування запису, які не може обійти навіть Full Access.
- **Робота, яку можна відновити.** Флот записує кожен крок до журналу, що лише
доповнюється, тож `fleet resume` підхоплює роботу з місця, де ви зупинились.
## Безпека
## Дізнатися більше
Codewhale працює на вашому комп’ютері з доступом, який ви йому надали. Режими погодження та правила репозиторію обмежують дії агента; додаткова пісочниця ОС створює надійнішу межу виконання там, де вона підтримується. Невідома ціна моделі залишається позначеною як невідома, а не подається як безкоштовна.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — кожен маршрут провайдера: хмарний,
шлюзовий і локальний
- [docs/FLEET.md](docs/FLEET.md) — флоти, журнал і відновлення
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — заморожений, нейтральний до провайдерів експериментальний пошук у Workflow
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, хуки й
конституція
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — як поєднуються
режими, хуки, правила дозволів, мінімальні вимоги безпеки, правила репозиторію,
затвердження та пісочниця
- [docs/HOOKS.md](docs/HOOKS.md) — одинадцять подій хуків життєвого циклу TUI,
їхні корисні навантаження та три з них, що можуть скеровувати хід (`codewhale
exec` і підкоманди CLI хуків не запускають)
- [docs/WEB.md](docs/WEB.md) — браузерний клієнт, доступний лише через
loopback, і його межа одноразової автентифікації
Точний порядок застосування політик описано в розділі [порядок авторизації](docs/AUTHORIZATION_ORDER.md), а локальні налаштування — у розділі [конфігурація](docs/CONFIGURATION.md).
Усе решта — режими, комбінації клавіш, деталі пісочниці, MCP, API середовища
виконання й архітектура — знаходиться в [docs](docs) і на
[codewhale.net](https://codewhale.net/).
## Документація
## Участь у проєкті
- [Провайдери та локальні моделі](docs/PROVIDERS.md)
- [Команди агентів](docs/FLEET.md)
- [MCP](docs/MCP.md), [хуки](docs/HOOKS.md) і [конфігурація](docs/CONFIGURATION.md)
- [Локальний вебклієнт](docs/WEB.md)
- [Уся документація](docs)
Звіти про проблеми, PR, кроки відтворення, журнали й побажання щодо функцій —
усе це справжня робота над проєктом, і перші внески вітаються. Коли PR не можна
злити як є, супровідники забирають те, що працює, і зберігають авторство — у
коміті, в журналі змін і в [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Долучайтеся до спільноти
- [Відкриті issues](https://github.com/Hmbown/CodeWhale/issues) — тут живуть
хороші перші внески
- [CONTRIBUTING.md](CONTRIBUTING.md) — налаштування середовища розробки й
процес PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — усі, хто сформував цей проєкт
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Codewhale стає кращим, коли люди користуються ним, повідомляють про незручності й допомагають їх виправляти. Якщо потрібного провайдера немає, робочий процес незручний або інтерфейс термінала заважає роботі, [створіть issue](https://github.com/Hmbown/CodeWhale/issues). Якщо ви знаєте, як це поліпшити, [відкрийте pull request](CONTRIBUTING.md). Ми раді першим внескам, а авторство прийнятої роботи зберігається за учасниками.
Долучайтеся до [Discord](https://discord.gg/37gfS3ksug) або додайте Hunter у WeChat (`hunterbown`) і попросіть приєднати вас до групи Whale Brothers.
## Історія проєкту
Codewhale починався як `deepseek-tui` і досі зберігає сумісність із його конфігурацією та сеансами. Тепер він нейтральний щодо провайдерів, підтримується незалежно й не пов’язаний із жодним постачальником моделей.
Дякуємо всім учасникам і спільнотам відкритого коду, які допомогли проєкту зрости. Див. [список учасників](docs/CONTRIBUTORS.md).
Дякуємо [DeepSeek](https://github.com/deepseek-ai) за моделі й підтримку, з
яких почався проєкт, [DataWhale](https://github.com/datawhalechina) 🐋 за те,
що прийняли нас у родину Китових Братів, а також
[OpenWarp](https://github.com/zerx-lab/warp) і
[Open Design](https://github.com/nexu-io/open-design) за співпрацю над досвідом
термінального агента.
## Ліцензія
[MIT](LICENSE). Частини, адаптовані з інших проєктів із відкритим кодом, зазначено в [повідомленнях про сторонні компоненти](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Незалежний проєкт спільноти, не пов'язаний із жодним
провайдером моделей.
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+99 -45
View File
@@ -1,79 +1,133 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale là tác nhân lập trình mã nguồn mở dành cho terminal, được xây dựng bằng Rust và được cải thiện công khai cùng những người sử dụng nó.
Một coding agent mã nguồn mở cho terminal của bạn — mang theo model của riêng bạn.
![Codewhale đang chạy trong terminal](assets/screenshot.webp)
Codewhale khởi đầu là một trải nghiệm gốc (native) cho DeepSeek. Từ đó, nó đã
phát triển thành một dự án do cộng đồng dẫn dắt: một coding harness hợp với một
cộng đồng quốc tế đang lớn dần và hỗ trợ càng nhiều model cùng provider càng
tốt — model mở trước tiên, hosted hay local, không cái nào được ưu ái hơn cái
nào.
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
Đưa cho nó một provider, một model và một nhiệm vụ. Nó đọc code của bạn, sửa
file, chạy lệnh, kiểm tra công việc của mình, rồi dừng lại khi nhiệm vụ hoàn
thành hoặc cần đến bạn. Đổi model giữa chừng bằng `/model`. Làm việc tương tác
trong TUI, hoặc chạy `codewhale exec` trong script và CI. Viết bằng Rust, giấy
phép MIT, và chạy trên máy của bạn.
Điều khác biệt so với các harness khác: **bạn chọn model cho từng vai trò, và
chúng không cần phải giống nhau.** Một fleet ghim provider, model và mức suy
luận riêng cho từng vai trò — nên một model nhanh và rẻ có thể điều phối một
model suy luận đắt tiền, hoặc một builder GLM có thể làm chung việc với một
reviewer Kimi. Hãy viết vai trò của riêng bạn, constitution của riêng bạn, và
harness đó là của bạn chứ không phải của chúng tôi.
Chúng tôi luôn tìm kiếm người đóng góp và cách cải thiện. Nếu một model hay
provider bạn dùng còn thiếu, hoặc có gì đó hỏng, báo cho chúng tôi biết là một
trong những điều hữu ích nhất bạn có thể làm — xem [Đóng góp](#đóng-góp).
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Bahasa Indonesia](README.id.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale chạy trong terminal](assets/screenshot.png)
## Cài đặt
```bash
npm install -g codewhale
codewhale
```
Trong lần chạy đầu tiên, Codewhale sẽ giúp bạn kết nối với nhà cung cấp hoặc tiếp tục làm việc ngoại tuyến. Codewhale cũng hỗ trợ Cargo, Docker, Nix, Scoop, các gói dựng sẵn, Android/Termux và bản sao CNB. Xem [hướng dẫn cài đặt](docs/INSTALL.md).
Mỗi shell chỉ cần một lệnh để bật tính năng hoàn thành bằng phím Tab — `codewhale completion bash|zsh|fish|powershell|elvish`. Xem [tính năng hoàn thành của shell](docs/INSTALL.md#8-shell-completions).
Cargo, Docker, Nix, Scoop, archive dựng sẵn, Android/Termux,một mirror CNB
cho người dùng không truy cập được GitHub đều được hướng dẫn trong
[docs/INSTALL.md](docs/INSTALL.md). Chuyển từ `deepseek-tui` sang? Cấu hình và
session của bạn được giữ nguyên — xem [docs/REBRAND.md](docs/REBRAND.md).
## Sử dụng
Hãy trò chuyện với Codewhale như khi bạn trao đổi với một đồng đội:
```text
Fix the failing tests and explain what changed.
```
Hoặc chạy tác vụ mà không cần mở TUI:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale có thể đọc kho mã nguồn, chỉnh sửa tệp, chạy lệnh, kiểm tra kết quả và tiếp tục làm việc hướng đến mục tiêu. Bạn quyết định mức quyền truy cập dành cho nó.
## Vì sao chọn Codewhale
Trong TUI: `/model` đổi provider và model cùng lúc, `/fleet` chạy một đội
worker, `/undo` hoàn tác lượt gần nhất, và `/restore <N>` đưa workspace về một
ảnh chụp trước đó (`/restore` không tham số chỉ liệt kê chúng). Khi vùng soạn
thảo trống, `Tab` chuyển vòng qua Plan / Work / Operate; khi vùng soạn thảo có
chữ, `Tab` lại hoàn tất lệnh slash và nhắc `@`. `Shift+Tab` chuyển vòng qua tư
thế quyền Ask / Auto-Review / Full Access bất cứ lúc nào. `!` chạy một lệnh
shell qua đường phê duyệt bình thường.
- **Dùng mô hình bạn muốn.** Kết nối với nhà cung cấp được lưu trữ hoặc với mô hình cục bộ thông qua Ollama, vLLM hay SGLang. Chuyển nhà cung cấp và mô hình bằng `/model`.
- **Luôn nắm quyền kiểm soát.** Plan chỉ cho phép đọc. Ask, Auto-Review và Full Access hiển thị rõ cách hoạt động của việc phê duyệt. `/undo` hoàn tác lượt gần nhất, còn `/restore` đưa không gian làm việc về một ảnh chụp trước đó.
- **Sắp xếp công việc dài hạn.** Lưu phiên, đặt `/goal` lâu dài, xem lại quy trình trước khi chạy và phối hợp các tác nhân mà không đưa chỉ dẫn nội bộ của chúng vào bản ghi hội thoại của bạn.
- **Mở rộng tác nhân bạn đang có.** Kết nối máy chủ MCP và kỹ năng, cấu hình hook, đồng thời lưu vai trò tác nhân dưới dạng các tệp dễ đọc trong dự án hoặc phần cài đặt cá nhân.
## Tính năng
Chạy `/help` trong TUI để xem các lệnh và phím tắt.
- **Model nào cũng được, provider nào cũng được.** DeepSeek, Claude, GPT, Kimi,
GLM, hơn 30 provider, và vLLM, SGLang hay Ollama của riêng bạn — không cần
key — đều chạy qua một runtime và một bộ công cụ. Ngân sách ngữ cảnh và giá
lấy từ route thật; giá chưa rõ hiển thị là chưa rõ, chứ không phải $0.
- **Một harness do bạn viết.** Vai trò là những tệp bạn có thể đọc và sửa — mỗi
vai trò một model, một tư thế công cụ và các chỉ dẫn thường trực — đặt trong dự
án để cả nhóm dùng chung, hoặc cạnh các thiết lập cá nhân để đi theo bạn giữa
các repo. Constitution ghi lại cách bạn muốn agent hành xử trong mọi phiên, để
harness khớp với cách làm của bạn thay vì của chúng tôi.
- **Chỉ đọc cho tới khi bạn cho phép thêm.** Chế độ Plan không đổi file, và mọi
lệnh rủi ro đều qua phê duyệt. Khi một sandbox của hệ điều hành thực sự bọc
lệnh, Codewhale nói rõ điều đó: Seatbelt trên macOS khi khả dụng, bubblewrap
tùy chọn trên Linux. `constitution.json` của repo được biên dịch thành các
chốt chặn ghi mà ngay cả Full Access cũng không thể bỏ qua.
- **Công việc bạn có thể tiếp tục.** Fleet ghi lại từng bước vào sổ cái chỉ ghi
thêm, nên `fleet resume` tiếp tục từ chỗ bạn dừng.
## An toàn
## Tìm hiểu thêm
Codewhale chạy trên máy của bạn với quyền truy cập do bạn cấp. Chế độ phê duyệt và quy tắc kho mã nguồn giới hạn những gì tác nhân được phép làm; cơ chế sandbox tùy chọn của hệ điều hành tạo thêm một ranh giới thực thi vững chắc hơn ở nơi được hỗ trợ. Giá mô hình chưa xác định sẽ vẫn được ghi là chưa xác định thay vì bị báo là miễn phí.
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — mọi route provider: dịch vụ,
gateway và cục bộ
- [docs/FLEET.md](docs/FLEET.md) — fleet, sổ cái và resume
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — tìm kiếm thử nghiệm trong Workflow, đã đóng băng và trung lập với provider
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`, hook và
constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — cách các chế độ,
hook, quy tắc quyền, mức an toàn tối thiểu, luật của repo, quy trình phê duyệt
và sandbox phối hợp với nhau
- [docs/HOOKS.md](docs/HOOKS.md) — mười một sự kiện hook trong vòng đời TUI,
payload của chúng và ba sự kiện có thể điều hướng một lượt (`codewhale exec`
và các lệnh con CLI không kích hoạt hook)
- [docs/WEB.md](docs/WEB.md) — trình duyệt nhúng chỉ chạy trên loopback và
ranh giới xác thực dùng một lần
Đọc [thứ tự cấp quyền](docs/AUTHORIZATION_ORDER.md) để biết chính xác các lớp chính sách và [cấu hình](docs/CONFIGURATION.md) để biết các cài đặt cục bộ.
Mọi thứ còn lại — chế độ, phím tắt, chi tiết sandbox, MCP, runtime API, kiến
trúc — nằm trong [docs](docs) và trên [codewhale.net](https://codewhale.net/).
## Tài liệu
## Đóng góp
- [Nhà cung cấp và mô hình cục bộ](docs/PROVIDERS.md)
- [Nhóm tác nhân](docs/FLEET.md)
- [MCP](docs/MCP.md), [hook](docs/HOOKS.md) và [cấu hình](docs/CONFIGURATION.md)
- [Ứng dụng web cục bộ](docs/WEB.md)
- [Toàn bộ tài liệu](docs)
Issue, PR, các bước tái hiện lỗi, log và yêu cầu tính năng đều là công việc
thực sự của dự án ở đây, và những đóng góp đầu tiên luôn được chào đón. Khi một
PR không thể merge nguyên trạng, maintainer sẽ harvest phần dùng được và tác
giả vẫn được ghi công — trong commit, trong changelog và trong
[docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md).
## Tham gia cộng đng
- [Issue đang mở](https://github.com/Hmbown/CodeWhale/issues) — những đóng góp
đầu tiên phù hợp nằm ở đây
- [CONTRIBUTING.md](CONTRIBUTING.md) — thiết lập môi trường dev và quy trình PR
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) — tất cả những người đã góp
phần định hình dự án
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
Codewhale trở nên tốt hơn khi mọi người sử dụng, phản hồi những điểm chưa ổn và cùng khắc phục. Nếu thiếu một nhà cung cấp, quy trình còn bất tiện hoặc giao diện terminal cản trở công việc, hãy [mở issue](https://github.com/Hmbown/CodeWhale/issues). Nếu bạn biết cách cải thiện, hãy [mở pull request](CONTRIBUTING.md). Chúng tôi chào đón những đóng góp đầu tiên và người đóng góp luôn được ghi nhận cho phần việc đã được hợp nhất.
Tham gia [Discord](https://discord.gg/37gfS3ksug), hoặc thêm Hunter trên WeChat (`hunterbown`) và đề nghị tham gia nhóm Whale Brothers.
## Lịch sử dự án
Codewhale bắt đầu với tên `deepseek-tui` và vẫn duy trì khả năng tương thích với cấu hình cùng phiên làm việc của dự án đó. Hiện nay Codewhale không phụ thuộc vào nhà cung cấp nào, được duy trì độc lập và không liên kết với bất kỳ nhà cung cấp mô hình nào.
Cảm ơn mọi người đóng góp và các cộng đồng mã nguồn mở đã giúp dự án phát triển. Xem [danh sách người đóng góp](docs/CONTRIBUTORS.md).
Cảm ơn [DeepSeek](https://github.com/deepseek-ai) vì các model và sự hỗ trợ đã
khởi đầu dự án, [DataWhale](https://github.com/datawhalechina) 🐋 vì đã chào
đón chúng tôio đại gia đình Whale Brother, và
[OpenWarp](https://github.com/zerx-lab/warp) cùng
[Open Design](https://github.com/nexu-io/open-design) vì đã hợp tác xây dựng
trải nghiệm agent trên terminal.
## Giấy phép
[MIT](LICENSE). Các phần được điều chỉnh từ những dự án nguồn mở khác được ghi trong [thông báo của bên thứ ba](docs/THIRD_PARTY_NOTICES.md).
[MIT](LICENSE). Dự án cộng đồng độc lập; không trực thuộc bất kỳ nhà cung cấp
model nào.
[![Biểu đồ Star History](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
+43 -45
View File
@@ -1,79 +1,77 @@
<!-- source: README.md sha256:a56bca473dbd -->
<!-- source: README.md sha256:4fb18fffb0fe -->
# Codewhale
Codewhale 是一款面向终端的开源编程智能体,使用 Rust 构建,并与用户一起在公开协作中不断改进
一个面向终端的开源编程智能体——模型由你自带
![Codewhale 在终端中运行](assets/screenshot.webp)
Codewhale 最初是为 DeepSeek 打造的原生体验,如今已成长为一个由社区驱动的项目:一套契合日益壮大的国际社区需求的编程工具,尽可能支持更多的模型与 provider——开放模型优先,托管或本地皆可,彼此之间没有谁被优先对待。
[English](README.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [繁體中文](README.zh-TW.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
给它一个 provider、一个模型和一个任务:它会读你的代码、改文件、跑命令、检查自己的工作,并在任务完成或需要你介入时停下。任务中途用 `/model` 切换模型。交互式工作用 TUI,脚本和 CI 用 `codewhale exec`。它用 Rust 编写,采用 MIT 许可,运行在你自己的机器上。
和其他 harness 不一样的地方在于:**每个角色用哪个模型由你决定,而且它们不必相同。** 一个 Fleet 为每个角色分别固定 provider、模型和推理档位——所以又快又便宜的模型可以指挥昂贵的推理模型,GLM 的 builder 也可以和 Kimi 的 reviewer 干同一份活。写下你自己的角色、你自己的 constitution,这套 harness 就是你的,而不是我们的。
我们一直在寻找贡献者和改进的方式。如果你在用的某个模型或 provider 还不支持,或者有什么东西坏了,告诉我们就是你能做的最有用的事之一——见[贡献](#贡献)。
[English](README.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [codewhale.net](https://codewhale.net/) · [Docs](docs) · [Changelog](CHANGELOG.md) · [Discord 社区](https://discord.gg/37gfS3ksug)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
[![Discord](https://img.shields.io/badge/Discord-join%20the%20community-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
![Codewhale 在终端中运行](assets/screenshot.png)
## 安装
```bash
npm install -g codewhale
codewhale
```
首次运行会帮助你连接提供商,也可以选择保持离线。Codewhale 还支持 Cargo、Docker、Nix、Scoop、预构建压缩包、Android/Termux 和 CNB 镜像。请参阅[安装指南](docs/INSTALL.md)。
每种 shell 只需一条命令即可启用 Tab 补全——`codewhale completion bash|zsh|fish|powershell|elvish`。请参阅 [shell 补全](docs/INSTALL.md#8-shell-completions)。
Cargo、Docker、Nix、Scoop、预编译归档、Android/Termux,以及面向无法访问 GitHub 用户的 CNB 镜像,均见 [docs/INSTALL.md](docs/INSTALL.md)。从 `deepseek-tui` 迁移过来?你的配置和会话可以直接沿用——见 [docs/REBRAND.md](docs/REBRAND.md)。
## 使用
像与队友交流一样向 Codewhale 描述任务:
```text
Fix the failing tests and explain what changed.
```
也可以不打开 TUI,直接运行任务:
```bash
codewhale exec "fix the failing tests and explain what changed"
codewhale auth set --provider deepseek # or export ANTHROPIC_API_KEY, etc.
codewhale # open the TUI
codewhale exec "fix the failing test" # headless
codewhale web # local browser client on 127.0.0.1
```
Codewhale 可以读取你的代码仓库、编辑文件、运行命令、检查结果,并持续推进目标。由你决定授予它多少访问权限。
## 为什么选择 Codewhale
在 TUI 中:`/model` 同时切换 provider 和模型,`/fleet` 构建并运行团队——一次一个角色,各自带着自己的模型,`/undo` 撤销上一轮,`/restore <N>` 把工作区回滚到更早的快照(不带参数的 `/restore` 只列出快照)。输入区为空时,`Tab` 在 Plan / Work / Operate 之间循环切换;输入区有内容时,`Tab` 改为补全斜杠命令和 `@` 提及。`Shift+Tab` 在任何时候都能循环切换 Ask / Auto-Review / Full Access 权限姿态。`!` 让 shell 命令经由正常的审批路径运行。
- **使用你想要的模型。** 连接托管提供商,或通过 Ollama、vLLM、SGLang 使用本地模型。使用 `/model` 切换提供商和模型。
- **掌控始终在你手中。** Plan 模式为只读。Ask、Auto-Review 和 Full Access 会清晰展示审批行为。`/undo` 可撤销上一轮操作,`/restore` 可将工作区恢复到较早的快照。
- **让长时间任务井然有序。** 保存会话、设置持久的 `/goal`、在工作流运行前进行审查,并协调多个智能体,同时不让其内部指令混入你的对话记录。
- **扩展你已有的智能体。** 连接 MCP 服务器和技能、配置钩子,并将智能体角色作为可读文件保存在项目或个人设置中。
## 功能
在 TUI 中运行 `/help` 可查看命令和键盘快捷键
- **任意模型,任意 provider——也可以任意混搭。** DeepSeek、Claude、GPT、Kimi、GLM 等 30 多家 provider,以及你自己的 vLLM、SGLang、Ollama——无需 key——全都跑在同一套运行时和同一套工具之上。保存下来的角色会显式记录它的 `provider``model` 和推理档位,所以一个 Fleet 可以在同一次运行里跨越多家厂商,角色的路由也不会取决于当时恰好激活的是哪个 provider。上下文预算与价格取自真实路由;价格未知时显示未知,而不是 $0
- **由你亲手写就的 harness。** 角色就是你能读、能改的文件——每个角色一个模型、一套工具姿态和一份常驻指令——放在项目里让团队共享,或放在你的个人设置旁边,跟着你在不同仓库之间走。constitution 记录你希望 agent 在每一次会话中如何行事,让这套 harness 贴合你的做法,而不是我们的。
- **默认只读,放开权限才更进一步。** Plan 模式不改动文件,审批把关每一次高风险命令。只有当命令确实被 OS 沙箱包装时,Codewhale 才会如实标明:macOS 上是可用时启用的 Seatbelt,Linux 上是需显式启用的 bubblewrap。仓库的 `constitution.json` 会编译成写入拦截,连 Full Access 也无法跳过。
- **随时可以续跑的工作。** Fleet 把每一步记录在只追加的账本里,`fleet resume` 从你停下的地方继续。
## 安全
## 了解更多
Codewhale 在你的机器上运行,并仅拥有你授予的访问权限。审批模式和仓库规则会限制智能体的行为;在支持的平台上,可选的操作系统沙箱可提供更强的执行边界。未知的模型价格会保持显示为未知,而不会被误报为免费。
- [docs/PROVIDERS.md](docs/PROVIDERS.md) — 每一条 provider 路由:托管、网关与本地
- [docs/FLEET.md](docs/FLEET.md) — Fleet、账本与恢复
- [docs/WORKFLOW_EXPERIMENTAL_SEARCH.md](docs/WORKFLOW_EXPERIMENTAL_SEARCH.md) — Workflow 内已冻结、provider 中立的实验性搜索
- [docs/CONFIGURATION.md](docs/CONFIGURATION.md) — `config.toml`、hooks 与 constitution
- [docs/AUTHORIZATION_ORDER.md](docs/AUTHORIZATION_ORDER.md) — 模式、hook、权限规则、安全下限、仓库规则、审批和沙箱如何组合
- [docs/HOOKS.md](docs/HOOKS.md) — 十一个 TUI 生命周期 hook 事件、其载荷,以及其中可引导回合的三个事件(`codewhale exec` 和 CLI 子命令不会触发 hooks)
- [docs/WEB.md](docs/WEB.md) — 仅限回环地址的内置浏览器客户端及其一次性身份验证边界
阅读[授权顺序](docs/AUTHORIZATION_ORDER.md)了解确切的策略层级,阅读[配置](docs/CONFIGURATION.md)了解本地设置
其余内容——模式、键位绑定、沙箱细节、MCP、运行时 API、架构——见 [docs](docs) 与 [codewhale.net](https://codewhale.net/)
## 文档
## 贡献
- [提供商和本地模型](docs/PROVIDERS.md)
- [智能体团队](docs/FLEET.md)
- [MCP](docs/MCP.md)、[钩子](docs/HOOKS.md)和[配置](docs/CONFIGURATION.md)
- [本地 Web 客户端](docs/WEB.md)
- [全部文档](docs)
Issue、PR、复现步骤、日志和功能请求,在这里都算真实的项目工作,也欢迎第一次贡献。当一个 PR 无法原样合并时,维护者会吸收其中可用的部分,并保留作者的署名——在提交、更新日志和 [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) 中。
## 加入社区
- [开放 issue](https://github.com/Hmbown/CodeWhale/issues) —— 适合入门的贡献在这里
- [CONTRIBUTING.md](CONTRIBUTING.md) —— 开发环境搭建与 PR 流程
- [docs/CONTRIBUTORS.md](docs/CONTRIBUTORS.md) —— 每一位塑造过这个项目的人
- [Buy me a coffee](https://www.buymeacoffee.com/hmbown)
当人们使用 Codewhale、反馈不顺手之处并帮助修复问题时,它就会变得更好。如果缺少某个提供商、工作流体验不佳,或终端界面妨碍了你,请[提交 issue](https://github.com/Hmbown/CodeWhale/issues)。如果你知道如何改进,请[提交 pull request](CONTRIBUTING.md)。我们欢迎首次贡献,贡献者也会保留已合入工作的署名
加入 [Discord](https://discord.gg/37gfS3ksug),或在微信添加 Hunter`hunterbown`)并申请加入 Whale Brothers 群。
## 项目历史
Codewhale 起初名为 `deepseek-tui`,至今仍保留与其配置和会话的兼容性。如今它已不偏向任何提供商,由社区独立维护,也不隶属于任何模型提供商。
感谢每一位贡献者,以及帮助项目成长的开源社区。请参阅[贡献者记录](docs/CONTRIBUTORS.md)。
感谢 [DeepSeek](https://github.com/deepseek-ai) 提供让项目起步的模型与支持,感谢 [DataWhale](https://github.com/datawhalechina) 🐋 欢迎我们加入“鲸兄弟”大家庭,也感谢 [OpenWarp](https://github.com/zerx-lab/warp) 与 [Open Design](https://github.com/nexu-io/open-design) 在终端智能体体验上的协作
## 许可证
[MIT](LICENSE)。从其他开源项目改编的部分记录在[第三方声明](docs/THIRD_PARTY_NOTICES.md)中
[MIT](LICENSE)。独立的社区项目,与任何模型 provider 均无隶属关系
[![Star History Chart](https://api.star-history.com/chart?repos=Hmbown/CodeWhale&type=date&legend=top-left)](https://www.star-history.com/?repos=Hmbown%2FCodeWhale&type=date)
-79
View File
@@ -1,79 +0,0 @@
<!-- source: README.md sha256:a56bca473dbd -->
# Codewhale
Codewhale 是一款在終端機中使用的開源程式設計代理,以 Rust 打造,並與使用者一起透過公開協作持續改進。
![Codewhale 在終端機中執行](assets/screenshot.webp)
[English](README.md) · [简体中文](README.zh-CN.md) · [日本語](README.ja-JP.md) · [Tiếng Việt](README.vi.md) · [Bahasa Indonesia](README.id.md) · [한국어](README.ko-KR.md) · [Español](README.es-419.md) · [Português](README.pt-BR.md) · [Русский](README.ru.md) · [Українська](README.uk.md) · [Français](README.fr.md) · [Deutsch](README.de.md) · [हिन्दी](README.hi.md) · [Türkçe](README.tr.md) · [Italiano](README.it.md) · [Polski](README.pl.md) · [العربية](README.ar.md) · [Català](README.ca.md)
[![CI](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml/badge.svg)](https://github.com/Hmbown/CodeWhale/actions/workflows/ci.yml)
[![crates.io](https://img.shields.io/crates/v/codewhale-cli?label=crates.io)](https://crates.io/crates/codewhale-cli)
[![npm](https://img.shields.io/npm/v/codewhale?label=npm)](https://www.npmjs.com/package/codewhale)
[![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/37gfS3ksug)
## 安裝
```bash
npm install -g codewhale
codewhale
```
第一次執行時,系統會協助你連線至供應商,也可以選擇保持離線。Codewhale 亦支援 Cargo、Docker、Nix、Scoop、預先建置的封存檔、Android/Termux 與 CNB 映像。請參閱[安裝指南](docs/INSTALL.md)。
每種 shell 只需一個指令即可啟用 Tab 自動完成——`codewhale completion bash|zsh|fish|powershell|elvish`。請參閱 [shell 自動完成](docs/INSTALL.md#8-shell-completions)。
## 使用
像和隊友交談一樣告訴 Codewhale 你的需求:
```text
Fix the failing tests and explain what changed.
```
你也可以不開啟 TUI,直接執行任務:
```bash
codewhale exec "fix the failing tests and explain what changed"
```
Codewhale 可以讀取你的程式碼儲存庫、編輯檔案、執行指令、檢查結果,並持續朝目標推進。你可以決定要授予它多少存取權限。
## 為何選擇 Codewhale
- **使用你想要的模型。** 連線至託管供應商,或透過 Ollama、vLLM、SGLang 使用本機模型。使用 `/model` 切換供應商與模型。
- **掌控權始終在你手中。** Plan 模式為唯讀。Ask、Auto-Review 與 Full Access 會清楚呈現核准行為。`/undo` 可復原上一輪操作,`/restore` 可將工作區還原至較早的快照。
- **讓長時間工作井然有序。** 儲存工作階段、設定持久的 `/goal`、在工作流程執行前加以審查,並協調多個代理,同時避免其內部指示混入你的對話記錄。
- **擴充你已有的代理。** 連接 MCP 伺服器與技能、設定掛鉤,並將代理角色以可讀檔案保存在專案或個人設定中。
在 TUI 中執行 `/help` 可查看指令與鍵盤快速鍵。
## 安全性
Codewhale 在你的電腦上執行,且只擁有你授予的存取權限。核准模式與儲存庫規則會限制代理可以執行的操作;在支援的平台上,選用的作業系統沙箱可提供更強的執行邊界。未知的模型價格會維持顯示為未知,而不會被誤報為免費。
閱讀[授權順序](docs/AUTHORIZATION_ORDER.md)以了解確切的政策層級,並閱讀[設定](docs/CONFIGURATION.md)以了解本機設定。
## 文件
- [供應商與本機模型](docs/PROVIDERS.md)
- [代理團隊](docs/FLEET.md)
- [MCP](docs/MCP.md)、[掛鉤](docs/HOOKS.md)與[設定](docs/CONFIGURATION.md)
- [本機網頁用戶端](docs/WEB.md)
- [所有文件](docs)
## 加入社群
當人們使用 Codewhale、回報不順手之處並協助修正問題時,它就會變得更好。如果缺少某個供應商、工作流程操作不便,或終端機介面妨礙了你,請[提出 issue](https://github.com/Hmbown/CodeWhale/issues)。如果你知道如何改善,請[提出 pull request](CONTRIBUTING.md)。我們歡迎首次貢獻,貢獻者也會保留已合併工作的署名。
加入 [Discord](https://discord.gg/37gfS3ksug),或在微信加入 Hunter`hunterbown`)並申請加入 Whale Brothers 群組。
## 專案歷史
Codewhale 最初名為 `deepseek-tui`,至今仍保留與其設定及工作階段的相容性。現在它不偏向任何供應商,由社群獨立維護,也不隸屬於任何模型供應商。
感謝每一位貢獻者,以及協助專案成長的開源社群。請參閱[貢獻者記錄](docs/CONTRIBUTORS.md)。
## 授權條款
[MIT](LICENSE)。從其他開放原始碼專案改編的部分記錄於[第三方聲明](docs/THIRD_PARTY_NOTICES.md)。
-39
View File
@@ -1,39 +0,0 @@
# Third-party notices
Source vendored or ported into this repository, beyond the crates resolved by
Cargo (whose licences are enforced by `deny.toml`).
## pi (`pi-mono`) — MIT
`crates/config/src/device_code.rs` is a Rust port of pi's OAuth device-code
polling loop and verification-URI check:
- `packages/ai/src/auth/oauth/device-code.ts` (`pollOAuthDeviceCodeFlow`,
the RFC 8628 polling behaviours)
- `packages/ai/src/auth/oauth/xai.ts` (`validateVerificationUri`)
Upstream: <https://github.com/badlogic/pi-mono>
```
MIT License
Copyright (c) 2025 Mario Zechner
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

+47 -125
View File
@@ -21,7 +21,7 @@
# `api_key` / `base_url` are
# still read as DeepSeek defaults when `[providers.deepseek]` is absent
# (backward compatibility).
provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | orcarouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | opencode-zen | meta | xai | modelstudio-token-plan | modelstudio-coding-plan
provider = "deepseek" # deepseek | deepseek-cn | deepseek-anthropic | nvidia-nim | openai | atlascloud | wanjie-ark | volcengine | openrouter | xiaomi-mimo | novita | fireworks | siliconflow | siliconflow-CN | arcee | moonshot | zai | stepfun | minimax | sglang | vllm | ollama | huggingface | together | qianfan | openai-codex | anthropic | openmodel | deepinfra | sakana | longcat | opencode-go | opencode-zen | meta | xai | modelstudio-token-plan | modelstudio-coding-plan
api_key = "YOUR_DEEPSEEK_API_KEY" # must be non-empty
base_url = "https://api.deepseek.com/beta"
# provider = "deepseek-cn" # legacy alias (official host is still https://api.deepseek.com)
@@ -43,14 +43,14 @@ base_url = "https://api.deepseek.com/beta"
# xiaomi/mimo-v2.5-pro — OpenRouter Xiaomi MiMo 2.5 Pro
# xiaomi/mimo-v2.5 — OpenRouter Xiaomi MiMo 2.5
# z-ai/glm-5.1 — OpenRouter Z.AI GLM 5.1
# z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2
# z-ai/glm-5.3 — OpenRouter Z.AI GLM 5.3 (live on Z.ai since 2026-08-13;
# metadata inherited from 5.2, unpriced)
# z-ai/glm-5.2 — OpenRouter Z.AI GLM 5.2 (default)
# z-ai/glm-5.3 — OpenRouter Z.AI GLM 5.3 (registered only; not released by Z.ai
# as of 2026-08-03 — metadata inherited from 5.2, unpriced)
# z-ai/glm-5-turbo — OpenRouter Z.AI GLM 5 Turbo (scout fast sibling)
# GLM-5.3 — default direct Z.AI Coding Plan model (live since 2026-08-13;
# metadata inherited from 5.2, unpriced)
# GLM-5.2 — direct Z.AI GLM 5.2 (previous default; explicit selections keep it)
# GLM-5.2 — default direct Z.AI Coding Plan model
# GLM-5.1 — direct Z.AI smaller model
# GLM-5.3 — direct Z.AI GLM 5.3 (registered only; not live on the Z.ai API
# as of 2026-08-03 — metadata inherited from 5.2, unpriced)
# GLM-5-Turbo — direct Z.AI fast model (scout fast sibling)
# step-3.7-flash — default direct StepFun / StepFlash model ID
# kimi-k3 — direct Moonshot K3 model ID (1M context)
@@ -93,17 +93,9 @@ default_text_model = "deepseek-v4-pro"
# Auto-Review / Full Access — not the reasoning tier.)
reasoning_effort = "max"
# NOTE: `show_thinking`, `thinking_default_expanded`, `thinking_preview_lines`,
# `help_expand_groups`, `pin_last_prompt`, and `cost_currency` live in
# `~/.codewhale/settings.toml`, not here — `Config` has no such fields and
# unknown keys are ignored. See crates/tui/src/settings.rs.
#
# Density (Grok-like compact defaults; turn these up if you want more shown):
# thinking_preview_lines = 2 # 0 header-only, 10 older dump
# thinking_default_expanded = false
# help_expand_groups = false # true = F1 starts fully expanded
# pin_last_prompt = true
# show_tool_details = false
# NOTE: `show_thinking`, `thinking_default_expanded`, and `cost_currency`
# live in `~/.codewhale/settings.toml`, not here — `Config` has no such
# fields and unknown keys are ignored. See crates/tui/src/settings.rs.
# ─────────────────────────────────────────────────────────────────────────────────
# Startup update check
@@ -130,10 +122,8 @@ check_interval_hours = 24
#
# Invalid slots are skipped with a warning, duplicate slots use the last entry,
# and unknown actions are preserved so the UI can show a disabled entry.
# Slash commands can be bound as slash.<name>, for example slash.workflow.
# `/hotbar on` writes the default slots: slash.workflow, slash.goal, slash.auto,
# then Plan/Work/Operate, palette, and sidebar. Commands that require arguments
# pre-fill the composer instead of running incomplete.
# Slash commands can be bound as slash.<name>, for example slash.mode. Commands
# that require arguments pre-fill the composer instead of running incomplete.
#
# [[hotbar]]
# slot = 1
@@ -266,44 +256,8 @@ memory_path = "~/.codewhale/memory.md"
allow_shell = true
approval_policy = "on-request" # on-request | untrusted | never
sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-access | external-sandbox
# Whether a workspace-write sandbox also lets shell commands reach the
# network. Default false: being allowed to edit this repository is not a
# reason to be allowed to open outbound connections, so `curl`, package
# installs, and `git fetch` are denied by the OS sandbox unless you opt in.
# When a command is denied, Codewhale offers an elevation prompt that grants
# network for that call only; set this to `true` to grant it for the whole
# session instead. `danger-full-access` and `yolo` are unsandboxed and
# unaffected by this key. Note that on platforms with no OS sandbox backend
# (default Linux without bubblewrap, and Windows) nothing is enforced either
# way -- `/status` and `doctor` both say so.
# sandbox_network_access = false
# Which other agents' instruction files to import as project instructions.
# Empty by default. Codewhale reads AGENTS.md (the cross-agent standard) and
# its own .codewhale/instructions.md without being asked; a CLAUDE.md,
# .cursorrules, or .github/copilot-instructions.md written as law for a
# different tool is not treated as law here until you say so. Codewhale's own
# files always outrank anything imported.
# Accepts: "claude", "cursor", "cline", "windsurf", "gemini", "copilot",
# "muse", or "all". Env: CODEWHALE_PROJECT_INSTRUCTION_IMPORTS (comma-separated).
# project_instruction_imports = ["claude"]
#
# Everything that reaches the model as standing project instruction authority —
# the repository-root -> workspace AGENTS.md chain, the global fallback layer,
# .codewhale/rules/*.md, and any imported foreign files — shares one 48 KiB
# aggregate budget. Instructions claim it first and are trimmed from the
# broadest scope inward, so the nearest-scope file is the last thing dropped.
# prompt_suggestion = true # opt-in: show ghost-text follow-up question in composer after each turn
# Optional tab/window title shown as `[title] …` in front of the terminal
# window title (the `Codewhale` / `reasoning…` / `using tool…` / `done`
# states). Multi-window setups can give each workspace its own `--config`
# file (or profile) so alt-tabbed sessions are identifiable at a glance.
# The `/title` command overrides this per session; `/config title … --save`
# persists a new default here. Run `/title off` to drop a session override.
# title = "workspace-x"
# Typed permission rules live in a sibling `permissions.toml` file, not in
# config.toml. Each `[[rules]]` entry accepts `tool`, optional `command`
# or `path`, optional absolute `workspace`, optional `command_exact = true`,
@@ -419,20 +373,7 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
#
# prefer_bwrap = false # default — use Landlock only
#
# Env override: CODEWHALE_PREFER_BWRAP=true
# Legacy alias (deprecated until 0.10.0): DEEPSEEK_PREFER_BWRAP=true
#
# With prefer_bwrap = true, the sandbox gets a private /dev and /proc plus a
# writable isolated /tmp by default, so toolchains work without widening the
# filesystem policy (#5410). Two optional escape hatches:
#
# bwrap_ro_roots = [] # extra host paths bind-mounted read-only, e.g.
# # ["/usr/lib", "/usr/lib64"] for system-library
# # linking when the root bind is narrowed
# bwrap_dev_roots = [] # host device nodes bind-mounted read-write, e.g.
# # ["/dev/null"] for redirection against the host
# # node — character/block devices only, never
# # directories, so this cannot widen file writes
# Env override: DEEPSEEK_PREFER_BWRAP=true
# auto_allow entries match by command prefix, not raw string.
# See command_safety.rs for the prefix dictionary.
@@ -443,7 +384,7 @@ sandbox_mode = "workspace-write" # read-only | workspace-write | danger-full-acc
# auto_allow = ["cargo check", "npm run"]
#
# auto_allow = []
max_subagents = 10 # optional (default 64, clamped to 1-128)
max_subagents = 10 # optional (1-20)
# Optional sub-agent tuning. max_concurrent overrides top-level max_subagents.
# [subagents]
@@ -454,7 +395,7 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# max_depth = 0 # opt out completely — the agent never spawns sub-agents
# max_depth = 1 # the agent may spawn sub-agents, but those may not spawn more
# max_depth = 2 # one more level of nesting, etc.
# Unset defaults to 3; any value is clamped to the hard ceiling (8). The depth
# Unset defaults to 3; any value is clamped to the hard ceiling (3). The depth
# limit is enforced in code, not requested of the model — a sub-agent past the
# limit cannot be spawned regardless of what the model decides.
# max_depth = 3
@@ -588,15 +529,6 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# moonshotai/kimi-k2.6,
# nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free, and nvidia/nemotron-3-ultra.
# OrcaRouter — OpenAI-compatible aggregation gateway (https://www.orcarouter.ai)
[providers.orcarouter]
# api_key = "YOUR_ORCAROUTER_API_KEY"
# base_url = "https://api.orcarouter.ai/v1"
# model = "deepseek/deepseek-v4-pro"
# Namespaced wire models pass through verbatim (deepseek/deepseek-v4-pro,
# deepseek/deepseek-v4-flash); OrcaRouter's own auto-routing model is
# selectable as "orcarouter/auto".
# Xiaomi MiMo OpenAI-compatible endpoint (https://platform.xiaomimimo.com)
[providers.xiaomi_mimo]
# api_key = "YOUR_XIAOMI_KEY"
@@ -674,12 +606,12 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# base_url = "https://api.z.ai/api/coding/paas/v4"
# # General API endpoint, if you are not using the Coding Plan:
# # base_url = "https://api.z.ai/api/paas/v4"
# model = "GLM-5.3" # default; GLM-5.2 is the previous default, GLM-5.1 the smaller model, GLM-5-Turbo the fast sub-agent sibling
# # GLM-5.3 is live on the Z.ai Coding Plan (2026-08-13). Its catalog metadata
# # (limits, reasoning options) is inherited from GLM-5.2 until Z.ai publishes
# # distinct 5.3 numbers, and it carries no price. An explicit model = "GLM-5.2"
# # keeps sending GLM-5.2; only the default moved. Accounts not provisioned for
# # 5.3 can still see a 429 with entitlement code 1311.
# model = "GLM-5.2" # default; GLM-5.1 is the smaller model, GLM-5-Turbo the fast sub-agent sibling
# # GLM-5.3 is registered/selectable (model = "GLM-5.3") so the id resolves to
# # Z.ai instead of being rewritten to another model, but it was NOT live on the
# # Z.ai API as of 2026-08-03 and will fail upstream until Z.ai ships it. Its
# # catalog metadata is inherited from GLM-5.2 pending official Z.ai release
# # metadata, and it carries no price. GLM-5.2 remains the default.
# StepFun / StepFlash direct OpenAI-compatible endpoint (https://platform.stepfun.ai)
[providers.stepfun]
@@ -800,7 +732,7 @@ max_subagents = 10 # optional (default 64, clamped to 1-128)
# api_key = "YOUR_XAI_API_KEY"
# auth_mode = "oauth" # or "device_code" / "grok_cli"
# base_url = "https://api.x.ai/v1"
# model = "grok-4.6" # or grok-4.5, grok-4.3, grok-build
# model = "grok-4.5" # or grok-4.3, grok-build
# Mistral AI — la Plateforme (https://console.mistral.ai/)
# OpenAI-compatible Chat Completions route.
@@ -1069,19 +1001,6 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer
# # Note: this only affects TUI labels/chrome — it does NOT change model output language.
# mention_menu_behavior = "fuzzy" # fuzzy | browser; browser lists immediate directory children for @-mentions.
# ─────────────────────────────────────────────────────────────────────────────────
# Transcript
# ─────────────────────────────────────────────────────────────────────────────────
# Prose — user messages, assistant answers, and reasoning/thinking — fills the
# full content width, consistent with tool/status cells and the wide-frame
# decision in #5322 (#5436). Owners who want a bounded reading measure on
# ultrawide terminals can cap it in columns.
[transcript]
# prose_measure = 120 # positive integer: cap prose wrap at N columns.
# 0 or absent = full content width (default).
# Must be a positive whole number; tool, diff, and
# status cells always keep the full content width.
# ─────────────────────────────────────────────────────────────────────────────────
# Feature Flags
# ─────────────────────────────────────────────────────────────────────────────────
@@ -1157,12 +1076,6 @@ exponential_base = 2.0
#
# [workshop]
# large_output_threshold_tokens = 4096
# # Optional model-visible byte ceilings (#5367). Absent keeps the
# # compile-time defaults (read 50KiB / read_file 16KiB, then the
# # compact 12K-char floor). Values raise the floor; they never lower
# # it. Hard cap is 2MiB.
# # read_result_max_bytes = 102400
# # tool_result_max_bytes = 102400
# [workshop.per_tool_thresholds]
# Bash = 2048 # shell output synthesised aggressively
# Web = 8192 # web results can be large; give them more room
@@ -1507,9 +1420,8 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# max_children = 1000
# # Maximum concurrently live agents inside one run (others wait for a slot).
# max_concurrent = 16
# # Maximum structural nesting depth accepted for Workflow IR (default 5).
# # This is separate from Runtime child delegation below.
# max_depth = 5
# # Maximum nested Workflow / child-orchestration depth.
# max_depth = 2
# # Default shared token budget for a Workflow run and its children.
# default_token_budget = 120000
# # Parallel write children that may share the parent worktree without
@@ -1521,13 +1433,15 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# persist_completed_across_restarts = true
# ─────────────────────────────────────────────────────────────────────────────────
# Agent Fleet roster, role registry, and execution requests (#3165, #3167)
# Agent Fleet trust, security, and role registry (#3165, #3167)
# ─────────────────────────────────────────────────────────────────────────────────
# [fleet]
# # Fleet stores member identity and route intent. Project trust, host identity,
# # secrets, approvals, sandboxing, filesystem/network reach, and tool authority
# # are Runtime policy and are deliberately not configured here. Pre-0.9.11
# # trust keys are accepted only as ignored migration input.
# # Default trust level for fleet workers: "sandbox" | "local" | "remote-verified" | "operator"
# default_trust_level = "sandbox"
# # Require SSH host-key verification before granting remote-verified trust
# require_identity_verification = true
# # Maximum trust level any worker may have
# max_trust_level = "operator"
#
# # Headless worker execution hardening (#3027)
# [fleet.exec]
@@ -1535,13 +1449,12 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# allowed_tools = []
# # Tools always disallowed (overrides role and task spec)
# disallowed_tools = ["exec_shell"]
# # Optional hard ceiling on worker steps (tool calls + model turns).
# # Omit or set 0 for the unbounded default; use a positive value to opt in.
# # Hard ceiling on worker steps (tool calls + model turns)
# max_turns = 500
# # Runtime child-agent depth for fleet workers. Shares ONE recursion axis with
# # standalone sub-agents (a fleet worker IS a headless sub-agent). This is an
# # execution request, not Fleet member identity. 0 blocks child agents (the
# # root worker still runs); 3 is the default and 8 is the opt-in hard ceiling.
# # Recursive child-agent depth for fleet workers. Shares ONE recursion axis
# # with standalone sub-agents (a fleet worker IS a headless sub-agent).
# # 0 blocks child agents (the root worker still runs); 3 is the default and the
# # cap, affording at least three nested delegation levels.
# max_spawn_depth = 3
# # Extra system prompt injected into every headless worker
# append_system_prompt = "Never modify .git/config or change remotes."
@@ -1564,6 +1477,11 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# description = "Runs linters and formatters"
# instructions = "Run cargo fmt --check and cargo clippy; never apply fixes."
#
# [fleet.profiles.ci-linter.permissions]
# allow_shell = true # the only three keys are allow_shell, trust,
# trust = false # and approval_required (FleetProfilePermissions
# approval_required = true # in crates/config/src/lib.rs)
#
# [fleet.profiles.pr-reviewer]
# slot = "reviewer"
# loadout = "inherit"
@@ -1578,8 +1496,7 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
#
# Multiple named Fleets may coexist alongside the default [fleet] table.
# Each [fleets.<name>] entry must include an `operator` field and may configure
# its own roles, profiles, and execution requests independently. Runtime policy
# remains separate from Fleet identity.
# its own trust levels, roles, profiles, and exec policy independently.
#
# Selection precedence (most specific wins):
# 1. Explicit fleet name — config.resolve_fleet("name")
@@ -1593,6 +1510,10 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
# [fleets.alice-team]
# # Required: the operator/leader identity for this fleet.
# operator = "alice"
# # These fields are identical to [fleet] and use the same defaults.
# default_trust_level = "local"
# require_identity_verification = true
# max_trust_level = "operator"
#
# [fleets.alice-team.exec]
# max_turns = 200
@@ -1611,6 +1532,7 @@ default_text_model = "deepseek-ai/deepseek-v4-pro"
#
# [fleets.bob-team]
# operator = "bob"
# default_trust_level = "sandbox"
#
# [fleets.bob-team.profiles.implementer]
# slot = "implementer"
+1 -4
View File
@@ -7,9 +7,6 @@ license.workspace = true
repository.workspace = true
description = "Model/provider registry and fallback strategy for Codewhale"
[lints]
workspace = true
[dependencies]
codewhale-config = { path = "../config", version = "0.9.11" }
codewhale-config = { path = "../config", version = "0.9.6" }
serde.workspace = true
+24 -184
View File
@@ -89,16 +89,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-v4-flash-vision-exp".to_string(),
provider: ProviderKind::Deepseek,
aliases: vec![
"flash-vision".to_string(),
"deepseek-v4flashvisionexp".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/deepseek-v4-pro".to_string(),
provider: ProviderKind::NvidiaNim,
@@ -281,27 +271,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek/deepseek-v4-pro".to_string(),
provider: ProviderKind::Orcarouter,
aliases: vec!["orcarouter-deepseek-v4-pro".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek/deepseek-v4-flash".to_string(),
provider: ProviderKind::Orcarouter,
aliases: vec!["orcarouter-deepseek-v4-flash".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "orcarouter/auto".to_string(),
provider: ProviderKind::Orcarouter,
aliases: vec!["orcarouter-auto".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "arcee-ai/trinity-large-thinking".to_string(),
provider: ProviderKind::Openrouter,
@@ -424,8 +393,8 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
// GLM-5.3 is live; capabilities still inherit from glm-5.2 until
// Z.ai publishes distinct 5.3 numbers. See
// GLM-5.3 capabilities are INHERITED FROM glm-5.2 PENDING OFFICIAL
// Z.AI RELEASE METADATA (2026-08-03); see
// crates/config/assets/models_dev.bundled.json
// `_meta.pending_release_metadata`.
ModelInfo {
@@ -442,20 +411,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "GLM-5.3".to_string(),
provider: ProviderKind::Zai,
aliases: vec![
"glm-5.3".to_string(),
"glm-5-3".to_string(),
"zai-glm-5.3".to_string(),
"zai-glm-5-3".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
// The first Z.ai row is the provider default. Keep this ordering
// aligned with `DEFAULT_ZAI_MODEL` in codewhale-config.
ModelInfo {
id: "GLM-5.2".to_string(),
provider: ProviderKind::Zai,
@@ -468,6 +423,20 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
// Listed after GLM-5.2 on purpose: the first Zai row is the
// provider default and GLM-5.2 keeps that seat.
ModelInfo {
id: "GLM-5.3".to_string(),
provider: ProviderKind::Zai,
aliases: vec![
"glm-5.3".to_string(),
"glm-5-3".to_string(),
"zai-glm-5.3".to_string(),
"zai-glm-5-3".to_string(),
],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "GLM-5.1".to_string(),
provider: ProviderKind::Zai,
@@ -759,13 +728,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gpt-oss:120b".to_string(),
provider: ProviderKind::OllamaCloud,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "deepseek-ai/DeepSeek-V4-Pro".to_string(),
provider: ProviderKind::Huggingface,
@@ -843,16 +805,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: true,
},
// Claude Opus 5 (GA 2026-07-24; API id/alias `claude-opus-5`, 1M
// context / 128K output per
// https://platform.claude.com/docs/en/about-claude/models/overview).
ModelInfo {
id: "claude-opus-5".to_string(),
provider: ProviderKind::Anthropic,
aliases: vec!["opus-5".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "claude-sonnet-4-6".to_string(),
provider: ProviderKind::Anthropic,
@@ -1174,17 +1126,10 @@ impl Default for ModelRegistry {
supports_reasoning: true,
},
// xAI / Grok (https://api.x.ai/v1)
ModelInfo {
id: "grok-4.6".to_string(),
provider: ProviderKind::Xai,
aliases: vec!["grok".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "grok-4.5".to_string(),
provider: ProviderKind::Xai,
aliases: vec!["xai-grok-4.5".to_string()],
aliases: vec!["grok".to_string(), "xai-grok-4.5".to_string()],
supports_tools: true,
supports_reasoning: true,
},
@@ -1223,64 +1168,6 @@ impl Default for ModelRegistry {
supports_tools: true,
supports_reasoning: false,
},
ModelInfo {
id: "gemini-3.1-pro-preview".to_string(),
provider: ProviderKind::Google,
aliases: vec!["gemini-3.1-pro".to_string()],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3-pro-preview".to_string(),
provider: ProviderKind::Google,
aliases: vec!["gemini-3-pro".to_string()],
supports_tools: true,
supports_reasoning: true,
},
// Gemini 3.7 Flash (2026-08 latest Flash; 1,048,576 in / 65,536 out,
// https://ai.google.dev/gemini-api/docs/models/gemini-3.7-flash).
ModelInfo {
id: "gemini-3.7-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.6-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.5-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-3.5-flash-lite".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: false,
},
ModelInfo {
id: "gemini-2.5-pro".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "gemini-2.5-flash".to_string(),
provider: ProviderKind::Google,
aliases: vec![],
supports_tools: true,
supports_reasoning: true,
},
ModelInfo {
id: "mistral-code-latest".to_string(),
provider: ProviderKind::Mistral,
@@ -1376,15 +1263,12 @@ impl ModelRegistry {
if let Some(name) = requested {
fallback_chain.push(format!("requested:{name}"));
if matches!(
provider_hint,
Some(ProviderKind::Ollama | ProviderKind::OllamaCloud)
) {
if provider_hint == Some(ProviderKind::Ollama) {
return ModelResolution {
requested: Some(name.to_string()),
resolved: ModelInfo {
id: name.trim().to_string(),
provider: provider_hint.expect("matched provider hint"),
provider: ProviderKind::Ollama,
aliases: Vec::new(),
supports_tools: true,
supports_reasoning: false,
@@ -1672,36 +1556,6 @@ mod tests {
assert_eq!(resolved.resolved.id, "deepseek-v4-pro");
}
#[test]
fn deepseek_vision_model_lists_and_resolves_with_aliases() {
let registry = ModelRegistry::default();
let listed = registry.list();
assert!(listed.iter().any(|model| {
model.provider == ProviderKind::Deepseek
&& model.id == "deepseek-v4-flash-vision-exp"
&& model.aliases
== [
"flash-vision".to_string(),
"deepseek-v4flashvisionexp".to_string(),
]
}));
for selector in [
"deepseek-v4-flash-vision-exp",
"flash-vision",
"deepseek-v4flashvisionexp",
] {
let resolved = registry.resolve(Some(selector), Some(ProviderKind::Deepseek));
assert_eq!(
resolved.resolved.id, "deepseek-v4-flash-vision-exp",
"{selector} must resolve to the experimental vision model"
);
assert_eq!(resolved.resolved.provider, ProviderKind::Deepseek);
assert!(!resolved.used_fallback, "{selector} must not fall back");
}
}
#[test]
fn deepseek_v4_pro_alias_resolves_to_nvidia_nim_when_provider_hinted() {
let registry = ModelRegistry::default();
@@ -2062,13 +1916,10 @@ mod tests {
fn zai_direct_models_resolve_when_provider_hinted() {
let registry = ModelRegistry::default();
// Keep the agent registry fallback aligned with codewhale-config's
// DEFAULT_ZAI_MODEL.
// GLM-5.2 is now the default direct Z.AI model.
let default = registry.resolve(None, Some(ProviderKind::Zai));
assert_eq!(default.resolved.provider, ProviderKind::Zai);
assert_eq!(default.resolved.id, "GLM-5.3");
assert!(default.used_fallback);
assert_eq!(default.fallback_chain, ["provider_default:zai"]);
assert_eq!(default.resolved.id, "GLM-5.2");
for (alias, expected) in [
("GLM-5.1", "GLM-5.1"),
@@ -2106,7 +1957,7 @@ mod tests {
(ProviderKind::MinimaxAnthropic, "MiniMax-M3"),
(ProviderKind::Openmodel, "deepseek-v4-flash"),
(ProviderKind::Meta, "muse-spark-1.2"),
(ProviderKind::Xai, "grok-4.6"),
(ProviderKind::Xai, "grok-4.5"),
] {
assert!(
models
@@ -2186,12 +2037,12 @@ mod tests {
let default = registry.resolve(None, Some(ProviderKind::Xai));
assert_eq!(default.resolved.provider, ProviderKind::Xai);
assert_eq!(default.resolved.id, "grok-4.6");
assert_eq!(default.resolved.id, "grok-4.5");
assert!(default.used_fallback);
let alias = registry.resolve(Some("grok"), Some(ProviderKind::Xai));
assert_eq!(alias.resolved.provider, ProviderKind::Xai);
assert_eq!(alias.resolved.id, "grok-4.6");
assert_eq!(alias.resolved.id, "grok-4.5");
assert!(!alias.used_fallback);
let fast = registry.resolve(
@@ -2234,7 +2085,6 @@ mod tests {
#[test]
fn grok_ids_stay_in_grok_family() {
assert_eq!(model_family("grok-4.6"), ModelFamily::Grok);
assert_eq!(model_family("grok-4.5"), ModelFamily::Grok);
assert_eq!(
model_family("grok-4.20-0309-non-reasoning"),
@@ -2430,16 +2280,6 @@ mod tests {
assert!(resolved.resolved.supports_reasoning);
}
#[test]
fn ollama_cloud_default_uses_the_hosted_catalog_model_id() {
let registry = ModelRegistry::default();
let resolved = registry.resolve(None, Some(ProviderKind::OllamaCloud));
assert_eq!(resolved.resolved.provider, ProviderKind::OllamaCloud);
assert_eq!(resolved.resolved.id, "gpt-oss:120b");
assert!(resolved.resolved.supports_reasoning);
}
#[test]
fn ollama_requested_model_tag_is_preserved() {
let registry = ModelRegistry::default();
+10 -13
View File
@@ -9,22 +9,19 @@ description = "App-server transport for Codewhale runtime integrations"
# `codewhale app-server` is owned by codewhale-cli; this crate is library-only.
autobins = false
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
axum.workspace = true
codewhale-agent = { path = "../agent", version = "0.9.11" }
codewhale-config = { path = "../config", version = "0.9.11" }
codewhale-core = { path = "../core", version = "0.9.11" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" }
codewhale-hooks = { path = "../hooks", version = "0.9.11" }
codewhale-mcp = { path = "../mcp", version = "0.9.11" }
codewhale-protocol = { path = "../protocol", version = "0.9.11" }
codewhale-release = { path = "../release", version = "0.9.11" }
codewhale-state = { path = "../state", version = "0.9.11" }
codewhale-tools = { path = "../tools", version = "0.9.11" }
codewhale-agent = { path = "../agent", version = "0.9.6" }
codewhale-config = { path = "../config", version = "0.9.6" }
codewhale-core = { path = "../core", version = "0.9.6" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
codewhale-hooks = { path = "../hooks", version = "0.9.6" }
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
codewhale-release = { path = "../release", version = "0.9.6" }
codewhale-state = { path = "../state", version = "0.9.6" }
codewhale-tools = { path = "../tools", version = "0.9.6" }
serde.workspace = true
serde_json.workspace = true
rustls.workspace = true
+5 -2
View File
@@ -224,7 +224,6 @@ fn endpoint_preserves_raw_model_ids(provider: ProviderKind, base_url: &str) -> b
provider,
ProviderKind::Custom
| ProviderKind::Ollama
| ProviderKind::OllamaCloud
| ProviderKind::Vllm
| ProviderKind::Sglang
| ProviderKind::OpencodeZen
@@ -486,13 +485,17 @@ mod tests {
use axum::http::{Method, Request};
use codewhale_config::provider::WireFormat;
use std::fs;
use std::sync::OnceLock;
use tokio::sync::mpsc;
use tower::ServiceExt;
use super::super::{app_router, build_state};
fn install_crypto_provider() {
crate::install_test_crypto_provider();
static INIT: OnceLock<()> = OnceLock::new();
INIT.get_or_init(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
/// Start a minimal upstream mock server that echoes back what it received.
File diff suppressed because it is too large Load Diff
-3
View File
@@ -7,7 +7,4 @@ license.workspace = true
repository.workspace = true
description = "Shared build-script helpers for embedding Codewhale build metadata"
[lints]
workspace = true
[dependencies]
+10 -38
View File
@@ -1,12 +1,12 @@
//! Shared build-script helpers for the `codewhale-cli`, `codewhale-tui`, and
//! `codewhale-telemetry` build scripts: rerun-condition declarations, the
//! embedded `CODEWHALE_BUILD_VERSION` metadata, and the release-only build sha.
//! embedded `DEEPSEEK_BUILD_VERSION` metadata, and the release-only build sha.
//! Only call these functions from a build script — they emit `cargo:`
//! directives on stdout.
//!
//! Two different shas live here and they are not interchangeable.
//! `CODEWHALE_BUILD_VERSION`/`CODEWHALE_BUILD_COMMIT` describe *the build the
//! environment asked for* (`CODEWHALE_BUILD_SHA`/`DEEPSEEK_BUILD_SHA`/`GITHUB_SHA`); an unstamped
//! `DEEPSEEK_BUILD_VERSION`/`CODEWHALE_BUILD_COMMIT` describe *the build the
//! environment asked for* (`DEEPSEEK_BUILD_SHA`/`GITHUB_SHA`); an unstamped
//! local build renders a `(dev)` marker instead.
//! `CODEWHALE_RELEASE_BUILD_SHA` describes a *published* binary and has no
//! fallback at all, because it leaves the machine.
@@ -21,7 +21,7 @@
//! would report whatever the checkout's HEAD is *now*, which breaks the
//! dogfood-receipt identity `scripts/release/install-dogfood.sh` verifies.
//! So the contract is: a sha appears in the version string only when the
//! build environment supplied one (`CODEWHALE_BUILD_SHA` wins over
//! build environment supplied one (`DEEPSEEK_BUILD_SHA` wins over
//! `GITHUB_SHA`), the build script reruns only when those variables change,
//! and a build nobody stamped says `(dev)`. CI and release builds are
//! byte-identical to the old behavior; dogfood builds pass the sha
@@ -37,14 +37,13 @@ use std::path::Path;
/// `manifest_dir` is accepted (and ignored) so build scripts keep one call
/// shape; it documents that the decision is per-crate, not global state.
pub fn declare_rerun_conditions(_manifest_dir: &Path) {
println!("cargo:rerun-if-env-changed=CODEWHALE_BUILD_SHA");
println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
}
/// Emit `cargo:rustc-env=CODEWHALE_BUILD_VERSION=...` — the package version,
/// Emit `cargo:rustc-env=DEEPSEEK_BUILD_VERSION=...` — the package version,
/// suffixed with the short build SHA when the environment supplied one
/// (`CODEWHALE_BUILD_SHA`, then `DEEPSEEK_BUILD_SHA`, then `GITHUB_SHA`), or with the literal `dev`
/// (`DEEPSEEK_BUILD_SHA`, then `GITHUB_SHA`), or with the literal `dev`
/// marker when it did not. `CODEWHALE_BUILD_COMMIT` is emitted only in the
/// stamped case.
///
@@ -58,9 +57,6 @@ pub fn emit_build_version(_manifest_dir: &Path, package_version: &str) {
.map(|sha| format!("{package_version} ({sha})"))
.unwrap_or_else(|| format!("{package_version} (dev)"));
println!("cargo:rustc-env=CODEWHALE_BUILD_VERSION={build_version}");
// Keep the pre-rebrand compile-time name through the 0.9.x compatibility
// window for downstream crates that still use `env!` with it.
println!("cargo:rustc-env=DEEPSEEK_BUILD_VERSION={build_version}");
if let Some(commit) = commit {
println!("cargo:rustc-env=CODEWHALE_BUILD_COMMIT={commit}");
@@ -74,7 +70,6 @@ pub fn emit_build_version(_manifest_dir: &Path, package_version: &str) {
/// make the build script rerun on every local commit, for a value that is
/// `None` on every local build by design.
pub fn declare_release_sha_rerun() {
println!("cargo:rerun-if-env-changed=CODEWHALE_BUILD_SHA");
println!("cargo:rerun-if-env-changed=DEEPSEEK_BUILD_SHA");
println!("cargo:rerun-if-env-changed=GITHUB_SHA");
}
@@ -109,14 +104,12 @@ pub fn emit_release_build_sha() {
/// The decision behind [`emit_release_build_sha`], with the environment
/// injected so it can be tested without mutating the process.
///
/// `CODEWHALE_BUILD_SHA` wins over the legacy `DEEPSEEK_BUILD_SHA`, which wins over
/// `GITHUB_SHA`; each must be a full 40-hex sha
/// `DEEPSEEK_BUILD_SHA` wins over `GITHUB_SHA`; both must be a full 40-hex sha
/// to be believed, and the result is the first 12 characters.
#[must_use]
pub fn release_build_sha(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
read_env("CODEWHALE_BUILD_SHA")
read_env("DEEPSEEK_BUILD_SHA")
.and_then(full_sha)
.or_else(|| read_env("DEEPSEEK_BUILD_SHA").and_then(full_sha))
.or_else(|| read_env("GITHUB_SHA").and_then(full_sha))
.and_then(short_sha)
}
@@ -128,9 +121,8 @@ fn build_commit() -> Option<String> {
/// The stamping decision with the environment injected, so the no-local-
/// fallback contract is testable without mutating the process (#5245).
fn build_commit_with(read_env: impl Fn(&str) -> Option<String>) -> Option<String> {
read_env("CODEWHALE_BUILD_SHA")
read_env("DEEPSEEK_BUILD_SHA")
.and_then(full_sha)
.or_else(|| read_env("DEEPSEEK_BUILD_SHA").and_then(full_sha))
.or_else(|| read_env("GITHUB_SHA").and_then(full_sha))
}
@@ -187,19 +179,7 @@ mod tests {
release_build_sha(|name| (name == "GITHUB_SHA").then(|| ci.to_string())),
Some("abcdef012345".to_string())
);
// The canonical Codewhale variable wins over the legacy
// DeepSeek-era one, which wins over the GitHub one.
assert_eq!(
release_build_sha(|name| match name {
"CODEWHALE_BUILD_SHA" => Some("e".repeat(40)),
"DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
"GITHUB_SHA" => Some(ci.to_string()),
_ => None,
}),
Some("e".repeat(12))
);
// The legacy name still stamps during the 0.9.x compatibility
// window, so existing release tooling keeps working.
// The Codewhale variable wins over the GitHub one.
assert_eq!(
release_build_sha(|name| match name {
"DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
@@ -246,13 +226,5 @@ mod tests {
}),
Some("f".repeat(40))
);
assert_eq!(
super::build_commit_with(|name| match name {
"CODEWHALE_BUILD_SHA" => Some("e".repeat(40)),
"DEEPSEEK_BUILD_SHA" => Some("f".repeat(40)),
_ => None,
}),
Some("e".repeat(40))
);
}
}
+14 -18
View File
@@ -7,9 +7,6 @@ license.workspace = true
repository.workspace = true
description = "Agentic terminal facade for open-source and open-weight coding models"
[lints]
workspace = true
[[bin]]
name = "codewhale"
path = "src/main.rs"
@@ -18,25 +15,24 @@ path = "src/main.rs"
anyhow.workspace = true
clap.workspace = true
clap_complete.workspace = true
codewhale-tui = { path = "../tui", version = "0.9.11" }
codewhale-agent = { path = "../agent", version = "0.9.11" }
codewhale-app-server = { path = "../app-server", version = "0.9.11" }
codewhale-config = { path = "../config", version = "0.9.11" }
codewhale-lane = { path = "../lane", version = "0.9.11" }
codewhale-workflow = { path = "../workflow", version = "0.9.11" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" }
codewhale-mcp = { path = "../mcp", version = "0.9.11" }
codewhale-paths = { path = "../paths", version = "0.9.11" }
codewhale-release = { path = "../release", version = "0.9.11" }
codewhale-secrets = { path = "../secrets", version = "0.9.11" }
codewhale-state = { path = "../state", version = "0.9.11" }
codewhale-telemetry = { path = "../telemetry", version = "0.9.11" }
codewhale-tui = { path = "../tui", version = "0.9.6" }
codewhale-agent = { path = "../agent", version = "0.9.6" }
codewhale-app-server = { path = "../app-server", version = "0.9.6" }
codewhale-config = { path = "../config", version = "0.9.6" }
codewhale-lane = { path = "../lane", version = "0.9.6" }
codewhale-workflow = { path = "../workflow", version = "0.9.6" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
codewhale-paths = { path = "../paths", version = "0.9.6" }
codewhale-release = { path = "../release", version = "0.9.6" }
codewhale-secrets = { path = "../secrets", version = "0.9.6" }
codewhale-state = { path = "../state", version = "0.9.6" }
codewhale-telemetry = { path = "../telemetry", version = "0.9.6" }
chrono.workspace = true
console = "0.16.3"
dirs.workspace = true
serde.workspace = true
serde_json.workspace = true
toml.workspace = true
reqwest = { workspace = true, features = ["blocking"] }
rustls.workspace = true
semver.workspace = true
@@ -49,7 +45,7 @@ webbrowser = "1.0"
zeroize = "1.8.2"
[build-dependencies]
codewhale-build-support = { path = "../build-support", version = "0.9.11" }
codewhale-build-support = { path = "../build-support", version = "0.9.6" }
# Parent-death cleanup for delegated server children (#3259): on Linux the
# dispatcher sets PR_SET_PDEATHSIG so the child is signalled if the dispatcher
+40 -45
View File
@@ -8,14 +8,14 @@
use std::io::{self, IsTerminal, Read, Write};
use std::net::IpAddr;
use std::thread;
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, anyhow, bail};
use clap::{Args, Subcommand, ValueEnum};
use codewhale_config::device_code::DevicePollOutcome;
use codewhale_config::{ConfigStore, ProviderKind};
use codewhale_secrets::Secrets;
use codewhale_secrets::account::{
ACCOUNT_ALLOW_FILE_SESSION_STORE_ENV as CLOUD_ALLOW_FILE_SESSION_STORE_ENV,
ACCOUNT_API_BASE_ENV as CLOUD_API_BASE_ENV, AccountAuthBundle as AuthBundle,
AccountSessionStore, AccountUser as CloudUser, DEFAULT_ACCOUNT_API_BASE as DEFAULT_API_BASE,
StoredAccountAuth as StoredCloudAuth, normalize_account_profile as normalized_profile,
@@ -29,8 +29,8 @@ const MIN_API_KEY_BYTES: usize = 8;
const MAX_API_KEY_BYTES: u64 = 4096;
const MAX_API_KEY_STDIN_BYTES: u64 = MAX_API_KEY_BYTES + 1024;
const MAX_KEY_LABEL_CHARS: usize = 80;
pub(crate) const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600;
pub(crate) const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600;
const DEFAULT_LOGIN_TIMEOUT_SECONDS: u64 = 600;
const MAX_LOGIN_TIMEOUT_SECONDS: u64 = 3600;
#[derive(Debug, Args)]
pub(crate) struct CloudArgs {
@@ -318,16 +318,16 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
validate_device_code(&device.device_code)?;
let server_lifetime =
Duration::from_secs(device.expires_in.clamp(1, MAX_LOGIN_TIMEOUT_SECONDS));
// The Codewhale account service answers HTTP 202 while the code is
// still pending, so the first response is already meaningful: poll
// immediately and sleep afterwards. It has no slow_down.
let bundle = codewhale_config::device_code::DeviceCodePoll::new(
timeout.min(server_lifetime),
"Codewhale account login timed out; run `codewhale account login` to try again",
)
.interval_seconds(Some(device.interval))
.max_interval_seconds(10)
.run(sleep, || {
let timeout = timeout.min(server_lifetime);
let interval = Duration::from_secs(device.interval.clamp(1, 10));
let started = Instant::now();
loop {
if started.elapsed() >= timeout {
bail!(
"Codewhale account login timed out; run `codewhale account login` to try again"
);
}
let response = self.transport.execute(CloudRequest {
method: HttpMethod::Post,
path: "/api/cli/device/token".to_string(),
@@ -340,14 +340,21 @@ impl<'a, T: CloudTransport> CloudClient<'a, T> {
200 => {
let bundle: AuthBundle = parse_json_body(&response.body)?;
validate_auth_bundle(&bundle)?;
Ok(DevicePollOutcome::Complete(bundle))
self.save_auth(bundle.clone())?;
return Ok(bundle);
}
202 => Ok(DevicePollOutcome::Pending),
_ => Err(response_error(&response)),
202 => {
let remaining = timeout.saturating_sub(started.elapsed());
if remaining.is_zero() {
bail!(
"Codewhale account login timed out; run `codewhale account login` to try again"
);
}
sleep(interval.min(remaining));
}
_ => return Err(response_error(&response)),
}
})?;
self.save_auth(bundle.clone())?;
Ok(bundle)
}
}
fn load_auth(&self) -> Result<Option<StoredCloudAuth>> {
@@ -525,31 +532,19 @@ pub(crate) fn run(args: CloudArgs, profile: Option<&str>, config: &ConfigStore)
}
fn cloud_session_secrets() -> Result<Secrets> {
// Codex-style storage contract: the OS credential manager is preferred
// but never required; without one, sessions live in the private 0600
// Codewhale secrets file. Only an unresolvable store path fails here.
secure_account_session_secrets().map_err(|err| anyhow!(err.to_string()))
}
/// `codewhale login` is a convenience entry to the account device flow — the
/// same path as `codewhale account login`, without re-spelling the subcommand.
pub(crate) fn run_account_login(
no_open: bool,
timeout_seconds: u64,
profile: Option<&str>,
config: &ConfigStore,
) -> Result<()> {
run(
CloudArgs {
api_base: None,
command: CloudCommand::Login(CloudLoginArgs {
no_open,
timeout_seconds,
}),
},
profile,
config,
)
match secure_account_session_secrets() {
Ok(secrets) => {
if secrets.backend_name().starts_with("file-based") {
eprintln!(
"warning: OS credential manager unavailable; {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1 explicitly enables the local 0600 Codewhale secrets file for cloud session tokens"
);
}
Ok(secrets)
}
Err(_) => bail!(
"Codewhale account login requires an OS credential manager for session tokens. Configure Keychain, Credential Manager, or Secret Service and try again. Headless users may explicitly opt into the local 0600 secrets file with {CLOUD_ALLOW_FILE_SESSION_STORE_ENV}=1"
),
}
}
pub(crate) fn reject_inline_api_key(api_key: Option<&str>) -> Result<()> {
+10
View File
@@ -5,6 +5,7 @@ use clap::Parser;
use codewhale_secrets::account::{
ACCOUNT_SESSION_SCHEMA_VERSION, AccountSession as AuthSession,
account_auth_slot as cloud_auth_slot,
account_file_session_store_opted_in_value as file_session_store_opted_in_value,
};
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
use serde_json::json;
@@ -272,6 +273,15 @@ fn user_codes_and_key_inputs_match_the_server_contract() {
assert!(validate_label(&"x".repeat(81)).is_err());
}
#[test]
fn file_session_store_requires_explicit_one_value() {
assert!(!file_session_store_opted_in_value(None));
assert!(!file_session_store_opted_in_value(Some("")));
assert!(!file_session_store_opted_in_value(Some("true")));
assert!(file_session_store_opted_in_value(Some("1")));
assert!(file_session_store_opted_in_value(Some(" 1 ")));
}
#[test]
fn device_flow_handles_pending_then_authorized_without_printing_tokens() {
let (temp, config) = test_config();
File diff suppressed because it is too large Load Diff
+114 -1040
View File
File diff suppressed because it is too large Load Diff
+75 -1208
View File
File diff suppressed because it is too large Load Diff
@@ -1,11 +1,11 @@
//! Diagnostic dispatch must be read-only whether the command uses the real
//! in-process TUI entry (`doctor`, `setup --status`) or stays in the CLI
//! (`auth status --diagnostic`). The single `codewhale` binary has no sibling
//! TUI executable to delegate to (#5259 single-binary argv0 dispatch). These
//! invariants stay: the dispatcher must not migrate legacy secrets, must not
//! rewrite legacy settings, and must not create any state under a sealed HOME.
//! `doctor --context-json` must still emit a machine-readable context source
//! map (`{"entries":[...]}`).
//! Diagnostic dispatch (`doctor`, `setup --status`) runs the real in-process
//! TUI entry via `run_tui_in_process` — the single `codewhale` binary calls
//! `codewhale_tui::run` directly, so there is no sibling TUI binary to delegate
//! to anymore (#5259 single-binary argv0 dispatch). These invariants stay: the
//! dispatcher must not migrate legacy secrets, must not rewrite legacy
//! settings, and must not create any state under a sealed HOME when running a
//! read-only diagnostic. `doctor --context-json` must still emit a
//! machine-readable context source map (`{"entries":[...]}`).
#![cfg(unix)]
@@ -19,14 +19,12 @@ use tempfile::TempDir;
#[test]
fn dispatcher_diagnostics_are_in_process_and_read_only() {
// (cli args, whether stdout must be a JSON object carrying an `entries`
// array, whether this is the structural auth diagnostic). Only
// `doctor --context-json` carries the context source map.
for (args, expects_entries_json, expects_auth_diagnostic) in [
(&["doctor"][..], false, false),
(&["doctor", "--json"][..], false, false),
(&["doctor", "--context-json"][..], true, false),
(&["setup", "--status"][..], false, false),
(&["auth", "status", "--diagnostic"][..], false, true),
// array). Only `doctor --context-json` carries the context source map.
for (args, expects_entries_json) in [
(&["doctor"][..], false),
(&["doctor", "--json"][..], false),
(&["doctor", "--context-json"][..], true),
(&["setup", "--status"][..], false),
] {
let fixture = TempDir::new().expect("fixture root");
let sealed_home = fixture.path().join("sealed-home");
@@ -82,48 +80,6 @@ fn dispatcher_diagnostics_are_in_process_and_read_only() {
);
}
if expects_auth_diagnostic {
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
stdout.contains(
"auth diagnostic (structural only; credential values are never printed and provider credential stores were not opened)"
),
"{stdout}"
);
assert!(
stdout.contains(&format!(
"codewhale home: {}",
codewhale_config::quote_os_path(&codewhale_home)
)),
"{stdout}"
);
assert!(
stdout.contains(&format!(
"config: {}",
codewhale_config::quote_os_path(&codewhale_home.join("config.toml"))
)),
"{stdout}"
);
assert!(
stdout.contains(&format!(
"settings: {}",
codewhale_config::quote_os_path(&codewhale_home.join("settings.toml"))
)),
"{stdout}"
);
assert!(
stdout.contains("secret backend: file (inspection: metadata_only)"),
"{stdout}"
);
assert!(
stdout.contains(
"legacy secret store: suppressed by explicit CODEWHALE_HOME isolation"
),
"{stdout}"
);
assert!(!stdout.contains("synthetic-legacy-fixture"), "{stdout}");
}
assert_eq!(
relative_paths(&sealed_home),
before_paths,
+7 -143
View File
@@ -28,20 +28,14 @@ while IFS= read -r line; do
fi
case "$method" in
initialize)
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{},"resources":{}},"serverInfo":{"name":"fake-mcp","version":"0"}}}\n' "$id"
printf '{"jsonrpc":"2.0","id":%s,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"fake-mcp","version":"0"}}}\n' "$id"
;;
tools/list)
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"whoami","description":"report the spawned process","inputSchema":{"type":"object","properties":{}}}]}}\n' "$id"
printf '{"jsonrpc":"2.0","id":%s,"result":{"tools":[{"name":"whoami","description":"report the spawned process"}]}}\n' "$id"
;;
tools/call)
printf '{"jsonrpc":"2.0","id":%s,"result":{"content":[{"type":"text","text":"spawned-child"}]}}\n' "$id"
;;
resources/list)
printf '{"jsonrpc":"2.0","id":%s,"result":{"resources":[{"uri":"file:///fake/readme.txt","name":"Fake readme","description":"resource from the spawned process","mimeType":"text/plain","size":16,"annotations":{"audience":["assistant"],"priority":0.75}}]}}\n' "$id"
;;
resources/read)
printf '{"jsonrpc":"2.0","id":%s,"result":{"contents":[{"uri":"file:///fake/readme.txt","mimeType":"text/plain","text":"spawned-resource"}]}}\n' "$id"
;;
*)
printf '{"jsonrpc":"2.0","id":%s,"error":{"code":-32601,"message":"unsupported method"}}\n' "$id"
;;
@@ -151,63 +145,7 @@ fn response_for(responses: &[Value], id: i64) -> &Value {
}
#[test]
fn mcp_server_enforces_jsonrpc_identity_and_initialize_lifecycle() {
let fixture = Fixture::new();
let (responses, stderr) = fixture.run_mcp_server(&[
json!({"id": 1, "method": "ping"}),
json!({"jsonrpc": "2.0", "id": null, "method": "ping"}),
json!({"jsonrpc": "2", "id": 2, "method": "ping"}),
json!({"jsonrpc": "2.0", "id": 3, "method": "tools/list"}),
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": {"name": "lifecycle-test", "version": "1"},
"capabilities": {}
}
}),
json!({"jsonrpc": "2.0", "id": 5, "method": "resources/list"}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "method": "ping"}),
json!({"jsonrpc": "2.0", "id": 6, "method": "tools/list"}),
json!({"jsonrpc": "2.0", "id": 7, "method": "shutdown"}),
]);
let null_id_responses: Vec<&Value> = responses
.iter()
.filter(|response| response["id"].is_null())
.collect();
assert_eq!(
null_id_responses.len(),
2,
"missing id must be a notification while explicit null receives a response: {responses:?}"
);
assert_eq!(null_id_responses[0]["error"]["code"], -32600);
assert!(null_id_responses[1]["result"].is_object());
assert_eq!(response_for(&responses, 2)["error"]["code"], -32600);
assert_eq!(response_for(&responses, 3)["error"]["code"], -32600);
assert!(
response_for(&responses, 3)["error"]["message"]
.as_str()
.is_some_and(|message| message.contains("completed initialize"))
);
assert_eq!(
response_for(&responses, 4)["result"]["protocolVersion"],
"2024-11-05"
);
assert_eq!(response_for(&responses, 5)["error"]["code"], -32600);
assert_eq!(response_for(&responses, 6)["result"]["tools"], json!([]));
assert!(
stderr.contains("codewhale mcp-server: stdio server exited"),
"missing clean shutdown receipt:\n{stderr}"
);
}
#[test]
fn mcp_server_proxies_tools_and_resources_from_the_configured_child_process() {
fn mcp_server_proxies_tools_from_the_configured_child_process() {
let fixture = Fixture::new();
let script = fixture.write_fake_server();
fixture.configure_servers(json!([{
@@ -219,17 +157,6 @@ fn mcp_server_proxies_tools_and_resources_from_the_configured_child_process() {
}]));
let (responses, stderr) = fixture.run_mcp_server(&[
json!({
"jsonrpc": "2.0",
"id": 0,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": {"name": "proxy-test", "version": "1"},
"capabilities": {}
}
}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({"jsonrpc": "2.0", "id": 1, "method": "tools/list"}),
json!({
"jsonrpc": "2.0",
@@ -237,80 +164,28 @@ fn mcp_server_proxies_tools_and_resources_from_the_configured_child_process() {
"method": "tools/call",
"params": {"name": "mcp__fake__whoami", "arguments": {}}
}),
json!({"jsonrpc": "2.0", "id": 3, "method": "resources/list"}),
json!({
"jsonrpc": "2.0",
"id": 4,
"method": "resources/read",
"params": {"uri": "file:///fake/readme.txt"}
}),
json!({"jsonrpc": "2.0", "id": 5, "method": "shutdown"}),
json!({"jsonrpc": "2.0", "id": 3, "method": "shutdown"}),
]);
let initialize = response_for(&responses, 0);
assert_eq!(initialize["result"]["protocolVersion"], "2024-11-05");
assert_eq!(
initialize["result"]["serverInfo"]["name"],
"codewhale-mcp-server"
);
assert!(initialize["result"]["capabilities"]["tools"].is_object());
assert!(initialize["result"]["capabilities"]["resources"].is_object());
let tools = response_for(&responses, 1)["result"]["tools"]
.as_array()
.unwrap_or_else(|| panic!("tools/list returned no array; stderr:\n{stderr}"))
.clone();
let names: Vec<&str> = tools
.iter()
.filter_map(|tool| tool["name"].as_str())
.filter_map(|tool| tool["tool_name"].as_str())
.collect();
assert_eq!(
names,
vec!["mcp__fake__whoami"],
vec!["whoami"],
"only the child's real tools may be exposed; the stub's fabricated \
`health`/`capabilities` must be gone. stderr:\n{stderr}"
);
assert_eq!(tools[0]["tool_name"], "whoami");
assert!(tools[0]["inputSchema"].is_object());
let call = response_for(&responses, 2);
assert_eq!(
call["result"]["content"][0]["text"], "spawned-child",
"the standard MCP result must come from the spawned process: {call}"
);
assert_eq!(
call["result"]["result"]["content"][0]["text"], "spawned-child",
"the legacy nested result must remain available: {call}"
);
let resources = response_for(&responses, 3)["result"]["resources"]
.as_array()
.unwrap_or_else(|| panic!("resources/list returned no array; stderr:\n{stderr}"));
assert_eq!(resources.len(), 1);
assert_eq!(resources[0]["uri"], "file:///fake/readme.txt");
assert_eq!(resources[0]["name"], "Fake readme");
assert_eq!(resources[0]["mimeType"], "text/plain");
assert_eq!(resources[0]["size"], 16);
assert_eq!(
resources[0]["annotations"]["audience"],
json!(["assistant"])
);
assert_eq!(resources[0]["annotations"]["priority"], 0.75);
assert_eq!(resources[0]["server_name"], "fake");
let read = response_for(&responses, 4);
assert_eq!(read["result"]["contents"][0]["text"], "spawned-resource");
assert_eq!(
read["result"]["resource"]["contents"][0]["text"], "spawned-resource",
"the legacy nested resource must remain available: {read}"
);
assert!(
!stderr.contains("deepseek-mcp"),
"stale identity in stderr:\n{stderr}"
);
assert!(
stderr.contains("codewhale mcp-server: stdio server exited"),
"missing Codewhale shutdown identity in stderr:\n{stderr}"
"the tool result must come from the spawned process: {call}"
);
}
@@ -326,17 +201,6 @@ fn mcp_server_reports_a_server_it_could_not_spawn() {
let (responses, stderr) = fixture.run_mcp_server(&[
json!({"jsonrpc": "2.0", "id": 1, "method": "server/list"}),
json!({
"jsonrpc": "2.0",
"id": 10,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"clientInfo": {"name": "failure-test", "version": "1"},
"capabilities": {}
}
}),
json!({"jsonrpc": "2.0", "method": "notifications/initialized"}),
json!({
"jsonrpc": "2.0",
"id": 2,
+7 -27
View File
@@ -371,43 +371,23 @@ fn the_openrouter_glm_sibling_resolves_to_its_own_gateway_wire_id() {
);
}
/// A Z.ai config that names no model lands on the deliberate default,
/// `GLM-5.3`, with `provider default` provenance. This is the surface where a
/// default move would otherwise change silently under a user.
/// Adding a sibling must not move anyone's route. A Z.ai config that names no
/// model still has to land on `GLM-5.2`: the newer `glm-5.3` is catalogued but
/// deliberately not the default, and this is the surface where that would
/// silently change under a user.
#[test]
fn zai_default_route_resolves_to_glm_5_3_with_provider_default_provenance() {
fn adding_a_glm_sibling_leaves_the_zai_default_route_untouched() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&[],
);
assert_eq!(
report.get("resolved").map(String::as_str),
Some("GLM-5.3"),
"the Z.ai default is GLM-5.3: {report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"{report:?}"
);
}
/// An explicit `GLM-5.2` selection keeps its own id after the default moved
/// to `GLM-5.3`: only the default changed, never a user's saved route.
#[test]
fn explicit_glm_5_2_selection_survives_the_default_move() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\nmodel = \"GLM-5.2\"\n",
&[],
);
assert_eq!(
report.get("resolved").map(String::as_str),
Some("GLM-5.2"),
"an explicit GLM-5.2 route must not be upgraded: {report:?}"
"the Z.ai default must stay GLM-5.2 after a newer sibling is added: {report:?}"
);
assert_ne!(
assert_eq!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"{report:?}"
@@ -41,25 +41,6 @@ fn env_off_beats_cli_on_end_to_end() {
);
}
/// A real endpoint must remain queued locally instead of becoming short-CLI
/// network latency. The next interactive session owns delivery.
#[test]
fn short_cli_exit_persists_without_network_delivery() {
let evidence = dispatch_and_read_telemetry_with_endpoint(None, None);
let pending = evidence
.pending
.expect("short CLI must seal its pending telemetry before process exit");
assert!(
pending.contains("\"event\":\"session_start\"")
&& pending.contains("\"event\":\"session_end\""),
"the pending buffer must contain the complete short CLI session: {pending}"
);
assert!(
evidence.dry_run.is_none(),
"a configured endpoint must stay pending rather than use the dry-run sink"
);
}
/// A value the resolver cannot parse resolves to off, rather than falling
/// through to the flag.
#[test]
@@ -130,19 +111,11 @@ fn config_set_true_reenables_a_historical_decline() {
struct DispatchEvidence {
telemetry_dir_exists: bool,
dry_run: Option<String>,
pending: Option<String>,
}
/// Run the real dispatcher into a keyless in-process command and report the
/// telemetry state it actually left behind.
fn dispatch_and_read_telemetry(telemetry_env: Option<&str>) -> DispatchEvidence {
dispatch_and_read_telemetry_with_endpoint(telemetry_env, Some(""))
}
fn dispatch_and_read_telemetry_with_endpoint(
telemetry_env: Option<&str>,
endpoint: Option<&str>,
) -> DispatchEvidence {
let fixture = TempDir::new().expect("fixture root");
let home = fixture.path().join("home");
let codewhale_home = fixture.path().join("codewhale-home");
@@ -152,11 +125,12 @@ fn dispatch_and_read_telemetry_with_endpoint(
}
let config_path = fixture.path().join("config.toml");
let mut config = "telemetry = true\n".to_string();
if let Some(endpoint) = endpoint {
config.push_str(&format!("telemetry_endpoint = {endpoint:?}\n"));
}
fs::write(&config_path, config).expect("write config");
fs::write(
&config_path,
// An explicitly empty endpoint is the network-free dry-run sink.
"telemetry = true\ntelemetry_endpoint = \"\"\n",
)
.expect("write config");
let mut command = Command::new(codewhale_binary());
command
@@ -197,15 +171,9 @@ fn dispatch_and_read_telemetry_with_endpoint(
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => panic!("read telemetry dry-run sink: {error}"),
};
let pending = match fs::read_to_string(telemetry_dir.join("buffer.jsonl")) {
Ok(contents) => Some(contents),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => panic!("read telemetry pending buffer: {error}"),
};
DispatchEvidence {
telemetry_dir_exists: telemetry_dir.exists(),
dry_run,
pending,
}
}
-14
View File
@@ -1,14 +0,0 @@
[package]
name = "codewhale-command-contract"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Prototype command capability and dispatch shapes for the staged TUI command extraction"
[lints]
workspace = true
[dependencies]
codewhale-core = { path = "../core", version = "0.9.11" }
-113
View File
@@ -1,113 +0,0 @@
//! Independent, object-safe capability shapes for staged command migration.
//!
//! FEAT-014 publishes these interfaces without implementing them for the TUI
//! or changing an existing command. Later work adopts them inside
//! `codewhale-tui` one command group at a time. Only after every group uses
//! these shapes will groups move physically into a commands crate.
use std::path::{Path, PathBuf};
use codewhale_core::request::{Message, SystemPrompt};
use crate::types::{
CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort,
};
/// Session identity, messages, queue operations, and token totals.
pub trait CommandSessionContext {
fn session_id(&self) -> Option<String>;
fn api_messages(&self) -> Vec<Message>;
fn add_message(&mut self, message: Message);
fn queued_message_count(&self) -> usize;
fn remove_queued_message(&mut self, index: usize) -> Result<(), String>;
fn total_tokens(&self) -> u64;
}
/// Model selection, provider identity, effort, and fallback chain.
pub trait CommandModelContext {
fn current_model(&self) -> String;
fn auto_model(&self) -> bool;
fn set_model_selection(&mut self, model: String, provider: Option<CommandProviderId>);
fn reasoning_effort(&self) -> CommandReasoningEffort;
fn provider_identity(&self) -> Option<CommandProviderId>;
fn fallback_chain(&self) -> Vec<CommandProviderId>;
}
/// Cost display and accounting operations.
pub trait CommandCostContext {
fn display_currency(&self) -> CommandCurrency;
fn session_cost_for_currency(&self, currency: CommandCurrency) -> f64;
fn subagent_cost_for_currency(&self, currency: CommandCurrency) -> f64;
fn accrue_cost_estimate(&mut self, amount: f64, currency: CommandCurrency);
fn record_turn_cost(
&mut self,
amount: f64,
currency: CommandCurrency,
route_receipt: Option<String>,
);
}
/// Operating mode, approval posture, shell access, and policy lock.
pub trait CommandModePolicyContext {
fn mode(&self) -> CommandMode;
fn set_mode(&mut self, mode: CommandMode);
fn approval_mode(&self) -> CommandApprovalMode;
fn allow_shell(&self) -> bool;
fn set_shell_access(&mut self, allow: bool);
fn policy_locked(&self) -> bool;
}
/// Read access to the effective system prompt.
pub trait CommandSystemPromptContext {
fn system_prompt(&self) -> Option<SystemPrompt>;
}
/// Active skill identity and skill-cache refresh.
pub trait CommandSkillsContext {
fn active_skill(&self) -> Option<String>;
fn active_skill_provenance(&self) -> Option<String>;
fn refresh_skill_cache(&mut self);
}
/// Workspace path and a bounded serialized work-state snapshot.
pub trait CommandWorkspaceContext {
fn workspace(&self) -> PathBuf;
fn work_state_snapshot(&self) -> Result<Option<String>, String>;
/// Session-aware canonical operation digest. Returns the final user-facing
/// digest text or a safe explicit error; never a serialized snapshot.
/// No-active-work and temporary-unavailability semantics are preserved by
/// the host implementation (FEAT-018 D5).
fn operation_digest(&mut self) -> Result<String, String>;
}
/// Stable-key translation with named replacements (FEAT-018 D3).
///
/// Message identity uses stable snake_case keys plus named replacements. The
/// TUI host maps those keys to the current catalog and preserves the existing
/// English fallback for intentionally incomplete locale packs. Unknown keys or
/// invalid replacement contracts fail safely and produce a command error; they
/// never panic and never display a raw lookup key.
pub trait CommandPresentationContext {
/// Resolve a stable message key with its named replacements.
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String>;
}
/// Portable receipt for a successful atomic media attachment (FEAT-018 D4).
/// Carries only the information needed for the existing confirmation text.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaAttachmentReceipt {
pub kind: String,
pub path: std::path::PathBuf,
}
/// Atomic composer/media capability (FEAT-018 D4).
///
/// The host performs media validation and composer insertion as one atomic
/// operation. Rejected, missing, unsupported, corrupt, or oversized media
/// leaves composer state unchanged and returns a safe error. Only portable
/// success information crosses the boundary; composer markup, mutable input
/// text, decoder internals, and TUI types never do.
pub trait CommandMediaContext {
/// Validate and insert a resolved media path atomically.
fn attach_media(&mut self, resolved_path: &Path) -> Result<MediaAttachmentReceipt, String>;
}
-149
View File
@@ -1,149 +0,0 @@
//! Generic handler transport for staged command migration.
//!
//! The output type is generic so FEAT-014 does not move or duplicate the
//! TUI-owned `CommandResult`. During in-place adoption, the TUI instantiates
//! `CommandHandler<crate::commands::CommandResult>`.
use crate::facets::{
CommandCostContext, CommandMediaContext, CommandModePolicyContext, CommandModelContext,
CommandPresentationContext, CommandSessionContext, CommandSkillsContext,
CommandSystemPromptContext, CommandWorkspaceContext,
};
/// A command handler that is either argument-only or capability-scoped.
#[derive(Clone, Copy)]
pub enum CommandHandler<R> {
Pure(fn(Option<&str>) -> R),
Contextual(fn(CommandContexts<'_>, Option<&str>) -> R),
}
/// Transport envelope with one independently optional facet slot.
pub struct CommandContexts<'a> {
session: Option<&'a mut dyn CommandSessionContext>,
model: Option<&'a mut dyn CommandModelContext>,
cost: Option<&'a mut dyn CommandCostContext>,
mode_policy: Option<&'a mut dyn CommandModePolicyContext>,
system_prompt: Option<&'a mut dyn CommandSystemPromptContext>,
skills: Option<&'a mut dyn CommandSkillsContext>,
workspace: Option<&'a mut dyn CommandWorkspaceContext>,
presentation: Option<&'a mut dyn CommandPresentationContext>,
media: Option<&'a mut dyn CommandMediaContext>,
}
/// Consumed envelope used when one handler needs several independent facets.
pub struct ContextParts<'a> {
pub session: Option<&'a mut dyn CommandSessionContext>,
pub model: Option<&'a mut dyn CommandModelContext>,
pub cost: Option<&'a mut dyn CommandCostContext>,
pub mode_policy: Option<&'a mut dyn CommandModePolicyContext>,
pub system_prompt: Option<&'a mut dyn CommandSystemPromptContext>,
pub skills: Option<&'a mut dyn CommandSkillsContext>,
pub workspace: Option<&'a mut dyn CommandWorkspaceContext>,
pub presentation: Option<&'a mut dyn CommandPresentationContext>,
pub media: Option<&'a mut dyn CommandMediaContext>,
}
impl<'a> CommandContexts<'a> {
pub fn empty() -> Self {
Self {
session: None,
model: None,
cost: None,
mode_policy: None,
system_prompt: None,
skills: None,
workspace: None,
presentation: None,
media: None,
}
}
pub fn into_parts(self) -> ContextParts<'a> {
ContextParts {
session: self.session,
model: self.model,
cost: self.cost,
mode_policy: self.mode_policy,
system_prompt: self.system_prompt,
skills: self.skills,
workspace: self.workspace,
presentation: self.presentation,
media: self.media,
}
}
pub fn with_session(mut self, value: &'a mut dyn CommandSessionContext) -> Self {
assert!(
self.session.replace(value).is_none(),
"session facet already set"
);
self
}
pub fn with_model(mut self, value: &'a mut dyn CommandModelContext) -> Self {
assert!(
self.model.replace(value).is_none(),
"model facet already set"
);
self
}
pub fn with_cost(mut self, value: &'a mut dyn CommandCostContext) -> Self {
assert!(self.cost.replace(value).is_none(), "cost facet already set");
self
}
pub fn with_mode_policy(mut self, value: &'a mut dyn CommandModePolicyContext) -> Self {
assert!(
self.mode_policy.replace(value).is_none(),
"mode-policy facet already set"
);
self
}
pub fn with_system_prompt(mut self, value: &'a mut dyn CommandSystemPromptContext) -> Self {
assert!(
self.system_prompt.replace(value).is_none(),
"system-prompt facet already set"
);
self
}
pub fn with_skills(mut self, value: &'a mut dyn CommandSkillsContext) -> Self {
assert!(
self.skills.replace(value).is_none(),
"skills facet already set"
);
self
}
pub fn with_workspace(mut self, value: &'a mut dyn CommandWorkspaceContext) -> Self {
assert!(
self.workspace.replace(value).is_none(),
"workspace facet already set"
);
self
}
pub fn with_presentation(mut self, value: &'a mut dyn CommandPresentationContext) -> Self {
assert!(
self.presentation.replace(value).is_none(),
"presentation facet already set"
);
self
}
pub fn with_media(mut self, value: &'a mut dyn CommandMediaContext) -> Self {
assert!(
self.media.replace(value).is_none(),
"media facet already set"
);
self
}
}
impl Default for CommandContexts<'_> {
fn default() -> Self {
Self::empty()
}
}
-20
View File
@@ -1,20 +0,0 @@
//! Prototype command boundary for the staged TUI command extraction.
//!
//! FEAT-014 defines shapes only. It does not implement them for `App`, change
//! production dispatch, move localization or shared TUI types, or move command
//! files. Later FEATs first adopt these shapes inside `codewhale-tui` one group
//! per PR; only after all groups are decoupled will they move to a commands
//! crate, again one group per PR.
pub mod facets;
pub mod handler;
pub mod metadata;
pub mod types;
pub use facets::*;
pub use handler::{CommandContexts, CommandHandler, ContextParts};
pub use metadata::{CommandDiscovery, CommandInfo, RegisterCommand};
pub use types::*;
#[cfg(test)]
mod tests;
-27
View File
@@ -1,27 +0,0 @@
//! Portable command registration metadata.
use crate::handler::CommandHandler;
/// Static metadata describing a command without importing localization.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct CommandInfo {
pub name: &'static str,
pub aliases: &'static [&'static str],
pub usage: &'static str,
/// Stable localization key resolved by the current TUI owner.
pub description_key: &'static str,
}
/// Discovery tier controlling palette and help visibility.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandDiscovery {
Primary,
Advanced,
Compatibility,
}
/// Target registration shape. Existing TUI commands adopt it group by group.
pub trait RegisterCommand<R> {
fn info() -> &'static CommandInfo;
fn handler() -> CommandHandler<R>;
}
-346
View File
@@ -1,346 +0,0 @@
use std::path::{Path, PathBuf};
use codewhale_core::request::{Message, SystemPrompt};
use crate::*;
struct Session;
impl CommandSessionContext for Session {
fn session_id(&self) -> Option<String> {
Some("session".into())
}
fn api_messages(&self) -> Vec<Message> {
vec![]
}
fn add_message(&mut self, _message: Message) {}
fn queued_message_count(&self) -> usize {
0
}
fn remove_queued_message(&mut self, _index: usize) -> Result<(), String> {
Ok(())
}
fn total_tokens(&self) -> u64 {
42
}
}
struct Model;
impl CommandModelContext for Model {
fn current_model(&self) -> String {
"auto".into()
}
fn auto_model(&self) -> bool {
true
}
fn set_model_selection(&mut self, _model: String, _provider: Option<CommandProviderId>) {}
fn reasoning_effort(&self) -> CommandReasoningEffort {
CommandReasoningEffort::Auto
}
fn provider_identity(&self) -> Option<CommandProviderId> {
None
}
fn fallback_chain(&self) -> Vec<CommandProviderId> {
vec![]
}
}
struct Cost;
impl CommandCostContext for Cost {
fn display_currency(&self) -> CommandCurrency {
CommandCurrency::Usd
}
fn session_cost_for_currency(&self, _currency: CommandCurrency) -> f64 {
1.0
}
fn subagent_cost_for_currency(&self, _currency: CommandCurrency) -> f64 {
0.5
}
fn accrue_cost_estimate(&mut self, _amount: f64, _currency: CommandCurrency) {}
fn record_turn_cost(
&mut self,
_amount: f64,
_currency: CommandCurrency,
_receipt: Option<String>,
) {
}
}
struct Policy;
impl CommandModePolicyContext for Policy {
fn mode(&self) -> CommandMode {
CommandMode::Plan
}
fn set_mode(&mut self, _mode: CommandMode) {}
fn approval_mode(&self) -> CommandApprovalMode {
CommandApprovalMode::Suggest
}
fn allow_shell(&self) -> bool {
false
}
fn set_shell_access(&mut self, _allow: bool) {}
fn policy_locked(&self) -> bool {
false
}
}
struct Prompt;
impl CommandSystemPromptContext for Prompt {
fn system_prompt(&self) -> Option<SystemPrompt> {
None
}
}
struct Skills;
impl CommandSkillsContext for Skills {
fn active_skill(&self) -> Option<String> {
None
}
fn active_skill_provenance(&self) -> Option<String> {
None
}
fn refresh_skill_cache(&mut self) {}
}
struct Workspace;
impl CommandWorkspaceContext for Workspace {
fn workspace(&self) -> PathBuf {
PathBuf::from(".")
}
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
Ok(None)
}
fn operation_digest(&mut self) -> Result<String, String> {
Ok("No active operations or to-do items.".to_string())
}
}
#[test]
fn all_seven_shapes_are_object_safe() {
fn session(_: &dyn CommandSessionContext) {}
fn model(_: &dyn CommandModelContext) {}
fn cost(_: &dyn CommandCostContext) {}
fn policy(_: &dyn CommandModePolicyContext) {}
fn prompt(_: &dyn CommandSystemPromptContext) {}
fn skills(_: &dyn CommandSkillsContext) {}
fn workspace(_: &dyn CommandWorkspaceContext) {}
session(&Session);
model(&Model);
cost(&Cost);
policy(&Policy);
prompt(&Prompt);
skills(&Skills);
workspace(&Workspace);
}
#[test]
fn envelope_carries_independent_facets() {
let mut session = Session;
let mut model = Model;
let parts = CommandContexts::empty()
.with_session(&mut session)
.with_model(&mut model)
.into_parts();
assert_eq!(parts.session.expect("session").total_tokens(), 42);
assert!(parts.model.expect("model").auto_model());
assert!(parts.cost.is_none());
}
fn pure(value: Option<&str>) -> String {
value.unwrap_or_default().to_owned()
}
fn contextual(_contexts: CommandContexts<'_>, value: Option<&str>) -> String {
value.unwrap_or_default().to_owned()
}
#[test]
fn handlers_are_plain_function_pointers() {
let pure_handler = CommandHandler::Pure(pure);
let contextual_handler = CommandHandler::Contextual(contextual);
match pure_handler {
CommandHandler::Pure(handler) => assert_eq!(handler(Some("x")), "x"),
_ => unreachable!(),
}
match contextual_handler {
CommandHandler::Contextual(handler) => {
assert_eq!(handler(CommandContexts::empty(), Some("y")), "y")
}
_ => unreachable!(),
}
}
struct Sample;
impl RegisterCommand<String> for Sample {
fn info() -> &'static CommandInfo {
static INFO: CommandInfo = CommandInfo {
name: "sample",
aliases: &["s"],
usage: "/sample",
description_key: "command.sample",
};
&INFO
}
fn handler() -> CommandHandler<String> {
CommandHandler::Pure(pure)
}
}
#[test]
fn registration_shape_has_no_app_dependency() {
assert_eq!(Sample::info().name, "sample");
assert!(matches!(Sample::handler(), CommandHandler::Pure(_)));
}
// ---------------------------------------------------------------------------
// FEAT-018: presentation, media, and digest capabilities (D2-D5)
// ---------------------------------------------------------------------------
struct Presentation;
impl CommandPresentationContext for Presentation {
fn translate(&self, key: &str, replacements: &[(&str, &str)]) -> Result<String, String> {
if key == "automation_usage" {
return Ok("Usage: /automation [list|show <id>]".to_string());
}
if key == "mcp_recommended_unknown_id" {
let command = replacements
.iter()
.find(|(name, _)| *name == "recommendations_command")
.map(|(_, value)| *value)
.unwrap_or("/mcp recommendations");
return Ok(format!("Unknown recommended MCP ID (try {command})"));
}
// D3: unknown keys fail safely without echoing the raw lookup key.
Err("unknown translation key".to_string())
}
}
struct Media;
impl CommandMediaContext for Media {
fn attach_media(&mut self, path: &Path) -> Result<MediaAttachmentReceipt, String> {
if path.extension().and_then(|ext| ext.to_str()) == Some("png") {
Ok(MediaAttachmentReceipt {
kind: "image".to_string(),
path: path.to_path_buf(),
})
} else {
Err("Unsupported attachment type".to_string())
}
}
}
struct DigestWorkspace;
impl CommandWorkspaceContext for DigestWorkspace {
fn workspace(&self) -> PathBuf {
PathBuf::from(".")
}
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
Ok(None)
}
fn operation_digest(&mut self) -> Result<String, String> {
Ok("No active operations or to-do items.".to_string())
}
}
#[test]
fn new_capabilities_are_object_safe_and_independently_transportable() {
fn presentation(_: &dyn CommandPresentationContext) {}
fn media(_: &dyn CommandMediaContext) {}
fn digest_workspace(_: &dyn CommandWorkspaceContext) {}
presentation(&Presentation);
media(&Media);
digest_workspace(&DigestWorkspace);
let mut presentation = Presentation;
let mut media = Media;
let parts = CommandContexts::empty()
.with_presentation(&mut presentation)
.with_media(&mut media)
.into_parts();
assert!(parts.presentation.is_some());
assert!(parts.media.is_some());
assert!(parts.session.is_none());
}
#[test]
fn translation_contract_resolves_known_keys_and_fails_safely() {
let presentation = Presentation;
assert_eq!(
presentation
.translate("automation_usage", &[])
.expect("known key"),
"Usage: /automation [list|show <id>]"
);
assert_eq!(
presentation
.translate(
"mcp_recommended_unknown_id",
&[("recommendations_command", "/mcp recommendations")],
)
.expect("known key with named replacement"),
"Unknown recommended MCP ID (try /mcp recommendations)"
);
let unknown = presentation.translate("no_such_key", &[]);
assert!(unknown.is_err(), "unknown key must fail safely");
let err = unknown.unwrap_err();
assert!(
!err.contains("no_such_key"),
"no raw lookup key exposure (D3)"
);
}
#[test]
fn media_contract_is_atomic_and_returns_only_portable_data() {
let mut media = Media;
let ok = media
.attach_media(Path::new("/tmp/photo.png"))
.expect("png");
assert_eq!(ok.kind, "image");
assert_eq!(ok.path, PathBuf::from("/tmp/photo.png"));
let err = media.attach_media(Path::new("/tmp/notes.txt")).unwrap_err();
assert!(!err.is_empty(), "safe error string");
}
#[test]
fn digest_operation_returns_final_text_and_safe_errors() {
let mut workspace = DigestWorkspace;
assert_eq!(
workspace.operation_digest().expect("digest"),
"No active operations or to-do items."
);
}
#[test]
fn envelope_rejects_duplicate_new_slots_deterministically() {
struct SecondPresentation;
impl CommandPresentationContext for SecondPresentation {
fn translate(&self, _key: &str, _r: &[(&str, &str)]) -> Result<String, String> {
Ok(String::new())
}
}
struct SecondMedia;
impl CommandMediaContext for SecondMedia {
fn attach_media(&mut self, _p: &Path) -> Result<MediaAttachmentReceipt, String> {
Err("unused".to_string())
}
}
let mut a = Presentation;
let mut b = SecondPresentation;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
CommandContexts::empty()
.with_presentation(&mut a)
.with_presentation(&mut b);
}));
assert!(result.is_err(), "duplicate presentation slot must assert");
let mut a = Media;
let mut b = SecondMedia;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
CommandContexts::empty()
.with_media(&mut a)
.with_media(&mut b);
}));
assert!(result.is_err(), "duplicate media slot must assert");
}
-51
View File
@@ -1,51 +0,0 @@
//! Prototype boundary values used by the command capability shapes.
//!
//! These types deliberately do not replace the current TUI-owned production
//! types in FEAT-014. During the in-place adoption stage, thin TUI adapters
//! convert between existing application values and these boundary values.
/// Stable provider identity at the command boundary.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandProviderId(pub String);
/// Provider-neutral reasoning preference exposed to commands.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CommandReasoningEffort {
Off,
Minimal,
Low,
Medium,
High,
XHigh,
Ultra,
Auto,
#[default]
Max,
}
/// Application mode visible to commands.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandMode {
Agent,
Auto,
Yolo,
Plan,
Operate,
}
/// Tool-approval posture visible to commands.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum CommandApprovalMode {
Auto,
Bypass,
#[default]
Suggest,
Never,
}
/// Cost currency used by command-facing accounting operations.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CommandCurrency {
Usd,
Cny,
}
+3 -6
View File
@@ -7,14 +7,11 @@ license.workspace = true
repository.workspace = true
description = "Config schema and precedence model for Codewhale"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" }
codewhale-paths = { path = "../paths", version = "0.9.11" }
codewhale-secrets = { path = "../secrets", version = "0.9.11" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
codewhale-paths = { path = "../paths", version = "0.9.6" }
codewhale-secrets = { path = "../secrets", version = "0.9.6" }
fd-lock = "4.0.4"
libc = "0.2"
serde.workspace = true
+10 -104
View File
@@ -4,12 +4,11 @@
"schema": "Matches crates/config/src/models_dev.rs ModelsDevCatalog ({ models, providers }).",
"role": "NOT a competing source of truth. Preferred metadata is the live Models.dev catalog published into ProviderLake (#4187). This asset is used only when live/cache rows are unavailable (offline startup, failed refresh, or empty cache).",
"source": "Compact offline seed of verified in-repo defaults (context/output from crates/tui/src/models.rs; USD pricing from crates/tui/src/pricing.rs) for providers Codewhale ships with. It is intentionally smaller than a full Models.dev dump; live refresh supersedes these rows on (provider, wire_model_id) identity.",
"honesty": "Pricing is intentionally OMITTED where a flat catalog row would be misleading: DeepSeek-native rows (priced via the time-aware DeepSeek table elsewhere, kept UnknownOrStale at the route layer), Grok 4.6 (rates double once a prompt reaches 200K tokens), aggregator-hosted DeepSeek rows (aggregator account terms, not DeepSeek Platform pricing), Xiaomi MiMo rows (published PAYG rates apply only to sk- pay-as-you-go keys; the catalog cannot distinguish that billing surface from credit/quota Token Plan keys, so MiMo stays unpriced), and Alibaba Model Studio Token/Coding Plan rows (upstream lists zero per-token cost because usage draws on plan quota, not per-token billing; a zero here would read as 'free'). Absent pricing surfaces as PricingSku::UnknownOrStale, never a fabricated zero.",
"honesty": "Pricing is intentionally OMITTED where the repo does not publish a trustworthy per-token rate: DeepSeek-native rows (priced via the time-aware DeepSeek table elsewhere, kept UnknownOrStale at the route layer), aggregator-hosted DeepSeek rows (aggregator account terms, not DeepSeek Platform pricing), Xiaomi MiMo rows (published PAYG rates apply only to sk- pay-as-you-go keys; the catalog cannot distinguish that billing surface from credit/quota Token Plan keys, so MiMo stays unpriced), and Alibaba Model Studio Token/Coding Plan rows (upstream lists zero per-token cost because usage draws on plan quota, not per-token billing; a zero here would read as 'free'). Absent pricing surfaces as PricingSku::UnknownOrStale, never a fabricated zero.",
"default_rows": "Each provider's `default: true` wire id equals that provider's built-in DEFAULT_*_MODEL so RouteResolver::new() and the descriptor stay in agreement when offline.",
"curated": "qwen3.8-max (GA) is curated ahead of upstream Models.dev, which as of 2026-08-03 lists only qwen3.8-max-preview; facts verified against the owner's Token Plan console (2026-08-03): ~1M context, 128K output, image understanding, always-on reasoning. deepseek-v4-flash-0731 keeps the console/in-repo wire id for the row upstream serves as deepseek-v4-flash. Coding Plan rows for qwen3.8-max-preview, deepseek-v4-pro, deepseek-v4-flash-0731, and glm-5.2 are curated from the Token Plan upstream entries (upstream alibaba-coding-plan does not list them yet); the in-repo route layer already offers the same model set on both plans. Upstream provider ids alibaba-token-plan(-cn) / alibaba-coding-plan(-cn) were merged onto the CodeWhale provider ids (live refresh normalizes them via ProviderKind aliases; the -cn regional variants stay upstream-id browse rows until Codewhale ships China endpoints).",
"pending_release_metadata": "GLM-5.3 is live on the Z.ai Coding Plan (docs.z.ai/devpack/overview and docs.z.ai/devpack/latest-model, recorded 2026-08-13) and is the default direct Z.ai model (DEFAULT_ZAI_MODEL); explicit GLM-5.2 selections keep their own id. First-party wire id is GLM-5.3; OpenRouter mirror is z-ai/glm-5.3. Capability/limit/dialect values still inherit from GLM-5.2 until Z.ai publishes distinct 5.3 numbers. Pricing stays absent: Coding Plan publishes credit multipliers, not a USD PAYG row we can stand behind. Z.ai may auto-route GLM-5.2/GLM-5.1 requests to GLM-5.3 on their side; Codewhale still sends the selected picker id. Do not send a [1m] suffix. Scope stays first-party Z.ai plus the OpenRouter mirror; add third-party gateway rows only against that gateway's own published roster.",
"currency_sweep_2026_08_17": "Rows re-verified against official pages on 2026-08-17 (#5470 follow-up): gpt-5.6-terra 2.00/12.00 (cache read 0.20, write 2.50) and gpt-5.6-luna 0.20/1.20 (0.02/0.25) per developers.openai.com model pages; claude-sonnet-5 2.00/10.00 (0.20/2.50) is now Anthropic's standard price (the 2026-09-01 increase was cancelled) and claude-opus-5 5.00/25.00 (0.50/6.25) was added; kimi-k3 3.00/15.00 (0.30) and kimi-k2.7-code-highspeed 1.90/8.00 (0.38) per platform.kimi.ai; MiniMax-M2.7-highspeed 0.60/2.40 (0.06/0.375) per platform.minimax.io; grok-4.5 (500K) and grok-4.3 (1M) carry limits only because xAI doubles their rates past 200K (same rule as grok-4.6); OpenRouter dots-studio/dots-3-note-preview:free carries limits only (its single free endpoint publishes $0, which this seed does not restate as a price).",
"coverage": "20 providers, 90 model rows (offline seed only)."
"pending_release_metadata": "glm-5.3 rows INHERIT every capability/limit/dialect value from glm-5.2 PENDING OFFICIAL Z.AI RELEASE METADATA (added 2026-08-03; glm-5.3 was not live on the Z.ai API at that date and pricing is deliberately absent). Correct here first. Scope is deliberate: only the first-party Z.ai row (GLM-5.3) and its OpenRouter mirror (z-ai/glm-5.3) exist. Metadata inheritance is not evidence of third-party availability, so no OpenCode Zen, OpenCode Go, Model Studio, or TelecomJS glm-5.3 row is seeded; add those only against that gateway's own published roster.",
"coverage": "20 providers, 80 chat offerings (offline seed only)."
},
"models": {
"deepseek-v4-pro": {
@@ -61,16 +60,6 @@
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
},
"deepseek-v4-flash-vision-exp": {
"id": "deepseek-v4-flash-vision-exp",
"base_model": "deepseek-v4-flash",
"name": "DeepSeek V4 Flash Vision (experimental)",
"family": "deepseek",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 1000000, "output": 384000 }
}
}
},
@@ -85,6 +74,7 @@
"id": "GLM-5.2",
"name": "GLM-5.2",
"family": "glm",
"default": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"tool_call": true,
@@ -96,7 +86,6 @@
"id": "GLM-5.3",
"name": "GLM-5.3",
"family": "glm",
"default": true,
"reasoning": true,
"reasoning_options": [{ "type": "effort", "values": ["high", "max"] }],
"tool_call": true,
@@ -139,8 +128,7 @@
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text", "image", "video"], "output": ["text"] },
"limit": { "context": 1048576, "output": 131072 },
"cost": { "input": 3.00, "output": 15.00, "cache_read": 0.30 }
"limit": { "context": 1048576, "output": 131072 }
},
"kimi-k2.7-code": {
"id": "kimi-k2.7-code",
@@ -153,16 +141,6 @@
"limit": { "context": 262144, "output": 262144 },
"cost": { "input": 0.95, "output": 4.00, "cache_read": 0.19 }
},
"kimi-k2.7-code-highspeed": {
"id": "kimi-k2.7-code-highspeed",
"name": "Kimi K2.7 Code (high-speed)",
"family": "kimi",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 262144, "output": 262144 },
"cost": { "input": 1.90, "output": 8.00, "cache_read": 0.38 }
},
"kimi-k2.6": {
"id": "kimi-k2.6",
"name": "Kimi K2.6",
@@ -208,19 +186,6 @@
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 204800, "output": 204800 },
"cost": { "input": 0.30, "output": 1.20, "cache_read": 0.06, "cache_write": 0.375 }
},
"MiniMax-M2.7-highspeed": {
"id": "MiniMax-M2.7-highspeed",
"name": "MiniMax M2.7 (high-speed)",
"family": "minimax",
"reasoning": true,
"reasoning_options": [
{ "type": "thinking", "values": ["always_on"], "default": "always_on" }
],
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 204800, "output": 204800 },
"cost": { "input": 0.60, "output": 2.40, "cache_read": 0.06, "cache_write": 0.375 }
}
}
},
@@ -257,19 +222,6 @@
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 204800, "output": 204800 },
"cost": { "input": 0.30, "output": 1.20, "cache_read": 0.06, "cache_write": 0.375 }
},
"MiniMax-M2.7-highspeed": {
"id": "MiniMax-M2.7-highspeed",
"name": "MiniMax M2.7 (high-speed)",
"family": "minimax",
"reasoning": true,
"reasoning_options": [
{ "type": "thinking", "values": ["always_on"], "default": "always_on" }
],
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 204800, "output": 204800 },
"cost": { "input": 0.60, "output": 2.40, "cache_read": 0.06, "cache_write": 0.375 }
}
}
},
@@ -906,7 +858,7 @@
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 2.00, "output": 12.00, "cache_read": 0.20, "cache_write": 2.50 }
"cost": { "input": 2.50, "output": 15.00, "cache_read": 0.25, "cache_write": 3.125 }
},
"gpt-5.6-luna": {
"id": "gpt-5.6-luna",
@@ -918,7 +870,7 @@
"structured_output": true,
"modalities": { "input": ["text", "image", "pdf"], "output": ["text"] },
"limit": { "context": 1050000, "input": 922000, "output": 128000 },
"cost": { "input": 0.20, "output": 1.20, "cache_read": 0.02, "cache_write": 0.25 }
"cost": { "input": 1.00, "output": 6.00, "cache_read": 0.10, "cache_write": 1.25 }
},
"gpt-5.3-codex": {
"id": "gpt-5.3-codex",
@@ -970,16 +922,6 @@
"limit": { "context": 200000, "output": 64000 },
"cost": { "input": 1.00, "output": 5.00, "cache_read": 0.10, "cache_write": 1.25 }
},
"claude-opus-5": {
"id": "claude-opus-5",
"name": "Claude Opus 5",
"family": "claude",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 5.00, "output": 25.00, "cache_read": 0.50, "cache_write": 6.25 }
},
"claude-sonnet-5": {
"id": "claude-sonnet-5",
"name": "Claude Sonnet 5",
@@ -988,7 +930,7 @@
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 128000 },
"cost": { "input": 2.00, "output": 10.00, "cache_read": 0.20, "cache_write": 2.50 }
"cost": { "input": 3.00, "output": 15.00, "cache_read": 0.30 }
},
"claude-fable-5": {
"id": "claude-fable-5",
@@ -1096,15 +1038,6 @@
"tool_call": true,
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000000, "output": 131072 }
},
"dots-studio/dots-3-note-preview:free": {
"id": "dots-studio/dots-3-note-preview:free",
"name": "Dots Studio Dots3-Note Preview (OpenRouter, free endpoint)",
"family": "dots",
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 512000, "output": 512000 }
}
}
},
@@ -1254,41 +1187,14 @@
"npm": "@ai-sdk/xai",
"env": ["XAI_API_KEY"],
"models": {
"grok-4.6": {
"id": "grok-4.6",
"name": "Grok 4.6",
"family": "grok",
"default": true,
"attachment": true,
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "medium", "high", "xhigh"], "default": "high" }
],
"tool_call": true,
"structured_output": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 500000 }
},
"grok-4.5": {
"id": "grok-4.5",
"name": "Grok 4.5",
"family": "grok",
"reasoning": true,
"reasoning_options": [
{ "type": "effort", "values": ["low", "medium", "high"], "default": "high" }
],
"tool_call": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 500000 }
},
"grok-4.3": {
"id": "grok-4.3",
"name": "Grok 4.3",
"family": "grok",
"default": true,
"reasoning": true,
"tool_call": true,
"modalities": { "input": ["text", "image"], "output": ["text"] },
"limit": { "context": 1000000 }
"modalities": { "input": ["text"], "output": ["text"] }
}
}
},
-135
View File
@@ -1,135 +0,0 @@
//! The TUI's user-facing operating mode. Lives in codewhale-config so
//! settings, receipts, and other crates can name it without depending on
//! the TUI; the TUI adds the localized picker strings through an extension
//! trait.
/// Supported application modes for the TUI.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AppMode {
Agent,
Auto,
/// Legacy compatibility alias; resolves to [`Self::Agent`] + bypass approvals.
Yolo,
Plan,
Operate,
}
impl AppMode {
/// Productive keyboard cycle: Plan -> Act -> Operate -> Plan.
///
/// `Auto` remains an internal variant while the real implementation is
/// redesigned; do not expose it through user-facing mode selection (#3733).
/// `Yolo` is kept for parse/back-compat only and is not in the Tab cycle.
/// Operate joins the visible cycle because ordinary messages can now
/// coordinate background workers without requiring a Workflow definition.
pub const CYCLE: [Self; 3] = [Self::Plan, Self::Agent, Self::Operate];
#[must_use]
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"agent" | "act" | "work" | "auto" | "1" => Some(Self::Agent),
"plan" | "2" => Some(Self::Plan),
"operate" | "operation" | "ops" | "3" => Some(Self::Operate),
// Invisible one-way permission shorthand only — never a visible mode.
"yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" => {
Some(Self::Yolo)
}
_ => None,
}
}
#[must_use]
pub fn from_setting(value: &str) -> Self {
// Unreleased Multitask never shipped; normalize leftover settings to Operate.
match value.trim().to_ascii_lowercase().as_str() {
"multitask" | "multi" | "5" => Self::Operate,
other => Self::parse(other).unwrap_or(Self::Agent),
}
}
#[must_use]
pub fn as_setting(self) -> &'static str {
match self {
Self::Agent => "agent",
Self::Auto => "agent",
// Write current permission vocabulary, not the legacy YOLO label.
Self::Yolo => "agent",
Self::Plan => "plan",
Self::Operate => "operate",
}
}
/// Short label used in the UI footer.
pub fn label(self) -> &'static str {
match self {
AppMode::Agent => "ACT",
AppMode::Auto => "ACT",
AppMode::Yolo => "ACT",
AppMode::Plan => "PLAN",
AppMode::Operate => "OPERATE",
}
}
#[must_use]
pub fn display_name(self) -> &'static str {
match self {
AppMode::Agent => "Act",
AppMode::Auto => "Act",
AppMode::Yolo => "Act",
AppMode::Plan => "Plan",
AppMode::Operate => "Operate",
}
}
#[must_use]
pub fn number(self) -> char {
match self {
AppMode::Agent | AppMode::Auto | AppMode::Yolo => '1',
AppMode::Plan => '2',
AppMode::Operate => '3',
}
}
#[must_use]
pub fn uses_agent_baseline(self) -> bool {
matches!(self, Self::Agent | Self::Auto | Self::Operate)
}
/// Operate gets a higher parallel launch floor so background fan-out is
/// not throttled to a single slot when config is low.
#[must_use]
pub fn mode_delegation_launch_floor(self) -> usize {
match self {
Self::Operate => 4,
_ => 1,
}
}
/// Description shown in help or onboarding text.
pub fn description(self) -> &'static str {
match self {
AppMode::Agent | AppMode::Auto => {
"Act mode - direct work in the current session with tools"
}
AppMode::Yolo => "Act mode with Full Access (legacy compatibility setting)",
AppMode::Plan => "Plan mode - research and design before implementing",
AppMode::Operate => "Operate mode - send tasks while Fleet workers run in parallel",
}
}
#[must_use]
pub fn next(self) -> Self {
let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else {
return Self::Agent;
};
Self::CYCLE[(index + 1) % Self::CYCLE.len()]
}
#[must_use]
pub fn previous(self) -> Self {
let Some(index) = Self::CYCLE.iter().position(|mode| *mode == self) else {
return Self::Agent;
};
Self::CYCLE[(index + Self::CYCLE.len() - 1) % Self::CYCLE.len()]
}
}
+2 -26
View File
@@ -249,15 +249,8 @@ pub fn score(prompt: &str) -> i32 {
}
}
// Length factor: long prompts tend to be more complex.
//
// Counted in characters, not bytes, as the doc comment above states. Half
// the keyword lists here are Chinese, so CJK input is a first-class case —
// and every CJK character is three UTF-8 bytes, which made `prompt.len()`
// award the long-prompt bonus at a third of the documented length. A
// 200-character Chinese prompt scored +2 (600 bytes) and classified as
// complex, where the same-length English prompt scored 0.
let len = prompt.chars().count();
// Length factor: long prompts tend to be more complex
let len = prompt.len();
if len > 500 {
score += 2;
} else if len > 200 {
@@ -362,23 +355,6 @@ mod tests {
assert_eq!(classify("what is the capital of France?"), FLASH_MODEL);
}
#[test]
fn length_bonus_counts_characters_not_utf8_bytes() {
// Keyword-free prompts of identical *length* must score identically
// regardless of script. "啊" is three UTF-8 bytes, so a byte-counted
// length factor gave the Chinese prompt a bonus the English one did
// not earn — and at 200 characters it flipped the classification.
let english = "a".repeat(200);
let chinese = "".repeat(200);
assert_eq!(score(&chinese), score(&english));
assert_eq!(classify(&chinese), FLASH_MODEL);
let english_long = "a".repeat(600);
let chinese_long = "".repeat(600);
assert_eq!(score(&chinese_long), score(&english_long));
assert_eq!(classify(&chinese_long), PRO_MODEL);
}
#[test]
fn test_score_never_negative() {
// Even for very simple queries, score should be predictable
+1 -13
View File
@@ -711,19 +711,7 @@ fn secret_free_fingerprint_input(base_url: &str) -> String {
.unwrap_or_default();
return normalize_base_url(&format!("{scheme}://{authority}{path}"));
}
// Scheme-less input still has an authority, and it can still carry
// `user:pass@` userinfo. Strip it exactly as the scheme branch does, so the
// digest input never contains a credential.
let without_query = trimmed.split(['?', '#']).next().unwrap_or_default();
let authority_end = without_query.find('/').unwrap_or(without_query.len());
let authority = &without_query[..authority_end];
let authority = authority
.rsplit_once('@')
.map_or(authority, |(_, host)| host);
if authority.is_empty() {
return REDACTED.to_string();
}
normalize_base_url(&format!("{authority}{}", &without_query[authority_end..]))
normalize_base_url(trimmed.split(['?', '#']).next().unwrap_or(REDACTED))
}
fn normalize_base_url(base_url: &str) -> String {
+8 -125
View File
@@ -306,73 +306,6 @@ fn fingerprint_never_hashes_secret_bearing_url_text() {
}
}
#[test]
fn fingerprint_strips_userinfo_from_a_scheme_less_base_url() {
// A base_url typed without a scheme took the fall-through branch, which
// only split off `?`/`#` — so `user:pass@host` went into SHA-256 verbatim,
// against the documented "userinfo never enters the digest function".
let expected = base_url_fingerprint("api.example.com/v1");
for url in [
"user:secret@api.example.com/v1",
"user:other-secret@api.example.com/v1",
"token@api.example.com/v1",
] {
assert_eq!(base_url_fingerprint(url), expected, "{url}");
}
}
#[test]
fn fingerprint_of_an_empty_base_url_is_the_redacted_constant() {
// The fall-through's `unwrap_or(REDACTED)` never fired — `split` always
// yields at least one (possibly empty) piece — so an empty base URL
// fingerprinted the empty string instead of the redacted sentinel.
let redacted = base_url_fingerprint("ftp://api.example.com");
for url in ["", " ", "?api_key=secret"] {
assert_eq!(base_url_fingerprint(url), redacted, "{url:?}");
}
// SHA-256("") is what empty/whitespace hashed to before the sentinel
// mapping. That digest is a persisted cache/receipt key, so flipping it
// back would be another undeclared persisted-key change.
const EMPTY_SHA256: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
assert_ne!(redacted, EMPTY_SHA256);
}
#[test]
fn changelog_declares_fingerprint_persisted_key_change() {
// `base_url_fingerprint` is serde-serialized (catalog cache, LiveOffering,
// pricing receipts, TurnRecord.routed_usage_source_ids). Empty input and
// scheme-less URLs with `@` hash differently than they did before
// 388125491. Before a release cut, Unreleased must say so; after the
// coordinated version bump, the current-version section owns the same
// declaration. An older release cannot satisfy this check, because stale
// caches would then look like corruption without a note for this build.
let changelog = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../CHANGELOG.md"));
let unreleased = changelog
.split_once("## [Unreleased]")
.expect("CHANGELOG has an Unreleased section")
.1
.split_once("\n## [")
.expect("Unreleased is followed by a released section")
.0;
let current_heading = format!("## [{}]", env!("CARGO_PKG_VERSION"));
let current_release = changelog
.split_once(&current_heading)
.map(|(_, tail)| tail.split("\n## [").next().unwrap_or(tail))
.unwrap_or_default();
let declared_change = format!("{unreleased}\n{current_release}");
for needle in [
"base_url_fingerprint",
"persisted-key",
"scheme-less",
"routed_usage_source_ids",
] {
assert!(
declared_change.contains(needle),
"Unreleased or the current release section must declare the {needle} persisted-key change:\n{declared_change}"
);
}
}
#[test]
fn ttl_marks_entries_stale_and_excludes_them_from_fresh() {
let fp = base_url_fingerprint("https://api.example.com");
@@ -688,13 +621,10 @@ fn bundled_asset_yields_real_chat_offerings_for_key_models() {
// proving real facts flow rather than `RouteLimits::default()` (unknown).
let glm = find(&rows, "zai", "GLM-5.2");
assert_eq!(glm.limit.as_ref().and_then(|l| l.context), Some(1_000_000));
assert!(
!glm.default_for_provider,
"GLM-5.2 is no longer the Z.ai default"
);
assert!(glm.default_for_provider);
// GLM-5.3 is the Z.ai default (matching DEFAULT_ZAI_MODEL); its limits
// still inherit from glm-5.2 until Z.ai publishes distinct 5.3 numbers.
// GLM-5.3 is a peer row whose limits are INHERITED FROM glm-5.2 pending
// official Z.ai release metadata. Adding it must not move the default.
let glm53 = find(&rows, "zai", "GLM-5.3");
assert_eq!(
glm53.limit.as_ref().and_then(|l| l.context),
@@ -705,8 +635,8 @@ fn bundled_asset_yields_real_chat_offerings_for_key_models() {
glm.limit.as_ref().and_then(|l| l.output)
);
assert!(
glm53.default_for_provider,
"GLM-5.3 must be the Z.ai default"
!glm53.default_for_provider,
"GLM-5.3 must not become the Z.ai default"
);
let kimi_k27 = find(&rows, "moonshot", "kimi-k2.7-code");
@@ -752,48 +682,6 @@ fn bundled_asset_yields_real_chat_offerings_for_key_models() {
Some("disabled")
);
let grok_46 = find(&rows, "xai", "grok-4.6");
assert!(grok_46.default_for_provider);
assert_eq!(
grok_46.limit.as_ref().and_then(|limit| limit.context),
Some(500_000)
);
assert_eq!(grok_46.attachment, Some(true));
assert_eq!(grok_46.structured_output, Some(true));
let grok_input_modalities = grok_46
.modalities
.as_ref()
.expect("Grok 4.6 modalities")
.input
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
assert_eq!(grok_input_modalities, ["text", "image"]);
assert_eq!(
grok_46.reasoning_options[0]
.get("default")
.and_then(serde_json::Value::as_str),
Some("high")
);
let grok_45 = find(&rows, "xai", "grok-4.5");
assert_eq!(
grok_45.reasoning_options[0]
.get("default")
.and_then(serde_json::Value::as_str),
Some("high")
);
let grok_45_values = grok_45.reasoning_options[0]
.get("values")
.and_then(serde_json::Value::as_array)
.expect("Grok 4.5 effort values");
assert_eq!(
grok_45_values
.iter()
.filter_map(|value| value.as_str())
.collect::<Vec<_>>(),
["low", "medium", "high"]
);
let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
assert_eq!(
minimax_m2_7.limit.as_ref().and_then(|limit| limit.context),
@@ -849,9 +737,9 @@ fn bundled_asset_pricing_is_honest() {
assert_eq!(cost.output, Some(4.40));
assert_eq!(cost.cache_read, Some(0.26));
// GLM-5.3 is live on the Coding Plan, but Z.ai has published no USD PAYG
// rate for it. Coding Plan credit multipliers are not USD, so every
// glm-5.3 row stays unpriced rather than inheriting glm-5.2's rates.
// GLM-5.3 was not live on the Z.ai API when it was added (2026-08-03) and
// Zhipu has published no rate for it, so every glm-5.3 row stays unpriced
// rather than inheriting glm-5.2's published rates.
for row in &rows {
if row.wire_model_id.to_ascii_lowercase().contains("glm-5.3") {
assert!(
@@ -868,11 +756,6 @@ fn bundled_asset_pricing_is_honest() {
let minimax_m3 = find(&rows, "minimax-anthropic", "MiniMax-M3");
assert!(minimax_m3.cost.is_none());
// Grok 4.6 also has a prompt-length tier, starting at 200K input tokens.
// The usage-aware TUI table prices it; a flat catalog row would underbill.
let grok_46 = find(&rows, "xai", "grok-4.6");
assert!(grok_46.cost.is_none());
let minimax_m2_7 = find(&rows, "minimax-anthropic", "MiniMax-M2.7");
let cost = minimax_m2_7.cost.as_ref().expect("M2.7 is priced");
assert_eq!(cost.input, Some(0.30));
-55
View File
@@ -60,19 +60,9 @@ where
/// pass (existing top-level values always win; shadowed duplicates are
/// dropped), until no literal `extras` table remains. Bounded passes keep a
/// pathological file from looping.
///
/// An `extras` key that is *not* table-like (a string, array, or number) has
/// nothing to lift, so it is left exactly where it is. Removing it would
/// delete user data this function cannot heal, on every subsequent write.
pub fn heal_extras_nesting(document: &mut toml_edit::DocumentMut) -> bool {
let mut healed = false;
for _ in 0..16 {
if document
.get("extras")
.is_none_or(|item| !item.is_table_like())
{
break;
}
let Some(extras) = document
.remove("extras")
.and_then(|item| item.into_table().ok())
@@ -454,51 +444,6 @@ fn table_like_at_path_mut<'a>(
#[cfg(test)]
mod tests {
#[test]
fn healing_keeps_a_non_table_extras_key_it_cannot_lift() {
// `extras` is where the config structs flatten unknown keys, so a
// scalar or array under that exact name round-trips through the typed
// path as ordinary user data. Healing used to `remove()` it before
// discovering it was not a table, dropping it on the very next
// `codewhale config set` — and reporting `healed == false` while doing
// so.
for body in [
"extras = \"opaque\"\nmodel = \"m\"\n",
"extras = [1, 2]\nmodel = \"m\"\n",
"model = \"m\"\nextras = 7\n",
] {
let tmp = tempfile::tempdir().expect("tempdir");
let path = tmp.path().join("config.toml");
std::fs::write(&path, body).expect("write fixture");
super::mutate_config_document(&path, |doc| {
super::set_config_document_value(doc, &["tui", "low_motion"], true)
})
.expect("mutate");
let saved = std::fs::read_to_string(&path).expect("read");
let parsed: toml::Value = toml::from_str(&saved).expect("parse");
assert!(
parsed.get("extras").is_some(),
"non-table `extras` was deleted by an unrelated write: {saved}"
);
assert!(saved.contains("low_motion = true"), "{saved}");
}
}
#[test]
fn healing_still_lifts_an_inline_extras_table() {
// The preservation guard above must not stop the real healing path:
// an inline table is table-like and still gets lifted.
let mut doc = "extras = { trust = true }\nmodel = \"m\"\n"
.parse::<toml_edit::DocumentMut>()
.expect("parse");
assert!(super::heal_extras_nesting(&mut doc));
let rendered = doc.to_string();
assert!(rendered.contains("trust = true"), "{rendered}");
assert!(!rendered.contains("extras"), "{rendered}");
}
#[test]
fn healing_lifts_nested_extras_towers_to_the_top_level() {
let tmp = tempfile::tempdir().expect("tempdir");
-460
View File
@@ -1,460 +0,0 @@
//! One RFC 8628 device-authorization polling loop, shared by every Codewhale
//! device-code flow (xAI/Grok device login, Codewhale account login).
//!
//! Ported from pi (<https://github.com/badlogic/pi-mono>), MIT licensed,
//! Copyright (c) 2025 Mario Zechner — see
//! `packages/ai/src/auth/oauth/device-code.ts` for the original
//! `pollOAuthDeviceCodeFlow`. The accumulated behaviours carried over from it:
//!
//! * the RFC 8628 §3.2 default of 5 seconds when the server omits `interval`;
//! * `slow_down` handling that **prefers a server-supplied interval** over the
//! client-tracked one. Trusting only the client-tracked value lets WSL/VM
//! clock drift poll early forever; RFC 8628 §3.5's +5s step is the fallback;
//! * a hard deadline derived from `expires_in`, never slept past even after
//! `slow_down` backoff;
//! * a distinct timeout message when at least one `slow_down` was seen, so the
//! clock-drift case is diagnosable instead of looking like a plain timeout.
//!
//! The loop is generic over the poll result and does no I/O of its own: the
//! caller supplies the poll and the sleep. Nothing here ever holds, formats, or
//! logs a token — `T` is opaque to this module and is never `Debug`-printed.
use std::time::{Duration, Instant};
use anyhow::{Result, bail};
/// RFC 8628 §3.2: when the authorization server omits `interval`, clients must
/// poll no faster than every 5 seconds.
pub const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
/// RFC 8628 §3.5: `slow_down` increases the polling interval by 5 seconds.
pub const SLOW_DOWN_STEP_SECS: u64 = 5;
/// Never poll faster than once a second, whatever the server asks for.
const MINIMUM_INTERVAL: Duration = Duration::from_secs(1);
/// What one poll of the token endpoint told us.
///
/// A terminal failure is reported by returning `Err` from the poll closure, so
/// each provider keeps its own error text.
pub enum DevicePollOutcome<T> {
/// The user approved; `T` is the provider's parsed token material.
Complete(T),
/// `authorization_pending` — keep the current interval.
Pending,
/// `slow_down` — back off. `interval_seconds` is the server's new minimum
/// when it supplied one (preferred over the client-tracked interval).
SlowDown { interval_seconds: Option<u64> },
}
/// A configured device-code polling run. Build one, then [`DeviceCodePoll::run`].
pub struct DeviceCodePoll {
interval: Duration,
max_interval: Option<Duration>,
lifetime: Duration,
wait_before_first_poll: bool,
timeout_message: String,
slow_down_timeout_message: Option<String>,
}
impl DeviceCodePoll {
/// Start a run that gives up after `lifetime` with `timeout_message`.
///
/// The interval starts at the RFC 8628 default of 5 seconds; callers pass
/// the server's `interval` through [`DeviceCodePoll::interval_seconds`].
#[must_use]
pub fn new(lifetime: Duration, timeout_message: impl Into<String>) -> Self {
Self {
interval: Duration::from_secs(DEFAULT_POLL_INTERVAL_SECS),
max_interval: None,
lifetime,
wait_before_first_poll: false,
timeout_message: timeout_message.into(),
slow_down_timeout_message: None,
}
}
/// Apply the server-advertised `interval`. `None` (or a zero/absent value,
/// which RFC 8628 permits) keeps the 5-second default.
#[must_use]
pub fn interval_seconds(mut self, seconds: Option<u64>) -> Self {
if let Some(seconds) = seconds.filter(|seconds| *seconds > 0) {
self.interval = self.clamp_interval(Duration::from_secs(seconds));
}
self
}
/// Cap the interval, including after `slow_down` backoff.
#[must_use]
pub fn max_interval_seconds(mut self, seconds: u64) -> Self {
self.max_interval = Some(Duration::from_secs(seconds.max(1)));
self.interval = self.clamp_interval(self.interval);
self
}
/// Sleep one interval before the first poll.
///
/// Device-code endpoints that answer `authorization_pending` (xAI) want
/// this; endpoints whose first response is already meaningful (the
/// Codewhale account service, which returns HTTP 202 while pending) poll
/// immediately and sleep afterwards.
#[must_use]
pub fn wait_before_first_poll(mut self, wait: bool) -> Self {
self.wait_before_first_poll = wait;
self
}
/// Message used instead of the plain timeout message when the run saw at
/// least one `slow_down`. This is the WSL/VM clock-drift tell.
#[must_use]
pub fn slow_down_timeout_message(mut self, message: impl Into<String>) -> Self {
self.slow_down_timeout_message = Some(message.into());
self
}
fn clamp_interval(&self, interval: Duration) -> Duration {
let interval = interval.max(MINIMUM_INTERVAL);
match self.max_interval {
Some(max) => interval.min(max),
None => interval,
}
}
/// Poll until the flow completes, fails, or the deadline passes.
///
/// `sleep` is injected so tests never wait in real time. `poll` returns
/// `Err` for any terminal failure (denied, expired, transport error).
pub fn run<T, S, P>(self, mut sleep: S, mut poll: P) -> Result<T>
where
S: FnMut(Duration),
P: FnMut() -> Result<DevicePollOutcome<T>>,
{
let deadline = Instant::now() + self.lifetime;
let mut interval = self.interval;
let mut saw_slow_down = false;
if self.wait_before_first_poll {
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
return Err(self.timed_out(saw_slow_down));
}
sleep(interval.min(remaining));
}
while Instant::now() < deadline {
match poll()? {
DevicePollOutcome::Complete(value) => return Ok(value),
DevicePollOutcome::Pending => {}
DevicePollOutcome::SlowDown { interval_seconds } => {
saw_slow_down = true;
// Prefer the server's new minimum when it gave one: a
// purely client-tracked interval polls early forever when
// the clock drifts (WSL, suspended VMs).
interval = match interval_seconds.filter(|seconds| *seconds > 0) {
Some(seconds) => self.clamp_interval(Duration::from_secs(seconds)),
None => {
self.clamp_interval(interval + Duration::from_secs(SLOW_DOWN_STEP_SECS))
}
};
}
}
// Never sleep past the code's expiry, even after slow_down backoff.
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
sleep(interval.min(remaining));
}
Err(self.timed_out(saw_slow_down))
}
fn timed_out(&self, saw_slow_down: bool) -> anyhow::Error {
match (saw_slow_down, self.slow_down_timeout_message.as_deref()) {
(true, Some(message)) => anyhow::anyhow!("{message}"),
_ => anyhow::anyhow!("{}", self.timeout_message),
}
}
}
/// Reject a device-code verification URI that must not be handed to a browser
/// opener.
///
/// Ported from pi's `validateVerificationUri`
/// (`packages/ai/src/auth/oauth/xai.ts`, MIT, Copyright (c) 2025 Mario
/// Zechner): the URI comes straight off the wire and is passed to the platform
/// "open this" call, so a malicious or compromised response could otherwise
/// launch `file:`, a custom app scheme, or a helper with attacker-chosen
/// arguments. pi requires `https:`; Codewhale additionally allows `http:` on a
/// loopback host, which is what self-hosted issuers and the device-code tests
/// use — matching the loopback allowance the account login already makes.
///
/// Embedded credentials are rejected in every case.
pub fn validate_browser_verification_uri(raw: &str, context: &str) -> Result<String> {
let trimmed = raw.trim();
let Ok(url) = url_scheme_and_host(trimmed) else {
bail!("{context} returned an unusable verification URI");
};
let (scheme, host, has_credentials) = url;
if has_credentials {
bail!("{context} returned a verification URI with embedded credentials");
}
let allowed = scheme == "https" || (scheme == "http" && is_loopback_host(&host));
if !allowed {
bail!("{context} returned an untrusted verification URI");
}
Ok(trimmed.to_string())
}
/// Minimal scheme/host/credential split, so this module stays free of a URL
/// dependency (`codewhale-config` deliberately has no `reqwest`/`url`).
fn url_scheme_and_host(raw: &str) -> Result<(String, String, bool), ()> {
let (scheme, rest) = raw.split_once("://").ok_or(())?;
if scheme.is_empty()
|| !scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'-' || b == b'.')
{
return Err(());
}
let authority = rest
.split(['/', '?', '#'])
.next()
.filter(|authority| !authority.is_empty())
.ok_or(())?;
let (credentials, hostport) = match authority.rsplit_once('@') {
Some((credentials, hostport)) => (!credentials.is_empty(), hostport),
None => (false, authority),
};
let host = match hostport.strip_prefix('[') {
// IPv6 literal: [::1]:8080
Some(rest) => rest.split_once(']').ok_or(())?.0.to_string(),
None => hostport.split(':').next().ok_or(())?.to_string(),
};
if host.is_empty() {
return Err(());
}
Ok((
scheme.to_ascii_lowercase(),
host.to_ascii_lowercase(),
credentials,
))
}
fn is_loopback_host(host: &str) -> bool {
if host == "localhost" || host == "::1" {
return true;
}
host.parse::<std::net::IpAddr>()
.is_ok_and(|address| address.is_loopback())
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
fn recording_sleep(log: &RefCell<Vec<Duration>>) -> impl FnMut(Duration) + '_ {
move |duration| log.borrow_mut().push(duration)
}
#[test]
fn completes_on_first_poll_without_waiting() {
let slept = RefCell::new(Vec::new());
let value = DeviceCodePoll::new(Duration::from_secs(60), "timed out")
.run(recording_sleep(&slept), || {
Ok(DevicePollOutcome::Complete("token"))
})
.expect("first poll completes");
assert_eq!(value, "token");
assert!(slept.borrow().is_empty(), "no sleep before the first poll");
}
#[test]
fn waits_one_interval_before_the_first_poll_when_asked() {
let slept = RefCell::new(Vec::new());
DeviceCodePoll::new(Duration::from_secs(60), "timed out")
.interval_seconds(Some(3))
.wait_before_first_poll(true)
.run(recording_sleep(&slept), || {
Ok(DevicePollOutcome::Complete(()))
})
.expect("completes after the initial wait");
assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(3)]);
}
#[test]
fn omitted_interval_uses_the_rfc_default_of_five_seconds() {
let slept = RefCell::new(Vec::new());
let mut polls = 0;
DeviceCodePoll::new(Duration::from_secs(600), "timed out")
.interval_seconds(None)
.run(recording_sleep(&slept), || {
polls += 1;
if polls == 1 {
Ok(DevicePollOutcome::Pending)
} else {
Ok(DevicePollOutcome::Complete(()))
}
})
.expect("completes");
assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(5)]);
}
#[test]
fn slow_down_without_an_interval_adds_five_seconds() {
let slept = RefCell::new(Vec::new());
let mut polls = 0;
DeviceCodePoll::new(Duration::from_secs(600), "timed out")
.interval_seconds(Some(2))
.run(recording_sleep(&slept), || {
polls += 1;
match polls {
1 => Ok(DevicePollOutcome::Pending),
2 => Ok(DevicePollOutcome::SlowDown {
interval_seconds: None,
}),
_ => Ok(DevicePollOutcome::Complete(())),
}
})
.expect("completes");
assert_eq!(
slept.borrow().as_slice(),
[Duration::from_secs(2), Duration::from_secs(7)]
);
}
#[test]
fn slow_down_prefers_a_server_supplied_interval() {
// The clock-drift fix: the server's new minimum wins over the
// client-tracked interval, in both directions.
let slept = RefCell::new(Vec::new());
let mut polls = 0;
DeviceCodePoll::new(Duration::from_secs(600), "timed out")
.interval_seconds(Some(2))
.run(recording_sleep(&slept), || {
polls += 1;
match polls {
1 => Ok(DevicePollOutcome::SlowDown {
interval_seconds: Some(30),
}),
_ => Ok(DevicePollOutcome::Complete(())),
}
})
.expect("completes");
assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(30)]);
}
#[test]
fn interval_never_drops_below_one_second_or_exceeds_the_cap() {
let slept = RefCell::new(Vec::new());
let mut polls = 0;
DeviceCodePoll::new(Duration::from_secs(600), "timed out")
.interval_seconds(Some(0))
.max_interval_seconds(10)
.run(recording_sleep(&slept), || {
polls += 1;
match polls {
1 => Ok(DevicePollOutcome::SlowDown {
interval_seconds: Some(99),
}),
_ => Ok(DevicePollOutcome::Complete(())),
}
})
.expect("completes");
// interval 0 falls back to the RFC default (5s), capped at 10s.
assert_eq!(slept.borrow().as_slice(), [Duration::from_secs(10)]);
}
#[test]
fn never_sleeps_past_the_deadline() {
let slept = RefCell::new(Vec::new());
let error = DeviceCodePoll::new(Duration::from_millis(30), "timed out")
.interval_seconds(Some(600))
.run(
|duration| {
slept.borrow_mut().push(duration);
std::thread::sleep(duration);
},
|| Ok(DevicePollOutcome::<()>::Pending),
)
.expect_err("deadline stops the loop");
assert_eq!(error.to_string(), "timed out");
for duration in slept.borrow().iter() {
assert!(
*duration <= Duration::from_millis(30),
"slept {duration:?} past a 30ms deadline"
);
}
}
#[test]
fn a_terminal_poll_error_stops_immediately() {
let slept = RefCell::new(Vec::new());
let error = DeviceCodePoll::new(Duration::from_secs(600), "timed out")
.run(recording_sleep(&slept), || {
Err::<DevicePollOutcome<()>, _>(anyhow::anyhow!("access_denied"))
})
.expect_err("terminal errors propagate");
assert_eq!(error.to_string(), "access_denied");
assert!(slept.borrow().is_empty());
}
#[test]
fn timing_out_after_slow_down_reports_the_clock_drift_message() {
let error = DeviceCodePoll::new(Duration::from_millis(5), "plain timeout")
.interval_seconds(Some(1))
.slow_down_timeout_message("clock drift timeout")
.run(std::thread::sleep, || {
Ok(DevicePollOutcome::<()>::SlowDown {
interval_seconds: None,
})
})
.expect_err("deadline stops the loop");
assert_eq!(error.to_string(), "clock drift timeout");
}
#[test]
fn timing_out_without_slow_down_reports_the_plain_message() {
let error = DeviceCodePoll::new(Duration::from_millis(5), "plain timeout")
.interval_seconds(Some(1))
.slow_down_timeout_message("clock drift timeout")
.run(std::thread::sleep, || Ok(DevicePollOutcome::<()>::Pending))
.expect_err("deadline stops the loop");
assert_eq!(error.to_string(), "plain timeout");
}
#[test]
fn verification_uri_must_be_https_or_loopback_http() {
assert_eq!(
validate_browser_verification_uri("https://accounts.x.ai/device", "xAI").unwrap(),
"https://accounts.x.ai/device"
);
assert!(validate_browser_verification_uri("http://127.0.0.1:8080/verify", "xAI").is_ok());
assert!(validate_browser_verification_uri("http://localhost/verify", "xAI").is_ok());
assert!(validate_browser_verification_uri("http://[::1]:9/verify", "xAI").is_ok());
for hostile in [
"http://accounts.x.ai/device",
"file:///etc/passwd",
"javascript:alert(1)",
"vscode://attacker/run",
"data:text/html,<script>",
"https://",
"not a url",
"",
] {
assert!(
validate_browser_verification_uri(hostile, "xAI").is_err(),
"accepted {hostile}"
);
}
}
#[test]
fn verification_uri_rejects_embedded_credentials() {
let error =
validate_browser_verification_uri("https://user:pass@accounts.x.ai/device", "xAI")
.expect_err("credentials must be rejected");
assert!(error.to_string().contains("embedded credentials"));
}
}
-83
View File
@@ -175,66 +175,6 @@ pub enum ExternalCredentialSource {
CodexCli,
KimiCodeCli,
GrokCli,
/// Official DeepSeek Harness (`dsh`) `$DSH_HOME/.credentials.yaml`.
DshCli,
/// Official Antigravity CLI (`agy`) `state.vscdb` OAuth token.
AgyCli,
}
/// Default DeepSeek Harness credentials document, resolved without probing.
///
/// Matches dsh-credentials-local: `$DSH_HOME/.credentials.yaml`, or
/// `~/.dsh/.credentials.yaml` when `DSH_HOME` is unset. Consent is pinned to
/// this exact path; a later `DSH_HOME` change is reported, never followed.
#[must_use]
pub fn default_dsh_credentials_path() -> PathBuf {
let home = match std::env::var_os("DSH_HOME") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => codewhale_paths::user_home()
.unwrap_or_else(|| PathBuf::from("."))
.join(".dsh"),
};
home.join(".credentials.yaml")
}
/// Default Antigravity credential store, resolved without probing: the
/// official `agy` CLI persists its OAuth token in the Antigravity app's
/// VSCode-style `state.vscdb` under the user profile. Consent is pinned to
/// this exact path; an ambient move is reported, never followed.
#[must_use]
pub fn default_agy_credentials_path() -> PathBuf {
let base = match std::env::var_os("ANTIGRAVITY_STATE_DIR") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => agy_profile_base(),
};
base.join("User").join("globalStorage").join("state.vscdb")
}
#[cfg(target_os = "macos")]
fn agy_profile_base() -> PathBuf {
codewhale_paths::user_home()
.unwrap_or_else(|| PathBuf::from("."))
.join("Library/Application Support/Antigravity")
}
#[cfg(all(unix, not(target_os = "macos")))]
fn agy_profile_base() -> PathBuf {
match std::env::var_os("XDG_CONFIG_HOME") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => codewhale_paths::user_home()
.unwrap_or_else(|| PathBuf::from("."))
.join(".config"),
}
.join("Antigravity")
}
#[cfg(windows)]
fn agy_profile_base() -> PathBuf {
match std::env::var_os("APPDATA") {
Some(value) if !value.is_empty() => PathBuf::from(value),
_ => codewhale_paths::user_home().unwrap_or_else(|| PathBuf::from(".")),
}
.join("Antigravity")
}
impl ExternalCredentialSource {
@@ -244,8 +184,6 @@ impl ExternalCredentialSource {
Self::CodexCli => "codex_cli",
Self::KimiCodeCli => "kimi_code_cli",
Self::GrokCli => "grok_cli",
Self::DshCli => "dsh_cli",
Self::AgyCli => "agy_cli",
}
}
@@ -256,8 +194,6 @@ impl ExternalCredentialSource {
Self::CodexCli => "Codex CLI",
Self::KimiCodeCli => "Kimi Code CLI",
Self::GrokCli => "Grok CLI",
Self::DshCli => "DeepSeek Harness",
Self::AgyCli => "Antigravity CLI",
}
}
}
@@ -517,25 +453,6 @@ mod tests {
}
}
#[test]
fn default_dsh_credentials_path_uses_dsh_home_or_dot_dsh() {
let previous = std::env::var_os("DSH_HOME");
unsafe {
std::env::set_var("DSH_HOME", "/opt/dsh-home");
}
let with_home = default_dsh_credentials_path();
match previous {
Some(value) => unsafe { std::env::set_var("DSH_HOME", value) },
None => unsafe { std::env::remove_var("DSH_HOME") },
}
assert_eq!(with_home, PathBuf::from("/opt/dsh-home/.credentials.yaml"));
assert_eq!(ExternalCredentialSource::DshCli.as_str(), "dsh_cli");
assert_eq!(
ExternalCredentialSource::DshCli.owner_label(),
"DeepSeek Harness"
);
}
#[test]
fn disclosed_paths_are_absolute_and_lexically_normalized_without_io() {
let resolved =
+137 -433
View File
File diff suppressed because it is too large Load Diff
+41 -328
View File
@@ -230,12 +230,7 @@ fn rollback(snapshots: &[Snapshot]) {
}
}
/// Hints that mark a config/JSON/env key as carrying a secret value.
///
/// Compound hints (`api_key`, `client_secret`) match as a substring of the
/// normalized key. Single-word hints (`token`, `secret`, `password`) match a
/// whole identifier segment so they describe a credential (`token`,
/// `api_token`) and not an English word (`tokens`, `tokenizer`).
/// Substrings that mark a config/JSON/env key as carrying a secret value.
const SENSITIVE_KEY_HINTS: &[&str] = &[
"api_key",
"apikey",
@@ -272,7 +267,7 @@ pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
object
.iter()
.map(|(key, value)| {
let value = if key_is_sensitive(key) {
let value = if sensitive_json_key(key) {
serde_json::Value::String(REDACTED.to_string())
} else {
redact_json_secrets(value)
@@ -289,6 +284,11 @@ pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
}
}
fn sensitive_json_key(key: &str) -> bool {
let key = key.to_ascii_lowercase();
SENSITIVE_KEY_HINTS.iter().any(|hint| key.contains(hint))
}
/// Redact secret-bearing values from arbitrary text so it is safe to put in a
/// setup report, log line, error message, or test snapshot.
///
@@ -296,16 +296,8 @@ pub fn redact_json_secrets(value: &serde_json::Value) -> serde_json::Value {
///
/// 1. **Keyed assignments.** Lines or whitespace-delimited inline tokens shaped
/// like `key = value`, `key: value`, or `key=value` whose key
/// (case-insensitively, ignoring quotes) matches a `SENSITIVE_KEY_HINTS`
/// credential identifier have their value replaced with [`REDACTED`]. The
/// spaced form (`key = value`) is matched anywhere on the line, not only
/// when the sensitive key owns the line's first separator — an `anyhow`
/// chain rendered with `{:#}` puts prose and its own `: ` separators in
/// front of the assignment, and that must not be a hole. Because such a
/// value can span several words (`authorization = Bearer <token>`),
/// everything from the value to the end of the line is dropped, exactly as
/// the whole-line form already does. Token *counts* in diagnostics
/// (`max tokens = 8192`) are not credentials and stay visible.
/// (case-insensitively, ignoring quotes) contains a `SENSITIVE_KEY_HINTS`
/// substring have their value replaced with [`REDACTED`].
/// 2. **Bare tokens.** Whitespace-delimited words beginning with a known
/// `SECRET_TOKEN_PREFIXES` are replaced wholesale.
///
@@ -340,34 +332,23 @@ fn redact_line(line: &str) -> String {
}
// Inline-assignment / bare-token pass: mask any whitespace-delimited word
// carrying a sensitive keyed value or a known bare secret prefix, plus the
// spaced `key = value` form that `redact_keyed_assignment` above only sees
// when the sensitive key owns the line's first separator.
// carrying a sensitive keyed value or a known bare secret prefix.
let mut changed = false;
let mut spaced = SpacedAssignment::None;
let mut masked: Vec<String> = Vec::new();
for word in body.split(' ') {
let trimmed = trim_word_punctuation(word);
if spaced == SpacedAssignment::AwaitingValue && !trimmed.is_empty() {
// The value may run to the end of the line, so drop the remainder
// rather than masking one word and leaking the rest.
masked.push(REDACTED.to_string());
changed = true;
break;
}
if let Some(redacted) = redact_inline_keyed_assignment(trimmed) {
changed = true;
masked.push(word.replace(trimmed, &redacted));
spaced = SpacedAssignment::None;
} else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
masked.push(word.replace(trimmed, REDACTED));
spaced = SpacedAssignment::None;
} else {
masked.push(word.to_string());
spaced = spaced.advance(trimmed);
}
}
let masked: Vec<String> = body
.split(' ')
.map(|word| {
let trimmed = word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'));
if let Some(redacted) = redact_inline_keyed_assignment(trimmed) {
changed = true;
word.replace(trimmed, &redacted)
} else if !trimmed.is_empty() && looks_like_secret_token(trimmed) {
changed = true;
word.replace(trimmed, REDACTED)
} else {
word.to_string()
}
})
.collect();
if changed {
format!("{}{newline}", masked.join(" "))
@@ -376,152 +357,6 @@ fn redact_line(line: &str) -> String {
}
}
/// Progress through a `key <space> <sep> <space> value` assignment as the
/// word-level pass walks a line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SpacedAssignment {
None,
/// The previous word was a bare sensitive key awaiting its separator.
SensitiveKey,
/// A sensitive key and its separator are both behind us.
AwaitingValue,
}
impl SpacedAssignment {
fn advance(self, trimmed: &str) -> Self {
// Runs of spaces produce empty words; they neither start nor cancel an
// assignment.
if trimmed.is_empty() {
return self;
}
if matches!(trimmed, "=" | ":") {
return if self == Self::SensitiveKey {
Self::AwaitingValue
} else {
Self::None
};
}
// `api_key=` / `api_key:` with the value in the next word. A word whose
// separator is *not* final was already offered to
// `redact_inline_keyed_assignment`, so it is not an assignment we own.
if let Some(key) = trimmed
.strip_suffix('=')
.or_else(|| trimmed.strip_suffix(':'))
{
return if key_is_sensitive(key) {
Self::AwaitingValue
} else {
Self::None
};
}
if key_is_sensitive(trimmed) {
return Self::SensitiveKey;
}
Self::None
}
}
fn trim_word_punctuation(word: &str) -> &str {
word.trim_matches(|c| matches!(c, '"' | '\'' | ',' | ';'))
}
/// Whether `raw`, normalized the way a config/env/JSON key is, matches a
/// [`SENSITIVE_KEY_HINTS`] credential identifier.
fn key_is_sensitive(raw: &str) -> bool {
let key_norm = normalize_sensitive_key(raw);
!key_norm.is_empty()
&& SENSITIVE_KEY_HINTS
.iter()
.any(|hint| key_matches_sensitive_hint(&key_norm, hint))
}
/// Normalize the identifier boundaries commonly used by config, env, and JSON
/// keys without turning English plurals such as `tokens` into `token`.
///
/// Punctuation and case transitions become `_`, so `oauth.token`,
/// `accessToken`, and `APIKey` share the same matching surface as
/// `oauth_token`, `access_token`, and `api_key`.
fn normalize_sensitive_key(raw: &str) -> String {
let mut normalized = String::with_capacity(raw.len());
let mut chars = raw.chars().peekable();
let mut previous = None;
while let Some(ch) = chars.next() {
if ch.is_ascii_alphanumeric() {
let next = chars.peek().copied();
let starts_case_segment = ch.is_ascii_uppercase()
&& previous.is_some_and(|previous: char| {
previous.is_ascii_lowercase()
|| previous.is_ascii_digit()
|| (previous.is_ascii_uppercase()
&& next.is_some_and(|next| next.is_ascii_lowercase()))
});
if starts_case_segment && !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
normalized.push(ch.to_ascii_lowercase());
} else if !normalized.is_empty() && !normalized.ends_with('_') {
normalized.push('_');
}
previous = Some(ch);
}
while normalized.ends_with('_') {
normalized.pop();
}
normalized
}
fn key_matches_sensitive_hint(key_norm: &str, hint: &str) -> bool {
if key_norm == hint {
return true;
}
// Compound hints already name a credential (`api_key`, `client_secret`).
// Substring is the right match: `openai_api_key` contains `api_key`.
if hint.contains('_') || hint.contains('-') {
return key_norm.contains(hint);
}
if hint == "token" {
// Camel-case normalization turns both credentials (`accessToken`) and
// ordinary usage metrics (`tokenBudget`, `tokenCount`) into segmented
// identifiers. A credential token is either the whole key, a suffix
// such as `access_token`, or an explicitly value-bearing `token_*`
// field. Metrics must stay visible in diagnostics and tool previews.
let is_metric_suffix = |suffix: &str| {
matches!(
suffix.split('_').next(),
Some(
"budget"
| "budgets"
| "count"
| "counts"
| "limit"
| "limits"
| "total"
| "totals"
| "usage"
| "used"
| "window"
| "windows"
)
)
};
if key_norm.ends_with("_token") {
return true;
}
if let Some(suffix) = key_norm.strip_prefix("token_") {
return !is_metric_suffix(suffix);
}
if let Some((_, suffix)) = key_norm.rsplit_once("_token_") {
return !is_metric_suffix(suffix);
}
return false;
}
// Single-word hints must be a whole identifier segment so `token`
// redacts `token` / `api_token` and not English `tokens`.
key_norm.split(['_', '-']).any(|segment| segment == hint)
}
fn redact_inline_keyed_assignment(word: &str) -> Option<String> {
let sep_idx = word.find(['=', ':'])?;
let (raw_key, rest) = word.split_at(sep_idx);
@@ -529,7 +364,14 @@ fn redact_inline_keyed_assignment(word: &str) -> Option<String> {
if raw_value.is_empty() {
return None;
}
if !key_is_sensitive(raw_key) {
let key_norm = raw_key
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_' && c != '-')
.to_ascii_lowercase();
if key_norm.is_empty()
|| !SENSITIVE_KEY_HINTS
.iter()
.any(|hint| key_norm.contains(hint))
{
return None;
}
Some(format!("{}{}{}", raw_key, &rest[..1], REDACTED))
@@ -546,8 +388,9 @@ fn redact_keyed_assignment(body: &str) -> Option<String> {
let key_norm = raw_key
.trim()
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'));
if !key_is_sensitive(key_norm) {
.trim_matches(|c| matches!(c, '"' | '\'' | '[' | ']'))
.to_ascii_lowercase();
if key_norm.is_empty() || !SENSITIVE_KEY_HINTS.iter().any(|h| key_norm.contains(h)) {
return None;
}
@@ -590,10 +433,6 @@ mod tests {
fs::read_to_string(path).unwrap()
}
fn synthetic_secret_fixture() -> String {
["abc123", "def456", "ghi"].concat()
}
#[test]
fn atomic_write_creates_parent_dirs_and_content() {
let tmp = tempfile::tempdir().unwrap();
@@ -707,18 +546,15 @@ mod tests {
#[test]
fn redact_masks_keyed_secrets_toml_and_json() {
let synthetic_secret = synthetic_secret_fixture();
let input = format!(
"\
let input = "\
api_key = \"sk-supersecretvalue123\"
provider = \"openai\"
\"token\": \"{synthetic_secret}\",
\"token\": \"abc123def456ghi\",
model = \"mimo-ultraspeed\"
PASSWORD=hunter2hunter2"
);
let out = redact_secrets(&input);
PASSWORD=hunter2hunter2";
let out = redact_secrets(input);
assert!(!out.contains("sk-supersecretvalue123"), "{out}");
assert!(!out.contains(&synthetic_secret), "{out}");
assert!(!out.contains("abc123def456ghi"), "{out}");
assert!(!out.contains("hunter2hunter2"), "{out}");
// Non-secret values survive untouched.
assert!(out.contains("provider = \"openai\""));
@@ -726,47 +562,6 @@ PASSWORD=hunter2hunter2"
assert!(out.matches(REDACTED).count() >= 3, "{out}");
}
#[test]
fn redact_json_masks_camel_case_and_dotted_secret_keys() {
let synthetic_secret = synthetic_secret_fixture();
let input = serde_json::json!({
"accessToken": synthetic_secret.clone(),
"refreshToken": synthetic_secret_fixture(),
"oauth.token": synthetic_secret_fixture(),
"APIKey": synthetic_secret_fixture(),
"maxTokens": 8192,
"tokenBudget": 4096,
"tokenCount": 1024,
"token_count": 512,
"tokenizer": "sentencepiece",
});
let out = redact_json_secrets(&input);
for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
assert_eq!(out[key], REDACTED, "{key}: {out}");
}
assert_eq!(out["maxTokens"], 8192);
assert_eq!(out["tokenBudget"], 4096);
assert_eq!(out["tokenCount"], 1024);
assert_eq!(out["token_count"], 512);
assert_eq!(out["tokenizer"], "sentencepiece");
assert!(!out.to_string().contains(&synthetic_secret), "{out}");
}
#[test]
fn redact_text_masks_camel_case_and_dotted_secret_assignments() {
let synthetic_secret = synthetic_secret_fixture();
for key in ["accessToken", "refreshToken", "oauth.token", "APIKey"] {
let out = redact_secrets(&format!("request failed: {key} = {synthetic_secret}"));
assert!(!out.contains(&synthetic_secret), "{key}: {out}");
assert!(out.contains(REDACTED), "{key}: {out}");
}
for key in ["tokenBudget", "tokenCount", "token_count"] {
let input = format!("model usage: {key} = 8192");
assert_eq!(redact_secrets(&input), input, "{key}");
}
}
#[test]
fn redact_masks_bare_token_prefixes() {
let out = redact_secrets("the leaked key sk-abcdef1234567890 appeared in a log");
@@ -786,88 +581,6 @@ PASSWORD=hunter2hunter2"
assert!(out.starts_with("Decision: use "), "{out}");
}
#[test]
fn redact_masks_spaced_assignment_that_is_not_the_first_separator() {
// The shape `redact_secrets(&format!("{error:#}"))` produces: an
// anyhow chain puts prose and its own `: ` separators in front of the
// assignment, so the sensitive key never owns the line's first
// separator and the whole-line pass declines the line.
let out = redact_secrets("request failed: api_key = AIzaSyDeadBeefLeak");
assert!(!out.contains("AIzaSyDeadBeefLeak"), "{out}");
assert!(out.contains(REDACTED), "{out}");
let synthetic_secret = synthetic_secret_fixture();
let out = redact_secrets(&format!("note: the token = {synthetic_secret}"));
assert!(!out.contains(&synthetic_secret), "{out}");
assert!(out.contains(REDACTED), "{out}");
}
#[test]
fn redact_masks_whole_multi_word_value_of_a_spaced_assignment() {
// Assemble the placeholder at runtime so secret scanners do not
// mistake a redaction fixture for a committed credential.
let bearer = ["Bear", "er"].concat();
let credential = ["abc123", "def456", "ghi"].concat();
let out = redact_secrets(&format!(
"mcp call failed: authorization = {bearer} {credential}"
));
assert!(!out.contains(&credential), "{out}");
assert!(!out.contains(&bearer), "{out}");
assert!(
out.starts_with("mcp call failed: authorization = "),
"{out}"
);
}
#[test]
fn redact_spaced_pass_leaves_ordinary_prose_alone() {
// No sensitive key, so the spaced-assignment state machine must not
// start swallowing the rest of the line.
let input = "the quick brown fox = jumps over the lazy dog";
assert_eq!(redact_secrets(input), input);
let input = "note: the model = deepseek-v4-pro and the seed = 7";
assert_eq!(redact_secrets(input), input);
}
#[test]
fn redact_leaves_token_count_diagnostics_intact() {
// "tokens" is the English plural of a usage metric, not a credential
// key. The spaced-assignment pass used to treat the "token" hint as a
// substring and then drop the rest of the line, which made the exact
// class of error people paste into issues unreadable.
for input in [
"stream error: max tokens = 8192 but budget = 4096",
"error: token expired",
"request failed: token count: 4096 exceeds the model limit",
"http 401: authorization header rejected",
"warning: password policy requires 12 characters",
"note: secret scanning found 3 issues",
] {
assert_eq!(redact_secrets(input), input, "{input}");
}
}
#[test]
fn redact_still_masks_a_bearer_token_assignment() {
// Counterpart of the diagnostic test above: a real credential keyed
// as `token` (or `api_token`) must still be dropped, including a
// multi-word Bearer value that is not a known bare-token prefix.
// The JWT is assembled at runtime so no scanner-shaped literal sits
// in the source tree — same precedent as the AWS fixture in
// `crates/workflow/src/redaction.rs`.
let jwt = ["eyJhbGciOiJIUzI1NiJ9", "e30", "c2lnbmF0dXJl"].join(".");
let out = redact_secrets(&format!("stream error: token = Bearer {jwt}"));
assert!(!out.contains(&jwt), "{out}");
assert!(!out.contains("Bearer"), "{out}");
assert!(out.starts_with("stream error: token = "), "{out}");
assert!(out.contains(REDACTED), "{out}");
let synthetic_secret = synthetic_secret_fixture();
let out = redact_secrets(&format!("note: api_token = {synthetic_secret}"));
assert!(!out.contains(&synthetic_secret), "{out}");
assert!(out.contains(REDACTED), "{out}");
}
#[test]
fn redact_preserves_line_structure() {
let input = "line1\nsecret = \"xyzsecretvalue\"\nline3";
+22 -242
View File
@@ -5,35 +5,32 @@
//! providers; runtime routing remains in `ConfigToml::resolve_runtime_options`.
use super::{
DEFAULT_ANTIGRAVITY_BASE_URL, DEFAULT_ANTIGRAVITY_MODEL, DEFAULT_ARCEE_BASE_URL,
DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL, DEFAULT_ATLASCLOUD_MODEL,
DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL, DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL,
DEFAULT_DEEPSEEK_ANTHROPIC_MODEL, DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL,
DEFAULT_EDENAI_BASE_URL, DEFAULT_EDENAI_MODEL, DEFAULT_FIREWORKS_BASE_URL,
DEFAULT_FIREWORKS_MODEL, DEFAULT_GOOGLE_BASE_URL, DEFAULT_GOOGLE_MODEL,
DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL, DEFAULT_LONGCAT_BASE_URL,
DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
DEFAULT_ARCEE_BASE_URL, DEFAULT_ARCEE_MODEL, DEFAULT_ATLASCLOUD_BASE_URL,
DEFAULT_ATLASCLOUD_MODEL, DEFAULT_DEEPINFRA_BASE_URL, DEFAULT_DEEPINFRA_MODEL,
DEFAULT_DEEPSEEK_ANTHROPIC_BASE_URL, DEFAULT_DEEPSEEK_ANTHROPIC_MODEL,
DEFAULT_DEEPSEEK_BASE_URL, DEFAULT_DEEPSEEK_MODEL, DEFAULT_FIREWORKS_BASE_URL,
DEFAULT_FIREWORKS_MODEL, DEFAULT_HUGGINGFACE_BASE_URL, DEFAULT_HUGGINGFACE_MODEL,
DEFAULT_LONGCAT_BASE_URL, DEFAULT_LONGCAT_MODEL, DEFAULT_META_BASE_URL, DEFAULT_META_MODEL,
DEFAULT_MINIMAX_ANTHROPIC_BASE_URL, DEFAULT_MINIMAX_BASE_URL, DEFAULT_MINIMAX_MODEL,
DEFAULT_MISTRAL_BASE_URL, DEFAULT_MISTRAL_MODEL, DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL,
DEFAULT_MODELSTUDIO_TOKEN_PLAN_BASE_URL, DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL,
DEFAULT_MOONSHOT_BASE_URL, DEFAULT_MOONSHOT_MODEL, DEFAULT_NOVITA_BASE_URL,
DEFAULT_NOVITA_MODEL, DEFAULT_NVIDIA_NIM_BASE_URL, DEFAULT_NVIDIA_NIM_MODEL,
DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_CLOUD_BASE_URL, DEFAULT_OLLAMA_CLOUD_MODEL,
DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL, DEFAULT_OPENAI_CODEX_BASE_URL,
DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL, DEFAULT_OPENCODE_GO_BASE_URL,
DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL, DEFAULT_OPENCODE_ZEN_MODEL,
DEFAULT_OPENMODEL_BASE_URL, DEFAULT_OPENMODEL_MODEL, DEFAULT_OPENROUTER_BASE_URL,
DEFAULT_OPENROUTER_MODEL, DEFAULT_ORCAROUTER_BASE_URL, DEFAULT_ORCAROUTER_MODEL,
DEFAULT_QIANFAN_BASE_URL, DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL,
DEFAULT_SGLANG_BASE_URL, DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL,
DEFAULT_SILICONFLOW_CN_BASE_URL, DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL,
DEFAULT_STEPFUN_MODEL, DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL,
DEFAULT_TOGETHER_BASE_URL, DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL,
DEFAULT_VOLCENGINE_BASE_URL, DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL,
DEFAULT_WANJIE_ARK_MODEL, DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL,
DEFAULT_XIAOMI_MIMO_BASE_URL, DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL,
DEFAULT_ZAI_MODEL, MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL,
MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL, ProviderKind,
DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, DEFAULT_OPENAI_BASE_URL,
DEFAULT_OPENAI_CODEX_BASE_URL, DEFAULT_OPENAI_CODEX_MODEL, DEFAULT_OPENAI_MODEL,
DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_GO_MODEL, DEFAULT_OPENCODE_ZEN_BASE_URL,
DEFAULT_OPENCODE_ZEN_MODEL, DEFAULT_OPENMODEL_BASE_URL, DEFAULT_OPENMODEL_MODEL,
DEFAULT_OPENROUTER_BASE_URL, DEFAULT_OPENROUTER_MODEL, DEFAULT_QIANFAN_BASE_URL,
DEFAULT_QIANFAN_MODEL, DEFAULT_SAKANA_BASE_URL, DEFAULT_SAKANA_MODEL, DEFAULT_SGLANG_BASE_URL,
DEFAULT_SGLANG_MODEL, DEFAULT_SILICONFLOW_BASE_URL, DEFAULT_SILICONFLOW_CN_BASE_URL,
DEFAULT_SILICONFLOW_MODEL, DEFAULT_STEPFUN_BASE_URL, DEFAULT_STEPFUN_MODEL,
DEFAULT_TELECOMJS_BASE_URL, DEFAULT_TELECOMJS_MODEL, DEFAULT_TOGETHER_BASE_URL,
DEFAULT_TOGETHER_MODEL, DEFAULT_VLLM_BASE_URL, DEFAULT_VLLM_MODEL, DEFAULT_VOLCENGINE_BASE_URL,
DEFAULT_VOLCENGINE_MODEL, DEFAULT_WANJIE_ARK_BASE_URL, DEFAULT_WANJIE_ARK_MODEL,
DEFAULT_XAI_BASE_URL, DEFAULT_XAI_MODEL, DEFAULT_XIAOMI_MIMO_BASE_URL,
DEFAULT_XIAOMI_MIMO_MODEL, DEFAULT_ZAI_BASE_URL, DEFAULT_ZAI_MODEL,
MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL, MODELSTUDIO_TOKEN_PLAN_ANTHROPIC_BASE_URL,
ProviderKind,
};
/// Wire protocol spoken by a provider.
@@ -140,12 +137,6 @@ pub struct CredentialHelp {
/// is never described as a generic Moonshot route.
pub const KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL: &str = "https://www.kimi.com/code/console";
/// Ollama's account page for creating API keys used by the hosted API.
pub const OLLAMA_CLOUD_API_KEY_URL: &str = "https://ollama.com/settings/keys";
/// Ollama Cloud's exact OpenAI-compatible API base URL.
pub const OLLAMA_CLOUD_BASE_URL: &str = DEFAULT_OLLAMA_CLOUD_BASE_URL;
/// Static metadata for a built-in model provider.
pub trait Provider: Send + Sync {
/// Provider enum variant represented by this entry.
@@ -243,12 +234,6 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
docs_url: Some("https://openrouter.ai/docs/api/reference/authentication"),
guidance: "Create an OpenRouter key from account settings.",
},
ProviderKind::Orcarouter => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://www.orcarouter.ai"),
docs_url: Some("https://www.orcarouter.ai"),
guidance: "Create an OrcaRouter API key from the OrcaRouter dashboard.",
},
ProviderKind::XiaomiMimo => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://platform.xiaomimimo.com/token-plan"),
@@ -309,12 +294,6 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
docs_url: Some("https://docs.ollama.com/api"),
guidance: "Local Ollama is keyless by default; configure a key only if your server requires one.",
},
ProviderKind::OllamaCloud => CredentialHelp {
acquisition: ApiKey,
credential_url: Some(OLLAMA_CLOUD_API_KEY_URL),
docs_url: Some("https://docs.ollama.com/api/authentication"),
guidance: "Ollama Cloud requires an API key. Save it for the ollama-cloud provider, set OLLAMA_CLOUD_API_KEY for Pi compatibility, or set Ollama's official OLLAMA_API_KEY.",
},
ProviderKind::Huggingface => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://huggingface.co/settings/tokens"),
@@ -425,12 +404,6 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
docs_url: None,
guidance: "Create a TelecomJS TokenHub API key, then use the provider's live model catalog to discover the models available to that key.",
},
ProviderKind::Edenai => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://app.edenai.run/settings/api-keys"),
docs_url: Some("https://www.edenai.co/docs"),
guidance: "Create an Eden AI API key from the Eden AI dashboard, then select models by their provider/model namespaced id.",
},
ProviderKind::ModelstudioTokenPlan
| ProviderKind::ModelstudioTokenPlanAnthropic
| ProviderKind::ModelstudioCodingPlan
@@ -440,18 +413,6 @@ pub const fn credential_help(kind: ProviderKind) -> CredentialHelp {
docs_url: Some("https://www.alibabacloud.com/help/en/model-studio/"),
guidance: "Sign in to Alibaba Cloud Model Studio (Bailian console), create or copy an API key, and select the plan endpoint matching your subscription (Token Plan or Coding Plan).",
},
ProviderKind::Antigravity => CredentialHelp {
acquisition: OAuth,
credential_url: None,
docs_url: Some("https://antigravity.google/docs/cli/reference"),
guidance: "Sign in with the official agy CLI (1.1.13). Codewhale can read that login's token read-only from the exact pinned state.vscdb after `codewhale auth external-consent`; it never writes or refreshes it. An ANTIGRAVITY_API_KEY or AGY_ADC_AUTH in the process wins over the file.",
},
ProviderKind::Google => CredentialHelp {
acquisition: ApiKey,
credential_url: Some("https://aistudio.google.com/apikey"),
docs_url: Some("https://ai.google.dev/gemini-api/docs/openai"),
guidance: "Create a Google AI Studio API key. Codewhale uses the official Gemini OpenAI-compatible endpoint and never reads Google OAuth files.",
},
ProviderKind::Custom => CredentialHelp {
acquisition: Configuration,
credential_url: None,
@@ -494,28 +455,6 @@ pub fn is_exact_kimi_code_route(kind: ProviderKind, base_url: &str) -> bool {
is_exact_https_route(base_url, "api.kimi.com", "coding/v1")
}
/// Whether a configured Ollama route is exactly the hosted OpenAI-compatible
/// endpoint.
///
/// Local Ollama remains keyless. Neighboring paths, HTTP downgrades, and
/// lookalike hosts remain custom routes so they cannot inherit an Ollama Cloud
/// credential or durable secret-store slot.
#[must_use]
pub fn is_exact_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
matches!(kind, ProviderKind::Ollama | ProviderKind::OllamaCloud)
&& is_exact_https_route(base_url, "ollama.com", "v1")
}
/// In-memory compatibility classifier for the released route-sensitive shape.
///
/// Only the old `ollama` identity at the exact hosted endpoint migrates. This
/// deliberately rejects neighboring paths, HTTP downgrades, and lookalike
/// hosts so no local/custom route can consume Ollama Cloud credentials.
#[must_use]
pub fn migrates_legacy_ollama_cloud_route(kind: ProviderKind, base_url: &str) -> bool {
kind == ProviderKind::Ollama && is_exact_ollama_cloud_route(kind, base_url)
}
/// Whether a configured route is exactly Moonshot's direct API endpoint.
///
/// Direct K3 owns a different reasoning-control dialect from the Kimi Code
@@ -526,16 +465,6 @@ pub fn is_exact_moonshot_platform_route(kind: ProviderKind, base_url: &str) -> b
kind == ProviderKind::Moonshot && is_exact_https_route(base_url, "api.moonshot.ai", "v1")
}
/// Whether a configured route is exactly xAI's first-party OpenAI-compatible
/// API endpoint.
///
/// Grok-specific request fields must not leak to a custom compatible gateway
/// merely because the operator selected the `xai` provider identity.
#[must_use]
pub fn is_exact_xai_platform_route(kind: ProviderKind, base_url: &str) -> bool {
kind == ProviderKind::Xai && is_exact_https_route(base_url, "api.x.ai", "v1")
}
/// Whether a configured route is one of Z.ai's exact first-party Chat
/// Completions endpoints.
///
@@ -581,15 +510,6 @@ pub fn is_exact_minimax_anthropic_route(kind: ProviderKind, base_url: &str) -> b
/// endpoint. It performs no discovery, credential lookup, or network I/O.
#[must_use]
pub fn credential_help_for_route(kind: ProviderKind, base_url: &str) -> CredentialHelp {
if is_exact_ollama_cloud_route(kind, base_url) {
return CredentialHelp {
acquisition: CredentialAcquisition::ApiKey,
credential_url: Some(OLLAMA_CLOUD_API_KEY_URL),
docs_url: Some("https://docs.ollama.com/api/authentication"),
guidance: "Ollama Cloud requires an API key. Create one in Ollama account settings, then save it for the ollama-cloud provider, set OLLAMA_CLOUD_API_KEY for Pi compatibility, or set Ollama's official OLLAMA_API_KEY.",
};
}
if is_exact_kimi_code_route(kind, base_url) {
return CredentialHelp {
acquisition: CredentialAcquisition::ApiKey,
@@ -827,17 +747,6 @@ provider!(
"openrouter",
aliases: ["open_router"]
);
provider!(
Orcarouter,
Orcarouter,
"orcarouter",
"OrcaRouter",
DEFAULT_ORCAROUTER_BASE_URL,
DEFAULT_ORCAROUTER_MODEL,
["ORCAROUTER_API_KEY"],
"orcarouter",
aliases: ["orca_router"]
);
provider!(
XiaomiMimo,
XiaomiMimo,
@@ -966,17 +875,6 @@ provider!(
"ollama",
aliases: ["ollama-local"]
);
provider!(
OllamaCloud,
OllamaCloud,
"ollama-cloud",
"Ollama Cloud",
DEFAULT_OLLAMA_CLOUD_BASE_URL,
DEFAULT_OLLAMA_CLOUD_MODEL,
["OLLAMA_CLOUD_API_KEY", "OLLAMA_API_KEY"],
"ollama_cloud",
aliases: ["ollama_cloud"]
);
provider!(
Huggingface,
Huggingface,
@@ -1026,30 +924,6 @@ provider!(
aliases: ["mistral-ai", "mistral_ai", "mistralai", "la-plateforme", "la_plateforme"]
);
provider!(
Antigravity,
Antigravity,
"antigravity",
"Google Antigravity",
DEFAULT_ANTIGRAVITY_BASE_URL,
DEFAULT_ANTIGRAVITY_MODEL,
["ANTIGRAVITY_API_KEY"],
"antigravity",
aliases: ["agy"]
);
provider!(
Google,
Google,
"google",
"Google Gemini",
DEFAULT_GOOGLE_BASE_URL,
DEFAULT_GOOGLE_MODEL,
["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"google",
aliases: ["google-gemini", "google_gemini", "gemini", "google-ai", "google_ai", "ai-studio", "aistudio"]
);
/// OpenAI Codex / ChatGPT OAuth provider using the Responses API.
pub struct OpenaiCodex;
@@ -1387,17 +1261,6 @@ provider!(
"telecomjs",
aliases: ["telecom-js", "telecom_js", "telecomjs-cn", "tokenhub"]
);
provider!(
Edenai,
Edenai,
"edenai",
"Eden AI",
DEFAULT_EDENAI_BASE_URL,
DEFAULT_EDENAI_MODEL,
["EDENAI_API_KEY"],
"edenai",
aliases: ["eden-ai", "eden_ai"]
);
/// Alibaba Cloud Model Studio — Token Plan (OpenAI-compatible Chat Completions).
///
@@ -1651,7 +1514,6 @@ static ATLASCLOUD: Atlascloud = Atlascloud;
static WANJIE_ARK: WanjieArk = WanjieArk;
static VOLCENGINE: Volcengine = Volcengine;
static OPENROUTER: Openrouter = Openrouter;
static ORCAROUTER: Orcarouter = Orcarouter;
static XIAOMI_MIMO: XiaomiMimo = XiaomiMimo;
static NOVITA: Novita = Novita;
static FIREWORKS: Fireworks = Fireworks;
@@ -1662,7 +1524,6 @@ static MOONSHOT: Moonshot = Moonshot;
static SGLANG: Sglang = Sglang;
static VLLM: Vllm = Vllm;
static OLLAMA: Ollama = Ollama;
static OLLAMA_CLOUD: OllamaCloud = OllamaCloud;
static HUGGINGFACE: Huggingface = Huggingface;
static TOGETHER: Together = Together;
static QIANFAN: Qianfan = Qianfan;
@@ -1681,9 +1542,7 @@ static OPENCODE_ZEN: OpencodeZen = OpencodeZen;
static META: Meta = Meta;
static XAI: Xai = Xai;
static MISTRAL: Mistral = Mistral;
static ANTIGRAVITY: Antigravity = Antigravity;
static TELECOMJS: Telecomjs = Telecomjs;
static EDENAI: Edenai = Edenai;
static MODELSTUDIO_TOKEN_PLAN: ModelstudioTokenPlan = ModelstudioTokenPlan;
static MODELSTUDIO_TOKEN_PLAN_ANTHROPIC: ModelstudioTokenPlanAnthropic =
ModelstudioTokenPlanAnthropic;
@@ -1692,7 +1551,7 @@ static MODELSTUDIO_CODING_PLAN_ANTHROPIC: ModelstudioCodingPlanAnthropic =
ModelstudioCodingPlanAnthropic;
static CUSTOM: Custom = Custom;
static PROVIDER_REGISTRY: [&dyn Provider; 47] = [
static PROVIDER_REGISTRY: [&dyn Provider; 42] = [
&DEEPSEEK,
&DEEPSEEK_ANTHROPIC,
&NVIDIA_NIM,
@@ -1701,7 +1560,6 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [
&WANJIE_ARK,
&VOLCENGINE,
&OPENROUTER,
&ORCAROUTER,
&XIAOMI_MIMO,
&NOVITA,
&FIREWORKS,
@@ -1712,7 +1570,6 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [
&SGLANG,
&VLLM,
&OLLAMA,
&OLLAMA_CLOUD,
&HUGGINGFACE,
&TOGETHER,
&QIANFAN,
@@ -1732,13 +1589,10 @@ static PROVIDER_REGISTRY: [&dyn Provider; 47] = [
&XAI,
&MISTRAL,
&TELECOMJS,
&EDENAI,
&MODELSTUDIO_TOKEN_PLAN,
&MODELSTUDIO_TOKEN_PLAN_ANTHROPIC,
&MODELSTUDIO_CODING_PLAN,
&MODELSTUDIO_CODING_PLAN_ANTHROPIC,
&Google,
&ANTIGRAVITY,
&CUSTOM,
];
@@ -1902,53 +1756,6 @@ mod tests {
}
}
#[test]
fn ollama_cloud_route_is_exact_and_requires_its_own_key() {
for base_url in [
OLLAMA_CLOUD_BASE_URL,
"https://ollama.com/v1/",
" HTTPS://OLLAMA.COM/v1/ ",
] {
for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
assert!(is_exact_ollama_cloud_route(provider, base_url));
let help = credential_help_for_route(provider, base_url);
assert_eq!(help.acquisition, CredentialAcquisition::ApiKey);
assert_eq!(help.credential_url, Some(OLLAMA_CLOUD_API_KEY_URL));
assert_eq!(
help.docs_url,
Some("https://docs.ollama.com/api/authentication")
);
assert!(help.guidance.contains("OLLAMA_CLOUD_API_KEY"));
assert!(help.guidance.contains("OLLAMA_API_KEY"));
}
}
for base_url in [
"http://ollama.com/v1",
"https://ollama.com",
"https://ollama.com/api",
"https://ollama.com/v1/preview",
"https://ollama.com.evil.example/v1",
"https://api.ollama.com/v1",
"https://ollama.com/v1?tenant=other",
] {
assert!(!is_exact_ollama_cloud_route(ProviderKind::Ollama, base_url));
assert!(!is_exact_ollama_cloud_route(
ProviderKind::OllamaCloud,
base_url
));
}
assert!(!is_exact_ollama_cloud_route(
ProviderKind::Openai,
OLLAMA_CLOUD_BASE_URL
));
let local = credential_help_for_route(ProviderKind::Ollama, DEFAULT_OLLAMA_BASE_URL);
assert_eq!(local.acquisition, CredentialAcquisition::LocalOptional);
assert_eq!(local.credential_url, None);
assert!(local.guidance.contains("keyless by default"));
}
#[test]
fn direct_moonshot_route_matching_is_exact() {
assert!(is_exact_moonshot_platform_route(
@@ -1976,33 +1783,6 @@ mod tests {
));
}
#[test]
fn direct_xai_route_matching_is_exact() {
assert!(is_exact_xai_platform_route(
ProviderKind::Xai,
"HTTPS://API.X.AI/v1/"
));
for neighboring_route in [
"https://api.x.ai/V1",
"http://api.x.ai/v1",
"https://api.x.ai:443/v1",
"https://api.x.ai/v1?preview=1",
"https://api.x.ai/v1#fragment",
"https://api.x.ai/v1//",
"https://api.x.ai/v1/chat/completions",
"https://gateway.example/v1",
] {
assert!(
!is_exact_xai_platform_route(ProviderKind::Xai, neighboring_route),
"{neighboring_route} must not inherit xAI-only request fields"
);
}
assert!(!is_exact_xai_platform_route(
ProviderKind::Openai,
DEFAULT_XAI_BASE_URL
));
}
#[test]
fn zai_chat_route_matching_is_exact() {
for route in [
+11 -36
View File
@@ -29,21 +29,17 @@ pub(crate) const DEFAULT_VOLCENGINE_BASE_URL: &str =
"https://ark.cn-beijing.volces.com/api/coding/v3";
pub(crate) const DEFAULT_OPENROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_OPENROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
pub(crate) const DEFAULT_ORCAROUTER_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_ORCAROUTER_FLASH_MODEL: &str = "deepseek/deepseek-v4-flash";
/// OrcaRouter's own auto-routing model: picks the best upstream model per
/// request. Resolved from the bare `auto` alias on the OrcaRouter provider.
pub(crate) const ORCAROUTER_AUTO_MODEL: &str = "orcarouter/auto";
pub(crate) const OPENROUTER_ARCEE_TRINITY_LARGE_THINKING_MODEL: &str =
"arcee-ai/trinity-large-thinking";
pub(crate) const OPENROUTER_GEMMA_4_31B_MODEL: &str = "google/gemma-4-31b-it";
pub(crate) const OPENROUTER_GEMMA_4_26B_A4B_MODEL: &str = "google/gemma-4-26b-a4b-it";
pub(crate) const OPENROUTER_GLM_5_1_MODEL: &str = "z-ai/glm-5.1";
pub(crate) const OPENROUTER_GLM_5_2_MODEL: &str = "z-ai/glm-5.2";
// GLM-5.3 is live on the Z.ai Coding Plan (2026-08-13). Capability/limit
// metadata still inherits from glm-5.2 until Z.ai publishes distinct 5.3
// numbers. No USD price. The OpenRouter id is registered so the alias
// resolves to OpenRouter rather than another vendor. See
// GLM-5.3: metadata INHERITED FROM glm-5.2 PENDING OFFICIAL Z.AI RELEASE
// METADATA (2026-08-03). Zhipu/Z.ai had not released GLM-5.3 on that date, so
// this id was never verified against OpenRouter's model metadata and cannot be
// until Z.ai ships it; it is registered so the alias resolves to OpenRouter
// rather than being rewritten to another vendor's model. See
// models_dev.bundled.json `_meta.pending_release_metadata`.
pub(crate) const OPENROUTER_GLM_5_3_MODEL: &str = "z-ai/glm-5.3";
pub(crate) const OPENROUTER_KIMI_K2_7_CODE_MODEL: &str = "moonshotai/kimi-k2.7-code";
@@ -86,7 +82,6 @@ pub(crate) const DEFAULT_KIMI_CODE_BASE_URL: &str = "https://api.kimi.com/coding
pub(crate) const DEFAULT_SGLANG_MODEL: &str = "deepseek-ai/DeepSeek-V4-Pro";
pub(crate) const DEFAULT_SGLANG_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash";
pub(crate) const DEFAULT_OPENROUTER_BASE_URL: &str = "https://openrouter.ai/api/v1";
pub(crate) const DEFAULT_ORCAROUTER_BASE_URL: &str = "https://api.orcarouter.ai/v1";
pub(crate) const XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL: &str = "https://api.xiaomimimo.com/v1";
pub(crate) const DEFAULT_XIAOMI_MIMO_BASE_URL: &str = "https://token-plan-sgp.xiaomimimo.com/v1";
pub(crate) const XIAOMI_MIMO_TOKEN_PLAN_CN_BASE_URL: &str =
@@ -113,18 +108,13 @@ pub(crate) const DEFAULT_VLLM_FLASH_MODEL: &str = "deepseek-ai/DeepSeek-V4-Flash
pub(crate) const DEFAULT_VLLM_BASE_URL: &str = "http://localhost:8000/v1";
pub(crate) const DEFAULT_OLLAMA_MODEL: &str = "deepseek-v4-flash";
pub(crate) const DEFAULT_OLLAMA_BASE_URL: &str = "http://localhost:11434/v1";
pub(crate) const DEFAULT_OLLAMA_CLOUD_MODEL: &str = "gpt-oss:120b";
pub(crate) const DEFAULT_OLLAMA_CLOUD_BASE_URL: &str = "https://ollama.com/v1";
// Z.ai (GLM Coding Plan) defaults. GLM-5.3 is live on the Z.ai Coding Plan
// (2026-08-13) and is the default for new Z.ai routes. Capability/limit
// metadata still inherits from glm-5.2 until Z.ai publishes distinct 5.3
// numbers; no USD price is claimed. See models_dev.bundled.json
// `_meta.pending_release_metadata`. Explicit GLM-5.2 selections keep their
// own id: only the default moved.
pub(crate) const DEFAULT_ZAI_MODEL: &str = ZAI_GLM_5_3_MODEL;
// Z.ai (GLM Coding Plan) defaults
pub(crate) const DEFAULT_ZAI_MODEL: &str = "GLM-5.2";
// GLM-5.3 is a peer of the default, never the default. Its capability/limit
// metadata is INHERITED FROM glm-5.2 PENDING OFFICIAL Z.AI RELEASE METADATA
// (2026-08-03). See models_dev.bundled.json `_meta.pending_release_metadata`.
pub(crate) const ZAI_GLM_5_3_MODEL: &str = "GLM-5.3";
pub(crate) const ZAI_GLM_5_2_MODEL: &str = "GLM-5.2";
pub(crate) const ZAI_GLM_5_1_MODEL: &str = "GLM-5.1";
pub(crate) const ZAI_GLM_5_TURBO_MODEL: &str = "GLM-5-Turbo";
pub(crate) const DEFAULT_ZAI_BASE_URL: &str = "https://api.z.ai/api/coding/paas/v4";
@@ -175,7 +165,7 @@ pub(crate) const DEFAULT_OPENCODE_ZEN_BASE_URL: &str = "https://opencode.ai/zen/
pub(crate) const DEFAULT_META_MODEL: &str = "muse-spark-1.2";
pub(crate) const DEFAULT_META_BASE_URL: &str = "https://api.meta.ai/v1";
// xAI / Grok API-key route defaults
pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.6";
pub(crate) const DEFAULT_XAI_MODEL: &str = "grok-4.5";
pub(crate) const DEFAULT_XAI_BASE_URL: &str = "https://api.x.ai/v1";
// Mistral AI (la Plateforme) defaults
pub(crate) const DEFAULT_MISTRAL_MODEL: &str = "mistral-code-latest";
@@ -183,9 +173,6 @@ pub(crate) const DEFAULT_MISTRAL_BASE_URL: &str = "https://api.mistral.ai/v1";
// TelecomJS (Jiangsu Telecom TokenHub) defaults
pub(crate) const DEFAULT_TELECOMJS_MODEL: &str = "deepseek-v4-pro";
pub(crate) const DEFAULT_TELECOMJS_BASE_URL: &str = "https://aigw.telecomjs.com/v1";
// Eden AI (OpenAI-compatible AI gateway) defaults
pub(crate) const DEFAULT_EDENAI_MODEL: &str = "deepseek/deepseek-v4-pro";
pub(crate) const DEFAULT_EDENAI_BASE_URL: &str = "https://api.edenai.run/v3";
// Alibaba Cloud Model Studio (DashScope) defaults
// Token Plan (Personal / Team): shared endpoint, OpenAI + Anthropic dialects
pub(crate) const DEFAULT_MODELSTUDIO_TOKEN_PLAN_MODEL: &str = "qwen3.8-max";
@@ -198,15 +185,3 @@ pub(crate) const DEFAULT_MODELSTUDIO_CODING_PLAN_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/v1";
pub(crate) const MODELSTUDIO_CODING_PLAN_ANTHROPIC_BASE_URL: &str =
"https://coding-intl.dashscope.aliyuncs.com/apps/anthropic";
/// Google Gemini OpenAI-compatible Chat Completions base URL.
pub const DEFAULT_GOOGLE_BASE_URL: &str =
"https://generativelanguage.googleapis.com/v1beta/openai/";
/// Default Gemini model for the Google provider (preview flagship, 2026-08).
pub const DEFAULT_GOOGLE_MODEL: &str = "gemini-3.1-pro-preview";
/// Antigravity cloud-code internal endpoint (credential plane only; the
/// wire protocol is not implemented and sends fail closed).
pub const DEFAULT_ANTIGRAVITY_BASE_URL: &str = "https://cloudcode-pa.googleapis.com/v1internal";
/// Placeholder model id; never sent — the route fails closed before transport.
pub const DEFAULT_ANTIGRAVITY_MODEL: &str = "gemini-3-pro-preview";
+1 -34
View File
@@ -42,8 +42,6 @@ pub enum ProviderKind {
#[serde(alias = "volcengine-ark", alias = "volcengine_ark", alias = "ark")]
Volcengine,
Openrouter,
#[serde(alias = "orca_router", alias = "orca")]
Orcarouter,
#[serde(alias = "mimo", alias = "xiaomi", alias = "xiaomi_mimo")]
XiaomiMimo,
#[serde(alias = "novita-ai", alias = "novita_ai")]
@@ -61,8 +59,6 @@ pub enum ProviderKind {
Sglang,
Vllm,
Ollama,
#[serde(alias = "ollama_cloud")]
OllamaCloud,
#[serde(alias = "hugging-face", alias = "hugging_face", alias = "hf")]
Huggingface,
#[serde(alias = "together-ai", alias = "together_ai", alias = "togetherai")]
@@ -191,30 +187,6 @@ pub enum ProviderKind {
alias = "alibaba-coding-plan-anthropic"
)]
ModelstudioCodingPlanAnthropic,
/// Google Antigravity (`agy` CLI) — consent-gated read-only credential
/// import only; the cloud-code wire protocol is not implemented and
/// requests fail closed with an actionable message.
#[serde(alias = "agy")]
Antigravity,
/// Google — Gemini OpenAI-compatible endpoint. Its own backend, not an
/// OpenAI alias: thought signatures on tool calls are captured and
/// replayed per Google's contract.
#[serde(
alias = "google-gemini",
alias = "google_gemini",
alias = "gemini",
alias = "google-ai",
alias = "google_ai",
alias = "ai-studio",
alias = "aistudio"
)]
Google,
/// Eden AI — OpenAI-compatible AI gateway (aggregator).
///
/// Serves a broad catalog of upstream models under `provider/model`
/// namespaced wire ids over the OpenAI Chat Completions protocol.
#[serde(alias = "eden-ai", alias = "eden_ai", alias = "edenai")]
Edenai,
/// User-defined OpenAI-compatible endpoint (#1519).
///
/// A single dynamic identity for arbitrary `[providers.<name>]
@@ -232,7 +204,7 @@ impl ProviderKind {
/// stay on the enum for serde and `provider_for_kind`, but they are not
/// first-class catalog rows. Plan is `mode` / base_url; dialect is
/// `wire = openai|anthropic` on the primary provider config.
pub const ALL: [Self; 42] = [
pub const ALL: [Self; 37] = [
Self::Deepseek,
Self::NvidiaNim,
Self::Openai,
@@ -240,7 +212,6 @@ impl ProviderKind {
Self::WanjieArk,
Self::Volcengine,
Self::Openrouter,
Self::Orcarouter,
Self::XiaomiMimo,
Self::Novita,
Self::Fireworks,
@@ -251,7 +222,6 @@ impl ProviderKind {
Self::Sglang,
Self::Vllm,
Self::Ollama,
Self::OllamaCloud,
Self::Huggingface,
Self::Together,
Self::Qianfan,
@@ -271,9 +241,6 @@ impl ProviderKind {
Self::Mistral,
Self::Telecomjs,
Self::ModelstudioTokenPlan,
Self::Google,
Self::Antigravity,
Self::Edenai,
Self::Custom,
];
-405
View File
@@ -1,405 +0,0 @@
//! Beginner provider setup templates (#5350).
//!
//! First-class providers already have a default URL and catalog. These
//! templates exist so `/provider` and Settings can offer a key-only path
//! for:
//! - first-class gateways users still treat as "paste a Base URL"
//! (OpenCode Zen / Go), and
//! - named OpenAI-compatible custom routes that are not `ProviderKind`
//! variants (SenseNova).
//!
//! Values here are limited to hosts, models, and env names already
//! documented in this repository. Agnes is catalogued as unpublished so
//! the UI can say so without inventing a URL.
//!
//! A `/models` 2xx from Test Connection is reachability only. It is not
//! model readiness.
use crate::OPENCODE_GO_CHAT_MODELS;
use crate::provider::{credential_help, provider_for_kind};
use crate::provider_kind::ProviderKind;
/// SenseTime SenseNova OpenAI-compatible host already shipped on this
/// branch as the `/provider` `S` preset.
pub const SENSENOVA_TEMPLATE_ID: &str = "sensenova";
pub const SENSENOVA_BASE_URL: &str = "https://token.sensenova.cn/v1";
pub const SENSENOVA_DEFAULT_MODEL: &str = "deepseek-v4-flash";
pub const SENSENOVA_API_KEY_ENV: &str = "SENSENOVA_API_KEY";
pub const SENSENOVA_MODELS: &[&str] = &[SENSENOVA_DEFAULT_MODEL];
/// Agnes is requested by #5350 but has no published OpenAI-compatible
/// host in this repository.
pub const AGNES_TEMPLATE_ID: &str = "agnes";
/// How a beginner template is applied.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProviderSetupApply {
/// Existing first-class `ProviderKind`. URL and models stay on the
/// registry; the template only names the key-only setup path.
FirstClass(ProviderKind),
/// Named `[providers.<id>] kind = "openai-compatible"` table with a
/// published host already recorded in this repository.
Compatible,
/// Catalog row with no published URL or model list. The UI must not
/// invent one.
Unpublished,
}
/// A built-in setup template. Compatible rows carry a fixed URL and a
/// proven model list; first-class rows delegate those facts to the
/// existing provider registry.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ProviderSetupTemplate {
pub id: &'static str,
pub display_name: &'static str,
pub apply: ProviderSetupApply,
base_url: Option<&'static str>,
default_model: Option<&'static str>,
models: &'static [&'static str],
api_key_env: Option<&'static str>,
docs_url: Option<&'static str>,
credential_url: Option<&'static str>,
guidance: &'static str,
}
impl ProviderSetupTemplate {
/// Published OpenAI-compatible host, when this repository has one.
#[must_use]
pub fn base_url(self) -> Option<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(kind) => {
Some(provider_for_kind(kind).default_base_url())
}
ProviderSetupApply::Compatible => self.base_url,
ProviderSetupApply::Unpublished => None,
}
}
/// Default model, when this repository has one.
#[must_use]
pub fn default_model(self) -> Option<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(kind) => Some(provider_for_kind(kind).default_model()),
ProviderSetupApply::Compatible => self.default_model,
ProviderSetupApply::Unpublished => None,
}
}
/// Models shown in setup / `/model` when the live catalog is empty or
/// Models.dev refresh failed. First-class Zen/Go use the curated
/// roster already owned by the route catalog.
#[must_use]
pub fn picker_models(self) -> Vec<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(ProviderKind::OpencodeZen) => {
crate::route::opencode_zen_picker_models()
}
ProviderSetupApply::FirstClass(ProviderKind::OpencodeGo) => {
OPENCODE_GO_CHAT_MODELS.to_vec()
}
ProviderSetupApply::FirstClass(_) => self
.default_model()
.map(|model| vec![model])
.unwrap_or_default(),
ProviderSetupApply::Compatible => self.models.to_vec(),
ProviderSetupApply::Unpublished => Vec::new(),
}
}
/// Canonical API-key environment variable name, when known.
#[must_use]
pub fn api_key_env(self) -> Option<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(kind) => {
provider_for_kind(kind).env_vars().first().copied()
}
ProviderSetupApply::Compatible => self.api_key_env,
ProviderSetupApply::Unpublished => None,
}
}
/// Provider-owned documentation URL already recorded in this repository.
#[must_use]
pub fn docs_url(self) -> Option<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(kind) => credential_help(kind).docs_url,
_ => self.docs_url,
}
}
/// Provider-owned credential page already recorded in this repository.
#[must_use]
pub fn credential_url(self) -> Option<&'static str> {
match self.apply {
ProviderSetupApply::FirstClass(kind) => credential_help(kind).credential_url,
_ => self.credential_url,
}
}
/// Concise, non-secret setup guidance. First-class rows reuse the
/// existing credential-help sentence.
#[must_use]
pub fn guidance(self) -> &'static str {
match self.apply {
ProviderSetupApply::FirstClass(kind) => credential_help(kind).guidance,
_ => self.guidance,
}
}
#[must_use]
pub fn is_first_class(self) -> bool {
matches!(self.apply, ProviderSetupApply::FirstClass(_))
}
#[must_use]
pub fn is_compatible(self) -> bool {
matches!(self.apply, ProviderSetupApply::Compatible)
}
#[must_use]
pub fn is_unpublished(self) -> bool {
matches!(self.apply, ProviderSetupApply::Unpublished)
}
/// Compact Settings-row value: fillable ids, then unpublished ids.
#[must_use]
pub fn settings_value() -> String {
let mut fillable = Vec::new();
let mut unpublished = Vec::new();
for template in provider_setup_templates() {
if template.is_unpublished() {
unpublished.push(template.id);
} else {
fillable.push(template.id);
}
}
match (fillable.is_empty(), unpublished.is_empty()) {
(true, true) => String::new(),
(false, true) => fillable.join(", "),
(true, false) => format!("{} unpublished", unpublished.join(", ")),
(false, false) => format!(
"{}; {} unpublished",
fillable.join(", "),
unpublished.join(", ")
),
}
}
}
const TEMPLATES: &[ProviderSetupTemplate] = &[
ProviderSetupTemplate {
id: "opencode-zen",
display_name: "OpenCode Zen",
apply: ProviderSetupApply::FirstClass(ProviderKind::OpencodeZen),
base_url: None,
default_model: None,
models: &[],
api_key_env: None,
docs_url: None,
credential_url: None,
guidance: "",
},
ProviderSetupTemplate {
id: "opencode-go",
display_name: "OpenCode Go",
apply: ProviderSetupApply::FirstClass(ProviderKind::OpencodeGo),
base_url: None,
default_model: None,
models: &[],
api_key_env: None,
docs_url: None,
credential_url: None,
guidance: "",
},
ProviderSetupTemplate {
id: SENSENOVA_TEMPLATE_ID,
display_name: "SenseNova",
apply: ProviderSetupApply::Compatible,
base_url: Some(SENSENOVA_BASE_URL),
default_model: Some(SENSENOVA_DEFAULT_MODEL),
models: SENSENOVA_MODELS,
api_key_env: Some(SENSENOVA_API_KEY_ENV),
docs_url: None,
credential_url: None,
guidance: "OpenAI-compatible SenseTime SenseNova host. Store an env var name, not a raw key.",
},
ProviderSetupTemplate {
id: AGNES_TEMPLATE_ID,
display_name: "Agnes",
apply: ProviderSetupApply::Unpublished,
base_url: None,
default_model: None,
models: &[],
api_key_env: None,
docs_url: None,
credential_url: None,
guidance: "Agnes has no published OpenAI-compatible URL in this repository, so it has no fillable preset.",
},
];
/// Every built-in setup template, first-class then compatible then unpublished.
#[must_use]
pub fn provider_setup_templates() -> &'static [ProviderSetupTemplate] {
TEMPLATES
}
/// Templates that persist as named OpenAI-compatible tables.
pub fn compatible_provider_setup_templates() -> impl Iterator<Item = &'static ProviderSetupTemplate>
{
TEMPLATES.iter().filter(|template| template.is_compatible())
}
/// Look up a template by id, documented alias, or first-class provider id.
#[must_use]
pub fn provider_setup_template(id: &str) -> Option<&'static ProviderSetupTemplate> {
let needle = id.trim().to_ascii_lowercase().replace('_', "-");
if needle.is_empty() {
return None;
}
TEMPLATES
.iter()
.find(|template| template.id == needle)
.or_else(|| {
TEMPLATES.iter().find(|template| match needle.as_str() {
"zen" | "opencodezen" => template.id == "opencode-zen",
"opencodego" => template.id == "opencode-go",
"sense-nova" | "meituan-sensenova" | "meituan-sensenova-cn" => {
template.id == SENSENOVA_TEMPLATE_ID
}
_ => false,
})
})
.or_else(|| {
let kind = ProviderKind::parse(&needle)?;
TEMPLATES.iter().find(|template| {
matches!(template.apply, ProviderSetupApply::FirstClass(candidate) if candidate == kind)
})
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{DEFAULT_OPENCODE_GO_BASE_URL, DEFAULT_OPENCODE_ZEN_BASE_URL};
#[test]
fn fillable_templates_have_https_hosts_and_models() {
for template in provider_setup_templates() {
if template.is_unpublished() {
assert!(template.base_url().is_none(), "{}", template.id);
assert!(template.picker_models().is_empty(), "{}", template.id);
assert!(template.api_key_env().is_none(), "{}", template.id);
continue;
}
let base_url = template
.base_url()
.unwrap_or_else(|| panic!("{} host", template.id));
assert!(
base_url.starts_with("https://"),
"{} base URL must be https: {base_url}",
template.id
);
let models = template.picker_models();
assert!(
!models.is_empty(),
"{} must list at least one model",
template.id
);
let default_model = template
.default_model()
.unwrap_or_else(|| panic!("{} default model", template.id));
assert!(
models
.iter()
.any(|model| model.eq_ignore_ascii_case(default_model)),
"{} default {default_model} missing from picker models {models:?}",
template.id
);
assert!(
template.api_key_env().is_some_and(|env| !env.is_empty()),
"{} must name an API key env",
template.id
);
}
}
#[test]
fn compatible_template_ids_do_not_shadow_built_ins() {
for template in compatible_provider_setup_templates() {
assert!(
ProviderKind::parse(template.id).is_none(),
"compatible template '{}' shadows ProviderKind",
template.id
);
}
}
#[test]
fn first_class_templates_reuse_registry_facts() {
let zen = provider_setup_template("opencode-zen").expect("zen");
assert_eq!(
zen.apply,
ProviderSetupApply::FirstClass(ProviderKind::OpencodeZen)
);
assert_eq!(zen.base_url(), Some(DEFAULT_OPENCODE_ZEN_BASE_URL));
assert_eq!(zen.api_key_env(), Some("OPENCODE_ZEN_API_KEY"));
assert_eq!(zen.docs_url(), Some("https://opencode.ai/docs/zen/"));
assert_eq!(zen.credential_url(), Some("https://opencode.ai/zen/"));
assert!(zen.picker_models().len() > 1);
assert!(zen.picker_models().contains(&"deepseek-v4-flash"));
let go = provider_setup_template("opencode-go").expect("go");
assert_eq!(
go.apply,
ProviderSetupApply::FirstClass(ProviderKind::OpencodeGo)
);
assert_eq!(go.base_url(), Some(DEFAULT_OPENCODE_GO_BASE_URL));
assert_eq!(go.picker_models(), OPENCODE_GO_CHAT_MODELS);
assert_eq!(go.docs_url(), Some("https://opencode.ai/docs/go/"));
}
#[test]
fn sensenova_uses_the_published_host_already_on_this_branch() {
let sense = provider_setup_template("meituan-sensenova").expect("sensenova alias");
assert_eq!(sense.id, SENSENOVA_TEMPLATE_ID);
assert!(sense.is_compatible());
assert_eq!(sense.base_url(), Some(SENSENOVA_BASE_URL));
assert_eq!(sense.default_model(), Some(SENSENOVA_DEFAULT_MODEL));
assert_eq!(sense.api_key_env(), Some(SENSENOVA_API_KEY_ENV));
assert_eq!(sense.picker_models(), SENSENOVA_MODELS);
assert!(sense.docs_url().is_none());
assert!(sense.credential_url().is_none());
}
#[test]
fn agnes_stays_unpublished_without_invented_values() {
let agnes = provider_setup_template("agnes").expect("agnes");
assert!(agnes.is_unpublished());
assert!(agnes.base_url().is_none());
assert!(agnes.default_model().is_none());
assert!(agnes.picker_models().is_empty());
assert!(agnes.api_key_env().is_none());
assert!(agnes.docs_url().is_none());
assert!(agnes.guidance().contains("no published"));
}
#[test]
fn settings_value_names_fillable_then_unpublished() {
assert_eq!(
ProviderSetupTemplate::settings_value(),
"opencode-zen, opencode-go, sensenova; agnes unpublished"
);
}
#[test]
fn zen_alias_from_provider_kind_parse_resolves() {
assert_eq!(
provider_setup_template("zen").map(|template| template.id),
Some("opencode-zen")
);
assert_eq!(
provider_setup_template("OPENCODE_ZEN").map(|template| template.id),
Some("opencode-zen")
);
}
}
+1 -7
View File
@@ -73,7 +73,7 @@ pub(crate) fn documented_server_side_web_search(
| "claude-sonnet-5"
| "claude-sonnet-4-6"
),
"xai" => matches!(wire_model_id.as_str(), "grok-4.6" | "grok-4.5"),
"xai" => wire_model_id == "grok-4.5",
_ => false,
};
if supported {
@@ -143,10 +143,6 @@ mod tests {
#[test]
fn documented_web_search_is_exact_and_provider_owned() {
assert_eq!(
documented_server_side_web_search("xai", "grok-4.6"),
CapabilityState::Supported
);
assert_eq!(
documented_server_side_web_search("xai", "grok-4.5"),
CapabilityState::Supported
@@ -164,8 +160,6 @@ mod tests {
("openrouter", "openai/gpt-5.6"),
("custom", "gpt-5.6"),
("openai", "gpt-5.6-sol"),
("xai", "grok-4.6-fast"),
("xai", "grok-4.6-latest"),
("xai", "grok-4.5-fast"),
("anthropic", "claude-haiku-4-5"),
] {
+7 -6
View File
@@ -1,9 +1,10 @@
//! Route foundation: additive, runtime-unwired types for EPIC #2608.
//!
//! This module tree introduces the canonical identity newtypes (#3084) and the
//! `ReadyRouteCandidate` / `RouteResolver` contract (#3384). The TUI, client,
//! and engine consume these types; they remain a self-contained seam so later
//! tracks can keep wiring through here.
//! `ReadyRouteCandidate` / `RouteResolver` contract (#3384) without touching
//! any runtime routing path. Nothing here is consumed by `config.rs`, the TUI,
//! the client, or the engine yet; it is a self-contained seam that later
//! tracks will wire in.
//!
//! Layering:
//! - [`ids`] — provider/model/wire string newtypes + namespace hints.
@@ -18,6 +19,8 @@
//! which is a re-export alias of [`crate::provider::WireFormat`] rather than a
//! fourth protocol synonym.
#![allow(dead_code)]
/// The selected endpoint's request/response wire shape.
///
/// Alias of [`crate::provider::WireFormat`]; intentionally NOT a new enum, to
@@ -41,9 +44,7 @@ pub use capabilities::{CapabilityState, RouteCapabilities};
pub use descriptor::{EndpointDescriptor, ProviderDescriptor};
pub use errors::RouteError;
pub use ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId};
pub use offering::{
ProviderModelOffering, RouteLimits, bundled_offerings, opencode_zen_picker_models,
};
pub use offering::{ProviderModelOffering, RouteLimits, bundled_offerings};
pub use resolver::{RouteRequest, RouteResolver};
#[cfg(test)]
+1 -39
View File
@@ -168,27 +168,6 @@ pub(crate) const OPENCODE_ZEN_CHAT_MODELS: &[&str] = &[
"deepseek-v4-flash-free",
];
/// Logical default plus every documented Zen wire id, for picker fallbacks
/// when Models.dev is stale or failed. `gpt-5.6` is the user-facing default;
/// `gpt-5.6-sol` is the proven Responses wire id.
#[must_use]
pub fn opencode_zen_picker_models() -> Vec<&'static str> {
let mut models = vec![crate::DEFAULT_OPENCODE_ZEN_MODEL];
for model in OPENCODE_ZEN_RESPONSES_MODELS
.iter()
.chain(OPENCODE_ZEN_MESSAGES_MODELS)
.chain(OPENCODE_ZEN_CHAT_MODELS)
{
if !models
.iter()
.any(|existing| existing.eq_ignore_ascii_case(model))
{
models.push(*model);
}
}
models
}
/// Return curated provider/model transport facts as owned offering rows.
///
/// OpenCode Zen's official catalog serves models over three protocol families.
@@ -232,7 +211,7 @@ pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
pricing: PricingSku::UnknownOrStale,
},
ProviderModelOffering {
provider: deepseek.clone(),
provider: deepseek,
canonical_model: Some(ModelId::from("deepseek-v4-flash")),
wire_model_id: WireModelId::from("deepseek-v4-flash"),
endpoint_key: "responses".to_string(),
@@ -241,23 +220,6 @@ pub fn bundled_offerings() -> Vec<ProviderModelOffering> {
capabilities: documented_capabilities,
pricing: PricingSku::UnknownOrStale,
},
// Vision-experimental sibling of v4-flash, verified live on
// api.deepseek.com /models (2026-08-21). Image input is the one
// documented difference; limits inherit the v4-flash row until
// DeepSeek publishes distinct numbers.
ProviderModelOffering {
provider: deepseek,
canonical_model: Some(ModelId::from("deepseek-v4-flash-vision-exp")),
wire_model_id: WireModelId::from("deepseek-v4-flash-vision-exp"),
endpoint_key: "chat".to_string(),
default_for_provider: false,
limits: documented_limits,
capabilities: RouteCapabilities {
image_input: CapabilityState::Supported,
..documented_capabilities
},
pricing: PricingSku::UnknownOrStale,
},
];
let provider = ProviderId::from("opencode-zen");
+5 -29
View File
@@ -233,15 +233,11 @@ impl RouteResolver {
}
}
if custom_endpoint {
// Capabilities and pricing belong to the exact provider endpoint
// offering that reported them. Reusing a provider enum and a
// first-party model id against a custom compatible endpoint does
// not prove that proxy serves the same modality, tool, reasoning,
// or billing contract. Keep the caller's model id and Chat
// pass-through above, but clear every unowned offering fact at the
// authority boundary instead of presenting it as verified.
selected.capabilities = RouteCapabilities::default();
selected.pricing = PricingSku::UnknownOrStale;
// A documented first-party server tool is an endpoint-owned fact.
// Reusing a provider enum/model id against a custom compatible
// endpoint cannot carry that fact across the authority boundary.
selected.capabilities.server_side_web_search =
super::capabilities::CapabilityState::Unknown;
}
let protocol = descriptor
@@ -339,14 +335,6 @@ impl RouteResolver {
// Try to match a catalog offering owned by THIS provider, either by
// canonical model id or by exact wire id. This keeps interpretation
// inside provider scope; offerings from other providers are ignored.
// DeepSeek and Z.ai also publish marketing-cased wire ids while saved
// selectors can be lowercase. Defer that fallback until exact matching
// is exhausted, and only accept a unique provider-owned match so
// catalog order can never choose between case-distinct model ids.
let allow_casefold_wire_match = class == ProviderClass::StrictDirect
&& matches!(provider_kind, ProviderKind::Deepseek | ProviderKind::Zai);
let mut casefold_match = None;
let mut casefold_ambiguous = false;
for offering in &self.offerings {
if offering.provider != *provider_id {
continue;
@@ -359,18 +347,6 @@ impl RouteResolver {
if matches_canonical || matches_wire {
return Ok(ResolvedOffering::from_offering(offering));
}
if allow_casefold_wire_match
&& offering.wire_model_id.as_str().eq_ignore_ascii_case(raw)
{
if casefold_match.is_some() {
casefold_ambiguous = true;
} else {
casefold_match = Some(offering);
}
}
}
if !casefold_ambiguous && let Some(offering) = casefold_match {
return Ok(ResolvedOffering::from_offering(offering));
}
// No catalog match. Apply class-specific pass-through rules.
+4 -192
View File
@@ -5,8 +5,7 @@ use super::errors::RouteError;
use super::ids::{LogicalModelRef, ModelId, NamespaceHint, ProviderId, WireModelId};
use super::resolver::{RouteRequest, RouteResolver};
use super::{
CapabilityState, LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource,
RouteCapabilities, SourcedLimitOverride,
LimitField, OverrideSource, RequestProtocol, ResolvedAuthSource, SourcedLimitOverride,
};
use crate::ProviderKind;
use crate::models_dev::ModelsDevCatalog;
@@ -391,74 +390,6 @@ fn resolver_routes_only_official_deepseek_flash_over_responses() {
assert_eq!(custom.endpoint().endpoint_key, "chat");
}
#[test]
fn resolver_routes_deepseek_vision_exp_over_chat_with_image_input() {
for base_url_override in [
None,
Some("https://api.deepseek.com/v1"),
Some("https://api.deepseek.com/beta"),
] {
let route = RouteResolver::new()
.resolve(&RouteRequest {
explicit_provider: Some(ProviderKind::Deepseek),
model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")),
saved_provider_model: None,
base_url_override: base_url_override.map(str::to_string),
limit_overrides: Vec::new(),
})
.expect("experimental vision route resolves");
assert_eq!(route.provider_kind(), ProviderKind::Deepseek);
assert_eq!(
route.canonical_model().map(ModelId::as_str),
Some("deepseek-v4-flash-vision-exp")
);
assert_eq!(
route.wire_model_id().as_str(),
"deepseek-v4-flash-vision-exp"
);
assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
assert_eq!(route.endpoint().endpoint_key, "chat");
assert_eq!(
route.capabilities().image_input,
CapabilityState::Supported,
"official endpoint {base_url_override:?} must retain the exact vision fact"
);
assert_eq!(route.capabilities().reasoning, CapabilityState::Supported);
assert_eq!(
route.capabilities().native_tool_calls,
CapabilityState::Supported
);
assert_eq!(route.limits().context_tokens, Some(1_000_000));
assert_eq!(route.limits().output_tokens, Some(384_000));
}
}
#[test]
fn resolver_keeps_custom_deepseek_same_name_capabilities_unverified() {
let route = RouteResolver::new()
.resolve(&RouteRequest {
explicit_provider: Some(ProviderKind::Deepseek),
model_selector: Some(LogicalModelRef::from("deepseek-v4-flash-vision-exp")),
saved_provider_model: None,
base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
limit_overrides: Vec::new(),
})
.expect("same-name custom proxy route resolves");
assert_eq!(route.protocol(), RequestProtocol::ChatCompletions);
assert_eq!(route.endpoint().endpoint_key, "chat");
assert_eq!(
route.wire_model_id().as_str(),
"deepseek-v4-flash-vision-exp"
);
assert_eq!(
route.capabilities(),
RouteCapabilities::default(),
"a custom proxy cannot inherit first-party capability facts by reusing the model id"
);
}
#[test]
fn resolver_aggregator_preserves_prefixed_wire_id_without_inferring_deepseek() {
let r = RouteResolver::new();
@@ -599,7 +530,7 @@ fn resolver_auto_falls_back_to_descriptor_default_without_catalog_default() {
assert!(out.logical_model().is_auto());
assert_eq!(
out.wire_model_id().as_str(),
"GLM-5.3",
"GLM-5.2",
"no catalog default → descriptor built-in default wins"
);
assert_eq!(
@@ -694,81 +625,6 @@ fn resolver_strict_direct_rejects_clearly_foreign_selector() {
}
}
#[test]
fn resolver_direct_owned_row_match_survives_casing_mismatch() {
let r = RouteResolver::new();
let out = r
.resolve(&req(Some(ProviderKind::Zai), Some("glm-5.2")))
.expect("lowercase selector on the owning Z.ai row must resolve");
assert_eq!(out.provider_kind(), ProviderKind::Zai);
assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
assert!(out.limits().has_known_limit());
let saved = RouteRequest {
saved_provider_model: Some(WireModelId::from("glm-5.2")),
..req(Some(ProviderKind::Zai), None)
};
let out = r
.resolve(&saved)
.expect("saved lowercase Z.ai model must resolve");
assert_eq!(out.wire_model_id().as_str(), "GLM-5.2");
let out = r
.resolve(&req(Some(ProviderKind::Deepseek), Some("Deepseek-V4-Pro")))
.expect("DeepSeek's own casing variant must resolve");
assert_eq!(out.provider_kind(), ProviderKind::Deepseek);
assert_eq!(out.wire_model_id().as_str(), "deepseek-v4-pro");
let custom = RouteRequest {
explicit_provider: Some(ProviderKind::Zai),
model_selector: Some(LogicalModelRef::from("glm-5.2")),
saved_provider_model: None,
base_url_override: Some("https://compatible.example.test/v1".to_string()),
limit_overrides: Vec::new(),
};
let out = r
.resolve(&custom)
.expect("custom endpoint keeps model pass-through semantics");
assert_eq!(out.wire_model_id().as_str(), "glm-5.2");
assert!(!out.limits().has_known_limit());
}
#[test]
fn resolver_direct_casefold_match_requires_one_owned_row() {
let raw = r#"{
"providers": {
"zai": {
"models": {
"CaseModel": {
"id": "CaseModel",
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 1000 }
},
"casemodel": {
"id": "casemodel",
"modalities": { "input": ["text"], "output": ["text"] },
"limit": { "context": 2000 }
}
}
}
}
}"#;
let catalog = ModelsDevCatalog::parse_json(raw).expect("Models.dev fixture parses");
let offerings = catalog
.provider_offerings("zai")
.expect("Z.ai provider offerings");
let r = RouteResolver::from_offerings(offerings);
let out = r
.resolve(&req(Some(ProviderKind::Zai), Some("CASEMODEL")))
.expect("ambiguous casefold stays an unknown direct model");
assert_eq!(out.wire_model_id().as_str(), "CASEMODEL");
assert!(
!out.limits().has_known_limit(),
"ambiguous rows must not lend arbitrary catalog metadata"
);
}
#[test]
fn resolver_strict_direct_rejects_other_provider_known_bare_offering() {
let r = RouteResolver::new();
@@ -1367,32 +1223,9 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() {
use crate::route::CapabilityState;
let resolver = RouteResolver::new();
let automatic = resolver
.resolve(&req(Some(ProviderKind::Xai), None))
.expect("xAI default resolves");
assert_eq!(automatic.wire_model_id().as_str(), "grok-4.6");
let direct = resolver
.resolve(&req(Some(ProviderKind::Xai), Some("grok-4.6")))
.resolve(&req(Some(ProviderKind::Xai), Some("grok-4.5")))
.expect("bundled direct xAI offering resolves");
assert_eq!(direct.wire_model_id().as_str(), "grok-4.6");
assert_eq!(
direct.capabilities().attachments,
CapabilityState::Supported
);
assert_eq!(
direct.capabilities().image_input,
CapabilityState::Supported
);
assert_eq!(direct.capabilities().reasoning, CapabilityState::Supported);
assert_eq!(
direct.capabilities().native_tool_calls,
CapabilityState::Supported
);
assert_eq!(
direct.capabilities().structured_output,
CapabilityState::Supported
);
assert_eq!(
direct.capabilities().server_side_web_search,
CapabilityState::Supported
@@ -1409,7 +1242,7 @@ fn provider_native_web_search_requires_exact_direct_endpoint_offering() {
let custom_endpoint = resolver
.resolve(&RouteRequest {
explicit_provider: Some(ProviderKind::Xai),
model_selector: Some(LogicalModelRef::from("grok-4.6")),
model_selector: Some(LogicalModelRef::from("grok-4.5")),
saved_provider_model: None,
base_url_override: Some("https://gateway.example.test/v1".to_string()),
limit_overrides: Vec::new(),
@@ -1442,27 +1275,6 @@ fn priced_offering_yields_token_pricing_sku() {
}
}
#[test]
fn custom_endpoint_does_not_inherit_first_party_pricing() {
use super::candidate::PricingSku;
let out = priced_deepseek_resolver()
.resolve(&RouteRequest {
explicit_provider: Some(ProviderKind::Deepseek),
model_selector: Some(LogicalModelRef::from("deepseek-v4-pro")),
saved_provider_model: None,
base_url_override: Some("https://deepseek-proxy.example.test/v1".to_string()),
limit_overrides: Vec::new(),
})
.expect("same-name custom proxy route resolves");
assert!(
matches!(out.pricing(), Some(PricingSku::UnknownOrStale)),
"a custom proxy cannot inherit first-party pricing by reusing the model id: {:?}",
out.pricing()
);
}
#[test]
fn unpriced_offering_stays_unknown() {
use super::candidate::PricingSku;
+17 -18
View File
@@ -40,8 +40,7 @@ pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
///
/// The notice is owed whenever
/// [`SetupState::telemetry_notice_decided_for`] does not match this string.
/// Bumping it re-shows the disclosure to prior acceptors and unanswered users,
/// so it is bumped only
/// Bumping it re-asks prior acceptors and unanswered users, so it is bumped only
/// when the collection policy, schema, or disclosure materially changes. Prior
/// declines remain off. Keying it to the app version would re-prompt every
/// release, which is nagging with extra steps.
@@ -331,13 +330,13 @@ pub struct SetupState {
pub inherited: bool,
// ── Telemetry notice ────────────────────────────────────────────────
/// [`TELEMETRY_NOTICE_VERSION`] whose telemetry disclosure was shown.
/// `None` means the notice is still owed.
/// [`TELEMETRY_NOTICE_VERSION`] whose telemetry notice the user has
/// answered. `None` means the notice is still owed.
///
/// Never auto-completed and never deferred-completed: unlike the
/// constitution checkpoint, which records a `Deferred` completion on the
/// skip-onboarding path, a telemetry notice that was not rendered leaves
/// this `None`. Collection follows the documented default
/// skip-onboarding path, a telemetry notice that was not rendered and
/// answered leaves this `None`. Collection follows the documented default
/// while the notice remains owed on the next interactive launch.
///
/// These are *fields* rather than a new [`SetupStep`] variant on purpose:
@@ -346,9 +345,9 @@ pub struct SetupState {
/// checkpoint — while unknown fields are ignored.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub telemetry_notice_decided_for: Option<String>,
/// The privacy preference recorded with the notice. `false` with any
/// recorded notice version is a durable opt-out; `true` records that the
/// default-on disclosure was shown for that version.
/// The user's answer to the notice. `false` with any recorded notice
/// version is a durable opt-out; `true` records acknowledgment of that
/// version's disclosure.
#[serde(default, skip_serializing_if = "is_false")]
pub telemetry_opt_in: bool,
}
@@ -483,20 +482,20 @@ impl SetupState {
self
}
/// True when the telemetry notice for `version` has not been shown.
/// True when the telemetry notice for `version` has not been answered.
///
/// A decision recorded against a *different* notice version does not
/// count: the content changed, so the disclosure is owed again.
/// count: the content changed, so the answer is stale and is owed again.
#[must_use]
pub fn needs_telemetry_notice(&self, version: &str) -> bool {
self.telemetry_notice_decided_for.as_deref() != Some(version)
}
/// Record the privacy preference associated with the telemetry notice.
/// Record the user's answer to the telemetry notice for `version`.
///
/// Default-on may be recorded only after the notice was actually rendered;
/// an explicit opt-out may also arrive from Settings. Deferral,
/// skip-onboarding, and non-interactive surfaces leave it untouched.
/// Call this only from a path where the notice was actually rendered and
/// the user actually answered. Deferral, skip-onboarding, and any
/// non-interactive surface must leave the record untouched.
pub fn record_telemetry_notice(
&mut self,
version: impl Into<String>,
@@ -507,15 +506,15 @@ impl SetupState {
self
}
/// True when the current default-on disclosure was shown and remains on.
/// True when the user was asked the current notice and kept counting on.
#[must_use]
pub fn telemetry_accepted(&self, version: &str) -> bool {
!self.needs_telemetry_notice(version) && self.telemetry_opt_in
}
/// True when the current notice record contains an explicit opt-out.
/// True when the user was asked the current notice and said no.
///
/// Distinct from "never shown": only a recorded decline is an opt-out, and
/// Distinct from "never asked": only a recorded decline is an opt-out, and
/// only an opt-out may be acted on destructively.
#[must_use]
pub fn telemetry_declined(&self, version: &str) -> bool {
+32 -538
View File
@@ -1143,9 +1143,6 @@ struct EnvGuard {
sglang_base_url: Option<OsString>,
vllm_api_key: Option<OsString>,
vllm_base_url: Option<OsString>,
ollama_cloud_api_key: Option<OsString>,
ollama_cloud_base_url: Option<OsString>,
ollama_cloud_model: Option<OsString>,
ollama_api_key: Option<OsString>,
ollama_base_url: Option<OsString>,
huggingface_api_key: Option<OsString>,
@@ -1166,9 +1163,6 @@ struct EnvGuard {
telecomjs_api_key: Option<OsString>,
telecomjs_base_url: Option<OsString>,
telecomjs_model: Option<OsString>,
edenai_api_key: Option<OsString>,
edenai_base_url: Option<OsString>,
edenai_model: Option<OsString>,
opencode_go_api_key: Option<OsString>,
opencode_go_base_url: Option<OsString>,
opencode_go_model: Option<OsString>,
@@ -1208,9 +1202,6 @@ impl EnvGuard {
telecomjs_api_key: env::var_os("TELECOMJS_API_KEY"),
telecomjs_base_url: env::var_os("TELECOMJS_BASE_URL"),
telecomjs_model: env::var_os("TELECOMJS_MODEL"),
edenai_api_key: env::var_os("EDENAI_API_KEY"),
edenai_base_url: env::var_os("EDENAI_BASE_URL"),
edenai_model: env::var_os("EDENAI_MODEL"),
opencode_go_api_key: env::var_os("OPENCODE_GO_API_KEY"),
opencode_go_base_url: env::var_os("OPENCODE_GO_BASE_URL"),
opencode_go_model: env::var_os("OPENCODE_GO_MODEL"),
@@ -1311,9 +1302,6 @@ impl EnvGuard {
sglang_base_url: env::var_os("SGLANG_BASE_URL"),
vllm_api_key: env::var_os("VLLM_API_KEY"),
vllm_base_url: env::var_os("VLLM_BASE_URL"),
ollama_cloud_api_key: env::var_os("OLLAMA_CLOUD_API_KEY"),
ollama_cloud_base_url: env::var_os("OLLAMA_CLOUD_BASE_URL"),
ollama_cloud_model: env::var_os("OLLAMA_CLOUD_MODEL"),
ollama_api_key: env::var_os("OLLAMA_API_KEY"),
ollama_base_url: env::var_os("OLLAMA_BASE_URL"),
huggingface_api_key: env::var_os("HUGGINGFACE_API_KEY"),
@@ -1346,9 +1334,6 @@ impl EnvGuard {
env::remove_var("TELECOMJS_API_KEY");
env::remove_var("TELECOMJS_BASE_URL");
env::remove_var("TELECOMJS_MODEL");
env::remove_var("EDENAI_API_KEY");
env::remove_var("EDENAI_BASE_URL");
env::remove_var("EDENAI_MODEL");
env::remove_var("OPENCODE_GO_API_KEY");
env::remove_var("OPENCODE_GO_BASE_URL");
env::remove_var("OPENCODE_GO_MODEL");
@@ -1449,9 +1434,6 @@ impl EnvGuard {
env::remove_var("SGLANG_BASE_URL");
env::remove_var("VLLM_API_KEY");
env::remove_var("VLLM_BASE_URL");
env::remove_var("OLLAMA_CLOUD_API_KEY");
env::remove_var("OLLAMA_CLOUD_BASE_URL");
env::remove_var("OLLAMA_CLOUD_MODEL");
env::remove_var("OLLAMA_API_KEY");
env::remove_var("OLLAMA_BASE_URL");
env::remove_var("HUGGINGFACE_API_KEY");
@@ -1507,9 +1489,6 @@ impl Drop for EnvGuard {
Self::restore_var("TELECOMJS_API_KEY", self.telecomjs_api_key.take());
Self::restore_var("TELECOMJS_BASE_URL", self.telecomjs_base_url.take());
Self::restore_var("TELECOMJS_MODEL", self.telecomjs_model.take());
Self::restore_var("EDENAI_API_KEY", self.edenai_api_key.take());
Self::restore_var("EDENAI_BASE_URL", self.edenai_base_url.take());
Self::restore_var("EDENAI_MODEL", self.edenai_model.take());
Self::restore_var("OPENCODE_GO_API_KEY", self.opencode_go_api_key.take());
Self::restore_var("OPENCODE_GO_BASE_URL", self.opencode_go_base_url.take());
Self::restore_var("OPENCODE_GO_MODEL", self.opencode_go_model.take());
@@ -1625,9 +1604,6 @@ impl Drop for EnvGuard {
Self::restore_var("SGLANG_BASE_URL", self.sglang_base_url.take());
Self::restore_var("VLLM_API_KEY", self.vllm_api_key.take());
Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take());
Self::restore_var("OLLAMA_CLOUD_API_KEY", self.ollama_cloud_api_key.take());
Self::restore_var("OLLAMA_CLOUD_BASE_URL", self.ollama_cloud_base_url.take());
Self::restore_var("OLLAMA_CLOUD_MODEL", self.ollama_cloud_model.take());
Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take());
Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take());
Self::restore_var("HUGGINGFACE_API_KEY", self.huggingface_api_key.take());
@@ -1642,54 +1618,29 @@ impl Drop for EnvGuard {
struct RecordingSecretsStore {
gets: Mutex<Vec<String>>,
sets: Mutex<Vec<String>>,
deletes: Mutex<Vec<String>>,
value: Option<String>,
values: std::collections::HashMap<String, String>,
}
impl RecordingSecretsStore {
fn with_value(value: &str) -> Self {
Self {
gets: Mutex::new(Vec::new()),
sets: Mutex::new(Vec::new()),
deletes: Mutex::new(Vec::new()),
value: Some(value.to_string()),
values: std::collections::HashMap::new(),
}
}
fn with_entries(entries: &[(&str, &str)]) -> Self {
Self {
gets: Mutex::new(Vec::new()),
sets: Mutex::new(Vec::new()),
deletes: Mutex::new(Vec::new()),
value: None,
values: entries
.iter()
.map(|(key, value)| ((*key).to_string(), (*value).to_string()))
.collect(),
}
}
fn empty() -> Self {
Self::with_entries(&[])
}
}
impl codewhale_secrets::KeyringStore for RecordingSecretsStore {
fn get(&self, key: &str) -> Result<Option<String>, codewhale_secrets::SecretsError> {
self.gets.lock().unwrap().push(key.to_string());
Ok(self.values.get(key).cloned().or_else(|| self.value.clone()))
Ok(self.value.clone())
}
fn set(&self, key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
self.sets.lock().unwrap().push(key.to_string());
fn set(&self, _key: &str, _value: &str) -> Result<(), codewhale_secrets::SecretsError> {
Ok(())
}
fn delete(&self, key: &str) -> Result<(), codewhale_secrets::SecretsError> {
self.deletes.lock().unwrap().push(key.to_string());
fn delete(&self, _key: &str) -> Result<(), codewhale_secrets::SecretsError> {
Ok(())
}
@@ -4142,12 +4093,6 @@ fn provider_kind_parses_openrouter_and_novita_aliases() {
ProviderKind::parse("ollama-local"),
Some(ProviderKind::Ollama)
);
for alias in ["ollama-cloud", "ollama_cloud"] {
assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::OllamaCloud));
let parsed: ConfigToml =
toml::from_str(&format!("provider = \"{alias}\"")).expect("ollama cloud alias");
assert_eq!(parsed.provider, ProviderKind::OllamaCloud);
}
assert_eq!(
ProviderKind::parse("wanjie-ark"),
Some(ProviderKind::WanjieArk)
@@ -4765,66 +4710,6 @@ model = "glm-5.2"
assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
}
#[test]
fn edenai_resolves_named_chat_gateway_and_environment_overrides() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
for alias in ["edenai", "eden-ai", "eden_ai"] {
assert_eq!(ProviderKind::parse(alias), Some(ProviderKind::Edenai));
let parsed: ConfigToml =
toml::from_str(&format!("provider = \"{alias}\"")).expect("Eden AI alias");
assert_eq!(parsed.provider, ProviderKind::Edenai);
}
let metadata = provider::resolve_provider("eden-ai").expect("Eden AI metadata");
assert_eq!(metadata.id(), "edenai");
assert_eq!(metadata.display_name(), "Eden AI");
assert_eq!(metadata.provider_config_key(), "edenai");
assert_eq!(metadata.default_base_url(), DEFAULT_EDENAI_BASE_URL);
assert_eq!(metadata.default_model(), DEFAULT_EDENAI_MODEL);
assert_eq!(metadata.env_vars(), &["EDENAI_API_KEY"]);
assert_eq!(
metadata.wire_policy(),
provider::WirePolicy::Fixed(provider::WireFormat::ChatCompletions)
);
let config: ConfigToml = toml::from_str(
r#"
provider = "edenai"
[providers.edenai]
api_key = "eden-config-key"
model = "anthropic/claude-sonnet-4-5"
"#,
)
.expect("Eden AI provider table");
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Edenai);
assert_eq!(resolved.base_url, DEFAULT_EDENAI_BASE_URL);
assert_eq!(resolved.model, "anthropic/claude-sonnet-4-5");
assert_eq!(resolved.api_key.as_deref(), Some("eden-config-key"));
assert_eq!(
resolved.api_key_source,
Some(RuntimeApiKeySource::ConfigFile)
);
unsafe {
std::env::set_var("EDENAI_API_KEY", "eden-env-key");
std::env::set_var("EDENAI_BASE_URL", "https://api.eu.edenai.run/v3");
std::env::set_var("EDENAI_MODEL", "deepseek/deepseek-v4-flash");
}
let env_config = ConfigToml {
provider: ProviderKind::Edenai,
..ConfigToml::default()
};
let resolved = env_config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.base_url, "https://api.eu.edenai.run/v3");
assert_eq!(resolved.model, "deepseek/deepseek-v4-flash");
assert_eq!(resolved.api_key.as_deref(), Some("eden-env-key"));
assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Env));
}
#[test]
fn opencode_zen_configures_model_aware_provider_with_catalog_proof() {
let _lock = env_lock();
@@ -4926,9 +4811,9 @@ fn meta_model_api_scopes_both_documented_key_names_to_official_endpoint() {
fn provider_metadata_registry_covers_every_provider_kind_once() {
let providers = provider::all_providers();
// Full registry keeps legacy dialect/plan kinds for provider_for_kind.
assert_eq!(providers.len(), 47);
assert_eq!(providers.len(), 42);
// Catalog surface is one identity per vendor (no dual-wire / plan rows).
assert_eq!(ProviderKind::ALL.len(), 42);
assert_eq!(ProviderKind::ALL.len(), 37);
assert!(ProviderKind::ALL.len() < providers.len());
let mut ids = std::collections::BTreeSet::new();
@@ -5066,40 +4951,6 @@ fn openrouter_provider_defaults_to_canonical_endpoint_and_model() {
assert_eq!(resolved.model, DEFAULT_OPENROUTER_MODEL);
}
#[test]
fn orcarouter_provider_defaults_to_canonical_endpoint_and_model() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::Orcarouter,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Orcarouter);
assert_eq!(resolved.base_url, DEFAULT_ORCAROUTER_BASE_URL);
assert_eq!(resolved.model, DEFAULT_ORCAROUTER_MODEL);
}
#[test]
fn orcarouter_provider_normalizes_deepseek_aliases() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::Orcarouter,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides {
model: Some("deepseek-v4-flash".to_string()),
..CliRuntimeOverrides::default()
});
assert_eq!(resolved.provider, ProviderKind::Orcarouter);
assert_eq!(resolved.model, DEFAULT_ORCAROUTER_FLASH_MODEL);
}
#[test]
fn xiaomi_mimo_provider_defaults_to_canonical_endpoint_and_model() {
let _lock = env_lock();
@@ -5237,29 +5088,25 @@ fn xiaomi_mimo_aliases_resolve_to_canonical_models() {
#[test]
fn zai_aliases_resolve_to_canonical_models() {
// GLM-5.3 is the default; the glm-5.1 alias must still resolve to 5.1
// GLM-5.2 is the default; the glm-5.1 alias must still resolve to 5.1
// (not to the default), and GLM-5-Turbo resolves to its own id.
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5.1"),
ZAI_GLM_5_1_MODEL
);
assert_eq!(DEFAULT_ZAI_MODEL, "GLM-5.3");
assert_eq!(DEFAULT_ZAI_MODEL, ZAI_GLM_5_3_MODEL);
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
DEFAULT_ZAI_MODEL
);
assert_eq!(DEFAULT_ZAI_MODEL, "GLM-5.2");
// GLM-5.3 is a peer, not the default: its aliases must land on its own id
// and must never fold into DEFAULT_ZAI_MODEL.
for alias in ["glm-5.3", "glm-5-3", "zai-glm-5.3", "zai-glm-5-3"] {
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, alias),
ZAI_GLM_5_3_MODEL,
"{alias} must canonicalize to GLM-5.3"
);
}
// GLM-5.2 is a peer, no longer the default: an explicit 5.2 selection
// must keep its own id and must never fold into DEFAULT_ZAI_MODEL.
for alias in ["glm-5.2", "glm-5-2", "zai-glm-5.2", "zai-glm-5-2"] {
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, alias),
ZAI_GLM_5_2_MODEL,
"{alias} must canonicalize to GLM-5.2"
);
assert_ne!(
normalize_model_for_provider(ProviderKind::Zai, alias),
DEFAULT_ZAI_MODEL,
@@ -5305,10 +5152,10 @@ fn zhipu_aliases_fold_into_zai_provider() {
);
assert_eq!(provider.model.as_deref(), Some("glm-5-2"));
// GLM aliases canonicalize under the Zai umbrella, to their own ids.
// GLM aliases canonicalize under the Zai umbrella.
assert_eq!(
normalize_model_for_provider(ProviderKind::Zai, "glm-5-2"),
ZAI_GLM_5_2_MODEL
DEFAULT_ZAI_MODEL
);
}
@@ -6015,238 +5862,6 @@ fn ollama_provider_defaults_to_local_endpoint_and_small_model() {
assert_eq!(resolved.api_key, None);
}
#[test]
fn ollama_cloud_endpoint_is_official_but_neighboring_routes_are_custom() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
assert!(provider_base_url_is_official(
ProviderKind::Ollama,
DEFAULT_OLLAMA_BASE_URL
));
for base_url in [
provider::OLLAMA_CLOUD_BASE_URL,
"https://ollama.com/v1/",
" HTTPS://OLLAMA.COM/v1/ ",
] {
for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
assert!(provider_base_url_is_official(provider, base_url));
assert!(!provider_preserves_custom_base_url_model(
provider, base_url
));
}
}
for base_url in [
"http://ollama.com/v1",
"https://ollama.com/api",
"https://ollama.com/v1/preview",
"https://ollama.com.evil.example/v1",
"https://ollama-gateway.example/v1",
] {
for provider in [ProviderKind::Ollama, ProviderKind::OllamaCloud] {
assert!(!provider_base_url_is_official(provider, base_url));
assert!(provider_preserves_custom_base_url_model(provider, base_url));
}
}
}
#[test]
fn explicit_ollama_cloud_defaults_to_hosted_route_and_is_not_keyless() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let config = ConfigToml {
provider: ProviderKind::OllamaCloud,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
assert_eq!(resolved.base_url, DEFAULT_OLLAMA_CLOUD_BASE_URL);
assert_eq!(resolved.model, DEFAULT_OLLAMA_CLOUD_MODEL);
assert_eq!(resolved.api_key, None);
}
#[test]
fn ollama_cloud_preserves_provider_authoritative_model_ids() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut configured = ConfigToml {
provider: ProviderKind::OllamaCloud,
..ConfigToml::default()
};
configured.providers.ollama_cloud.model = Some("vendor/model:tag".to_string());
let resolved = configured.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.model, "vendor/model:tag");
let persisted_root = ConfigToml {
provider: ProviderKind::OllamaCloud,
default_text_model: Some("deepseek-v4-flash:0731".to_string()),
..ConfigToml::default()
};
let resolved = persisted_root.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.model, "deepseek-v4-flash:0731");
}
#[test]
fn ollama_cloud_env_prefers_pi_compatible_name_then_official_name() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
unsafe {
env::set_var("DEEPSEEK_PROVIDER", "ollama-cloud");
env::set_var("OLLAMA_CLOUD_API_KEY", "pi-compatible-key");
env::set_var("OLLAMA_API_KEY", "official-fallback-key");
}
let preferred = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(preferred.provider, ProviderKind::OllamaCloud);
assert_eq!(preferred.api_key.as_deref(), Some("pi-compatible-key"));
assert_eq!(preferred.api_key_source, Some(RuntimeApiKeySource::Env));
// Safety: same serialized test restores both values through EnvGuard.
unsafe { env::remove_var("OLLAMA_CLOUD_API_KEY") };
let fallback = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(fallback.api_key.as_deref(), Some("official-fallback-key"));
assert_eq!(fallback.api_key_source, Some(RuntimeApiKeySource::Env));
}
#[test]
fn local_ollama_never_consumes_the_cloud_specific_environment_key() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
unsafe { env::set_var("OLLAMA_CLOUD_API_KEY", "must-not-reach-local-ollama") };
let config = ConfigToml {
provider: ProviderKind::Ollama,
..ConfigToml::default()
};
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Ollama);
assert_eq!(resolved.base_url, DEFAULT_OLLAMA_BASE_URL);
assert_eq!(resolved.api_key, None);
}
#[test]
fn exact_legacy_ollama_cloud_tuple_migrates_in_memory_without_writes() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let store = Arc::new(RecordingSecretsStore::with_entries(&[(
"ollama",
"legacy-cloud-key",
)]));
let secrets = Secrets::new(store.clone());
let mut config = ConfigToml {
provider: ProviderKind::Ollama,
..ConfigToml::default()
};
config.providers.ollama.base_url = Some(provider::OLLAMA_CLOUD_BASE_URL.to_string());
config.providers.ollama.model = Some("legacy-cloud-model".to_string());
let before = toml::to_string(&config).expect("serialize pre-migration config");
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
assert_eq!(resolved.base_url, provider::OLLAMA_CLOUD_BASE_URL);
assert_eq!(resolved.model, "legacy-cloud-model");
assert_eq!(resolved.api_key.as_deref(), Some("legacy-cloud-key"));
assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Keyring));
assert_eq!(
store.gets.lock().unwrap().as_slice(),
["ollama-cloud", "ollama"]
);
assert!(store.sets.lock().unwrap().is_empty());
assert!(store.deletes.lock().unwrap().is_empty());
assert_eq!(
toml::to_string(&config).expect("serialize post-migration config"),
before,
"runtime migration must not rewrite the parsed config"
);
}
#[test]
fn explicit_ollama_cloud_uses_only_its_new_secret_slot() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let store = Arc::new(RecordingSecretsStore::with_entries(&[
("ollama-cloud", "cloud-key"),
("ollama", "must-not-be-consumed"),
]));
let secrets = Secrets::new(store.clone());
let mut config = ConfigToml {
provider: ProviderKind::OllamaCloud,
..ConfigToml::default()
};
config.providers.ollama.base_url = Some(provider::OLLAMA_CLOUD_BASE_URL.to_string());
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.provider, ProviderKind::OllamaCloud);
assert_eq!(resolved.api_key.as_deref(), Some("cloud-key"));
assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama-cloud"]);
assert!(store.sets.lock().unwrap().is_empty());
assert!(store.deletes.lock().unwrap().is_empty());
}
#[test]
fn explicit_ollama_cloud_never_falls_back_to_local_secret_slot() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let store = Arc::new(RecordingSecretsStore::with_entries(&[(
"ollama",
"must-not-be-consumed",
)]));
let secrets = Secrets::new(store.clone());
let config = ConfigToml {
provider: ProviderKind::OllamaCloud,
..ConfigToml::default()
};
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.api_key, None);
assert_eq!(store.gets.lock().unwrap().as_slice(), ["ollama-cloud"]);
}
#[test]
fn neighboring_legacy_ollama_routes_do_not_migrate_or_probe_cloud_secrets() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
for base_url in [
"http://ollama.com/v1",
"https://ollama.com/api",
"https://ollama.com/v1/preview",
"https://ollama.com.evil.example/v1",
"https://ollama-gateway.example/v1",
] {
let store = Arc::new(RecordingSecretsStore::with_entries(&[
("ollama-cloud", "cloud-key"),
("ollama", "legacy-key"),
]));
let secrets = Secrets::new(store.clone());
let mut config = ConfigToml {
provider: ProviderKind::Ollama,
..ConfigToml::default()
};
config.providers.ollama.base_url = Some(base_url.to_string());
let resolved =
config.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
assert_eq!(resolved.provider, ProviderKind::Ollama, "{base_url}");
assert_eq!(resolved.api_key, None, "{base_url}");
assert!(store.gets.lock().unwrap().is_empty(), "{base_url}");
}
}
#[test]
fn self_hosted_providers_do_not_probe_secret_store_by_default() {
let _lock = env_lock();
@@ -6593,7 +6208,7 @@ fn ollama_provider_preserves_model_tags() {
}
#[test]
fn ollama_custom_remote_does_not_inherit_ambient_or_saved_official_key() {
fn ollama_remote_env_url_does_not_inherit_ambient_optional_key() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
// Safety: test-only environment mutation guarded by a module mutex.
@@ -6603,20 +6218,12 @@ fn ollama_custom_remote_does_not_inherit_ambient_or_saved_official_key() {
env::set_var("OLLAMA_API_KEY", "ollama-env-key");
}
let store = Arc::new(RecordingSecretsStore::with_value("ollama-saved-key"));
let secrets = Secrets::new(store.clone());
let resolved = ConfigToml::default()
.resolve_runtime_options_with_secrets(&CliRuntimeOverrides::default(), &secrets);
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert_eq!(resolved.provider, ProviderKind::Ollama);
assert_eq!(resolved.base_url, "http://ollama.example/v1");
assert_eq!(resolved.api_key, None);
assert_eq!(resolved.api_key_source, None);
assert!(
store.gets.lock().unwrap().is_empty(),
"a custom Ollama endpoint must not read the official ollama secret slot"
);
}
#[test]
@@ -7290,7 +6897,10 @@ fn sentinel_config_values_fall_through_without_becoming_runtime_keys() {
assert_eq!(resolved.api_key_source, None);
assert!(custom_store.gets.lock().unwrap().is_empty());
let empty_store = Arc::new(RecordingSecretsStore::empty());
let empty_store = Arc::new(RecordingSecretsStore {
gets: Mutex::new(Vec::new()),
value: None,
});
let empty_secrets = Secrets::new(empty_store);
let mut xiaomi = ConfigToml {
provider: ProviderKind::XiaomiMimo,
@@ -7559,7 +7169,7 @@ fn workflow_config_defaults_match_product_surface() {
assert_eq!(defaults.auto_start_child_limit, 16);
assert_eq!(defaults.max_children, 1000);
assert_eq!(defaults.max_concurrent, 16);
assert_eq!(defaults.max_depth, 5);
assert_eq!(defaults.max_depth, 2);
assert_eq!(defaults.default_token_budget, 120_000);
assert_eq!(defaults.max_parallel_writes_without_worktree, 0);
assert!(defaults.persist_completed_activity);
@@ -7604,7 +7214,7 @@ default_token_budget = 50000
assert!(workflow.require_approval_for_writes);
assert_eq!(workflow.auto_start_child_limit, 16);
assert_eq!(workflow.max_concurrent, 16);
assert_eq!(workflow.max_depth, 5);
assert_eq!(workflow.max_depth, 2);
assert_eq!(workflow.max_parallel_writes_without_worktree, 0);
assert!(workflow.persist_completed_activity);
assert!(workflow.persist_completed_across_restarts);
@@ -7628,11 +7238,6 @@ fn fleet_exec_config_default_matches_subagent_depth() {
const { assert!(DEFAULT_SPAWN_DEPTH <= MAX_SPAWN_DEPTH_CEILING) };
}
#[test]
fn fleet_exec_model_turns_are_unbounded_by_default() {
assert_eq!(FleetExecConfig::default().max_turns, 0);
}
#[test]
fn fleet_exec_config_parses_max_spawn_depth() {
let config: ConfigToml = toml::from_str(
@@ -7675,7 +7280,7 @@ fn fleet_profile_defaults_round_trip_through_config() {
}
#[test]
fn fleet_profile_explicit_config_parses_legacy_permissions_as_ignored_input() {
fn fleet_profile_explicit_config_parses_role_loadout_permissions() {
let config: ConfigToml = toml::from_str(
r#"
[fleet.profiles.verifier]
@@ -7727,8 +7332,6 @@ concurrency = 3
assert!(profile.permissions.approval_required);
assert_eq!(profile.delegation.max_spawn_depth, Some(0));
assert_eq!(profile.delegation.max_concurrency, Some(3));
let serialized = toml::to_string_pretty(&profile).expect("profile serializes");
assert!(!serialized.contains("permissions"));
}
#[test]
@@ -8096,7 +7699,7 @@ max_spawn_depth = 2
}
#[test]
fn named_fleet_parses_legacy_authority_keys_for_migration() {
fn named_fleet_parses_operator_and_trust() {
let config: ConfigToml = toml::from_str(
r#"
[fleets.alice-team]
@@ -8114,7 +7717,7 @@ max_trust_level = "operator"
}
#[test]
fn named_fleet_has_no_authority_defaults_when_legacy_fields_are_absent() {
fn named_fleet_defaults_apply_when_fields_absent() {
let config: ConfigToml = toml::from_str(
r#"
[fleets.minimal]
@@ -8125,9 +7728,9 @@ operator = "bob"
let fleet = config.fleets.get("minimal").expect("minimal fleet");
assert_eq!(fleet.operator, "bob");
assert!(fleet.default_trust_level.is_empty());
assert!(!fleet.require_identity_verification);
assert!(fleet.max_trust_level.is_empty());
assert_eq!(fleet.default_trust_level, "sandbox");
assert!(fleet.require_identity_verification);
assert_eq!(fleet.max_trust_level, "operator");
assert!(fleet.roles.is_empty());
assert!(fleet.profiles.is_empty());
}
@@ -8377,7 +7980,7 @@ fn named_fleet_error_messages_are_actionable() {
}
#[test]
fn named_fleet_view_preserves_legacy_input_without_making_it_policy() {
fn named_fleet_as_fleet_config_view_matches_fields() {
let config: ConfigToml = toml::from_str(
r#"
[fleets.team]
@@ -8401,7 +8004,7 @@ max_turns = 100
}
#[test]
fn named_fleet_serialization_drops_legacy_authority_keys() {
fn named_fleet_serializes_and_round_trips() {
let config: ConfigToml = toml::from_str(
r#"
[fleets.my-fleet]
@@ -8416,10 +8019,9 @@ max_spawn_depth = 1
let fleet = config.fleets.get("my-fleet").expect("my-fleet");
let serialized = toml::to_string_pretty(fleet).expect("serializes");
assert!(!serialized.contains("default_trust_level"));
let round_tripped: NamedFleetConfigToml = toml::from_str(&serialized).expect("round trips");
assert_eq!(round_tripped.operator, "alice");
assert!(round_tripped.default_trust_level.is_empty());
assert_eq!(round_tripped.default_trust_level, "local");
assert_eq!(round_tripped.exec.max_spawn_depth, 1);
}
@@ -8616,114 +8218,6 @@ fn a_run_scoped_off_is_a_kill_switch_and_not_a_revocation() {
assert!(!resolved.telemetry_explicit_off);
}
/// #5441: the resolved consent must name its source, because "telemetry: on"
/// with no provenance hides the one default users most need to see.
#[test]
fn telemetry_consent_names_its_source() {
let guard = TelemetryEnvGuard::take();
// Nobody said anything: on, by default.
let (on, source) = resolved_telemetry_consent(None);
assert!(on);
assert_eq!(source, TelemetrySource::Default);
// The config file owns the answer.
let (on, source) = resolved_telemetry_consent(Some(true));
assert!(on);
assert_eq!(source, TelemetrySource::Config);
// A persisted off is a floor and is named as the decision.
let (on, source) = resolved_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Config);
// An explicit environment "on" loses to the persisted off: re-enabling
// is writing the durable register the off was written in.
guard.set("1");
let (on, source) = resolved_telemetry_consent(Some(false));
assert!(!on);
assert_eq!(source, TelemetrySource::Config);
// An environment kill switch decides and is named.
guard.set("0");
let (on, source) = resolved_telemetry_consent(Some(true));
assert!(!on);
assert_eq!(source, TelemetrySource::Env);
// An unreadable environment value is a kill switch, never "on".
guard.set("yes-please");
let (on, source) = resolved_telemetry_consent(Some(true));
assert!(!on);
assert_eq!(source, TelemetrySource::Env);
// A clean environment "on" with nothing in the file is env-owned.
guard.set("1");
let (on, source) = resolved_telemetry_consent(None);
assert!(on);
assert_eq!(source, TelemetrySource::Env);
}
/// #5441: the runtime receipt carries the same source the surfaces print.
#[test]
fn resolved_runtime_options_reports_telemetry_source() {
let guard = TelemetryEnvGuard::take();
let resolved = ConfigToml::default().resolve_runtime_options(&CliRuntimeOverrides::default());
assert!(resolved.telemetry);
assert_eq!(resolved.telemetry_source, TelemetrySource::Default);
// The CLI flag owns the answer for this run, off or on.
let cli = CliRuntimeOverrides {
telemetry: Some(false),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
assert!(!resolved.telemetry);
assert_eq!(resolved.telemetry_source, TelemetrySource::Cli);
// A kill switch still beats `--telemetry true`, and the source says so.
guard.set("0");
let cli = CliRuntimeOverrides {
telemetry: Some(true),
..CliRuntimeOverrides::default()
};
let resolved = ConfigToml::default().resolve_runtime_options(&cli);
assert!(!resolved.telemetry);
assert_eq!(resolved.telemetry_source, TelemetrySource::Env);
}
/// #5441: `config get telemetry` reports the resolved consent with its
/// source instead of "key not found" on a machine whose batches ship.
#[test]
fn config_display_for_telemetry_reports_resolved_consent_with_source() {
let guard = TelemetryEnvGuard::take();
let config = ConfigToml::default();
assert_eq!(
config.get_display_value("telemetry").as_deref(),
Some("on (default)")
);
let config = ConfigToml {
telemetry: Some(false),
..ConfigToml::default()
};
assert_eq!(
config.get_display_value("telemetry").as_deref(),
Some("off (config)")
);
guard.set("0");
let config = ConfigToml {
telemetry: Some(true),
..ConfigToml::default()
};
assert_eq!(
config.get_display_value("telemetry").as_deref(),
Some("off (env)")
);
}
#[test]
fn the_dispatcher_states_the_floor_rather_than_letting_the_child_infer_it() {
// The child cannot tell an operator's declared kill switch from the
+8 -12
View File
@@ -7,24 +7,20 @@ license.workspace = true
repository.workspace = true
description = "Core runtime boundaries for Codewhale"
[lints]
workspace = true
[dependencies]
anyhow.workspace = true
chrono.workspace = true
serde.workspace = true
thiserror.workspace = true
tokio-util.workspace = true
codewhale-agent = { path = "../agent", version = "0.9.11" }
codewhale-config = { path = "../config", version = "0.9.11" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.11" }
codewhale-hooks = { path = "../hooks", version = "0.9.11" }
codewhale-mcp = { path = "../mcp", version = "0.9.11" }
codewhale-protocol = { path = "../protocol", version = "0.9.11" }
codewhale-state = { path = "../state", version = "0.9.11" }
codewhale-tools = { path = "../tools", version = "0.9.11" }
regex = "1.11"
codewhale-agent = { path = "../agent", version = "0.9.6" }
codewhale-config = { path = "../config", version = "0.9.6" }
codewhale-execpolicy = { path = "../execpolicy", version = "0.9.6" }
codewhale-hooks = { path = "../hooks", version = "0.9.6" }
codewhale-mcp = { path = "../mcp", version = "0.9.6" }
codewhale-protocol = { path = "../protocol", version = "0.9.6" }
codewhale-state = { path = "../state", version = "0.9.6" }
codewhale-tools = { path = "../tools", version = "0.9.6" }
serde_json = { workspace = true, features = ["preserve_order"] }
tokio = { workspace = true, features = ["time"] }
tracing.workspace = true
+306
View File
@@ -0,0 +1,306 @@
//! Core engine (issue #5261).
//!
//! Move, don't rewrite: the turn loop, session, thread manager, the TUI's
//! `run_event_loop`, and the chat client's request-building are all destined
//! for this crate. **Only request-building and fragments have moved so far.**
//! The turn loop still lives in `crates/tui/src/core/engine/turn_loop.rs` and
//! is what every interactive and headless turn runs today; this module is the
//! boundary that move lands against, not the current owner of turn execution.
//! The TUI crate depends on `core`, not the reverse.
//!
//! Approved crates that the engine needs are already in `crates/core`'s
//! Cargo.toml: `config`, `execpolicy`, `protocol`, `state`, `tools`, `mcp`,
//! `hooks`, `agent`. Things that stay in the TUI (`ratatui`, `crossterm`,
//! `prompt_zones` rendering) are not imported here; the engine is
//! terminal-free so it can start a session with no TUI attached.
//!
//! This module is intentionally small on this first cut: it formalizes the
//! `ThreadId`/`SessionId` boundary, the `Op`-in / `EventMsg`-out channels in
//! `crates/protocol`, the `Journal` leaf, and the `Thread`-owned headless
//! `spawn` that TUI and `codewhale exec` both go through. The full turn
//! loop, guards (`StuckGuard`, `ReadRepeatGuard`, `ToolCallBudget`), stream
//! retry budget, and the four-way `RuntimeThreadManager` split live in the
//! `thread/` submodules so follow-ons (#5262, #5263, #5264) have a place to
//! land without another boundary move.
//!
//! Back-compat: persisted `state.json` / `threads` shape is unchanged.
use std::path::PathBuf;
use std::sync::{Arc, Mutex as StdMutex};
use codewhale_protocol::event_msg::EventMsg;
use codewhale_protocol::ids::{SessionId, ThreadId};
use codewhale_protocol::op::{Op, OpEnvelope};
use codewhale_state::StateStore;
use tokio::sync::mpsc;
use crate::ids::ThreadId as CoreThreadId;
use crate::journal::Journal;
use crate::session::{Session, Thread};
pub mod thread;
// ---------------------------------------------------------------------------
// Engine handle — the mailbox every consumer (TUI, CLI exec, app-server,
// tests) holds. Mirrors `crates/tui/src/core/engine/handle.rs` but lives
// in `core` so the mailbox API is reviewable on its own.
/// Reason the active turn was cancelled.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CancelReason {
User,
External,
Preempted,
Internal,
}
/// Handle to communicate with the core engine via the `Op`-in /
/// `EventMsg`-out channels. The TUI's `EngineHandle` and the headless
/// `exec` both hold this type; `handle.steer`, `cancel`, `approve_tool_call`
/// etc are the same code path in both modes so `crates/execpolicy` stays
/// the authority identically.
#[derive(Clone)]
pub struct EngineHandle {
pub tx_op: mpsc::Sender<OpEnvelope>,
pub rx_event: Arc<tokio::sync::RwLock<mpsc::Receiver<EventMsg>>>,
cancel_token: Arc<StdMutex<tokio_util::sync::CancellationToken>>,
}
impl EngineHandle {
pub async fn send(&self, op: OpEnvelope) -> anyhow::Result<()> {
self.tx_op
.send(op)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
pub fn cancel(&self) {
self.cancel_with_reason(CancelReason::User);
}
pub fn cancel_with_reason(&self, _reason: CancelReason) {
if let Ok(token) = self.cancel_token.lock() {
token.cancel();
}
}
pub async fn steer(
&self,
thread_id: ThreadId,
content: impl Into<String>,
) -> anyhow::Result<()> {
let env = OpEnvelope {
op_id: format!("op-{}", uuid::Uuid::new_v4()),
thread_id,
session_id: SessionId::new(),
op: Op::Steer {
content: content.into(),
},
};
self.tx_op
.send(env)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
Ok(())
}
}
// ---------------------------------------------------------------------------
// Engine config — the minimal fields the core engine needs to start a
// session headlessly. Full `EngineConfig` from `crates/tui/src/core/engine.rs`
// is larger (tools, mcp, prompts, etc); those follow in later slices. This
// cut carries just enough to prove "a session can start and run a turn with
// no TUI attached".
#[derive(Debug, Clone)]
pub struct EngineConfig {
pub workspace: PathBuf,
pub model: String,
pub model_provider: String,
pub thread_id: ThreadId,
pub session_id: SessionId,
pub max_steps: u32,
}
impl Default for EngineConfig {
fn default() -> Self {
Self {
workspace: PathBuf::from("."),
model: "deepseek-v4-flash".to_string(),
model_provider: "deepseek".to_string(),
thread_id: ThreadId::new(),
session_id: SessionId::new(),
max_steps: 32,
}
}
}
// ---------------------------------------------------------------------------
// Core engine — spawns in a background tokio task (mirrors
// `crates/tui/src/core/engine.rs` `spawn_engine` / `spawn_supervised`).
pub struct Engine {
rx_op: mpsc::Receiver<OpEnvelope>,
tx_event: mpsc::Sender<EventMsg>,
journal: Journal,
session: Session,
thread: Thread,
}
const ENGINE_OP_CHANNEL_CAPACITY: usize = 32;
const ENGINE_EVENT_CHANNEL_CAPACITY: usize = 128;
impl Engine {
#[must_use]
pub fn new(config: EngineConfig, _state: StateStore) -> (Self, EngineHandle) {
let (tx_op, rx_op) = mpsc::channel(ENGINE_OP_CHANNEL_CAPACITY);
let (tx_event, rx_event) = mpsc::channel(ENGINE_EVENT_CHANNEL_CAPACITY);
let thread = Thread::new(
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
config.workspace.clone(),
config.model.clone(),
);
let session = Session::new(
CoreThreadId::from_string(config.thread_id.as_str().to_string()),
config.workspace.clone(),
config.model.clone(),
);
let handle = EngineHandle {
tx_op,
rx_event: Arc::new(tokio::sync::RwLock::new(rx_event)),
cancel_token: Arc::new(StdMutex::new(tokio_util::sync::CancellationToken::new())),
};
let engine = Self {
rx_op,
tx_event,
journal: Journal::new(),
session,
thread,
};
(engine, handle)
}
/// Run the engine loop. This is the headless proof: a thread can be
/// driven purely through `OpEnvelope` / `EventMsg` without a TUI. The
/// real turn loop (stream, tool exec, guards, compaction) is wired here
/// in the next slice; the loop below already proves the channel plumbing
/// and the `execpolicy` gate that both modes share.
pub async fn run(mut self) {
while let Some(env) = self.rx_op.recv().await {
let _ = self
.tx_event
.send(EventMsg::TurnStarted {
thread_id: env.thread_id.clone(),
session_id: env.session_id.clone(),
turn_id: format!("turn-{}", uuid::Uuid::new_v4()),
})
.await;
match env.op {
Op::SendMessage { content, .. } => {
// Append to journal (the tree) — branching only moves leaf.
self.journal.append("user", serde_json::json!(content));
self.thread.leaf_id = self.journal.leaf_id.clone();
self.session.bump_revision();
let turn_id = format!("turn-{}", uuid::Uuid::new_v4());
let _ = self
.tx_event
.send(EventMsg::TurnComplete {
thread_id: env.thread_id.clone(),
session_id: env.session_id.clone(),
turn_id,
status: "completed".to_string(),
error: None,
})
.await;
}
Op::Steer { content } => {
self.journal.append("user", serde_json::json!(content));
self.thread.leaf_id = self.journal.leaf_id.clone();
}
Op::Shutdown | Op::Cancel => break,
_ => {}
}
}
}
}
/// Spawn the engine in a background task (mirrors `spawn_engine` in the
/// old `crates/tui/src/core/engine.rs`). Returns the handle that TUI,
/// CLI exec, app-server, and tests all share — one `Op`-in / `EventMsg`-out
/// API.
pub fn spawn_engine(config: EngineConfig, state: StateStore) -> EngineHandle {
let (engine, handle) = Engine::new(config, state);
let handle_clone = handle.clone();
tokio::spawn(async move {
engine.run().await;
});
handle_clone
}
/// Spawn with supervision (mirrors `spawn_supervised`).
pub fn spawn_supervised(config: EngineConfig, state: StateStore) -> EngineHandle {
spawn_engine(config, state)
}
// ---------------------------------------------------------------------------
// Headless helper — no TUI is constructed. This currently proves that core
// can own session lifecycle behind the shared `Op` channel; outbound model
// dispatch is a later #5261 slice and is not claimed here.
/// Start a headless session and expose its shared operation channel.
///
/// Callers can enqueue operations and observe `EventMsg`s through the returned
/// handle. Outbound model dispatch is intentionally not claimed by this helper
/// until that part of the engine has moved into core.
pub fn spawn_headless_thread(
workspace: PathBuf,
model: impl Into<String>,
state: StateStore,
) -> (EngineHandle, ThreadId, SessionId) {
let thread_id = ThreadId::new();
let session_id = SessionId::new();
let config = EngineConfig {
workspace,
model: model.into(),
model_provider: "deepseek".to_string(),
thread_id: thread_id.clone(),
session_id: session_id.clone(),
max_steps: 32,
};
let handle = spawn_engine(config, state);
(handle, thread_id, session_id)
}
#[cfg(test)]
mod tests {
use super::*;
use codewhale_state::StateStore;
#[tokio::test]
async fn headless_session_can_be_started_with_no_tui() {
let dir = tempfile::tempdir().unwrap();
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
let (handle, thread_id, _session_id) =
spawn_headless_thread(dir.path().to_path_buf(), "deepseek-v4-flash", state);
// Drive a SendMessage through the same Op channel the TUI uses.
let env = OpEnvelope {
op_id: "op-1".into(),
thread_id: thread_id.clone(),
session_id: SessionId::new(),
op: Op::SendMessage {
content: "hello".into(),
mode: "agent".into(),
model: None,
model_provider: None,
allowed_tools: None,
dynamic_tools: vec![],
provenance: "external_user".into(),
},
};
handle.send(env).await.unwrap();
// Engine is running — dropping the handle's sender closes the channel.
drop(handle);
}
}
+58
View File
@@ -0,0 +1,58 @@
//! Thread events — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
//! (issue #5261 / #3313).
//!
//! The TUI's `runtime_threads.rs` emits `RuntimeEventEnvelope` for the
//! app-server SSE stream and `Event` for the transcript. This module owns
//! that mapping in `core` so the headless `exec` and the TUI render the
//! same envelope for the same turn — byte-identical on the wire.
use codewhale_protocol::event_msg::EventMsg;
use codewhale_protocol::ids::{SessionId, ThreadId};
/// Narrow the `EventMsg` to the envelope shape the app-server expects.
/// The real `RuntimeEventEnvelope` adds `seq` + `timestamp`; this helper
/// stamps them consistently so headless and TUI produce identical sequences.
#[must_use]
pub fn to_envelope_seq(
seq: u64,
thread_id: ThreadId,
_session_id: SessionId,
msg: EventMsg,
) -> codewhale_protocol::runtime::RuntimeEventEnvelope {
codewhale_protocol::runtime::RuntimeEventEnvelope {
schema_version: codewhale_protocol::runtime::RUNTIME_EVENT_ENVELOPE_SCHEMA_VERSION,
seq,
event: msg.kind_str().to_string(),
kind: msg.kind_str().to_string(),
thread_id: thread_id.to_string(),
turn_id: None,
item_id: None,
timestamp: chrono::Utc::now().to_rfc3339(),
created_at: None,
payload: serde_json::to_value(&msg).unwrap_or(serde_json::Value::Null),
extra: Default::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn envelope_preserves_thread_and_kind() {
let tid = ThreadId::new();
let sid = SessionId::new();
let env = to_envelope_seq(
1,
tid.clone(),
sid.clone(),
EventMsg::TurnStarted {
thread_id: tid.clone(),
session_id: sid.clone(),
turn_id: "turn-1".into(),
},
);
assert_eq!(env.thread_id, tid.to_string());
assert_eq!(env.seq, 1);
}
}
+65
View File
@@ -0,0 +1,65 @@
//! Turn executor — the `monitor_turn` / `handle_deepseek_turn` leg
//! (issue #5261 / #3313).
//!
//! This will own `handle_deepseek_turn`, the steer/subagent drains,
//! `refresh_system_prompt()`, `should_compact`/`compact_messages_safe`,
//! `MessageRequest` build, parallel tool exec, `StuckGuard`/
//! `ReadRepeatGuard`/`ToolCallBudget`, and stream retry budget. The move
//! is file-by-file from `crates/tui/src/core/engine/turn_loop.rs`
//! (5,706 lines) so the diff stays reviewable. Until the move lands this
//! file carries the executor type and the `execpolicy` gate that guarantees
//! approvals route through the turn context identically in both modes.
use codewhale_execpolicy::ExecPolicyEngine;
use codewhale_protocol::ids::{SessionId, ThreadId};
/// Per-turn execution context. The `execpolicy` engine is the sole authority
/// for approvals; both TUI and headless construct it from the same
/// `permissions.toml` / `ConfigStore` so the gate never diverges.
#[derive(Debug)]
pub struct TurnExecutor {
pub thread_id: ThreadId,
pub session_id: SessionId,
pub exec_policy: ExecPolicyEngine,
pub max_steps: u32,
}
impl TurnExecutor {
#[must_use]
pub fn new(
thread_id: ThreadId,
session_id: SessionId,
exec_policy: ExecPolicyEngine,
max_steps: u32,
) -> Self {
Self {
thread_id,
session_id,
exec_policy,
max_steps,
}
}
#[must_use]
pub fn can_execute(&self, step: u32) -> bool {
step < self.max_steps
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn executor_respects_max_steps() {
let ex = TurnExecutor::new(
ThreadId::new(),
SessionId::new(),
ExecPolicyEngine::new(vec![], vec![]),
2,
);
assert!(ex.can_execute(0));
assert!(ex.can_execute(1));
assert!(!ex.can_execute(2));
}
}
+26
View File
@@ -0,0 +1,26 @@
//! `RuntimeThreadManager` split per #3313 (issue #5261).
//!
//! The TUI's `crates/tui/src/runtime_threads.rs` (≈8,259 lines, `monitor_turn`
//! ≈1,035 lines) is the largest file in the tree. The split is pure code
//! motion, persisted JSON shape unchanged:
//! - `store` — `RuntimeThreadStore` / persisted JSON state
//! (`<root>/{threads,turns,items,events}` + `state.json`)
//! - `executor` — turn execution (`monitor_turn`, `handle_deepseek_turn`,
//! steer/subagent drains, `refresh_system_prompt`, compaction, parallel
//! tool exec, `StuckGuard`/`ReadRepeatGuard`/`ToolCallBudget`, stream retry)
//! - `events` — `RuntimeEventEnvelope` mapping + `EventMsg` fan-out
//! - `types` — `ThreadId`/`SessionId`, `ThreadStatus`, `Thread` etc
//!
//! This cut lands the four files and the re-exports so `crates/tui` can
//! `pub use codewhale_core::engine::thread::*` and the next slice can `git mv`
//! the impls file-by-file without a flag day. The behaviour stays in the TUI
//! until the move completes; `core` already owns the boundary.
pub mod events;
pub mod executor;
pub mod store;
pub mod types;
pub use events::*;
pub use store::*;
pub use types::*;
+56
View File
@@ -0,0 +1,56 @@
//! `RuntimeThreadStore` — persisted JSON state (issue #5261 / #3313).
//!
//! The store is the `state.json` + `<root>/{threads,turns,items,events}`
//! layout that `crates/state` already owns. This module is the `core`
//! owner for that layout so the TUI's `RuntimeThreadManager` can be split
//! without changing the file shape. The current `ThreadManager` in
//! `crates/core/src/lib.rs` already uses `StateStore`; this file is the
//! next home for that impl once the `git mv` lands. Until then it
//! documents the contract and exposes the typed store handle.
use codewhale_protocol::ids::ThreadId;
use codewhale_state::StateStore;
/// Typed handle over `StateStore` that the executor and events modules share.
/// The methods are thin wrappers so the store boundary is greppable and the
/// persisted shape can be asserted in one place (back-compat tests hold).
#[derive(Debug, Clone)]
pub struct ThreadStore {
inner: StateStore,
root: std::path::PathBuf,
}
impl ThreadStore {
#[must_use]
pub fn new(inner: StateStore, root: std::path::PathBuf) -> Self {
Self { inner, root }
}
#[must_use]
pub fn state(&self) -> &StateStore {
&self.inner
}
#[must_use]
pub fn root(&self) -> &std::path::Path {
&self.root
}
pub fn thread_exists(&self, id: &ThreadId) -> anyhow::Result<bool> {
Ok(self.inner.get_thread(id.as_str())?.is_some())
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn store_wraps_state() {
let dir = tempdir().unwrap();
let state = StateStore::open(Some(dir.path().join("state.db"))).unwrap();
let store = ThreadStore::new(state, dir.path().to_path_buf());
assert!(!store.thread_exists(&ThreadId::new()).unwrap());
}
}
+15
View File
@@ -0,0 +1,15 @@
//! Thread types for the `crates/core` boundary (issue #5261 / #3313).
//!
//! Re-exports the protocol ids plus the thread-status enums that every
//! consumer (TUI, CLI, app-server, tests) needs. The TUI's
//! `runtime_threads.rs` and `core/engine.rs` both import from here after the
//! move so `is_terminal` / `is_active` / `is_paused` is a single `Status`
//! trait, not three copies.
pub use codewhale_protocol::ids::{SessionId, ThreadId};
pub use codewhale_protocol::{Status, ThreadStatus};
/// Back-compat alias: the TUI's `RuntimeThread` is the same shape as the
/// protocol `Thread` now that the ids are typed. Callers that still name
/// `RuntimeThread` get this alias so the rename is mechanical.
pub type RuntimeThread = codewhale_protocol::Thread;

Some files were not shown because too many files have changed in this diff Show More