mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge remote-tracking branch 'origin/main' into codex/v0912-route-contract-phase1-20260828
Signed-off-by: CodeWhale Bot <[email protected]> # Conflicts: # CHANGELOG.md # crates/tui/CHANGELOG.md
This commit is contained in:
commit
c62e627a32
128 files changed
+9856
-354
No files matched your search
@@ -0,0 +1,109 @@
|
||||
name: Codewhale PR Review
|
||||
|
||||
# Advisory AI code review by Codewhale itself (`codewhale review --pr --post`)
|
||||
# on every non-draft PR: one COMMENT review with a summary body plus inline
|
||||
# line comments, anchored to the PR head SHA. CODEOWNERS (@Hmbown) stays the
|
||||
# human owner — this review posts alongside it and never approves.
|
||||
#
|
||||
# Setup:
|
||||
# 1. Provider key: Settings -> Secrets and variables -> Actions ->
|
||||
# New repository secret `DEEPSEEK_API_KEY` (or the env var matching a
|
||||
# different provider, see crates/secrets `env_for`).
|
||||
# 2. Optional model override: repository variable
|
||||
# `CODEWHALE_REVIEW_MODEL` (e.g. `deepseek-chat`).
|
||||
# 3. Optional identity: to post as the Codewhale Agent GitHub App instead
|
||||
# of the workflow's github-token identity, set repository variable
|
||||
# `CODEWHALE_APP_ID` and secret `CODEWHALE_APP_PRIVATE_KEY`; the job
|
||||
# mints an installation token via actions/create-github-app-token.
|
||||
# Until DEEPSEEK_API_KEY exists the job no-ops with a notice (stays green),
|
||||
# so this workflow is safe to merge before it is configured.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
branches: [master, main]
|
||||
|
||||
concurrency:
|
||||
group: codewhale-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
codewhale-review:
|
||||
name: Codewhale review
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HAS_PROVIDER_KEY: ${{ secrets.DEEPSEEK_API_KEY != '' }}
|
||||
HAS_APP_KEY: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY != '' }}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Skip when provider key is unset
|
||||
if: env.HAS_PROVIDER_KEY != 'true'
|
||||
run: echo "::notice::DEEPSEEK_API_KEY is not set — skipping Codewhale review. Add the secret to enable it."
|
||||
|
||||
- name: Checkout repository
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Mint Codewhale Agent app token
|
||||
if: env.HAS_PROVIDER_KEY == 'true' && env.HAS_APP_KEY == 'true' && vars.CODEWHALE_APP_ID != ''
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v2
|
||||
with:
|
||||
app-id: ${{ vars.CODEWHALE_APP_ID }}
|
||||
private-key: ${{ secrets.CODEWHALE_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Install Rust toolchain
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
|
||||
- name: Install native build deps
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
run: |
|
||||
for i in 1 2 3; 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
|
||||
|
||||
- name: Cache cargo build
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
uses: Swatinem/rust-cache@v2
|
||||
|
||||
- name: Build codewhale
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
run: cargo build --release -p codewhale-cli
|
||||
|
||||
- name: Run Codewhale PR review
|
||||
if: env.HAS_PROVIDER_KEY == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
CODEWHALE_REVIEW_MODEL: ${{ vars.CODEWHALE_REVIEW_MODEL }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
MODEL_ARGS=()
|
||||
if [ -n "${CODEWHALE_REVIEW_MODEL:-}" ]; then
|
||||
MODEL_ARGS=(--model "$CODEWHALE_REVIEW_MODEL")
|
||||
fi
|
||||
set +e
|
||||
OUTPUT=$(./target/release/codewhale review --pr "$PR_NUMBER" --post "${MODEL_ARGS[@]}" 2>&1)
|
||||
STATUS=$?
|
||||
set -e
|
||||
echo "$OUTPUT"
|
||||
if [ "$STATUS" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
# The review is advisory: a provider-side outage (balance, auth,
|
||||
# rate limit, upstream 5xx) must not block the PR. Real review
|
||||
# failures still fail the job with the original exit status.
|
||||
if echo "$OUTPUT" | grep -qE 'LLM error: HTTP (401|402|403|408|429|5[0-9][0-9])'; then
|
||||
echo "::warning::Codewhale review could not run (provider error; see log above). The PR is not blocked — provider funding/config is founder-gated."
|
||||
exit 0
|
||||
fi
|
||||
exit "$STATUS"
|
||||
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
|
||||
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
|
||||
Confirmation is required; missing credentials fail closed; cloud jobs share
|
||||
the existing `/jobs` surface as `kind=cloud`. See
|
||||
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
|
||||
- `/login` reports the Codewhale account session and provider-key next
|
||||
steps. The internal cloud-agent credential is not user surface: there is
|
||||
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
|
||||
entry for it — membership (`codewhale login`) is the only door.
|
||||
- Route Contract Phase 1: `RouteResolver` is the runtime path for
|
||||
`resolve_runtime_options`; `codewhale providers export --json` ships the
|
||||
owned descriptor catalog; CLI `--provider` accepts any catalog route id
|
||||
@@ -43,6 +52,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
actions, while Extensions and Problems route recovery through the existing
|
||||
`/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands
|
||||
(#5643, #5655).
|
||||
- Plugin prompt matching (#5579): sending a task can toast the next review
|
||||
step when the prompt strongly matches an installed-but-idle plugin or a
|
||||
marketplace catalog you added (for example a Supabase prompt suggesting
|
||||
`/plugin trust supabase`). A live composer CTA offers the same review
|
||||
command while you type, matching turns append a bounded
|
||||
`<recommended_plugins>` user block, and `request_plugin_install` surfaces
|
||||
review without installing. `/plugin suggest` now ranks manifest keywords and
|
||||
catalog candidates, never auto-installs, and on-disk plugin changes also
|
||||
nudge `/plugin reload` between turns.
|
||||
|
||||
|
||||
- Added `/import-claude` (#5557): reads `~/.claude.json` and
|
||||
`~/.claude/settings.json` read-only and renders an explicit, reviewable
|
||||
@@ -160,6 +179,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Background shells are first-class work-strip rows (`▾ Shells N`) you can
|
||||
open, watch, and cancel by the `shell_*` id on the row. `/jobs cancel all`
|
||||
cancels running shells; it no longer looks up a task named `all`. The
|
||||
composer hourglass crumb no longer stands in for a shell surface.
|
||||
- `codewhale logout` and `/logout` now clear the Codewhale account session
|
||||
and the Daytona secret slot, not only provider API keys. The TUI crate's
|
||||
leftover `login --api-key` path no longer claims to save a key.
|
||||
- Account sessions no longer read the macOS Keychain. Unsigned or rebuilt
|
||||
`codewhale` binaries were a new Keychain ACL principal every time, so
|
||||
`codewhale web` and the TUI popped a password dialog on start. Sessions
|
||||
|
||||
Generated
-12
@@ -878,7 +878,6 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.20",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
"uuid",
|
||||
]
|
||||
@@ -1084,7 +1083,6 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"qrcode",
|
||||
"ratatui",
|
||||
"ratatui-core",
|
||||
"regex",
|
||||
"reqwest 0.13.4",
|
||||
"rio-vt",
|
||||
@@ -2236,12 +2234,6 @@ dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.15.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.16.1"
|
||||
@@ -5707,13 +5699,9 @@ checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-sink",
|
||||
"futures-util",
|
||||
"hashbrown 0.15.5",
|
||||
"libc",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ tower-http = { version = "0.7", features = ["cors"] }
|
||||
tracing = "0.1"
|
||||
tracing-appender = "0.2"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
|
||||
tokio-util = { version = "0.7.16", features = ["io", "full"] }
|
||||
uuid = { version = "1.11", features = ["v4"] }
|
||||
mimalloc = { version = "0.1", default-features = false }
|
||||
|
||||
|
||||
@@ -605,7 +605,8 @@ fn run_with<T: CloudTransport, W: Write>(
|
||||
let _ =
|
||||
client.poll_device(&device, Duration::from_secs(login.timeout_seconds), sleeper)?;
|
||||
let user = client.me()?;
|
||||
write_account(out, "Signed in to Codewhale.", profile, api_base, &user)
|
||||
write_account(out, "Signed in to Codewhale.", profile, api_base, &user)?;
|
||||
Ok(())
|
||||
}
|
||||
CloudCommand::Status => match client.load_auth()? {
|
||||
Some(_) => {
|
||||
|
||||
@@ -323,6 +323,10 @@ fn device_flow_handles_pending_then_authorized_without_printing_tokens() {
|
||||
assert!(output.contains("ABCD-EFGH-JKLM"));
|
||||
assert!(output.contains("Account ID: acct-123"));
|
||||
assert!(output.contains("Profile: work"));
|
||||
// No-brand invariant: login signs in the account; the internal
|
||||
// cloud-agent credential is never taught here.
|
||||
assert!(!output.to_lowercase().contains("daytona"), "{output}");
|
||||
assert!(!output.contains("set-slot"), "{output}");
|
||||
assert!(!output.contains("access-never-print"));
|
||||
assert!(!output.contains("refresh-never-print"));
|
||||
assert_eq!(opened.len(), 1);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
//! First-class Daytona cloud-agent offload: `codewhale dispatch`.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use clap::{Args, ValueEnum};
|
||||
use codewhale_tui::cloud_dispatch::{
|
||||
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
|
||||
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
|
||||
format_status, plan_dispatch,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
enum ForgeArg {
|
||||
Github,
|
||||
Cnb,
|
||||
Gitee,
|
||||
}
|
||||
|
||||
impl From<ForgeArg> for Forge {
|
||||
fn from(value: ForgeArg) -> Self {
|
||||
match value {
|
||||
ForgeArg::Github => Forge::Github,
|
||||
ForgeArg::Cnb => Forge::Cnb,
|
||||
ForgeArg::Gitee => Forge::Gitee,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub(crate) struct DispatchArgs {
|
||||
/// Task for the remote agent. Required unless listing or inspecting a job.
|
||||
#[arg(value_name = "PROMPT")]
|
||||
prompt: Vec<String>,
|
||||
/// Forge that should receive the branch and PR: github, cnb, or gitee.
|
||||
#[arg(long, value_enum)]
|
||||
remote: Option<ForgeArg>,
|
||||
/// Branch the remote agent will raise (default: codewhale/cloud-<unix>).
|
||||
#[arg(long)]
|
||||
branch: Option<String>,
|
||||
/// Required to create Daytona spend or push. Without this, only a proposal is written.
|
||||
#[arg(long)]
|
||||
confirm: bool,
|
||||
/// Show remotes and whether Daytona credentials are present (never prints secrets).
|
||||
#[arg(long)]
|
||||
status: bool,
|
||||
/// List first-class cloud jobs (same kind shown by `/jobs`).
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
/// Inspect one cloud job.
|
||||
#[arg(long, value_name = "ID")]
|
||||
show: Option<String>,
|
||||
/// Cancel one cloud job.
|
||||
#[arg(long, value_name = "ID")]
|
||||
cancel: Option<String>,
|
||||
/// Workspace whose git remotes are classified (default: current directory).
|
||||
#[arg(long)]
|
||||
cwd: Option<PathBuf>,
|
||||
}
|
||||
|
||||
pub(crate) fn run(args: DispatchArgs) -> Result<()> {
|
||||
let mut out = io::stdout().lock();
|
||||
run_with(args, &mut out)
|
||||
}
|
||||
|
||||
fn run_with<W: Write>(args: DispatchArgs, out: &mut W) -> Result<()> {
|
||||
if [
|
||||
args.status,
|
||||
args.list,
|
||||
args.show.is_some(),
|
||||
args.cancel.is_some(),
|
||||
!args.prompt.is_empty(),
|
||||
]
|
||||
.iter()
|
||||
.filter(|flag| **flag)
|
||||
.count()
|
||||
> 1
|
||||
{
|
||||
bail!("Use one of: a prompt, --status, --list, --show <id>, or --cancel <id>.");
|
||||
}
|
||||
|
||||
let workspace = args
|
||||
.cwd
|
||||
.clone()
|
||||
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
|
||||
let remotes = discover_remotes(&workspace);
|
||||
let credentials = discover_credentials();
|
||||
let store = CloudJobStore::from_env()?;
|
||||
|
||||
if args.status
|
||||
|| (args.prompt.is_empty() && args.show.is_none() && args.cancel.is_none() && !args.list)
|
||||
{
|
||||
writeln!(out, "{}", format_status(&remotes, &credentials))?;
|
||||
return Ok(());
|
||||
}
|
||||
if args.list {
|
||||
writeln!(out, "{}", format_job_list(&store.list()?))?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(id) = args.show.as_deref() {
|
||||
writeln!(out, "{}", format_job(&store.load(id)?))?;
|
||||
return Ok(());
|
||||
}
|
||||
if let Some(id) = args.cancel.as_deref() {
|
||||
writeln!(out, "{}", format_job(&cancel_job(&store, id)?))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let prompt = args.prompt.join(" ");
|
||||
if prompt.starts_with("cloud_") && args.confirm && prompt.split_whitespace().count() == 1 {
|
||||
return write_outcome(
|
||||
out,
|
||||
confirm_job(&store, prompt.trim(), &credentials, &LiveDaytonaLauncher)?,
|
||||
);
|
||||
}
|
||||
|
||||
let plan = plan_dispatch(
|
||||
&remotes,
|
||||
&prompt,
|
||||
args.remote.map(Forge::from),
|
||||
args.branch.as_deref(),
|
||||
)?;
|
||||
let outcome = execute_dispatch(
|
||||
&store,
|
||||
plan,
|
||||
args.confirm,
|
||||
&credentials,
|
||||
&LiveDaytonaLauncher,
|
||||
)?;
|
||||
write_outcome(out, outcome)
|
||||
}
|
||||
|
||||
fn write_outcome<W: Write>(out: &mut W, outcome: DispatchOutcome) -> Result<()> {
|
||||
match outcome {
|
||||
DispatchOutcome::Proposal(job) | DispatchOutcome::Accepted(job) => {
|
||||
writeln!(out, "{}", format_job(&job))?;
|
||||
Ok(())
|
||||
}
|
||||
DispatchOutcome::Refused(job) => {
|
||||
writeln!(out, "{}", format_job(&job))?;
|
||||
bail!("{}", job.note);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{Cli, Commands};
|
||||
use clap::Parser;
|
||||
|
||||
fn args(argv: &[&str]) -> DispatchArgs {
|
||||
let cli = Cli::try_parse_from(argv).unwrap();
|
||||
let Some(Commands::Dispatch(args)) = cli.command else {
|
||||
panic!("expected dispatch command");
|
||||
};
|
||||
args
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_the_obvious_dispatch_command() {
|
||||
let parsed = args(&[
|
||||
"codewhale",
|
||||
"dispatch",
|
||||
"fix",
|
||||
"the",
|
||||
"flake",
|
||||
"--remote",
|
||||
"github",
|
||||
]);
|
||||
assert_eq!(parsed.prompt, ["fix", "the", "flake"]);
|
||||
assert_eq!(parsed.remote, Some(ForgeArg::Github));
|
||||
assert!(!parsed.confirm);
|
||||
assert!(args(&["codewhale", "cloud-agent", "--status"]).status);
|
||||
assert!(Cli::try_parse_from(["codewhale", "dispatch", "--remote", "gitlab"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_is_fail_closed_and_never_prints_secrets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut output = Vec::new();
|
||||
run_with(
|
||||
DispatchArgs {
|
||||
prompt: Vec::new(),
|
||||
remote: None,
|
||||
branch: None,
|
||||
confirm: false,
|
||||
status: true,
|
||||
list: false,
|
||||
show: None,
|
||||
cancel: None,
|
||||
cwd: Some(temp.path().to_path_buf()),
|
||||
},
|
||||
&mut output,
|
||||
)
|
||||
.unwrap();
|
||||
let text = String::from_utf8(output).unwrap();
|
||||
assert!(text.contains("Codewhale cloud dispatch"));
|
||||
assert!(!text.contains("Daytona"));
|
||||
assert!(!text.contains("sk-"));
|
||||
assert!(!text.contains("Bearer"));
|
||||
}
|
||||
}
|
||||
+214
-8
@@ -3,6 +3,7 @@
|
||||
mod cloud;
|
||||
mod config_bundles;
|
||||
mod credential_handoff;
|
||||
mod dispatch;
|
||||
mod metrics;
|
||||
#[cfg(not(target_env = "ohos"))]
|
||||
mod update;
|
||||
@@ -272,6 +273,9 @@ New integrations should prefer `codewhale app-server`.")]
|
||||
/// Sign in to your Codewhale account and manage account-scoped provider keys.
|
||||
#[command(visible_alias = "cloud")]
|
||||
Account(cloud::CloudArgs),
|
||||
/// Offload a coding agent to Daytona. Never spends or pushes without --confirm.
|
||||
#[command(visible_alias = "cloud-agent")]
|
||||
Dispatch(dispatch::DispatchArgs),
|
||||
/// Run MCP server mode over stdio.
|
||||
McpServer,
|
||||
/// Read/write/list config values.
|
||||
@@ -1934,7 +1938,7 @@ fn run() -> Result<()> {
|
||||
&store,
|
||||
)
|
||||
}
|
||||
Some(Commands::Logout) => run_logout_command(&mut store),
|
||||
Some(Commands::Logout) => run_logout_command(&mut store, cli.profile.as_deref()),
|
||||
Some(Commands::Auth(args)) => match args.command {
|
||||
AuthCommand::XaiDevice => {
|
||||
let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides);
|
||||
@@ -1971,6 +1975,7 @@ fn run() -> Result<()> {
|
||||
cloud::reject_inline_api_key(cli.api_key.as_deref())?;
|
||||
cloud::run(args, cli.profile.as_deref(), &store)
|
||||
}
|
||||
Some(Commands::Dispatch(args)) => dispatch::run(args),
|
||||
Some(Commands::McpServer) => {
|
||||
// `codewhale serve --mcp` delegates to the TUI and arms there, so
|
||||
// without this the same user action reported differently depending
|
||||
@@ -2256,19 +2261,24 @@ fn reject_legacy_login_provider_args(args: &LoginArgs) -> Result<()> {
|
||||
)
|
||||
}
|
||||
|
||||
fn run_logout_command(store: &mut ConfigStore) -> Result<()> {
|
||||
run_logout_command_with_secrets(store, &Secrets::auto_detect())
|
||||
fn run_logout_command(store: &mut ConfigStore, profile: Option<&str>) -> Result<()> {
|
||||
run_logout_command_with_secrets(store, &Secrets::auto_detect(), profile)
|
||||
}
|
||||
|
||||
fn run_logout_command_with_secrets(store: &mut ConfigStore, secrets: &Secrets) -> Result<()> {
|
||||
fn run_logout_command_with_secrets(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
profile: Option<&str>,
|
||||
) -> Result<()> {
|
||||
codewhale_config::with_xai_oauth_revocation_transaction(|| {
|
||||
run_logout_command_with_secrets_unlocked(store, secrets)
|
||||
run_logout_command_with_secrets_unlocked(store, secrets, profile)
|
||||
})
|
||||
}
|
||||
|
||||
fn run_logout_command_with_secrets_unlocked(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
profile: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let original_config = store.config.clone();
|
||||
store.config.api_key = None;
|
||||
@@ -2288,7 +2298,16 @@ fn run_logout_command_with_secrets_unlocked(
|
||||
store.config = original_config;
|
||||
return Err(error);
|
||||
}
|
||||
let keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
|
||||
let mut keyring_failures = clear_all_provider_api_keys_from_keyring(secrets);
|
||||
if let Err(error) = clear_daytona_slot(secrets) {
|
||||
keyring_failures.push(format!(
|
||||
"{}: {error}",
|
||||
codewhale_secrets::DAYTONA_TOKEN_SLOT
|
||||
));
|
||||
}
|
||||
if let Err(error) = clear_account_session(profile) {
|
||||
keyring_failures.push(format!("account session: {error}"));
|
||||
}
|
||||
if keyring_failures.is_empty() {
|
||||
println!("logged out");
|
||||
} else {
|
||||
@@ -2301,6 +2320,32 @@ fn run_logout_command_with_secrets_unlocked(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_daytona_slot(secrets: &Secrets) -> Result<(), codewhale_secrets::SecretsError> {
|
||||
if secrets
|
||||
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)?
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
secrets.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn clear_account_session(profile: Option<&str>) -> Result<(), String> {
|
||||
use codewhale_secrets::account::{
|
||||
ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
|
||||
secure_account_session_secrets,
|
||||
};
|
||||
let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
|
||||
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
|
||||
AccountSessionStore::new(secrets, profile, &api_base)
|
||||
.clear()
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
/// Map [`ProviderKind`] to the canonical provider credential slot.
|
||||
fn provider_slot(provider: ProviderKind) -> &'static str {
|
||||
// Shared-account families (SiliconFlow China, the four Model Studio
|
||||
@@ -3078,6 +3123,8 @@ fn auth_status_all_providers_with_runtime(
|
||||
) -> Vec<String> {
|
||||
let active_provider = store.config.provider;
|
||||
let mut lines = Vec::new();
|
||||
lines.push(account_status_line());
|
||||
lines.push(String::new());
|
||||
lines.push(format!(
|
||||
"active provider: {} (set via config or CODEWHALE_PROVIDER)",
|
||||
active_provider.as_str()
|
||||
@@ -3164,9 +3211,42 @@ fn auth_status_all_providers_with_runtime(
|
||||
lines.push(String::new());
|
||||
lines.push("* = active provider (from config or CODEWHALE_PROVIDER)".to_string());
|
||||
lines.push("Run `codewhale auth status --provider <id>` for detailed info.".to_string());
|
||||
lines.push("Account sign-in is `codewhale login`.".to_string());
|
||||
lines
|
||||
}
|
||||
|
||||
fn account_status_line() -> String {
|
||||
use codewhale_secrets::account::{
|
||||
ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
|
||||
secure_account_session_secrets,
|
||||
};
|
||||
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
|
||||
match secure_account_session_secrets() {
|
||||
Ok(secrets) => {
|
||||
match AccountSessionStore::new(secrets, None, &api_base)
|
||||
.runtime_info_at(chrono::Utc::now())
|
||||
{
|
||||
Ok(info) => {
|
||||
let state = match info.state {
|
||||
AccountSessionState::SignedOut => "not signed in",
|
||||
AccountSessionState::Authenticated => "signed in",
|
||||
AccountSessionState::OfflineCached => "offline (cached)",
|
||||
AccountSessionState::Expired => "expired",
|
||||
AccountSessionState::Revoked => "revoked",
|
||||
};
|
||||
format!("account: {state} (api {api_base})")
|
||||
}
|
||||
Err(error) => format!("account: unavailable ({error})"),
|
||||
}
|
||||
}
|
||||
Err(error) => format!("account: unavailable ({error})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostic_path_state(path: &Path, directory: bool) -> &'static str {
|
||||
match std::fs::symlink_metadata(path) {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => "present (symlink; not followed)",
|
||||
@@ -6748,6 +6828,29 @@ verbosity = "project-imported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_parses_daytona_slot_commands_as_unknown() {
|
||||
// The internal cloud-agent slot must not be a user command: parsing
|
||||
// rejects it and `auth --help` never teaches it.
|
||||
for argv in [
|
||||
vec![
|
||||
"codewhale",
|
||||
"auth",
|
||||
"set-slot",
|
||||
"daytona",
|
||||
"--api-key-stdin",
|
||||
],
|
||||
vec!["codewhale", "auth", "clear-slot", "daytona"],
|
||||
] {
|
||||
let error = Cli::try_parse_from(argv).expect_err("slot commands must not parse");
|
||||
assert_eq!(error.kind(), ErrorKind::InvalidSubcommand, "{error}");
|
||||
}
|
||||
let help = help_for(&["codewhale", "auth", "--help"]);
|
||||
assert!(!help.contains("set-slot"), "{help}");
|
||||
assert!(!help.contains("clear-slot"), "{help}");
|
||||
assert!(!help.to_lowercase().contains("daytona"), "{help}");
|
||||
}
|
||||
|
||||
/// #5198: `auth set` shares the login resolver — provider auth markers go
|
||||
/// user-global even when the ambient config is workspace-scoped.
|
||||
#[test]
|
||||
@@ -7515,6 +7618,13 @@ verbosity = "project-imported"
|
||||
|
||||
let output = auth_status_all_providers(&store, &secrets).join("\n");
|
||||
|
||||
assert!(output.contains("account:"), "{output}");
|
||||
assert!(output.contains("codewhale login"), "{output}");
|
||||
// No-brand invariant: the internal cloud-agent slot is not user
|
||||
// surface, so status never names it or teaches a set-slot command.
|
||||
assert!(!output.to_lowercase().contains("daytona"), "{output}");
|
||||
assert!(!output.contains("set-slot"), "{output}");
|
||||
|
||||
// Should list all known providers
|
||||
assert!(output.contains("deepseek"));
|
||||
assert!(output.contains("arcee"));
|
||||
@@ -8544,7 +8654,7 @@ verbosity = "project-imported"
|
||||
|
||||
let secrets = no_keyring_secrets();
|
||||
|
||||
run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
|
||||
run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
|
||||
|
||||
assert!(store.config.api_key.is_none());
|
||||
assert!(store.config.providers.deepseek.api_key.is_none());
|
||||
@@ -8590,7 +8700,7 @@ verbosity = "project-imported"
|
||||
.set(provider_slot(ProviderKind::Fireworks), "fw-stale")
|
||||
.expect("seed fireworks key");
|
||||
|
||||
run_logout_command_with_secrets(&mut store, &secrets).expect("logout should succeed");
|
||||
run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
|
||||
|
||||
for provider in [ProviderKind::Deepseek, ProviderKind::Fireworks] {
|
||||
assert!(
|
||||
@@ -8602,6 +8712,102 @@ verbosity = "project-imported"
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logout_clears_account_session_and_daytona_slot() {
|
||||
use codewhale_secrets::account::{
|
||||
AccountAuthBundle, AccountSession, AccountSessionStore, AccountUser,
|
||||
DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets,
|
||||
};
|
||||
|
||||
let _lock = env_lock();
|
||||
let dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let home = dir
|
||||
.path()
|
||||
.canonicalize()
|
||||
.expect("canonical temp root")
|
||||
.join("codewhale-home");
|
||||
let _home = ScopedEnvVar::set("CODEWHALE_HOME", &home.to_string_lossy());
|
||||
let path = home.join("config.toml");
|
||||
let mut store = ConfigStore::load(Some(path.clone())).expect("store should load");
|
||||
|
||||
let secrets = no_keyring_secrets();
|
||||
secrets
|
||||
.set(codewhale_secrets::DAYTONA_TOKEN_SLOT, "dtn_logout")
|
||||
.expect("seed daytona token");
|
||||
|
||||
let account = secure_account_session_secrets().expect("account store");
|
||||
AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
|
||||
.save(AccountAuthBundle {
|
||||
token_type: "Bearer".to_string(),
|
||||
access_token: "access-logout".to_string(),
|
||||
refresh_token: "refresh-logout".to_string(),
|
||||
session: Some(AccountSession {
|
||||
id: "session-logout".to_string(),
|
||||
..AccountSession::default()
|
||||
}),
|
||||
user: Some(AccountUser {
|
||||
id: "acct-logout".to_string(),
|
||||
..AccountUser::default()
|
||||
}),
|
||||
})
|
||||
.expect("seed account session");
|
||||
|
||||
run_logout_command_with_secrets(&mut store, &secrets, None).expect("logout should succeed");
|
||||
|
||||
assert!(
|
||||
secrets
|
||||
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
|
||||
.expect("read daytona")
|
||||
.is_none(),
|
||||
"daytona slot survived logout"
|
||||
);
|
||||
let account = secure_account_session_secrets().expect("account store after logout");
|
||||
assert!(
|
||||
AccountSessionStore::new(account, None, DEFAULT_ACCOUNT_API_BASE)
|
||||
.load()
|
||||
.expect("load account")
|
||||
.is_none(),
|
||||
"account session survived logout"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_set_slot_daytona_has_no_user_surface() {
|
||||
// The internal cloud-agent credential is managed by Codewhale, not
|
||||
// users: no CLI command may write or clear it, and no help text may
|
||||
// teach it. Membership (`codewhale login`) is the only door.
|
||||
use codewhale_secrets::InMemoryKeyringStore;
|
||||
use std::sync::Arc;
|
||||
|
||||
for argv in [
|
||||
vec![
|
||||
"codewhale",
|
||||
"auth",
|
||||
"set-slot",
|
||||
"daytona",
|
||||
"--api-key",
|
||||
"dtn_saved",
|
||||
],
|
||||
vec!["codewhale", "auth", "clear-slot", "daytona"],
|
||||
] {
|
||||
assert!(
|
||||
Cli::try_parse_from(argv).is_err(),
|
||||
"slot commands must not parse"
|
||||
);
|
||||
}
|
||||
|
||||
let inner = Arc::new(InMemoryKeyringStore::new());
|
||||
let secrets = Secrets::new(inner);
|
||||
assert!(
|
||||
secrets
|
||||
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
|
||||
.expect("read slot")
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_migrate_moves_plaintext_keys_into_keyring_and_strips_file() {
|
||||
use codewhale_secrets::{InMemoryKeyringStore, KeyringStore};
|
||||
|
||||
@@ -15,7 +15,6 @@ 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" }
|
||||
|
||||
@@ -27,6 +27,15 @@ use thiserror::Error;
|
||||
/// with credentials saved before the CodeWhale rename. macOS users can verify
|
||||
/// entries with `security find-generic-password -s deepseek -a <provider>`.
|
||||
pub const DEFAULT_SERVICE: &str = "deepseek";
|
||||
/// Secret-store slot consumed by Daytona cloud dispatch (`codewhale dispatch`).
|
||||
///
|
||||
/// Login writes here; dispatch looks this slot up after `DAYTONA_API_KEY` and
|
||||
/// `CWC_DAYTONA_TOKEN`. Do not invent a second Daytona credential name.
|
||||
pub const DAYTONA_TOKEN_SLOT: &str = "daytona";
|
||||
/// First-class Daytona process env that dispatch also accepts.
|
||||
pub const DAYTONA_API_KEY_ENV: &str = "DAYTONA_API_KEY";
|
||||
/// CWC alias that Daytona dispatch also accepts.
|
||||
pub const CWC_DAYTONA_TOKEN_ENV: &str = "CWC_DAYTONA_TOKEN";
|
||||
/// Select the secret storage backend. Supported values are `file` (default)
|
||||
/// and `system`/`keyring` for the OS credential store.
|
||||
pub const SECRET_BACKEND_ENV: &str = "CODEWHALE_SECRET_BACKEND";
|
||||
@@ -1216,6 +1225,7 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
&["TELECOMJS_API_KEY"]
|
||||
}
|
||||
"edenai" | "eden-ai" | "eden_ai" => &["EDENAI_API_KEY"],
|
||||
"daytona" => &[DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV],
|
||||
// One Alibaba Cloud Model Studio account authenticates every plan /
|
||||
// dialect variant; all four names share one env convention.
|
||||
"modelstudio-token-plan"
|
||||
@@ -1242,6 +1252,31 @@ pub fn env_for(name: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Report whether a Daytona token is present without revealing it.
|
||||
///
|
||||
/// Order matches dispatch: secret-store slot `daytona`, then
|
||||
/// [`DAYTONA_API_KEY_ENV`], then [`CWC_DAYTONA_TOKEN_ENV`].
|
||||
#[must_use]
|
||||
pub fn daytona_credential_source(secrets: &Secrets) -> Option<&'static str> {
|
||||
if secrets
|
||||
.get(DAYTONA_TOKEN_SLOT)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Some("secret-store");
|
||||
}
|
||||
for var in [DAYTONA_API_KEY_ENV, CWC_DAYTONA_TOKEN_ENV] {
|
||||
if std::env::var(var)
|
||||
.ok()
|
||||
.is_some_and(|value| !value.trim().is_empty())
|
||||
{
|
||||
return Some("env");
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1295,6 +1330,8 @@ mod tests {
|
||||
"EDENAI_API_KEY",
|
||||
"MODELSTUDIO_API_KEY",
|
||||
"DASHSCOPE_API_KEY",
|
||||
DAYTONA_API_KEY_ENV,
|
||||
CWC_DAYTONA_TOKEN_ENV,
|
||||
SECRET_BACKEND_ENV,
|
||||
LEGACY_SECRET_BACKEND_ENV,
|
||||
] {
|
||||
@@ -2408,6 +2445,23 @@ mod tests {
|
||||
assert_eq!(secrets.get("deepseek").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daytona_slot_resolves_secret_store_then_dispatch_envs() {
|
||||
let _lock = env_lock();
|
||||
clear_known_envs();
|
||||
let secrets = Secrets::new(std::sync::Arc::new(InMemoryKeyringStore::new()));
|
||||
assert_eq!(daytona_credential_source(&secrets), None);
|
||||
assert_eq!(DAYTONA_TOKEN_SLOT, "daytona");
|
||||
|
||||
secrets.set(DAYTONA_TOKEN_SLOT, "dtn_store").unwrap();
|
||||
assert_eq!(daytona_credential_source(&secrets), Some("secret-store"));
|
||||
secrets.delete(DAYTONA_TOKEN_SLOT).unwrap();
|
||||
|
||||
let _key = EnvVarGuard::set(DAYTONA_API_KEY_ENV, "dtn_env");
|
||||
assert_eq!(daytona_credential_source(&secrets), Some("env"));
|
||||
assert_eq!(env_for("daytona").as_deref(), Some("dtn_env"));
|
||||
}
|
||||
|
||||
#[path = "diagnostic_tests.rs"]
|
||||
mod diagnostic_tests;
|
||||
}
|
||||
@@ -9,6 +9,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Added
|
||||
|
||||
- Add `codewhale dispatch` / `/dispatch` so a local session can propose a
|
||||
Codewhale cloud agent against an explicit `github`, `cnb`, or `gitee` remote.
|
||||
Confirmation is required; missing credentials fail closed; cloud jobs share
|
||||
the existing `/jobs` surface as `kind=cloud`. See
|
||||
[DAYTONA_CLOUD_DISPATCH.md](docs/DAYTONA_CLOUD_DISPATCH.md).
|
||||
- `/login` reports the Codewhale account session and provider-key next
|
||||
steps. The internal cloud-agent credential is not user surface: there is
|
||||
no `auth set-slot`/`auth clear-slot` command, no hint, and no completion
|
||||
entry for it — membership (`codewhale login`) is the only door.
|
||||
- Route Contract Phase 1: `RouteResolver` is the runtime path for
|
||||
`resolve_runtime_options`; `codewhale providers export --json` ships the
|
||||
owned descriptor catalog; CLI `--provider` accepts any catalog route id
|
||||
@@ -43,6 +52,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
actions, while Extensions and Problems route recovery through the existing
|
||||
`/mcp login`, `/mcp reload`, `/mcp validate`, and `/plugin validate` commands
|
||||
(#5643, #5655).
|
||||
- Plugin prompt matching (#5579): sending a task can toast the next review
|
||||
step when the prompt strongly matches an installed-but-idle plugin or a
|
||||
marketplace catalog you added (for example a Supabase prompt suggesting
|
||||
`/plugin trust supabase`). A live composer CTA offers the same review
|
||||
command while you type, matching turns append a bounded
|
||||
`<recommended_plugins>` user block, and `request_plugin_install` surfaces
|
||||
review without installing. `/plugin suggest` now ranks manifest keywords and
|
||||
catalog candidates, never auto-installs, and on-disk plugin changes also
|
||||
nudge `/plugin reload` between turns.
|
||||
|
||||
|
||||
- Added `/import-claude` (#5557): reads `~/.claude.json` and
|
||||
`~/.claude/settings.json` read-only and renders an explicit, reviewable
|
||||
@@ -160,6 +179,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
### Fixed
|
||||
|
||||
- Background shells are first-class work-strip rows (`▾ Shells N`) you can
|
||||
open, watch, and cancel by the `shell_*` id on the row. `/jobs cancel all`
|
||||
cancels running shells; it no longer looks up a task named `all`. The
|
||||
composer hourglass crumb no longer stands in for a shell surface.
|
||||
- `codewhale logout` and `/logout` now clear the Codewhale account session
|
||||
and the Daytona secret slot, not only provider API keys. The TUI crate's
|
||||
leftover `login --api-key` path no longer claims to save a key.
|
||||
- Account sessions no longer read the macOS Keychain. Unsigned or rebuilt
|
||||
`codewhale` binaries were a new Keychain ACL principal every time, so
|
||||
`codewhale web` and the TUI popped a password dialog on start. Sessions
|
||||
|
||||
@@ -59,13 +59,11 @@ fd-lock = "4.0.4"
|
||||
dirs.workspace = true
|
||||
futures-util = "0.3.31"
|
||||
oauth2 = "5"
|
||||
# ratatui-core >= 0.1.1 queries cursor position inside `Terminal::clear()`
|
||||
# and races our input loop at startup (ratatui/ratatui#2483/#2640).
|
||||
# ColorCompatBackend answers `get_cursor_position()` from tracked state
|
||||
# (#2640's recommended workaround), so the pin is no longer needed and lru
|
||||
# can resolve to >= 0.18.2 (RUSTSEC-2026-0253).
|
||||
# No ratatui-core pin: ColorCompatBackend answers `get_cursor_position()`
|
||||
# from tracked state (ratatui#2640's recommended workaround), so the CPR query
|
||||
# inside `Terminal::clear()` no longer races the input loop. See
|
||||
# `tui/color_compat.rs`.
|
||||
ratatui = { version = "=0.30.2", features = ["unstable-rendered-line-info"] }
|
||||
ratatui-core = "0.1"
|
||||
regex = "1.11"
|
||||
reqwest = { workspace = true, features = ["blocking", "stream", "form", "http2"] }
|
||||
rusqlite.workspace = true
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Activa o desactiva els diagnòstics LSP",
|
||||
"CmdShareDescription": "Exporta la sessió actual com a URL web compartible",
|
||||
"CmdJobsDescription": "Inspecciona i controla els treballs Bash en segon pla",
|
||||
"CmdDispatchDescription": "Envia un agent cloud de Codewhale per crear una branca i obrir un PR",
|
||||
"CmdLinksDescription": "Mostra enllaços de Codewhale, la comunitat i els proveïdors",
|
||||
"CmdLoadDescription": "Carrega una sessió des d'un fitxer",
|
||||
"CmdLogoutDescription": "Esborra la clau d'API i torna a la configuració",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Eines de plugin executables heretades ({count}) a {dir}:",
|
||||
"CmdPluginBundleNotFound": "No s'ha trobat el paquet de plugins '{name}'",
|
||||
"CmdPluginBundleReloaded": "S'han recarregat {count} paquets de plugins per a {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Això sembla feina de {name}. Revisa-ho amb /plugin trust {name} abans d’activar-ho.",
|
||||
"PluginPromptSuggestEnable": "Això sembla feina de {name}. Activa-ho amb /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Això sembla feina de {name}. Instal·la-ho del catàleg `{catalog}` amb /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Instal·lar el connector {name}?",
|
||||
"PluginCtaReview": "Revisa",
|
||||
"PluginCtaDismiss": "Descarta",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersió: {version}\nFont: {origin} ({scope})\nEstat: {state}\nConfiança: {trust}\nComponents: {inventory}\nPermisos sol·licitats: {permissions}\nServidors MCP: {mcp}\nNo compatible/inactiu: {unsupported}\nHash de contingut: {content_hash}\nHash de capacitats: {capability_hash}\nRuta: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Diagnòstics de plugins ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Paquet de plugins '{name}': {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Canvia de tema o obre el selector de temes",
|
||||
"CmdProviderDescription": "Canvia el proveïdor i/o model actiu",
|
||||
"CmdAuthDescription": "Gestiona els fluxos d'autenticació dels proveïdors",
|
||||
"CmdLoginDescription": "Inicia sessió a Codewhale o desa claus de proveïdor",
|
||||
"CmdQueueDescription": "Mostra o edita els missatges en cua",
|
||||
"CmdQueueUsage": "Ús: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "S'està editant el missatge en cua:",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "LSP-Diagnosen ein- oder ausschalten",
|
||||
"CmdShareDescription": "Aktuelle Sitzung als teilbare Web-URL exportieren",
|
||||
"CmdJobsDescription": "Bash-Hintergrundjobs ansehen und steuern",
|
||||
"CmdDispatchDescription": "Codewhale-Cloud-Agenten senden, um einen Branch und PR zu öffnen",
|
||||
"CmdLinksDescription": "Codewhale-, Community- und Provider-Links anzeigen",
|
||||
"CmdLoadDescription": "Sitzung aus Datei laden",
|
||||
"CmdLogoutDescription": "API-Schlüssel löschen und zum Setup zurückkehren",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Ausführbare Legacy-Plugin-Tools ({count}) in {dir}:",
|
||||
"CmdPluginBundleNotFound": "Plugin-Bundle '{name}' nicht gefunden",
|
||||
"CmdPluginBundleReloaded": "{count} Plugin-Bundles für {workspace} neu geladen.",
|
||||
"PluginPromptSuggestTrust": "Das sieht nach {name}-Arbeit aus. Vor dem Aktivieren mit /plugin trust {name} prüfen.",
|
||||
"PluginPromptSuggestEnable": "Das sieht nach {name}-Arbeit aus. Mit /plugin enable {name} aktivieren.",
|
||||
"PluginPromptSuggestMarketplace": "Das sieht nach {name}-Arbeit aus. Aus Katalog `{catalog}` installieren: /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "{name}-Plugin installieren?",
|
||||
"PluginCtaReview": "Prüfen",
|
||||
"PluginCtaDismiss": "Verwerfen",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersion: {version}\nQuelle: {origin} ({scope})\nStatus: {state}\nVertrauen: {trust}\nKomponenten: {inventory}\nAngeforderte Berechtigungen: {permissions}\nMCP-Server: {mcp}\nNicht unterstützt/inaktiv: {unsupported}\nInhalts-Hash: {content_hash}\nFähigkeits-Hash: {capability_hash}\nPfad: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Plugin-Diagnose ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Plugin-Bundle '{name}': {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Theme wechseln oder Theme-Auswahl öffnen",
|
||||
"CmdProviderDescription": "Aktiven Provider und/oder Modell wechseln",
|
||||
"CmdAuthDescription": "Provider-Authentifizierung verwalten",
|
||||
"CmdLoginDescription": "Bei Codewhale anmelden oder Provider-Schlüssel speichern",
|
||||
"CmdQueueDescription": "Nachrichten in der Warteschlange ansehen oder bearbeiten",
|
||||
"CmdQueueUsage": "Verwendung: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Nachricht aus der Warteschlange bearbeiten:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Toggle LSP diagnostics on or off",
|
||||
"CmdShareDescription": "Export current session as a shareable web URL",
|
||||
"CmdJobsDescription": "Inspect and control background Bash jobs",
|
||||
"CmdDispatchDescription": "Send a Codewhale cloud agent to raise a branch and open a PR",
|
||||
"CmdLinksDescription": "Show Codewhale, community, and provider links",
|
||||
"CmdLoadDescription": "Load session from file",
|
||||
"CmdLogoutDescription": "Clear API key and return to setup",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "Legacy executable plugin tools ({count}) in {dir}:",
|
||||
"CmdPluginBundleNotFound": "Plugin bundle '{name}' not found",
|
||||
"CmdPluginBundleReloaded": "Reloaded {count} plugin bundles for {workspace}.",
|
||||
"PluginPromptSuggestTrust": "This looks like {name} work. Review it with /plugin trust {name} before enabling.",
|
||||
"PluginPromptSuggestEnable": "This looks like {name} work. Enable it with /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "This looks like {name} work. Install it from catalog `{catalog}` with /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Install {name} plugin?",
|
||||
"PluginCtaReview": "Review",
|
||||
"PluginCtaDismiss": "Dismiss",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersion: {version}\nSource: {origin} ({scope})\nState: {state}\nTrust: {trust}\nComponents: {inventory}\nRequested permissions: {permissions}\nMCP servers: {mcp}\nUnsupported/inactive: {unsupported}\nContent hash: {content_hash}\nCapability hash: {capability_hash}\nPath: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Plugin diagnostics ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Plugin bundle '{name}': {action}.",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "Switch theme or open the theme picker",
|
||||
"CmdProviderDescription": "Switch the active provider and/or model",
|
||||
"CmdAuthDescription": "Manage provider authentication flows",
|
||||
"CmdLoginDescription": "Sign in to Codewhale or store provider keys",
|
||||
"CmdQueueDescription": "View or edit queued messages",
|
||||
"CmdQueueUsage": "Usage: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Editing queued message:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP encendido o apagado",
|
||||
"CmdShareDescription": "Exportar la sesión actual como una URL web compartible",
|
||||
"CmdJobsDescription": "Inspeccionar y controlar trabajos de shell en segundo plano",
|
||||
"CmdDispatchDescription": "Enviar un agente cloud de Codewhale para crear una rama y abrir un PR",
|
||||
"CmdLinksDescription": "Mostrar enlaces de Codewhale, la comunidad y los proveedores",
|
||||
"CmdLoadDescription": "Cargar la sesión desde un archivo",
|
||||
"CmdLogoutDescription": "Limpiar la clave de API y volver a la configuración",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "Herramientas ejecutables heredadas de plugins ({count}) en {dir}:",
|
||||
"CmdPluginBundleNotFound": "No se encontró el paquete de plugin '{name}'",
|
||||
"CmdPluginBundleReloaded": "Se recargaron {count} paquetes de plugins para {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Esto parece trabajo de {name}. Revísalo con /plugin trust {name} antes de habilitarlo.",
|
||||
"PluginPromptSuggestEnable": "Esto parece trabajo de {name}. Habilítalo con /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Esto parece trabajo de {name}. Instálalo del catálogo `{catalog}` con /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "¿Instalar el plugin {name}?",
|
||||
"PluginCtaReview": "Revisar",
|
||||
"PluginCtaDismiss": "Descartar",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersión: {version}\nFuente: {origin} ({scope})\nEstado: {state}\nConfianza: {trust}\nComponentes: {inventory}\nPermisos solicitados: {permissions}\nServidores MCP: {mcp}\nNo compatible/inactivo: {unsupported}\nHash de contenido: {content_hash}\nHash de capacidades: {capability_hash}\nRuta: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Diagnósticos de plugins ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Paquete de plugin '{name}': {action}.",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "Alternar entre tema claro y oscuro",
|
||||
"CmdProviderDescription": "Cambiar o mostrar el backend LLM activo (deepseek | nvidia-nim | ollama)",
|
||||
"CmdAuthDescription": "Gestionar flujos de autenticación de proveedores",
|
||||
"CmdLoginDescription": "Iniciar sesión en Codewhale o guardar claves de proveedor",
|
||||
"CmdQueueDescription": "Ver o editar mensajes en cola",
|
||||
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Editando mensaje en cola:",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Activer ou désactiver les diagnostics LSP",
|
||||
"CmdShareDescription": "Exporter la session en cours en URL web partageable",
|
||||
"CmdJobsDescription": "Inspecter et contrôler les jobs Bash en arrière-plan",
|
||||
"CmdDispatchDescription": "Envoyer un agent cloud Codewhale pour créer une branche et ouvrir une PR",
|
||||
"CmdLinksDescription": "Afficher les liens Codewhale, communauté et fournisseurs",
|
||||
"CmdLoadDescription": "Charger une session depuis un fichier",
|
||||
"CmdLogoutDescription": "Effacer la clé API et revenir à la configuration",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Outils de plugins exécutables hérités ({count}) dans {dir} :",
|
||||
"CmdPluginBundleNotFound": "Bundle de plugins '{name}' introuvable",
|
||||
"CmdPluginBundleReloaded": "{count} bundles de plugins rechargés pour {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Cela ressemble à un travail {name}. Vérifiez-le avec /plugin trust {name} avant de l’activer.",
|
||||
"PluginPromptSuggestEnable": "Cela ressemble à un travail {name}. Activez-le avec /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Cela ressemble à un travail {name}. Installez-le depuis le catalogue `{catalog}` avec /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Installer le plugin {name} ?",
|
||||
"PluginCtaReview": "Examiner",
|
||||
"PluginCtaDismiss": "Ignorer",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID : {id}\nVersion : {version}\nSource : {origin} ({scope})\nÉtat : {state}\nConfiance : {trust}\nComposants : {inventory}\nPermissions demandées : {permissions}\nServeurs MCP : {mcp}\nNon pris en charge/inactif : {unsupported}\nHash du contenu : {content_hash}\nHash des capacités : {capability_hash}\nChemin : {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Diagnostics des plugins ({count}) :",
|
||||
"CmdPluginBundleMutationSuccess": "Bundle de plugins '{name}' : {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Changer de thème ou ouvrir le sélecteur de thème",
|
||||
"CmdProviderDescription": "Changer le fournisseur et/ou le modèle actif",
|
||||
"CmdAuthDescription": "Gérer les flux d'authentification des fournisseurs",
|
||||
"CmdLoginDescription": "Se connecter à Codewhale ou enregistrer des clés fournisseur",
|
||||
"CmdQueueDescription": "Voir ou modifier les messages en file",
|
||||
"CmdQueueUsage": "Usage : /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Modification du message en file :",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "LSP डायग्नोस्टिक्स चालू/बंद करें",
|
||||
"CmdShareDescription": "वर्तमान सत्र को साझा करने योग्य वेब URL के रूप में एक्सपोर्ट करें",
|
||||
"CmdJobsDescription": "बैकग्राउंड Bash जॉब देखें और नियंत्रित करें",
|
||||
"CmdDispatchDescription": "शाखा बनाने और PR खोलने के लिए Codewhale क्लाउड एजेंट भेजें",
|
||||
"CmdLinksDescription": "Codewhale, समुदाय और प्रोवाइडर लिंक दिखाएँ",
|
||||
"CmdLoadDescription": "फ़ाइल से सत्र लोड करें",
|
||||
"CmdLogoutDescription": "API कुंजी साफ़ करें और सेटअप पर लौटें",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "{dir} में लीगेसी एक्ज़िक्यूटेबल प्लगिन टूल ({count}):",
|
||||
"CmdPluginBundleNotFound": "प्लगिन बंडल '{name}' नहीं मिला",
|
||||
"CmdPluginBundleReloaded": "{workspace} के लिए {count} प्लगिन बंडल पुनः लोड किए।",
|
||||
"PluginPromptSuggestTrust": "यह {name} वाला काम लगता है। सक्षम करने से पहले /plugin trust {name} से समीक्षा करें।",
|
||||
"PluginPromptSuggestEnable": "यह {name} वाला काम लगता है। /plugin enable {name} से सक्षम करें।",
|
||||
"PluginPromptSuggestMarketplace": "यह {name} वाला काम लगता है। कैटलॉग `{catalog}` से /plugin marketplace install {catalog} {name} से इंस्टॉल करें।",
|
||||
"PluginCtaInstallPrompt": "{name} प्लगइन इंस्टॉल करें?",
|
||||
"PluginCtaReview": "समीक्षा",
|
||||
"PluginCtaDismiss": "बंद करें",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nसंस्करण: {version}\nस्रोत: {origin} ({scope})\nस्थिति: {state}\nट्रस्ट: {trust}\nघटक: {inventory}\nअनुरोधित अनुमतियाँ: {permissions}\nMCP सर्वर: {mcp}\nअसमर्थित/निष्क्रिय: {unsupported}\nकंटेंट हैश: {content_hash}\nक्षमता हैश: {capability_hash}\nपथ: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "प्लगिन डायग्नोस्टिक्स ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "प्लगिन बंडल '{name}': {action}।",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "थीम बदलें या थीम चयनकर्ता खोलें",
|
||||
"CmdProviderDescription": "सक्रिय प्रोवाइडर और/या मॉडल बदलें",
|
||||
"CmdAuthDescription": "प्रोवाइडर प्रमाणीकरण प्रवाह प्रबंधित करें",
|
||||
"CmdLoginDescription": "Codewhale में साइन इन करें या प्रदाता कुंजी सहेजें",
|
||||
"CmdQueueDescription": "कतारबद्ध संदेश देखें या संपादित करें",
|
||||
"CmdQueueUsage": "उपयोग: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "कतारबद्ध संदेश संपादित हो रहा है:",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Aktifkan atau nonaktifkan diagnostik LSP",
|
||||
"CmdShareDescription": "Ekspor sesi saat ini sebagai URL web yang dapat dibagikan",
|
||||
"CmdJobsDescription": "Periksa dan kendalikan job Bash latar belakang",
|
||||
"CmdDispatchDescription": "Kirim agen cloud Codewhale untuk membuat branch dan membuka PR",
|
||||
"CmdLinksDescription": "Tampilkan tautan Codewhale, komunitas, dan penyedia",
|
||||
"CmdLoadDescription": "Muat sesi dari file",
|
||||
"CmdLogoutDescription": "Hapus kunci API dan kembali ke penyiapan",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Alat plugin eksekusi lama ({count}) di {dir}:",
|
||||
"CmdPluginBundleNotFound": "Bundel plugin '{name}' tidak ditemukan",
|
||||
"CmdPluginBundleReloaded": "Memuat ulang {count} bundel plugin untuk {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Ini tampak seperti pekerjaan {name}. Tinjau dengan /plugin trust {name} sebelum mengaktifkan.",
|
||||
"PluginPromptSuggestEnable": "Ini tampak seperti pekerjaan {name}. Aktifkan dengan /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Ini tampak seperti pekerjaan {name}. Pasang dari katalog `{catalog}` dengan /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Pasang plugin {name}?",
|
||||
"PluginCtaReview": "Tinjau",
|
||||
"PluginCtaDismiss": "Tutup",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersi: {version}\nSumber: {origin} ({scope})\nStatus: {state}\nKepercayaan: {trust}\nKomponen: {inventory}\nIzin yang diminta: {permissions}\nServer MCP: {mcp}\nTidak didukung/nonaktif: {unsupported}\nHash konten: {content_hash}\nHash kapabilitas: {capability_hash}\nJalur: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Diagnostik plugin ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Bundel plugin '{name}': {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Ganti tema atau buka pemilih tema",
|
||||
"CmdProviderDescription": "Ganti penyedia dan/atau model aktif",
|
||||
"CmdAuthDescription": "Kelola alur autentikasi penyedia",
|
||||
"CmdLoginDescription": "Masuk ke Codewhale atau simpan kunci penyedia",
|
||||
"CmdQueueDescription": "Lihat atau edit pesan dalam antrean",
|
||||
"CmdQueueUsage": "Penggunaan: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Mengedit pesan dalam antrean:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "LSP 診断のオン・オフを切り替え",
|
||||
"CmdShareDescription": "現在のセッションを共有可能な Web URL としてエクスポート",
|
||||
"CmdJobsDescription": "バックグラウンドのシェルジョブを確認・制御",
|
||||
"CmdDispatchDescription": "Codewhale クラウドエージェントを送り、ブランチと PR を作成",
|
||||
"CmdLinksDescription": "Codewhale、コミュニティ、プロバイダーのリンクを表示",
|
||||
"CmdLoadDescription": "ファイルからセッションを読み込み",
|
||||
"CmdLogoutDescription": "API キーを消去してセットアップに戻る",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "従来の実行可能プラグインツール ({count}) — {dir}:",
|
||||
"CmdPluginBundleNotFound": "プラグインバンドル '{name}' が見つかりません",
|
||||
"CmdPluginBundleReloaded": "{workspace} のプラグインバンドル {count} 件を再読み込みしました。",
|
||||
"PluginPromptSuggestTrust": "これは {name} 向けの作業に見えます。有効化の前に /plugin trust {name} で確認してください。",
|
||||
"PluginPromptSuggestEnable": "これは {name} 向けの作業に見えます。/plugin enable {name} で有効化できます。",
|
||||
"PluginPromptSuggestMarketplace": "これは {name} 向けの作業に見えます。カタログ `{catalog}` から /plugin marketplace install {catalog} {name} でインストールできます。",
|
||||
"PluginCtaInstallPrompt": "{name} プラグインをインストールしますか?",
|
||||
"PluginCtaReview": "確認",
|
||||
"PluginCtaDismiss": "閉じる",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nバージョン: {version}\n出所: {origin} ({scope})\n状態: {state}\n信頼: {trust}\nコンポーネント: {inventory}\n要求権限: {permissions}\nMCP サーバー: {mcp}\n未対応/無効: {unsupported}\nコンテンツハッシュ: {content_hash}\n権限ハッシュ: {capability_hash}\nパス: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "プラグイン診断 ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "プラグインバンドル '{name}': {action}。",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "テーマを切り替え(ダーク/ライト/グレースケール/システム)",
|
||||
"CmdProviderDescription": "現在の LLM バックエンドを切り替え・確認(deepseek | nvidia-nim | ollama)",
|
||||
"CmdAuthDescription": "プロバイダー認証フローを管理",
|
||||
"CmdLoginDescription": "Codewhaleにサインインするか、プロバイダーキーを保存",
|
||||
"CmdQueueDescription": "キューされたメッセージを確認・編集",
|
||||
"CmdQueueUsage": "使用方法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "キューされたメッセージを編集中:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "LSP 진단을 켜거나 끕니다",
|
||||
"CmdShareDescription": "현재 세션을 공유 가능한 웹 URL로 내보냅니다",
|
||||
"CmdJobsDescription": "백그라운드 Bash 작업을 확인하고 제어합니다",
|
||||
"CmdDispatchDescription": "Codewhale 클라우드 에이전트를 보내 브랜치를 만들고 PR을 엽니다",
|
||||
"CmdLinksDescription": "Codewhale, 커뮤니티 및 프로바이더 링크를 표시합니다",
|
||||
"CmdLoadDescription": "파일에서 세션을 불러옵니다",
|
||||
"CmdLogoutDescription": "API 키를 지우고 설정 화면으로 돌아갑니다",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "{dir}의 기존 실행형 플러그인 도구 ({count}개):",
|
||||
"CmdPluginBundleNotFound": "플러그인 번들 '{name}'을(를) 찾을 수 없습니다",
|
||||
"CmdPluginBundleReloaded": "{workspace}의 플러그인 번들 {count}개를 다시 불러왔습니다.",
|
||||
"PluginPromptSuggestTrust": "이 작업은 {name}와(과) 관련이 있어 보입니다. 활성화하기 전에 /plugin trust {name}으로 검토하세요.",
|
||||
"PluginPromptSuggestEnable": "이 작업은 {name}와(과) 관련이 있어 보입니다. /plugin enable {name}으로 활성화하세요.",
|
||||
"PluginPromptSuggestMarketplace": "이 작업은 {name}와(과) 관련이 있어 보입니다. 카탈로그 `{catalog}`에서 /plugin marketplace install {catalog} {name}으로 설치하세요.",
|
||||
"PluginCtaInstallPrompt": "{name} 플러그인을 설치할까요?",
|
||||
"PluginCtaReview": "검토",
|
||||
"PluginCtaDismiss": "닫기",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\n버전: {version}\n출처: {origin} ({scope})\n상태: {state}\n신뢰: {trust}\n구성 요소: {inventory}\n요청 권한: {permissions}\nMCP 서버: {mcp}\n미지원/비활성: {unsupported}\n콘텐츠 해시: {content_hash}\n기능 해시: {capability_hash}\n경로: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "플러그인 진단 ({count}개):",
|
||||
"CmdPluginBundleMutationSuccess": "플러그인 번들 '{name}': {action}.",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "테마를 전환하거나 테마 선택 화면을 엽니다",
|
||||
"CmdProviderDescription": "현재 프로바이더 및/또는 모델을 전환합니다",
|
||||
"CmdAuthDescription": "프로바이더 인증 흐름을 관리합니다",
|
||||
"CmdLoginDescription": "Codewhale에 로그인하거나 프로바이더 키를 저장합니다",
|
||||
"CmdQueueDescription": "대기 중인 메시지를 확인하거나 편집합니다",
|
||||
"CmdQueueUsage": "사용법: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "대기 중인 메시지 편집 중:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Alternar diagnóstico LSP ligado ou desligado",
|
||||
"CmdShareDescription": "Exportar a sessão atual como uma URL web compartilhável",
|
||||
"CmdJobsDescription": "Inspecionar e controlar jobs de shell em segundo plano",
|
||||
"CmdDispatchDescription": "Enviar um agente cloud da Codewhale para criar um branch e abrir um PR",
|
||||
"CmdLinksDescription": "Exibir links do Codewhale, da comunidade e dos provedores",
|
||||
"CmdLoadDescription": "Carregar a sessão de um arquivo",
|
||||
"CmdLogoutDescription": "Limpar a chave de API e voltar à configuração",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "Ferramentas executáveis legadas de plugins ({count}) em {dir}:",
|
||||
"CmdPluginBundleNotFound": "Pacote de plugin '{name}' não encontrado",
|
||||
"CmdPluginBundleReloaded": "Foram recarregados {count} pacotes de plugins para {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Isso parece um trabalho de {name}. Revise com /plugin trust {name} antes de ativar.",
|
||||
"PluginPromptSuggestEnable": "Isso parece um trabalho de {name}. Ative com /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Isso parece um trabalho de {name}. Instale do catálogo `{catalog}` com /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Instalar o plugin {name}?",
|
||||
"PluginCtaReview": "Revisar",
|
||||
"PluginCtaDismiss": "Dispensar",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nVersão: {version}\nOrigem: {origin} ({scope})\nEstado: {state}\nConfiança: {trust}\nComponentes: {inventory}\nPermissões solicitadas: {permissions}\nServidores MCP: {mcp}\nNão suportado/inativo: {unsupported}\nHash do conteúdo: {content_hash}\nHash de capacidades: {capability_hash}\nCaminho: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Diagnósticos de plugins ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Pacote de plugin '{name}': {action}.",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "Alternar tema: escuro, claro, tons de cinza ou sistema",
|
||||
"CmdProviderDescription": "Trocar ou exibir o backend LLM ativo (deepseek | nvidia-nim | ollama)",
|
||||
"CmdAuthDescription": "Gerenciar fluxos de autenticação de provedores",
|
||||
"CmdLoginDescription": "Entrar no Codewhale ou salvar chaves de provedor",
|
||||
"CmdQueueDescription": "Ver ou editar mensagens enfileiradas",
|
||||
"CmdQueueUsage": "Uso: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Editando mensagem enfileirada:",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Включить или выключить диагностику LSP",
|
||||
"CmdShareDescription": "Экспортировать текущую сессию как веб-ссылку для общего доступа",
|
||||
"CmdJobsDescription": "Просмотр и управление фоновыми заданиями Bash",
|
||||
"CmdDispatchDescription": "Отправить облачного агента Codewhale, чтобы создать ветку и открыть PR",
|
||||
"CmdLinksDescription": "Показать ссылки на Codewhale, сообщество и провайдеров",
|
||||
"CmdLoadDescription": "Загрузить сессию из файла",
|
||||
"CmdLogoutDescription": "Удалить API-ключ и вернуться к настройке",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Устаревшие исполняемые плагины ({count}) в {dir}:",
|
||||
"CmdPluginBundleNotFound": "Пакет плагина '{name}' не найден",
|
||||
"CmdPluginBundleReloaded": "Перезагружено пакетов плагинов для {workspace}: {count}.",
|
||||
"PluginPromptSuggestTrust": "Похоже на работу с {name}. Перед включением проверьте: /plugin trust {name}",
|
||||
"PluginPromptSuggestEnable": "Похоже на работу с {name}. Включите: /plugin enable {name}",
|
||||
"PluginPromptSuggestMarketplace": "Похоже на работу с {name}. Установите из каталога `{catalog}`: /plugin marketplace install {catalog} {name}",
|
||||
"PluginCtaInstallPrompt": "Установить плагин {name}?",
|
||||
"PluginCtaReview": "Проверить",
|
||||
"PluginCtaDismiss": "Скрыть",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nВерсия: {version}\nИсточник: {origin} ({scope})\nСостояние: {state}\nДоверие: {trust}\nКомпоненты: {inventory}\nЗапрошенные разрешения: {permissions}\nСерверы MCP: {mcp}\nНеподдерживаемые/неактивные: {unsupported}\nХэш содержимого: {content_hash}\nХэш возможностей: {capability_hash}\nПуть: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Диагностика плагинов ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Пакет плагина '{name}': {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Сменить тему или открыть выбор темы",
|
||||
"CmdProviderDescription": "Сменить активного провайдера и/или модель",
|
||||
"CmdAuthDescription": "Управление способами аутентификации провайдеров",
|
||||
"CmdLoginDescription": "Войти в Codewhale или сохранить ключи провайдера",
|
||||
"CmdQueueDescription": "Просмотр и редактирование сообщений в очереди",
|
||||
"CmdQueueUsage": "Использование: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Редактирование сообщения из очереди:",
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"CmdLspDescription": "Увімкнути або вимкнути діагностику LSP",
|
||||
"CmdShareDescription": "Експортувати поточний сеанс як веб-URL для поширення",
|
||||
"CmdJobsDescription": "Переглянути й керувати фоновими завданнями Bash",
|
||||
"CmdDispatchDescription": "Надіслати хмарного агента Codewhale, щоб створити гілку й відкрити PR",
|
||||
"CmdLinksDescription": "Показати посилання Codewhale, спільноти та провайдерів",
|
||||
"CmdLoadDescription": "Завантажити сеанс з файлу",
|
||||
"CmdLogoutDescription": "Очистити ключ API та повернутися до налаштування",
|
||||
@@ -451,6 +452,12 @@
|
||||
"CmdPluginLegacyListHeader": "Застарілі виконувані інструменти-плагіни ({count}) в {dir}:",
|
||||
"CmdPluginBundleNotFound": "Набір плагінів '{name}' не знайдено",
|
||||
"CmdPluginBundleReloaded": "Перезавантажено {count} наборів плагінів для {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Схоже на роботу з {name}. Перед увімкненням перевірте: /plugin trust {name}",
|
||||
"PluginPromptSuggestEnable": "Схоже на роботу з {name}. Увімкніть: /plugin enable {name}",
|
||||
"PluginPromptSuggestMarketplace": "Схоже на роботу з {name}. Встановіть із каталогу `{catalog}`: /plugin marketplace install {catalog} {name}",
|
||||
"PluginCtaInstallPrompt": "Встановити плагін {name}?",
|
||||
"PluginCtaReview": "Перевірити",
|
||||
"PluginCtaDismiss": "Сховати",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nВерсія: {version}\nДжерело: {origin} ({scope})\nСтан: {state}\nДовіра: {trust}\nКомпоненти: {inventory}\nЗапитувані дозволи: {permissions}\nСервери MCP: {mcp}\nНепідтримувані/неактивні: {unsupported}\nХеш вмісту: {content_hash}\nХеш можливостей: {capability_hash}\nШлях: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Діагностика плагінів ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Набір плагінів '{name}': {action}.",
|
||||
@@ -473,6 +480,7 @@
|
||||
"CmdThemeDescription": "Змінити тему або відкрити вибір теми",
|
||||
"CmdProviderDescription": "Змінити активного провайдера та/або модель",
|
||||
"CmdAuthDescription": "Керувати потоками автентифікації провайдерів",
|
||||
"CmdLoginDescription": "Увійти в Codewhale або зберегти ключі провайдера",
|
||||
"CmdQueueDescription": "Переглянути або редагувати повідомлення в черзі",
|
||||
"CmdQueueUsage": "Використання: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Редагування повідомлення з черги:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "Bật hoặc tắt tính năng chẩn đoán LSP",
|
||||
"CmdShareDescription": "Xuất phiên hiện tại thành một liên kết web có thể chia sẻ",
|
||||
"CmdJobsDescription": "Kiểm tra và kiểm soát các lệnh chạy ngầm",
|
||||
"CmdDispatchDescription": "Gửi tác nhân đám mây Codewhale để tạo nhánh và mở PR",
|
||||
"CmdLinksDescription": "Hiển thị liên kết Codewhale, cộng đồng và nhà cung cấp",
|
||||
"CmdLoadDescription": "Tải phiên làm việc từ tệp",
|
||||
"CmdLogoutDescription": "Xóa khóa API và quay lại bước thiết lập",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "Công cụ plugin thực thi cũ ({count}) trong {dir}:",
|
||||
"CmdPluginBundleNotFound": "Không tìm thấy gói plugin '{name}'",
|
||||
"CmdPluginBundleReloaded": "Đã tải lại {count} gói plugin cho {workspace}.",
|
||||
"PluginPromptSuggestTrust": "Có vẻ đây là công việc {name}. Xem xét bằng /plugin trust {name} trước khi bật.",
|
||||
"PluginPromptSuggestEnable": "Có vẻ đây là công việc {name}. Bật bằng /plugin enable {name}.",
|
||||
"PluginPromptSuggestMarketplace": "Có vẻ đây là công việc {name}. Cài từ catalog `{catalog}` bằng /plugin marketplace install {catalog} {name}.",
|
||||
"PluginCtaInstallPrompt": "Cài plugin {name}?",
|
||||
"PluginCtaReview": "Xem lại",
|
||||
"PluginCtaDismiss": "Bỏ",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID: {id}\nPhiên bản: {version}\nNguồn: {origin} ({scope})\nTrạng thái: {state}\nTin cậy: {trust}\nThành phần: {inventory}\nQuyền được yêu cầu: {permissions}\nMáy chủ MCP: {mcp}\nKhông hỗ trợ/chưa hoạt động: {unsupported}\nMã băm nội dung: {content_hash}\nMã băm khả năng: {capability_hash}\nĐường dẫn: {path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "Chẩn đoán plugin ({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "Gói plugin '{name}': {action}.",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "Chuyển đổi giao diện hoặc mở bảng chọn giao diện",
|
||||
"CmdProviderDescription": "Chuyển đổi hoặc xem backend LLM đang hoạt động (deepseek | nvidia-nim | ollama)",
|
||||
"CmdAuthDescription": "Quản lý quy trình xác thực nhà cung cấp",
|
||||
"CmdLoginDescription": "Đăng nhập Codewhale hoặc lưu khóa nhà cung cấp",
|
||||
"CmdQueueDescription": "Xem hoặc chỉnh sửa các tin nhắn đang chờ xử lý",
|
||||
"CmdQueueUsage": "Cách dùng: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "Đang chỉnh sửa tin nhắn đang chờ:",
|
||||
|
||||
@@ -400,6 +400,7 @@
|
||||
"CmdLspDescription": "切换 LSP 诊断的开启或关闭",
|
||||
"CmdShareDescription": "将当前会话导出为可共享的 Web URL",
|
||||
"CmdJobsDescription": "查看并管理后台 shell 作业",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 云端代理以创建分支并打开拉取请求",
|
||||
"CmdLinksDescription": "显示 Codewhale、社区与提供商链接",
|
||||
"CmdLoadDescription": "从文件加载会话",
|
||||
"CmdLogoutDescription": "清除 API 密钥并返回设置",
|
||||
@@ -454,6 +455,12 @@
|
||||
"CmdPluginLegacyListHeader": "{dir} 中的旧版可执行插件工具({count}):",
|
||||
"CmdPluginBundleNotFound": "未找到插件包“{name}”",
|
||||
"CmdPluginBundleReloaded": "已为 {workspace} 重新加载 {count} 个插件包。",
|
||||
"PluginPromptSuggestTrust": "这看起来像 {name} 相关工作。启用前请先审查:/plugin trust {name}",
|
||||
"PluginPromptSuggestEnable": "这看起来像 {name} 相关工作。启用它:/plugin enable {name}",
|
||||
"PluginPromptSuggestMarketplace": "这看起来像 {name} 相关工作。从目录 `{catalog}` 安装:/plugin marketplace install {catalog} {name}",
|
||||
"PluginCtaInstallPrompt": "安装 {name} 插件?",
|
||||
"PluginCtaReview": "审查",
|
||||
"PluginCtaDismiss": "关闭",
|
||||
"CmdPluginBundleDetail": "{name}\n========================================\nID:{id}\n版本:{version}\n来源:{origin}({scope})\n状态:{state}\n信任:{trust}\n组件:{inventory}\n请求的权限:{permissions}\nMCP 服务器:{mcp}\n不支持/未启用:{unsupported}\n内容哈希:{content_hash}\n能力哈希:{capability_hash}\n路径:{path}",
|
||||
"CmdPluginBundleDiagnosticsHeader": "插件诊断({count}):",
|
||||
"CmdPluginBundleMutationSuccess": "插件包“{name}”:{action}。",
|
||||
@@ -476,6 +483,7 @@
|
||||
"CmdThemeDescription": "切换主题或打开主题选择器",
|
||||
"CmdProviderDescription": "切换当前提供商 和/或 模型(deepseek | nvidia-nim | ollama)",
|
||||
"CmdAuthDescription": "管理提供商身份验证流程",
|
||||
"CmdLoginDescription": "登录 Codewhale 或保存提供商密钥",
|
||||
"CmdQueueDescription": "查看或编辑已排队的消息",
|
||||
"CmdQueueUsage": "用法: /queue [list|send <n>|edit <n>|drop <n>|clear]",
|
||||
"CmdQueueDraftHeader": "正在编辑已排队的消息:",
|
||||
|
||||
@@ -186,6 +186,7 @@
|
||||
"CmdAnchorDescription": "釘選關鍵事實,在壓縮後自動注入上下文",
|
||||
"CmdAttachDescription": "附加圖片或視頻媒體;文本檔案或目錄請使用 @path",
|
||||
"CmdAuthDescription": "管理提供者驗證流程",
|
||||
"CmdLoginDescription": "登入 Codewhale 或儲存供應商金鑰",
|
||||
"CmdAutomationDescription": "管理持久化的計劃自動化",
|
||||
"AutomationUsage": "用法: /automation [list|show <id>|pause <id>|resume <id>|delete <id> [--confirm <token>]|run <id>]",
|
||||
"AutomationManagerUnavailable": "此工作階段無法使用自動化管理器。",
|
||||
@@ -288,6 +289,7 @@
|
||||
"CmdHotbarDescription": "開啟 Hotbar 設定",
|
||||
"CmdInitDescription": "為項目生成 AGENTS.md",
|
||||
"CmdJobsDescription": "檢視並管理後台 shell 作業",
|
||||
"CmdDispatchDescription": "派遣 Codewhale 雲端代理以建立分支並開啟拉取請求",
|
||||
"CmdLaneDescription": "檢視與控制持久化的 Lane(執行中的工作流程)",
|
||||
"CmdLinksDescription": "顯示 Codewhale、社群與提供商鏈接",
|
||||
"CmdLoadDescription": "從檔案載入工作階段",
|
||||
@@ -355,6 +357,12 @@
|
||||
"CmdPluginBundleNoneFound": "未發現 Codewhale 外掛包。",
|
||||
"CmdPluginBundleNotFound": "未找到外掛包“{name}”",
|
||||
"CmdPluginBundleReloaded": "已為 {workspace} 重新載入 {count} 個外掛包。",
|
||||
"PluginPromptSuggestTrust": "這看起來像 {name} 相關工作。啟用前請先審查:/plugin trust {name}",
|
||||
"PluginPromptSuggestEnable": "這看起來像 {name} 相關工作。啟用它:/plugin enable {name}",
|
||||
"PluginPromptSuggestMarketplace": "這看起來像 {name} 相關工作。從目錄 `{catalog}` 安裝:/plugin marketplace install {catalog} {name}",
|
||||
"PluginCtaInstallPrompt": "安裝 {name} 插件?",
|
||||
"PluginCtaReview": "審查",
|
||||
"PluginCtaDismiss": "關閉",
|
||||
"CmdPluginBundleUsage": "用法:/plugin [list|show <name>|validate [name]|install <spec>|update <name>|uninstall <name>|trust <name> [review-token]|enable <name>|disable <name>|revoke <name>|reload|tools [name]]",
|
||||
"CmdPluginDescription": "檢查和管理受信任的外掛包;舊版可執行工具保持独立",
|
||||
"CmdPluginDetailApproval": "審批:{approval}",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -181,6 +181,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
|
||||
"cmd_hotbar_description" => MessageId::CmdHotbarDescription,
|
||||
"cmd_init_description" => MessageId::CmdInitDescription,
|
||||
"cmd_jobs_description" => MessageId::CmdJobsDescription,
|
||||
"cmd_dispatch_description" => MessageId::CmdDispatchDescription,
|
||||
"cmd_lane_description" => MessageId::CmdLaneDescription,
|
||||
"cmd_links_description" => MessageId::CmdLinksDescription,
|
||||
"cmd_load_description" => MessageId::CmdLoadDescription,
|
||||
|
||||
@@ -3009,14 +3009,107 @@ pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Logout - clear the active provider's saved API key and return to
|
||||
/// onboarding. The on-disk scrub targets the user-global config document
|
||||
/// (#5193) and the provider's durable secret-store slot is deleted too, so
|
||||
/// the cleared key cannot reappear through the read chain (#5196). Exact
|
||||
/// named custom providers clear only their own table (cae14f4b9). For a
|
||||
/// full every-provider wipe, use `codewhale auth logout`; for single-provider
|
||||
/// key replacement, use `codewhale auth clear --provider <id>` and
|
||||
/// `codewhale auth set --provider <id>`.
|
||||
/// Unified login status. Account device flow stays on the CLI so this
|
||||
/// command never freezes the TUI and never invents a second OAuth broker.
|
||||
/// The internal cloud-agent credential is not user surface: membership
|
||||
/// (`codewhale login`) is the only door, never a provider key.
|
||||
pub fn login(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
let raw = arg.map(str::trim).unwrap_or("");
|
||||
let token = raw.split_whitespace().next().unwrap_or("");
|
||||
match token {
|
||||
"" | "status" => CommandResult::message(login_status_text(app)),
|
||||
"key" | "provider" => CommandResult::with_message_and_action(
|
||||
"Open the provider picker to store an API key. Account sign-in is `codewhale login`.",
|
||||
AppAction::OpenProviderPicker,
|
||||
),
|
||||
"account" => CommandResult::message(
|
||||
"TUI cannot start the browser device flow without freezing the session.\n\
|
||||
Run `codewhale login` (same as `codewhale account login`) in a terminal.\n\
|
||||
Then `/login` to confirm the session landed."
|
||||
.to_string(),
|
||||
),
|
||||
other => CommandResult::error(format!(
|
||||
"Usage: /login [status|account|key]\nUnknown argument: {other}"
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn login_status_text(app: &App) -> String {
|
||||
use codewhale_secrets::account::{
|
||||
ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
|
||||
secure_account_session_secrets,
|
||||
};
|
||||
|
||||
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
|
||||
let account = match secure_account_session_secrets() {
|
||||
Ok(secrets) => {
|
||||
match AccountSessionStore::new(secrets, None, &api_base)
|
||||
.runtime_info_at(chrono::Utc::now())
|
||||
{
|
||||
Ok(info) => match info.state {
|
||||
AccountSessionState::SignedOut => format!("not signed in (api {api_base})"),
|
||||
AccountSessionState::Authenticated => format!("signed in (api {api_base})"),
|
||||
AccountSessionState::OfflineCached => {
|
||||
format!("offline cached (api {api_base})")
|
||||
}
|
||||
AccountSessionState::Expired => format!("expired (api {api_base})"),
|
||||
AccountSessionState::Revoked => format!("revoked (api {api_base})"),
|
||||
},
|
||||
Err(error) => format!("unavailable ({error})"),
|
||||
}
|
||||
}
|
||||
Err(error) => format!("unavailable ({error})"),
|
||||
};
|
||||
let provider = app.provider_identity_for_persistence();
|
||||
format!(
|
||||
"Codewhale login\n\
|
||||
Account: {account}\n\
|
||||
Active provider: {provider}\n\
|
||||
\n\
|
||||
Sign in: `codewhale login`\n\
|
||||
Provider key: `codewhale auth set --provider <id>` or `/login key`\n\
|
||||
Sign out: `/logout` or `codewhale logout`"
|
||||
)
|
||||
}
|
||||
|
||||
fn clear_local_account_session() -> Result<bool, String> {
|
||||
use codewhale_secrets::account::{
|
||||
ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE,
|
||||
secure_account_session_secrets,
|
||||
};
|
||||
let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?;
|
||||
let api_base = std::env::var(ACCOUNT_API_BASE_ENV)
|
||||
.ok()
|
||||
.map(|value| value.trim().trim_end_matches('/').to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string());
|
||||
let store = AccountSessionStore::new(secrets, None, &api_base);
|
||||
let had = store.load().map_err(|error| error.to_string())?.is_some();
|
||||
store.clear().map_err(|error| error.to_string())?;
|
||||
Ok(had)
|
||||
}
|
||||
|
||||
fn clear_daytona_slot() -> Result<bool, String> {
|
||||
let secrets = codewhale_secrets::Secrets::auto_detect();
|
||||
let had = secrets
|
||||
.get(codewhale_secrets::DAYTONA_TOKEN_SLOT)
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_some_and(|value| !value.trim().is_empty());
|
||||
if had {
|
||||
secrets
|
||||
.delete(codewhale_secrets::DAYTONA_TOKEN_SLOT)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
Ok(had)
|
||||
}
|
||||
|
||||
/// Logout — clear the active provider key, the Codewhale account session,
|
||||
/// and the Daytona slot. Named custom providers still clear only their own
|
||||
/// table. For a full every-provider wipe, use `codewhale logout`.
|
||||
pub fn logout(app: &mut App) -> CommandResult {
|
||||
let provider_name = app.provider_identity_for_persistence().to_string();
|
||||
match clear_active_provider_api_key(&provider_name) {
|
||||
@@ -3026,10 +3119,24 @@ pub fn logout(app: &mut App) -> CommandResult {
|
||||
app.onboarding_provider = app.api_provider;
|
||||
app.onboarding_missing_key_recovery = true;
|
||||
app.api_key_env_only = false;
|
||||
let mut cleared = vec![format!("provider key ({provider_name})")];
|
||||
match clear_local_account_session() {
|
||||
Ok(true) => cleared.push("Codewhale account session".to_string()),
|
||||
Ok(false) => {}
|
||||
Err(error) => cleared.push(format!("account session not cleared ({error})")),
|
||||
}
|
||||
match clear_daytona_slot() {
|
||||
Ok(true) => cleared.push("internal cloud-agent token".to_string()),
|
||||
Ok(false) => {}
|
||||
Err(error) => {
|
||||
cleared.push(format!("internal cloud-agent token not cleared ({error})"))
|
||||
}
|
||||
}
|
||||
CommandResult::with_message_and_action(
|
||||
format!(
|
||||
"Cleared API key for {provider_name}. \
|
||||
Use `codewhale auth clear --provider <id>` to clear a different provider."
|
||||
"Cleared {}. \
|
||||
Use `codewhale login` to sign in again, or `codewhale auth set --provider <id>` to store a provider key.",
|
||||
cleared.join(", ")
|
||||
),
|
||||
AppAction::OpenProviderPicker,
|
||||
)
|
||||
@@ -3063,6 +3170,7 @@ mod tests {
|
||||
let vars = vec![
|
||||
EnvVarGuard::set("HOME", home),
|
||||
EnvVarGuard::set("USERPROFILE", home),
|
||||
EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")),
|
||||
EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"),
|
||||
EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", config_path),
|
||||
EnvVarGuard::remove("CODEWHALE_ALLOW_SHELL"),
|
||||
|
||||
@@ -22,6 +22,7 @@ impl CommandGroup for ConfigCommands {
|
||||
Box::new(FunctionCommand::new(&CONFIG_INFO, run_config)),
|
||||
Box::new(FunctionCommand::new(&IMPORT_CLAUDE_INFO, run_import_claude)),
|
||||
Box::new(FunctionCommand::new(&PERMISSIONS_INFO, run_permissions)),
|
||||
Box::new(FunctionCommand::new(&LOGIN_INFO, run_login)),
|
||||
Box::new(FunctionCommand::new(&AUTH_INFO, run_auth)),
|
||||
Box::new(FunctionCommand::new(&RAIL_INFO, run_rail)),
|
||||
Box::new(FunctionCommand::new(&SETTINGS_INFO, run_settings)),
|
||||
@@ -56,6 +57,12 @@ static PERMISSIONS_INFO: CommandInfo = CommandInfo {
|
||||
usage: "/permissions [list|remove <rule-number> [--confirm <token>]]",
|
||||
description_id: MessageId::CmdPermissionsDescription,
|
||||
};
|
||||
static LOGIN_INFO: CommandInfo = CommandInfo {
|
||||
name: "login",
|
||||
aliases: &[],
|
||||
usage: "/login [status|account|key]",
|
||||
description_id: MessageId::CmdLoginDescription,
|
||||
};
|
||||
static AUTH_INFO: CommandInfo = CommandInfo {
|
||||
name: "auth",
|
||||
aliases: &[],
|
||||
@@ -130,6 +137,9 @@ fn run_import_claude(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
fn run_permissions(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
run_registered(app, "permissions", arg)
|
||||
}
|
||||
fn run_login(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
run_registered(app, "login", arg)
|
||||
}
|
||||
fn run_auth(app: &mut App, arg: Option<&str>) -> CommandResult {
|
||||
run_registered(app, "auth", arg)
|
||||
}
|
||||
@@ -170,6 +180,7 @@ pub(in crate::commands) fn dispatch(
|
||||
"permissions" | "permission-rules" | "permission_rules" => {
|
||||
permissions::permissions_command(app, arg)
|
||||
}
|
||||
"login" => config::login(app, arg),
|
||||
"auth" => match arg.map(str::trim) {
|
||||
Some("xai-device") | Some("xai_device") => {
|
||||
CommandResult::action(crate::tui::app::AppAction::StartXaiDeviceLogin)
|
||||
|
||||
@@ -546,33 +546,6 @@ fn resolve_asr_choice(_config: &Config) -> (String, String) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Available ASR providers (for `voice --list` / settings UI).
|
||||
#[allow(dead_code)]
|
||||
pub fn available_asr_providers() -> Vec<(&'static str, &'static str, &'static str)> {
|
||||
vec![
|
||||
(
|
||||
"local-whisper",
|
||||
"Local Whisper.cpp",
|
||||
"free, offline, mac/win/linux/harmony — brew install whisper-cpp",
|
||||
),
|
||||
(
|
||||
"groq",
|
||||
"Groq Whisper large-v3-turbo",
|
||||
"free tier, fast, needs GROQ_API_KEY",
|
||||
),
|
||||
(
|
||||
"xiaomi",
|
||||
"Xiaomi MiMo ASR (mimo-v2.5-asr)",
|
||||
"needs XIAOMI_API_KEY, streaming, high quality",
|
||||
),
|
||||
(
|
||||
"openai",
|
||||
"OpenAI whisper-1",
|
||||
"needs OPENAI_API_KEY, compatible",
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
pub async fn capture_and_transcribe(
|
||||
app: &mut App,
|
||||
config: &Config,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
//! * [`legacy`] — the separate `[tools].plugin_dir` executable inventory,
|
||||
//! which shares no trust state with declarative bundles.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -149,95 +149,75 @@ fn plugins_with_kimi_home_override(
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank already installed bundle metadata for a task without changing trust,
|
||||
/// enablement, disk state, or network state. A full remote plugin marketplace
|
||||
/// needs separately curated publisher/provenance policy; the existing plugin
|
||||
/// registry is intentionally local-only for this release.
|
||||
/// Rank installed bundles and locally-added marketplace candidates for a task
|
||||
/// without changing trust, enablement, disk state, or network state.
|
||||
fn suggest_bundles(app: &App, task: &str) -> CommandResult {
|
||||
let task = task.trim();
|
||||
if task.chars().count() < 3 {
|
||||
return CommandResult::error("Usage: /plugin suggest <task of at least 3 characters>");
|
||||
}
|
||||
|
||||
let mut skills = BTreeMap::new();
|
||||
for plugin in app.plugin_registry.list() {
|
||||
let mut description_parts = plugin
|
||||
.manifest
|
||||
.plugin
|
||||
.description
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut keywords = Vec::new();
|
||||
for skill in &plugin.skill_snapshots {
|
||||
description_parts.push(skill.name.clone());
|
||||
description_parts.push(skill.description.clone());
|
||||
keywords.push(skill.name.clone());
|
||||
keywords.extend(skill.aliases.iter().cloned());
|
||||
}
|
||||
skills.insert(
|
||||
plugin.name().to_string(),
|
||||
crate::skills::RegistryEntry {
|
||||
source: plugin.id.as_str().to_string(),
|
||||
description: (!description_parts.is_empty()).then(|| description_parts.join(" ")),
|
||||
keywords,
|
||||
domains: plugin.inventory.network_hosts.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let index = crate::skills::RegistryDocument { skills };
|
||||
let recommendations = crate::skills::recommend::recommend_remote_skills(task, &index, 3);
|
||||
let marketplace =
|
||||
crate::plugins::recommend::load_marketplace_candidates(app.plugin_registry.state_path());
|
||||
let recommendations = crate::plugins::recommend::recommend_plugins_for_task(
|
||||
task,
|
||||
app.plugin_registry.as_ref(),
|
||||
&marketplace,
|
||||
crate::plugins::recommend::RecommendOptions::default(),
|
||||
);
|
||||
if recommendations.is_empty() {
|
||||
return CommandResult::message(format!(
|
||||
"No installed plugin bundles matched `{}`.\n\nInstall a reviewed bundle with /plugin install <source>. Nothing was installed, trusted, or enabled.",
|
||||
"No installed or catalog plugin matched `{}`.\n\nInstall a reviewed bundle with /plugin install <source>, or add a catalog with /plugin marketplace add. Nothing was installed, trusted, or enabled.",
|
||||
escape_review_text(task)
|
||||
));
|
||||
}
|
||||
|
||||
let mut output = format!(
|
||||
"Suggested installed plugins for `{}`:\n",
|
||||
escape_review_text(task)
|
||||
);
|
||||
let mut output = format!("Suggested plugins for `{}`:\n", escape_review_text(task));
|
||||
output.push_str("─────────────────────────────\n");
|
||||
for recommendation in recommendations {
|
||||
let Some(plugin) = app.plugin_registry.get(&recommendation.entry.source) else {
|
||||
continue;
|
||||
let description = match &recommendation.source {
|
||||
crate::plugins::recommend::PluginMatchSource::Installed { id } => app
|
||||
.plugin_registry
|
||||
.get(id)
|
||||
.and_then(|plugin| plugin.manifest.plugin.description.clone())
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.unwrap_or_else(|| "No description provided.".to_string()),
|
||||
crate::plugins::recommend::PluginMatchSource::Marketplace { .. } => marketplace
|
||||
.iter()
|
||||
.find(|candidate| {
|
||||
candidate.name.eq_ignore_ascii_case(&recommendation.name)
|
||||
&& matches!(
|
||||
&recommendation.source,
|
||||
crate::plugins::recommend::PluginMatchSource::Marketplace { catalog_id }
|
||||
if candidate.catalog_id.as_str() == catalog_id
|
||||
)
|
||||
})
|
||||
.and_then(|candidate| candidate.description.clone())
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.unwrap_or_else(|| "Catalog plugin.".to_string()),
|
||||
};
|
||||
let state = match &recommendation.source {
|
||||
crate::plugins::recommend::PluginMatchSource::Installed { id } => app
|
||||
.plugin_registry
|
||||
.get(id)
|
||||
.map(|plugin| plugin.state_label())
|
||||
.unwrap_or("installed"),
|
||||
crate::plugins::recommend::PluginMatchSource::Marketplace { .. } => "not installed",
|
||||
};
|
||||
let description = plugin
|
||||
.manifest
|
||||
.plugin
|
||||
.description
|
||||
.as_deref()
|
||||
.filter(|description| !description.trim().is_empty())
|
||||
.unwrap_or("No description provided.");
|
||||
let why = recommendation
|
||||
.matched_terms
|
||||
.iter()
|
||||
.map(|term| escape_review_text(term))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let next_step = if plugin.active() {
|
||||
format!("Already active: /plugin show {}", plugin.name())
|
||||
} else if !plugin.trusted() {
|
||||
format!("Review before enabling: /plugin trust {}", plugin.name())
|
||||
} else if !plugin.enabled {
|
||||
format!(
|
||||
"Enable if that review still applies: /plugin enable {}",
|
||||
plugin.name()
|
||||
)
|
||||
} else {
|
||||
format!("Inspect its inactive state: /plugin show {}", plugin.name())
|
||||
};
|
||||
let _ = writeln!(
|
||||
output,
|
||||
" {} — {} · {}",
|
||||
escape_review_text(plugin.name()),
|
||||
plugin.state_label(),
|
||||
escape_review_text(description)
|
||||
" {} — {state} · {}",
|
||||
escape_review_text(&recommendation.name),
|
||||
escape_review_text(&description)
|
||||
);
|
||||
let _ = writeln!(output, " Why: {why}");
|
||||
let _ = writeln!(output, " {next_step}");
|
||||
let _ = writeln!(output, " {}", recommendation.command());
|
||||
}
|
||||
output.push_str("\nNothing was installed, trusted, or enabled.");
|
||||
CommandResult::message(output)
|
||||
|
||||
@@ -162,16 +162,43 @@ fn suggest_ranks_installed_plugins_without_trusting_or_enabling_them() {
|
||||
let result = plugins(&mut app, Some("suggest spreadsheet import"));
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
let message = result.message.expect("suggestion message");
|
||||
assert!(message.contains("Suggested installed plugins"), "{message}");
|
||||
assert!(message.contains("Suggested plugins"), "{message}");
|
||||
assert!(message.contains("demo — disabled"), "{message}");
|
||||
assert!(message.contains("Why:"), "{message}");
|
||||
assert!(message.contains("/plugin trust demo"), "{message}");
|
||||
assert!(
|
||||
message.contains("spreadsheet") || message.contains("import"),
|
||||
"{message}"
|
||||
);
|
||||
assert!(message.contains("Nothing was installed, trusted, or enabled."));
|
||||
assert!(!codewhale_home.join("plugins/state.json").exists());
|
||||
let plugin = app.plugin_registry.get("demo").expect("demo plugin");
|
||||
assert!(!plugin.enabled && !plugin.trusted());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suggest_matches_manifest_keywords_for_a_named_integration() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let codewhale_home = root.path().join("home");
|
||||
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
|
||||
let bundle = root.path().join(".codewhale/plugins/supabase");
|
||||
fs::create_dir_all(&bundle).unwrap();
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\", \"postgres\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let (mut app, _temp) = create_test_app(root.path());
|
||||
|
||||
let result = plugins(&mut app, Some("suggest add supabase auth"));
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
let message = result.message.expect("suggestion message");
|
||||
assert!(message.contains("supabase"), "{message}");
|
||||
assert!(message.contains("/plugin trust supabase"), "{message}");
|
||||
assert!(message.contains("Nothing was installed, trusted, or enabled."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trust_requires_content_and_capability_bound_review_token() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
//! `/dispatch` — first-class Codewhale cloud-agent offload.
|
||||
|
||||
use codewhale_command_contract::facets::CommandWorkspaceContext;
|
||||
use codewhale_command_contract::handler::{CommandContexts, CommandHandler};
|
||||
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
|
||||
|
||||
use crate::cloud_dispatch::{
|
||||
CloudJobStore, DispatchOutcome, Forge, LiveDaytonaLauncher, cancel_job, confirm_job,
|
||||
discover_credentials, discover_remotes, execute_dispatch, format_job, format_job_list,
|
||||
format_status, plan_dispatch,
|
||||
};
|
||||
use crate::commands::CommandResult;
|
||||
|
||||
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
|
||||
name: "dispatch",
|
||||
aliases: &["cloud-agent", "cloud-dispatch"],
|
||||
usage: "/dispatch [list|show <id>|confirm <id>|cancel <id>|<prompt> [--remote github|cnb|gitee]]",
|
||||
description_key: "cmd_dispatch_description",
|
||||
};
|
||||
|
||||
pub(in crate::commands) struct DispatchCmd;
|
||||
|
||||
impl RegisterCommand<CommandResult> for DispatchCmd {
|
||||
fn info() -> &'static CommandInfo {
|
||||
&COMMAND_INFO
|
||||
}
|
||||
|
||||
fn handler() -> CommandHandler<CommandResult> {
|
||||
CommandHandler::Contextual(dispatch_contextual)
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_contextual(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult {
|
||||
let mut parts = contexts.into_parts();
|
||||
let workspace = parts.workspace.as_deref_mut().expect("workspace facet");
|
||||
dispatch(workspace, arg)
|
||||
}
|
||||
|
||||
fn dispatch(workspace: &mut dyn CommandWorkspaceContext, args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
let store = match CloudJobStore::from_env() {
|
||||
Ok(store) => store,
|
||||
Err(error) => return CommandResult::error(error.to_string()),
|
||||
};
|
||||
if raw.is_empty() {
|
||||
return CommandResult::message(format_status(
|
||||
&discover_remotes(&workspace.workspace()),
|
||||
&discover_credentials(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut parts = raw.splitn(2, char::is_whitespace);
|
||||
let verb = parts.next().unwrap_or("").to_ascii_lowercase();
|
||||
let rest = parts.next().map(str::trim).unwrap_or("");
|
||||
|
||||
match verb.as_str() {
|
||||
"list" => match store.list() {
|
||||
Ok(jobs) => CommandResult::message(format_job_list(&jobs)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
},
|
||||
"show" | "inspect" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch show <id>");
|
||||
}
|
||||
match store.load(rest) {
|
||||
Ok(job) => CommandResult::message(format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
"confirm" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch confirm <id>");
|
||||
}
|
||||
match confirm_job(&store, rest, &discover_credentials(), &LiveDaytonaLauncher) {
|
||||
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
"cancel" | "kill" | "stop" => {
|
||||
if rest.is_empty() {
|
||||
return CommandResult::error("Usage: /dispatch cancel <id>");
|
||||
}
|
||||
match cancel_job(&store, rest) {
|
||||
Ok(job) => CommandResult::message(format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
_ => propose_or_run(workspace, &store, raw, false),
|
||||
}
|
||||
}
|
||||
|
||||
fn propose_or_run(
|
||||
workspace: &mut dyn CommandWorkspaceContext,
|
||||
store: &CloudJobStore,
|
||||
raw: &str,
|
||||
confirm: bool,
|
||||
) -> CommandResult {
|
||||
let (prompt, requested) = match split_prompt_and_remote(raw) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => return CommandResult::error(error),
|
||||
};
|
||||
let remotes = discover_remotes(&workspace.workspace());
|
||||
let plan = match plan_dispatch(&remotes, &prompt, requested, None) {
|
||||
Ok(plan) => plan,
|
||||
Err(error) => return CommandResult::error(error.to_string()),
|
||||
};
|
||||
match execute_dispatch(
|
||||
store,
|
||||
plan,
|
||||
confirm,
|
||||
&discover_credentials(),
|
||||
&LiveDaytonaLauncher,
|
||||
) {
|
||||
Ok(outcome) => CommandResult::message(outcome_message(&outcome)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn split_prompt_and_remote(raw: &str) -> Result<(String, Option<Forge>), String> {
|
||||
let mut remote = None;
|
||||
let mut prompt_parts = Vec::new();
|
||||
let mut tokens = raw.split_whitespace().peekable();
|
||||
while let Some(token) = tokens.next() {
|
||||
if token == "--remote" {
|
||||
let Some(value) = tokens.next() else {
|
||||
return Err("Usage: /dispatch <prompt> --remote github|cnb|gitee".to_string());
|
||||
};
|
||||
remote = Some(
|
||||
Forge::parse(value)
|
||||
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = token.strip_prefix("--remote=") {
|
||||
remote = Some(
|
||||
Forge::parse(value)
|
||||
.ok_or_else(|| "Remote must be github, cnb, or gitee.".to_string())?,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
prompt_parts.push(token);
|
||||
}
|
||||
let prompt = prompt_parts.join(" ");
|
||||
if prompt.is_empty() {
|
||||
return Err("Usage: /dispatch <prompt> [--remote github|cnb|gitee]".to_string());
|
||||
}
|
||||
Ok((prompt, remote))
|
||||
}
|
||||
|
||||
fn outcome_message(outcome: &DispatchOutcome) -> String {
|
||||
match outcome {
|
||||
DispatchOutcome::Proposal(job)
|
||||
| DispatchOutcome::Refused(job)
|
||||
| DispatchOutcome::Accepted(job) => format_job(job),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct FakeWorkspace(PathBuf);
|
||||
impl CommandWorkspaceContext for FakeWorkspace {
|
||||
fn workspace(&self) -> PathBuf {
|
||||
self.0.clone()
|
||||
}
|
||||
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
|
||||
Ok(None)
|
||||
}
|
||||
fn operation_digest(&mut self) -> Result<String, String> {
|
||||
Ok("digest".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_prompt_and_explicit_remote() {
|
||||
let (prompt, remote) = split_prompt_and_remote("fix the flake --remote cnb").unwrap();
|
||||
assert_eq!(prompt, "fix the flake");
|
||||
assert_eq!(remote, Some(Forge::Cnb));
|
||||
assert!(split_prompt_and_remote("--remote gitee").is_err());
|
||||
assert_eq!(
|
||||
split_prompt_and_remote("open pr --remote=github")
|
||||
.unwrap()
|
||||
.1,
|
||||
Some(Forge::Github)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_is_contextual_and_argument_aware() {
|
||||
assert!(matches!(
|
||||
DispatchCmd::handler(),
|
||||
CommandHandler::Contextual(_)
|
||||
));
|
||||
assert_eq!(
|
||||
DispatchCmd::info().description_key,
|
||||
"cmd_dispatch_description"
|
||||
);
|
||||
assert_eq!(
|
||||
DispatchCmd::info().aliases,
|
||||
&["cloud-agent", "cloud-dispatch"]
|
||||
);
|
||||
assert!(DispatchCmd::info().usage.starts_with("/dispatch"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bare_dispatch_is_a_status_card_not_a_silent_launch() {
|
||||
let mut workspace = FakeWorkspace(PathBuf::from("."));
|
||||
let result = dispatch(&mut workspace, None);
|
||||
assert!(!result.is_error, "{result:?}");
|
||||
let message = result.message.expect("status message");
|
||||
assert!(message.contains("Cloud agents"));
|
||||
assert!(message.contains("fails closed") || message.contains("ready"));
|
||||
assert!(result.action.is_none());
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ use crate::tui::app::{AppAction, ShellJobAction};
|
||||
pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo {
|
||||
name: "jobs",
|
||||
aliases: &["job", "zuoye"],
|
||||
usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id>]",
|
||||
usage: "/jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|cancel <id|all>]",
|
||||
description_key: "cmd_jobs_description",
|
||||
};
|
||||
|
||||
@@ -25,6 +25,10 @@ impl RegisterCommand<CommandResult> for JobsCmd {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_cancel_all_token(id: &str) -> bool {
|
||||
id.eq_ignore_ascii_case("all")
|
||||
}
|
||||
|
||||
fn jobs(args: Option<&str>) -> CommandResult {
|
||||
let raw = args.unwrap_or("").trim();
|
||||
if raw.is_empty() || raw.eq_ignore_ascii_case("list") {
|
||||
@@ -39,6 +43,7 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
match action.as_str() {
|
||||
"list" => CommandResult::action(AppAction::ShellJob(ShellJobAction::List)),
|
||||
"show" | "inspect" => match id {
|
||||
Some(id) if id.starts_with("cloud_") => show_cloud_job(id),
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Show {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
@@ -70,20 +75,42 @@ fn jobs(args: Option<&str>) -> CommandResult {
|
||||
None => CommandResult::error("Usage: /jobs close-stdin <id>"),
|
||||
},
|
||||
"cancel" | "kill" | "stop" => match id {
|
||||
Some(id) if id.starts_with("cloud_") => cancel_cloud_job(id),
|
||||
Some(id) if is_cancel_all_token(id) => {
|
||||
CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
}
|
||||
Some(id) => CommandResult::action(AppAction::ShellJob(ShellJobAction::Cancel {
|
||||
id: id.to_string(),
|
||||
})),
|
||||
None => CommandResult::error("Usage: /jobs cancel <id>"),
|
||||
None => CommandResult::error(
|
||||
"Usage: /jobs cancel <id|all> — id is the shell_* shown on the Shells work-strip row",
|
||||
),
|
||||
},
|
||||
"cancel-all" | "kill-all" | "stop-all" => {
|
||||
CommandResult::action(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
}
|
||||
_ => CommandResult::error(
|
||||
"Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <id>|cancel <id>|cancel-all]",
|
||||
"Usage: /jobs [list|show <id>|poll <id>|wait <id>|stdin <id> <input>|close-stdin <id>|cancel <id|all>]",
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn show_cloud_job(id: &str) -> CommandResult {
|
||||
match crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.load(id)) {
|
||||
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_cloud_job(id: &str) -> CommandResult {
|
||||
match crate::cloud_dispatch::CloudJobStore::from_env()
|
||||
.and_then(|store| crate::cloud_dispatch::cancel_job(&store, id))
|
||||
{
|
||||
Ok(job) => CommandResult::message(crate::cloud_dispatch::format_job(&job)),
|
||||
Err(error) => CommandResult::error(error.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -108,6 +135,41 @@ mod tests {
|
||||
cancel_all.action,
|
||||
Some(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
));
|
||||
|
||||
let cloud = jobs(Some("show cloud_deadbeef"));
|
||||
assert!(cloud.action.is_none());
|
||||
assert!(cloud.message.is_some() || cloud.is_error);
|
||||
|
||||
let cancel_all_spaced = jobs(Some("cancel all"));
|
||||
assert!(matches!(
|
||||
cancel_all_spaced.action,
|
||||
Some(AppAction::ShellJob(ShellJobAction::CancelAll))
|
||||
));
|
||||
|
||||
let cancel_id = jobs(Some("cancel shell_abcd"));
|
||||
assert!(matches!(
|
||||
cancel_id.action,
|
||||
Some(AppAction::ShellJob(ShellJobAction::Cancel { id })) if id == "shell_abcd"
|
||||
));
|
||||
|
||||
let bare_cancel = jobs(Some("cancel"));
|
||||
assert!(bare_cancel.action.is_none());
|
||||
assert!(
|
||||
bare_cancel
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("shell_*") && message.contains("<id|all>")),
|
||||
"bare cancel must name the work-strip id space, not a task: {:?}",
|
||||
bare_cancel.message
|
||||
);
|
||||
assert!(
|
||||
!bare_cancel
|
||||
.message
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("Task")),
|
||||
"bare cancel must not mention Task: {:?}",
|
||||
bare_cancel.message
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
|
||||
mod attachment;
|
||||
mod automation;
|
||||
mod dispatch;
|
||||
mod jobs;
|
||||
mod mcp;
|
||||
mod network;
|
||||
@@ -30,6 +31,10 @@ impl CommandGroup for UtilityCommands {
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<jobs::JobsCmd>().expect("jobs registration"),
|
||||
),
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<dispatch::DispatchCmd>()
|
||||
.expect("dispatch registration"),
|
||||
),
|
||||
Box::new(ContextualCommand::from_contract::<mcp::McpCmd>().expect("mcp registration"),),
|
||||
Box::new(
|
||||
ContextualCommand::from_contract::<network::NetworkCmd>()
|
||||
|
||||
@@ -610,6 +610,35 @@ mod tests {
|
||||
assert!(message.contains("Hub workflows"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_slash_command_reports_status_and_key_opens_picker() {
|
||||
let mut app = create_test_app();
|
||||
let status = execute("/login", &mut app);
|
||||
assert!(!status.is_error);
|
||||
let message = status.message.expect("login status");
|
||||
assert!(message.contains("Codewhale login"), "{message}");
|
||||
assert!(message.contains("Account:"), "{message}");
|
||||
assert!(message.contains("codewhale login"), "{message}");
|
||||
// No-brand invariant: the internal cloud-agent slot is not user
|
||||
// surface, so status never names it or teaches a set-slot command.
|
||||
assert!(!message.contains("Daytona"), "{message}");
|
||||
assert!(!message.contains("set-slot"), "{message}");
|
||||
|
||||
let key = execute("/login key", &mut app);
|
||||
assert!(!key.is_error);
|
||||
assert_eq!(key.action, Some(AppAction::OpenProviderPicker));
|
||||
|
||||
let daytona = execute("/login daytona", &mut app);
|
||||
assert!(daytona.is_error);
|
||||
let err = daytona.message.expect("usage");
|
||||
assert!(err.contains("Usage: /login [status|account|key]"), "{err}");
|
||||
|
||||
let unknown = execute("/login oauth", &mut app);
|
||||
assert!(unknown.is_error);
|
||||
let err = unknown.message.expect("usage");
|
||||
assert!(err.contains("Usage: /login"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xai_device_auth_slash_command_starts_login() {
|
||||
let mut app = create_test_app();
|
||||
@@ -921,9 +950,9 @@ mod tests {
|
||||
has_config = true;
|
||||
assert_eq!(
|
||||
commands.len(),
|
||||
13,
|
||||
14,
|
||||
"config group (group-local metadata exception) expected \
|
||||
exactly 13 commands, got {}",
|
||||
exactly 14 commands, got {}",
|
||||
commands.len()
|
||||
);
|
||||
}
|
||||
@@ -1083,6 +1112,7 @@ mod tests {
|
||||
fn flagship_orchestration_and_workspace_commands_are_visible_at_the_palette_root() {
|
||||
for name in [
|
||||
"auto",
|
||||
"dispatch",
|
||||
"goal",
|
||||
"hooks",
|
||||
"tokens",
|
||||
@@ -1863,11 +1893,13 @@ mod tests {
|
||||
// FEAT-015 shipped no production contextual command, so the assertion
|
||||
// below used to exclude nothing. FEAT-018 migrates the utility group;
|
||||
// the remaining non-fixture commands must still use the legacy
|
||||
// concrete-App path. The seven utility entries are asserted separately
|
||||
// by the FEAT-018 public-dispatch and inventory tests (Phase 6).
|
||||
// concrete-App path. The migrated utility entries (the FEAT-018 seven
|
||||
// plus `/dispatch`, which joins the same portable path) are asserted
|
||||
// separately by the FEAT-018 public-dispatch and inventory tests.
|
||||
const FEAT_018_UTILITY: &[&str] = &[
|
||||
"attach",
|
||||
"automation",
|
||||
"dispatch",
|
||||
"jobs",
|
||||
"mcp",
|
||||
"network",
|
||||
|
||||
@@ -3521,12 +3521,26 @@ impl Engine {
|
||||
cache_control: None,
|
||||
}];
|
||||
}
|
||||
let recommended_plugins = crate::plugins::recommend::recommended_plugins_user_fragment(
|
||||
&text,
|
||||
self.plugin_registry.as_ref(),
|
||||
&crate::plugins::recommend::load_marketplace_candidates(
|
||||
self.plugin_registry.state_path(),
|
||||
),
|
||||
);
|
||||
let expanded = crate::image_attach::expand_attachment_blocks(&text);
|
||||
let mut content = Vec::with_capacity(2 + expanded.blocks.len());
|
||||
let mut content = Vec::with_capacity(3 + expanded.blocks.len());
|
||||
content.push(ContentBlock::Text {
|
||||
text,
|
||||
cache_control: None,
|
||||
});
|
||||
// Append-only on this turn. Never spliced into the pinned system prefix.
|
||||
if let Some(fragment) = recommended_plugins {
|
||||
content.push(ContentBlock::Text {
|
||||
text: fragment,
|
||||
cache_control: None,
|
||||
});
|
||||
}
|
||||
content.extend(expanded.blocks);
|
||||
if let Some(notice) = crate::image_attach::notice_block(&expanded.notices) {
|
||||
content.push(notice);
|
||||
|
||||
@@ -136,7 +136,9 @@ impl Engine {
|
||||
// headless entry points install the merged notification policy before
|
||||
// tool setup, including method=off, quiet/category, and attention.
|
||||
// The tool returns a truthful suppressed/delivered receipt.
|
||||
builder = builder.with_notify_tool();
|
||||
builder = builder
|
||||
.with_notify_tool()
|
||||
.with_request_plugin_install_tool();
|
||||
|
||||
// Register the `registry_sync` tool for fetching and caching
|
||||
// MCP Registry server metadata. Rides on `Feature::Mcp` — the same
|
||||
|
||||
@@ -140,10 +140,6 @@ pub fn feature_from_key(key: &str) -> Option<Feature> {
|
||||
.map(|spec| spec.id)
|
||||
}
|
||||
|
||||
pub fn feature_spec_by_key(key: &str) -> Option<&'static FeatureSpec> {
|
||||
FEATURES.iter().find(|spec| spec.key == key)
|
||||
}
|
||||
|
||||
pub fn render_feature_table(features: &Features) -> String {
|
||||
let mut output = String::from("feature\tstage\tenabled\n");
|
||||
for spec in FEATURES {
|
||||
|
||||
@@ -183,10 +183,6 @@ pub fn load_workspace_agent_profiles_tolerant(
|
||||
load_agent_profiles_from_dir_tolerant(dir, ProfileOrigin::Workspace)
|
||||
}
|
||||
|
||||
pub fn load_personal_agent_profiles_tolerant() -> Result<(Vec<AgentProfile>, Vec<String>)> {
|
||||
load_agent_profiles_from_dir_tolerant(personal_agent_profile_dir()?, ProfileOrigin::Personal)
|
||||
}
|
||||
|
||||
pub fn load_agent_profiles_from_dir_tolerant(
|
||||
dir: impl AsRef<Path>,
|
||||
origin: ProfileOrigin,
|
||||
|
||||
+377
-56
@@ -29,6 +29,7 @@ mod auto_reasoning;
|
||||
mod automation_manager;
|
||||
mod child_env;
|
||||
mod client;
|
||||
pub mod cloud_dispatch;
|
||||
mod codex_model_cache;
|
||||
mod command_safety;
|
||||
mod commands;
|
||||
@@ -300,10 +301,10 @@ enum Commands {
|
||||
},
|
||||
/// Create default AGENTS.md in current directory
|
||||
Init,
|
||||
/// Save an API key to the shared user config
|
||||
/// Sign in to your Codewhale account (use the `codewhale` CLI).
|
||||
Login {
|
||||
/// API key to store (otherwise read from stdin)
|
||||
#[arg(long)]
|
||||
/// Legacy provider-key flag: rejected with a redirect to `auth set`.
|
||||
#[arg(long, hide = true)]
|
||||
api_key: Option<String>,
|
||||
},
|
||||
/// Remove the saved API key
|
||||
@@ -1192,10 +1193,22 @@ impl FeatureToggles {
|
||||
#[derive(Args, Debug, Clone)]
|
||||
struct ReviewArgs {
|
||||
/// Review staged changes instead of the working tree
|
||||
#[arg(long, conflicts_with = "base")]
|
||||
#[arg(long, conflicts_with_all = ["pr", "base"])]
|
||||
staged: bool,
|
||||
/// Review GitHub pull request #N instead of a local diff (fetched via `gh`)
|
||||
#[arg(long, conflicts_with_all = ["staged", "base", "path"])]
|
||||
pr: Option<u32>,
|
||||
/// Repository in `owner/name` form for --pr. Defaults to the current
|
||||
/// workspace's `gh` config (i.e. the repo gh thinks you're in).
|
||||
#[arg(long, requires = "pr")]
|
||||
repo: Option<String>,
|
||||
/// Post the review to the pull request (one COMMENT review with inline
|
||||
/// line comments plus a summary). Requires --pr; without it the review
|
||||
/// is only printed locally.
|
||||
#[arg(long, requires = "pr")]
|
||||
post: bool,
|
||||
/// Base ref to diff against (e.g. origin/main)
|
||||
#[arg(long)]
|
||||
#[arg(long, conflicts_with_all = ["staged", "pr"])]
|
||||
base: Option<String>,
|
||||
/// Limit diff to a specific path
|
||||
#[arg(long)]
|
||||
@@ -7999,28 +8012,19 @@ fn apply_saved_reasoning_preference(config: &mut Config, settings: &crate::setti
|
||||
config.reasoning_effort_inferred_from_legacy_alias = false;
|
||||
}
|
||||
|
||||
fn read_api_key_from_stdin() -> Result<String> {
|
||||
let mut stdin = io::stdin();
|
||||
if stdin.is_terminal() {
|
||||
bail!("No API key provided. Pass --api-key or pipe one via stdin.");
|
||||
}
|
||||
let mut buffer = String::new();
|
||||
stdin.read_to_string(&mut buffer)?;
|
||||
let api_key = buffer.trim().to_string();
|
||||
if api_key.is_empty() {
|
||||
bail!("No API key provided via stdin.");
|
||||
}
|
||||
Ok(api_key)
|
||||
}
|
||||
|
||||
fn run_login(api_key: Option<String>) -> Result<()> {
|
||||
let api_key = match api_key {
|
||||
Some(key) => key,
|
||||
None => read_api_key_from_stdin()?,
|
||||
};
|
||||
let saved = config::save_api_key(&api_key)?;
|
||||
println!("Saved API key to {}", saved.describe());
|
||||
Ok(())
|
||||
if api_key.is_some() {
|
||||
bail!(
|
||||
"`login --api-key` is not account sign-in. \
|
||||
Use `codewhale login` for the Codewhale account, \
|
||||
or `codewhale auth set --provider <id>` for a provider key."
|
||||
);
|
||||
}
|
||||
bail!(
|
||||
"This binary's `login` command does not store provider keys. \
|
||||
Use the `codewhale` CLI: `codewhale login` for the Codewhale account device flow, \
|
||||
or `codewhale auth set --provider <id>` for a provider key."
|
||||
);
|
||||
}
|
||||
|
||||
fn run_logout() -> Result<()> {
|
||||
@@ -8166,6 +8170,19 @@ fn pick_session_id() -> Result<String> {
|
||||
async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
|
||||
use crate::client::DeepSeekClient;
|
||||
|
||||
if args.pr.is_some() && !is_command_available("gh") {
|
||||
bail!(
|
||||
"`gh` CLI not found on PATH. Install GitHub CLI \
|
||||
(https://cli.github.com) and authenticate (`gh auth login`) \
|
||||
so `codewhale review --pr` can fetch the pull request."
|
||||
);
|
||||
}
|
||||
// Fetched before the diff so a missing/hidden PR fails before any model
|
||||
// route is resolved or billed.
|
||||
let pr_view = match args.pr {
|
||||
Some(number) => Some((number, run_gh_pr_view(number, args.repo.as_deref())?)),
|
||||
None => None,
|
||||
};
|
||||
let diff = collect_diff(&args)?;
|
||||
if diff.trim().is_empty() {
|
||||
bail!("No diff to review.");
|
||||
@@ -8180,17 +8197,27 @@ async fn run_review(config: &Config, args: ReviewArgs) -> Result<()> {
|
||||
let execution_config = config_for_cli_route(config, &route);
|
||||
let route_provider = execution_config.provider_identity_for(route.provider);
|
||||
let model = route.model.clone();
|
||||
let user_prompt =
|
||||
format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff.");
|
||||
// PR reviews run under the structured JSON review contract so findings
|
||||
// carry file/line positions that can be posted as inline review comments.
|
||||
let (user_prompt, system) = if let Some((number, view)) = &pr_view {
|
||||
(
|
||||
format_pr_prompt(*number, view, &diff),
|
||||
SystemPrompt::Text(crate::tools::review::review_system_prompt().to_string()),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
format!("Review the following diff and provide feedback:\n\n{diff}\n\nEnd of diff."),
|
||||
SystemPrompt::Text(
|
||||
"You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
|
||||
Provide findings ordered by severity with file references, then open questions, then a brief summary."
|
||||
.to_string(),
|
||||
),
|
||||
)
|
||||
};
|
||||
let reasoning_effort = route.reasoning_effort.and_then(|effort| {
|
||||
cli_reasoning_effort_value_for_prompt(&execution_config, &model, effort, &user_prompt)
|
||||
});
|
||||
|
||||
let system = SystemPrompt::Text(
|
||||
"You are a senior code reviewer. Focus on bugs, risks, behavioral regressions, and missing tests. \
|
||||
Provide findings ordered by severity with file references, then open questions, then a brief summary."
|
||||
.to_string(),
|
||||
);
|
||||
let client = DeepSeekClient::new(&execution_config)?;
|
||||
let request_route = client.effective_route_envelope(&model, chrono::Utc::now());
|
||||
let request = MessageRequest {
|
||||
@@ -8223,8 +8250,20 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
output.push_str(&text);
|
||||
}
|
||||
}
|
||||
// A truncated review must not become a receipt or a success. The partial
|
||||
let structured = pr_view
|
||||
.as_ref()
|
||||
.map(|_| crate::tools::review::ReviewOutput::from_str(&output));
|
||||
// A truncated review must not be posted or become a receipt. The partial
|
||||
// text is still printed for diagnostics below.
|
||||
if args.post && !review_incomplete {
|
||||
let (number, view) = pr_view
|
||||
.as_ref()
|
||||
.expect("--post requires --pr (enforced by clap)");
|
||||
let review = structured
|
||||
.as_ref()
|
||||
.expect("structured output exists for PR reviews");
|
||||
post_pr_review(*number, view, args.repo.as_deref(), review, &diff)?;
|
||||
}
|
||||
let receipt = if args.write_receipt && !review_incomplete {
|
||||
let parsed_output = crate::tools::review::ReviewOutput::from_str(&output);
|
||||
let receipt = crate::tools::review::build_review_receipt(
|
||||
@@ -8257,6 +8296,13 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
"model": model,
|
||||
"success": !review_incomplete,
|
||||
"content": output,
|
||||
"pr": pr_view.as_ref().map(|(number, view)| serde_json::json!({
|
||||
"number": number,
|
||||
"url": view.url,
|
||||
"title": view.title,
|
||||
"head_sha": view.head_sha,
|
||||
})),
|
||||
"review": structured,
|
||||
"stop_reason": review_stop_reason,
|
||||
"error": review_error,
|
||||
"receipt_path": receipt
|
||||
@@ -8268,6 +8314,20 @@ Provide findings ordered by severity with file references, then open questions,
|
||||
if let Some(error) = review_error {
|
||||
anyhow::bail!(error);
|
||||
}
|
||||
} else if let Some((number, view)) = &pr_view {
|
||||
let review = structured
|
||||
.as_ref()
|
||||
.expect("structured output exists for PR reviews");
|
||||
println!(
|
||||
"{}",
|
||||
render_pr_review_markdown(*number, view, review, args.post)
|
||||
);
|
||||
if let Some((path, _)) = receipt {
|
||||
eprintln!("Review receipt written: {}", path.display());
|
||||
}
|
||||
if let Some(error) = review_error {
|
||||
anyhow::bail!(error);
|
||||
}
|
||||
} else {
|
||||
println!("{output}");
|
||||
if let Some((path, _)) = receipt {
|
||||
@@ -8469,6 +8529,9 @@ struct GhPullRequest {
|
||||
base: String,
|
||||
head: String,
|
||||
url: String,
|
||||
/// Head commit SHA (`headRefOid`). Anchors posted review comments to the
|
||||
/// exact revision that was reviewed.
|
||||
head_sha: String,
|
||||
}
|
||||
|
||||
fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
|
||||
@@ -8479,7 +8542,7 @@ fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
|
||||
cmd.arg("--repo").arg(r);
|
||||
}
|
||||
cmd.arg("--json")
|
||||
.arg("title,body,baseRefName,headRefName,url");
|
||||
.arg("title,body,baseRefName,headRefName,url,headRefOid");
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run `gh pr view`: {e}"))?;
|
||||
@@ -8503,6 +8566,7 @@ fn run_gh_pr_view(number: u32, repo: Option<&str>) -> Result<GhPullRequest> {
|
||||
base: pick("baseRefName"),
|
||||
head: pick("headRefName"),
|
||||
url: pick("url"),
|
||||
head_sha: pick("headRefOid"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8540,6 +8604,223 @@ fn run_gh_pr_checkout(number: u32, repo: Option<&str>) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Resolve the `owner/name` repository `gh` believes the current workspace
|
||||
/// belongs to. `gh api` needs an explicit repository path, unlike `gh pr`
|
||||
/// which infers it from the working directory.
|
||||
fn run_gh_repo_name() -> Result<String> {
|
||||
let mut cmd = crate::dependencies::Gh::command()
|
||||
.ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
|
||||
cmd.arg("repo")
|
||||
.arg("view")
|
||||
.arg("--json")
|
||||
.arg("nameWithOwner")
|
||||
.arg("--jq")
|
||||
.arg(".nameWithOwner");
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run `gh repo view`: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
bail!("gh repo view failed: {stderr}");
|
||||
}
|
||||
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if name.is_empty() {
|
||||
bail!("`gh repo view` returned an empty repository name");
|
||||
}
|
||||
Ok(name)
|
||||
}
|
||||
|
||||
/// True when `path` appears as a file touched by the unified diff. Guards the
|
||||
/// inline-comment payload: GitHub rejects the whole review with a 422 when a
|
||||
/// comment's path is not part of the diff.
|
||||
fn diff_touches_path(diff: &str, path: &str) -> bool {
|
||||
diff.contains(&format!("+++ b/{path}")) || diff.contains(&format!("--- a/{path}"))
|
||||
}
|
||||
|
||||
/// Map structured review issues to GitHub inline-review-comment payloads.
|
||||
/// Issues without a locatable position stay in the summary body instead.
|
||||
fn inline_review_comments(
|
||||
review: &crate::tools::review::ReviewOutput,
|
||||
diff: &str,
|
||||
) -> Vec<serde_json::Value> {
|
||||
review
|
||||
.issues
|
||||
.iter()
|
||||
.filter_map(|issue| {
|
||||
let path = issue.path.as_deref()?.trim().trim_start_matches("./");
|
||||
if path.is_empty() || !diff_touches_path(diff, path) {
|
||||
return None;
|
||||
}
|
||||
let line = issue.line?;
|
||||
Some(serde_json::json!({
|
||||
"path": path,
|
||||
"line": line,
|
||||
"body": format!(
|
||||
"**[{}] {}**\n\n{}",
|
||||
issue.severity.to_uppercase(),
|
||||
issue.title,
|
||||
issue.description
|
||||
),
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Render a structured review as the markdown body printed for — and, with
|
||||
/// `--post`, attached to — a pull-request review.
|
||||
fn render_pr_review_markdown(
|
||||
number: u32,
|
||||
view: &GhPullRequest,
|
||||
review: &crate::tools::review::ReviewOutput,
|
||||
posted: bool,
|
||||
) -> String {
|
||||
let mut body = String::new();
|
||||
body.push_str("## Codewhale review\n\n");
|
||||
if !review.summary.is_empty() {
|
||||
body.push_str(review.summary.trim());
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
if !review.issues.is_empty() {
|
||||
body.push_str("### Findings\n\n");
|
||||
for issue in &review.issues {
|
||||
let location = match (&issue.path, issue.line) {
|
||||
(Some(path), Some(line)) => format!("`{}:{line}`", path.trim()),
|
||||
(Some(path), None) => format!("`{}`", path.trim()),
|
||||
_ => String::new(),
|
||||
};
|
||||
if location.is_empty() {
|
||||
body.push_str(&format!(
|
||||
"- **[{}] {}**\n",
|
||||
issue.severity.to_uppercase(),
|
||||
issue.title
|
||||
));
|
||||
} else {
|
||||
body.push_str(&format!(
|
||||
"- **[{}] {}** ({location})\n",
|
||||
issue.severity.to_uppercase(),
|
||||
issue.title
|
||||
));
|
||||
}
|
||||
if !issue.description.is_empty() {
|
||||
body.push_str(&format!(" {}\n", issue.description));
|
||||
}
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
if !review.suggestions.is_empty() {
|
||||
body.push_str("### Suggestions\n\n");
|
||||
for suggestion in &review.suggestions {
|
||||
let location = match (&suggestion.path, suggestion.line) {
|
||||
(Some(path), Some(line)) => format!("`{}:{line}`", path.trim()),
|
||||
(Some(path), None) => format!("`{}`", path.trim()),
|
||||
_ => String::new(),
|
||||
};
|
||||
if location.is_empty() {
|
||||
body.push_str(&format!("- {}\n", suggestion.suggestion));
|
||||
} else {
|
||||
body.push_str(&format!("- {location} — {}\n", suggestion.suggestion));
|
||||
}
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
if !review.overall_assessment.is_empty() {
|
||||
body.push_str("### Assessment\n\n");
|
||||
body.push_str(review.overall_assessment.trim());
|
||||
body.push_str("\n\n");
|
||||
}
|
||||
if posted {
|
||||
body.push_str(&format!(
|
||||
"---\n*Advisory review by Codewhale (`codewhale review --pr {number} --post`, \
|
||||
head `{head}`). Line-specific findings are also posted as inline review \
|
||||
comments. CODEOWNERS approval still governs merge.*\n",
|
||||
head = if view.head_sha.is_empty() {
|
||||
"unknown"
|
||||
} else {
|
||||
view.head_sha.as_str()
|
||||
}
|
||||
));
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
/// Post one COMMENT review: summary body plus inline comments anchored to the
|
||||
/// PR head SHA. Never approves or requests changes — the review is advisory
|
||||
/// and posts alongside CODEOWNERS, like `claude-review.yml`.
|
||||
fn run_gh_post_pr_review(
|
||||
repo: &str,
|
||||
number: u32,
|
||||
body: &str,
|
||||
commit_id: &str,
|
||||
comments: &[serde_json::Value],
|
||||
) -> Result<()> {
|
||||
let mut payload = serde_json::json!({
|
||||
"body": body,
|
||||
"event": "COMMENT",
|
||||
});
|
||||
if !commit_id.is_empty() {
|
||||
payload["commit_id"] = serde_json::json!(commit_id);
|
||||
}
|
||||
if !comments.is_empty() {
|
||||
payload["comments"] = serde_json::json!(comments);
|
||||
}
|
||||
let mut cmd = crate::dependencies::Gh::command()
|
||||
.ok_or_else(|| anyhow::anyhow!("gh not found on PATH"))?;
|
||||
cmd.arg("api")
|
||||
.arg("--method")
|
||||
.arg("POST")
|
||||
.arg(format!("repos/{repo}/pulls/{number}/reviews"))
|
||||
.arg("--input")
|
||||
.arg("-")
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped());
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run `gh api`: {e}"))?;
|
||||
if let Some(stdin) = child.stdin.as_mut() {
|
||||
use std::io::Write;
|
||||
stdin
|
||||
.write_all(serde_json::to_string(&payload)?.as_bytes())
|
||||
.map_err(|e| anyhow::anyhow!("Failed to write review payload: {e}"))?;
|
||||
}
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to wait for `gh api`: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
bail!("gh api POST repos/{repo}/pulls/{number}/reviews failed: {stderr}");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Publish a completed PR review: resolve the repository, render the summary,
|
||||
/// and post it (with inline comments where the diff confirms the position).
|
||||
fn post_pr_review(
|
||||
number: u32,
|
||||
view: &GhPullRequest,
|
||||
repo: Option<&str>,
|
||||
review: &crate::tools::review::ReviewOutput,
|
||||
diff: &str,
|
||||
) -> Result<()> {
|
||||
let repo_name = match repo.map(str::trim).filter(|repo| !repo.is_empty()) {
|
||||
Some(repo) => repo.to_string(),
|
||||
None => run_gh_repo_name()?,
|
||||
};
|
||||
let inline = inline_review_comments(review, diff);
|
||||
let body = render_pr_review_markdown(number, view, review, true);
|
||||
if inline.is_empty() {
|
||||
run_gh_post_pr_review(&repo_name, number, &body, &view.head_sha, &[])?;
|
||||
} else if let Err(err) =
|
||||
run_gh_post_pr_review(&repo_name, number, &body, &view.head_sha, &inline)
|
||||
{
|
||||
// One model-estimated line outside the diff hunks makes GitHub reject
|
||||
// the whole review; retry summary-only rather than lose the review.
|
||||
eprintln!("warning: inline review comments rejected ({err}); retrying summary-only");
|
||||
run_gh_post_pr_review(&repo_name, number, &body, &view.head_sha, &[])?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format the PR review prompt that lands in the composer. Caps the
|
||||
/// diff at 200 KiB so a massive PR doesn't blow the model's context
|
||||
/// window before the user even hits Enter — they can always ask the
|
||||
@@ -8599,27 +8880,31 @@ fn format_pr_prompt(number: u32, view: &GhPullRequest, diff: &str) -> String {
|
||||
}
|
||||
|
||||
fn collect_diff(args: &ReviewArgs) -> Result<String> {
|
||||
let mut cmd = crate::dependencies::Git::command()
|
||||
.ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
|
||||
cmd.arg("diff");
|
||||
if args.staged {
|
||||
cmd.arg("--cached");
|
||||
}
|
||||
if let Some(base) = &args.base {
|
||||
cmd.arg(format!("{base}...HEAD"));
|
||||
}
|
||||
if let Some(path) = &args.path {
|
||||
cmd.arg("--").arg(path);
|
||||
}
|
||||
let mut diff = if let Some(number) = args.pr {
|
||||
run_gh_pr_diff(number, args.repo.as_deref())?
|
||||
} else {
|
||||
let mut cmd = crate::dependencies::Git::command()
|
||||
.ok_or_else(|| anyhow::anyhow!("git not found on PATH"))?;
|
||||
cmd.arg("diff");
|
||||
if args.staged {
|
||||
cmd.arg("--cached");
|
||||
}
|
||||
if let Some(base) = &args.base {
|
||||
cmd.arg(format!("{base}...HEAD"));
|
||||
}
|
||||
if let Some(path) = &args.path {
|
||||
cmd.arg("--").arg(path);
|
||||
}
|
||||
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git diff failed: {}", stderr.trim());
|
||||
}
|
||||
let mut diff = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let output = cmd
|
||||
.output()
|
||||
.map_err(|e| anyhow::anyhow!("Failed to run git diff. Is git installed? ({e})"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
bail!("git diff failed: {}", stderr.trim());
|
||||
}
|
||||
String::from_utf8_lossy(&output.stdout).to_string()
|
||||
};
|
||||
if diff.len() > args.max_chars {
|
||||
diff = crate::utils::truncate_with_ellipsis(&diff, args.max_chars, "\n...[truncated]\n");
|
||||
}
|
||||
@@ -8627,7 +8912,9 @@ fn collect_diff(args: &ReviewArgs) -> Result<String> {
|
||||
}
|
||||
|
||||
fn review_target_label(args: &ReviewArgs) -> String {
|
||||
let mut label = if args.staged {
|
||||
let mut label = if let Some(number) = args.pr {
|
||||
format!("pr:{number}")
|
||||
} else if args.staged {
|
||||
"staged".to_string()
|
||||
} else if let Some(base) = args
|
||||
.base
|
||||
@@ -14426,6 +14713,40 @@ reasoning = "high"
|
||||
assert!(!serialized.contains("local-test-key"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_review_comments_keep_only_diff_locatable_issues() {
|
||||
let issue = |severity: &str, path: Option<&str>, line: Option<u32>| {
|
||||
crate::tools::review::ReviewIssue {
|
||||
severity: severity.to_string(),
|
||||
title: format!("{severity} finding"),
|
||||
description: "detail".to_string(),
|
||||
path: path.map(str::to_string),
|
||||
line,
|
||||
}
|
||||
};
|
||||
let review = crate::tools::review::ReviewOutput {
|
||||
summary: "summary".to_string(),
|
||||
issues: vec![
|
||||
issue("error", Some("./crates/tui/src/lib.rs"), Some(10)),
|
||||
issue("warning", Some("docs/NOT_IN_DIFF.md"), Some(3)),
|
||||
issue("info", Some("crates/tui/src/lib.rs"), None),
|
||||
issue("error", None, Some(7)),
|
||||
],
|
||||
suggestions: Vec::new(),
|
||||
overall_assessment: String::new(),
|
||||
};
|
||||
let diff = "diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs\n\
|
||||
index 111..222 100644\n\
|
||||
--- a/crates/tui/src/lib.rs\n\
|
||||
+++ b/crates/tui/src/lib.rs\n";
|
||||
let comments = inline_review_comments(&review, diff);
|
||||
// GitHub rejects the entire review (422) when a comment's path is not
|
||||
// part of the diff, and inline comments need a line to anchor to.
|
||||
assert_eq!(comments.len(), 1);
|
||||
assert_eq!(comments[0]["path"], "crates/tui/src/lib.rs");
|
||||
assert_eq!(comments[0]["line"], 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn configured_workflow_default_keeps_named_custom_route() {
|
||||
let config = custom_exec_config("custom-a");
|
||||
|
||||
@@ -432,9 +432,11 @@ pub enum MessageId {
|
||||
GoalContinuationStopped,
|
||||
CmdInitDescription,
|
||||
CmdJobsDescription,
|
||||
CmdDispatchDescription,
|
||||
CmdLinksDescription,
|
||||
CmdLoadDescription,
|
||||
CmdLogoutDescription,
|
||||
CmdLoginDescription,
|
||||
CmdMcpDescription,
|
||||
McpRecommendedUnknownId,
|
||||
McpRecommendationsHeading,
|
||||
@@ -576,6 +578,12 @@ pub enum MessageId {
|
||||
CmdPluginLegacyListHeader,
|
||||
CmdPluginBundleNotFound,
|
||||
CmdPluginBundleReloaded,
|
||||
PluginPromptSuggestTrust,
|
||||
PluginPromptSuggestEnable,
|
||||
PluginPromptSuggestMarketplace,
|
||||
PluginCtaInstallPrompt,
|
||||
PluginCtaReview,
|
||||
PluginCtaDismiss,
|
||||
CmdPluginBundleDetail,
|
||||
CmdPluginBundleDiagnosticsHeader,
|
||||
CmdPluginBundleMutationSuccess,
|
||||
@@ -2248,9 +2256,11 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdAgentDescription,
|
||||
MessageId::CmdInitDescription,
|
||||
MessageId::CmdJobsDescription,
|
||||
MessageId::CmdDispatchDescription,
|
||||
MessageId::CmdLinksDescription,
|
||||
MessageId::CmdLoadDescription,
|
||||
MessageId::CmdLogoutDescription,
|
||||
MessageId::CmdLoginDescription,
|
||||
MessageId::CmdMcpDescription,
|
||||
MessageId::McpRecommendedUnknownId,
|
||||
MessageId::McpRecommendationsHeading,
|
||||
@@ -2391,6 +2401,12 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[
|
||||
MessageId::CmdPluginLegacyListHeader,
|
||||
MessageId::CmdPluginBundleNotFound,
|
||||
MessageId::CmdPluginBundleReloaded,
|
||||
MessageId::PluginPromptSuggestTrust,
|
||||
MessageId::PluginPromptSuggestEnable,
|
||||
MessageId::PluginPromptSuggestMarketplace,
|
||||
MessageId::PluginCtaInstallPrompt,
|
||||
MessageId::PluginCtaReview,
|
||||
MessageId::PluginCtaDismiss,
|
||||
MessageId::CmdPluginBundleDetail,
|
||||
MessageId::CmdPluginBundleDiagnosticsHeader,
|
||||
MessageId::CmdPluginBundleMutationSuccess,
|
||||
|
||||
@@ -499,22 +499,6 @@ pub(crate) fn load_staged_skill_snapshots(
|
||||
Ok(snapshots)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn load_plugin_for_test(manifest_path: &Path) -> Result<LoadedPlugin, String> {
|
||||
let discovery_root = manifest_path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.ok_or_else(|| "test plugin manifest needs a bundle and discovery root".to_string())?
|
||||
.canonicalize()
|
||||
.map_err(|error| format!("failed to canonicalize test discovery root: {error}"))?;
|
||||
load_plugin(
|
||||
manifest_path,
|
||||
&discovery_root,
|
||||
PluginScope::User,
|
||||
PluginOrigin::CodeWhaleHome,
|
||||
)
|
||||
}
|
||||
|
||||
fn plugin_id(scope: PluginScope, name: &str, canonical_root: &Path) -> PluginId {
|
||||
let mut hasher = Sha256::new();
|
||||
// v2 intentionally invalidates receipts produced by the former lossy
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Pure keyword matcher over plugin metadata.
|
||||
//!
|
||||
//! Matches live as data: explicit `keywords`, `domains` (scheme / `www.` /
|
||||
//! path stripped), and the plugin `name`. There is no `regex` dependency —
|
||||
//! matching is substring search guarded by ASCII word boundaries.
|
||||
|
||||
use std::cmp::Reverse;
|
||||
|
||||
/// A plugin to match a draft against.
|
||||
pub struct KeywordCandidate<'a> {
|
||||
pub name: &'a str,
|
||||
pub domains: &'a [String],
|
||||
pub keywords: &'a [String],
|
||||
}
|
||||
|
||||
/// Return the index of the single candidate whose keyword matches `draft`.
|
||||
///
|
||||
/// Returns `None` when `draft` has fewer than 3 characters or nothing matches.
|
||||
/// Longer keywords take precedence; a keyword matches only when the occurrence
|
||||
/// is flanked by ASCII word boundaries.
|
||||
pub fn match_plugin_keyword(draft: &str, candidates: &[KeywordCandidate<'_>]) -> Option<usize> {
|
||||
if draft.chars().count() < 3 {
|
||||
return None;
|
||||
}
|
||||
let draft_lc = draft.to_ascii_lowercase();
|
||||
let haystack = draft_lc.as_bytes();
|
||||
|
||||
let mut pairs: Vec<(String, usize)> = Vec::new();
|
||||
for (idx, candidate) in candidates.iter().enumerate() {
|
||||
for keyword in effective_keywords(candidate) {
|
||||
pairs.push((keyword, idx));
|
||||
}
|
||||
}
|
||||
pairs.sort_by_key(|(keyword, _)| Reverse(keyword.len()));
|
||||
|
||||
pairs
|
||||
.iter()
|
||||
.find(|(keyword, _)| keyword_matches(haystack, keyword.as_bytes()))
|
||||
.map(|(_, idx)| *idx)
|
||||
}
|
||||
|
||||
fn effective_keywords(candidate: &KeywordCandidate<'_>) -> Vec<String> {
|
||||
let mut keywords = Vec::new();
|
||||
for keyword in candidate.keywords {
|
||||
let normalized = keyword.trim().to_ascii_lowercase();
|
||||
if !normalized.is_empty() {
|
||||
keywords.push(normalized);
|
||||
}
|
||||
}
|
||||
for domain in candidate.domains {
|
||||
if let Some(normalized) = normalize_domain(domain) {
|
||||
keywords.push(normalized);
|
||||
}
|
||||
}
|
||||
let name = candidate.name.trim().to_ascii_lowercase();
|
||||
if !name.is_empty() {
|
||||
keywords.push(name);
|
||||
}
|
||||
keywords
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_domain(domain: &str) -> Option<String> {
|
||||
let trimmed = domain.trim();
|
||||
let after_scheme = match trimmed.find("://") {
|
||||
Some(i) => &trimmed[i + 3..],
|
||||
None => trimmed,
|
||||
};
|
||||
let host = after_scheme
|
||||
.split(['/', '?', '#'])
|
||||
.next()
|
||||
.unwrap_or(after_scheme)
|
||||
.to_ascii_lowercase();
|
||||
let host = host.strip_prefix("www.").unwrap_or(&host);
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn keyword_matches(haystack: &[u8], keyword: &[u8]) -> bool {
|
||||
if keyword.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let len = haystack.len();
|
||||
haystack
|
||||
.windows(keyword.len())
|
||||
.enumerate()
|
||||
.any(|(start, window)| {
|
||||
if window != keyword {
|
||||
return false;
|
||||
}
|
||||
let end = start + keyword.len();
|
||||
let start_ok = start == 0 || is_word(haystack[start - 1]) != is_word(haystack[start]);
|
||||
let end_ok = end == len || is_word(haystack[end - 1]) != is_word(haystack[end]);
|
||||
start_ok && end_ok
|
||||
})
|
||||
}
|
||||
|
||||
fn is_word(byte: u8) -> bool {
|
||||
byte.is_ascii_alphanumeric() || byte == b'_'
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn candidate<'a>(
|
||||
name: &'a str,
|
||||
domains: &'a [String],
|
||||
keywords: &'a [String],
|
||||
) -> KeywordCandidate<'a> {
|
||||
KeywordCandidate {
|
||||
name,
|
||||
domains,
|
||||
keywords,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn longest_keyword_takes_precedence() {
|
||||
let short = vec!["editor".to_string()];
|
||||
let long = vec!["code editor".to_string()];
|
||||
let candidates = [
|
||||
candidate("plugin-a", &[], &short),
|
||||
candidate("plugin-b", &[], &long),
|
||||
];
|
||||
assert_eq!(
|
||||
match_plugin_keyword("my code editor rocks", &candidates),
|
||||
Some(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_boundary_required() {
|
||||
let keywords = vec!["box".to_string()];
|
||||
let candidates = [candidate("box", &[], &keywords)];
|
||||
assert_eq!(match_plugin_keyword("i love boxing", &candidates), None);
|
||||
assert_eq!(match_plugin_keyword("i love box", &candidates), Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxing_does_not_match_box() {
|
||||
let keywords = vec!["box".to_string()];
|
||||
let candidates = [candidate("box", &[], &keywords)];
|
||||
assert_eq!(match_plugin_keyword("try boxing drills", &candidates), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn domains_match_inside_pasted_urls() {
|
||||
let domains = vec!["figma.com".to_string()];
|
||||
let none: Vec<String> = Vec::new();
|
||||
let candidates = [candidate("design-app", &domains, &none)];
|
||||
assert_eq!(
|
||||
match_plugin_keyword("open https://www.figma.com/board/x please", &candidates),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(
|
||||
match_plugin_keyword("open figma.com please", &candidates),
|
||||
Some(0)
|
||||
);
|
||||
assert_eq!(match_plugin_keyword("open figma please", &candidates), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_is_used_as_fallback() {
|
||||
let none: Vec<String> = Vec::new();
|
||||
let candidates = [candidate("obsidian", &[], &none)];
|
||||
assert_eq!(
|
||||
match_plugin_keyword("open obsidian now", &candidates),
|
||||
Some(0)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draft_below_min_length_never_matches() {
|
||||
let keywords = vec!["go".to_string()];
|
||||
let candidates = [candidate("go", &[], &keywords)];
|
||||
assert_eq!(match_plugin_keyword("go", &candidates), None);
|
||||
let git = vec!["git".to_string()];
|
||||
let candidates = [candidate("git", &[], &git)];
|
||||
assert_eq!(match_plugin_keyword("git", &candidates), Some(0));
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,10 @@ pub mod export;
|
||||
pub mod install;
|
||||
pub mod manifest;
|
||||
pub mod marketplace;
|
||||
pub mod matcher;
|
||||
pub mod mutation;
|
||||
mod path_identity;
|
||||
pub mod recommend;
|
||||
pub mod registry;
|
||||
pub mod runtime;
|
||||
pub mod types;
|
||||
|
||||
@@ -0,0 +1,672 @@
|
||||
//! Deterministic plugin suggestions for a user task.
|
||||
//!
|
||||
//! Ranks installed bundles and locally-added marketplace candidates. A
|
||||
//! suggestion is never an install, trust, enable, or network side effect.
|
||||
//! Proactive toasts must use a high `min_score` so description-only matches
|
||||
//! do not nag; `/plugin suggest` can rank more loosely.
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use crate::skills::install::{RegistryDocument, RegistryEntry};
|
||||
use crate::skills::recommend::recommend_remote_skills;
|
||||
|
||||
use super::marketplace::types::MarketplaceCandidate;
|
||||
use super::registry::PluginRegistry;
|
||||
use super::types::LoadedPlugin;
|
||||
|
||||
const DEFAULT_LIMIT: usize = 3;
|
||||
/// Keyword and name matches score 700–900; description fallbacks are ~120.
|
||||
pub const PROACTIVE_MIN_SCORE: usize = 700;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RecommendOptions {
|
||||
pub limit: usize,
|
||||
pub min_score: usize,
|
||||
pub include_active: bool,
|
||||
}
|
||||
|
||||
impl Default for RecommendOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
limit: DEFAULT_LIMIT,
|
||||
min_score: 0,
|
||||
include_active: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RecommendOptions {
|
||||
#[must_use]
|
||||
pub fn proactive() -> Self {
|
||||
Self {
|
||||
limit: 1,
|
||||
min_score: PROACTIVE_MIN_SCORE,
|
||||
include_active: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginMatchSource {
|
||||
Installed { id: String },
|
||||
Marketplace { catalog_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginNextStep {
|
||||
AlreadyActive,
|
||||
Trust,
|
||||
Enable,
|
||||
Inspect,
|
||||
MarketplaceInstall {
|
||||
catalog_id: String,
|
||||
},
|
||||
/// `/plugin install` only when a catalog entry carries a real source spec.
|
||||
SourceInstall {
|
||||
spec: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTaskRecommendation {
|
||||
pub name: String,
|
||||
pub source: PluginMatchSource,
|
||||
pub matched_terms: Vec<String>,
|
||||
pub score: usize,
|
||||
pub next_step: PluginNextStep,
|
||||
}
|
||||
|
||||
impl PluginTaskRecommendation {
|
||||
#[must_use]
|
||||
pub fn command(&self) -> String {
|
||||
match &self.next_step {
|
||||
PluginNextStep::AlreadyActive | PluginNextStep::Inspect => {
|
||||
format!("/plugin show {}", self.name)
|
||||
}
|
||||
PluginNextStep::Trust => format!("/plugin trust {}", self.name),
|
||||
PluginNextStep::Enable => format!("/plugin enable {}", self.name),
|
||||
PluginNextStep::MarketplaceInstall { catalog_id } => {
|
||||
format!("/plugin marketplace install {catalog_id} {}", self.name)
|
||||
}
|
||||
PluginNextStep::SourceInstall { spec } => format!("/plugin install {spec}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const RECOMMENDED_PLUGINS_INTRO: &str =
|
||||
"Here is a list of plugins that are available but not installed.";
|
||||
const MAX_RECOMMENDED_PLUGINS: usize = 8;
|
||||
|
||||
/// One matcher-driven candidate for the live composer CTA or the
|
||||
/// append-only `<recommended_plugins>` user fragment.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginKeywordMatch {
|
||||
pub name: String,
|
||||
pub id: String,
|
||||
pub next_step: PluginNextStep,
|
||||
keywords: Vec<String>,
|
||||
domains: Vec<String>,
|
||||
}
|
||||
|
||||
impl PluginKeywordMatch {
|
||||
#[must_use]
|
||||
pub fn command(&self) -> String {
|
||||
PluginTaskRecommendation {
|
||||
name: self.name.clone(),
|
||||
source: match &self.next_step {
|
||||
PluginNextStep::MarketplaceInstall { catalog_id } => {
|
||||
PluginMatchSource::Marketplace {
|
||||
catalog_id: catalog_id.clone(),
|
||||
}
|
||||
}
|
||||
_ => PluginMatchSource::Installed {
|
||||
id: self.id.clone(),
|
||||
},
|
||||
},
|
||||
matched_terms: Vec::new(),
|
||||
score: 0,
|
||||
next_step: self.next_step.clone(),
|
||||
}
|
||||
.command()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load marketplace catalogs the user added locally. Never invents a remote
|
||||
/// official marketplace URL.
|
||||
#[must_use]
|
||||
pub fn load_marketplace_candidates(
|
||||
state_path: Option<&std::path::Path>,
|
||||
) -> Vec<MarketplaceCandidate> {
|
||||
let Some(store) = crate::plugins::marketplace::store::MarketplaceStore::open(state_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Ok(state) = store.load() else {
|
||||
return Vec::new();
|
||||
};
|
||||
state
|
||||
.catalogs()
|
||||
.values()
|
||||
.flat_map(|catalog| catalog.catalog.candidates.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Keyword candidates that can still be reviewed: installed-but-idle plugins
|
||||
/// and uninstalled catalog entries. Already-active plugins are omitted.
|
||||
#[must_use]
|
||||
pub fn idle_and_catalog_keyword_matches(
|
||||
registry: &PluginRegistry,
|
||||
marketplace: &[MarketplaceCandidate],
|
||||
) -> Vec<PluginKeywordMatch> {
|
||||
let installed = registry.list();
|
||||
let installed_names = installed
|
||||
.iter()
|
||||
.map(|plugin| plugin.name().to_ascii_lowercase())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut out = Vec::new();
|
||||
for plugin in &installed {
|
||||
if plugin.active() {
|
||||
continue;
|
||||
}
|
||||
let next_step = if !plugin.trusted() {
|
||||
PluginNextStep::Trust
|
||||
} else if !plugin.enabled {
|
||||
PluginNextStep::Enable
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let mut keywords = plugin.manifest.plugin.keywords.clone();
|
||||
keywords.push(plugin.name().to_string());
|
||||
out.push(PluginKeywordMatch {
|
||||
name: plugin.name().to_string(),
|
||||
id: plugin.id.as_str().to_string(),
|
||||
next_step,
|
||||
keywords,
|
||||
domains: plugin.inventory.network_hosts.clone(),
|
||||
});
|
||||
}
|
||||
for candidate in marketplace {
|
||||
if candidate.has_errors() {
|
||||
continue;
|
||||
}
|
||||
if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
let mut keywords = candidate.keywords.clone();
|
||||
keywords.push(candidate.name.clone());
|
||||
if let Some(display) = &candidate.display_name {
|
||||
keywords.push(display.clone());
|
||||
}
|
||||
keywords.extend(candidate.categories.iter().cloned());
|
||||
let mut domains = Vec::new();
|
||||
if let Some(homepage) = &candidate.homepage {
|
||||
domains.push(homepage.clone());
|
||||
}
|
||||
if let Some(repository) = &candidate.repository {
|
||||
domains.push(repository.clone());
|
||||
}
|
||||
let next_step = match &candidate.install_plan {
|
||||
crate::plugins::marketplace::types::MarketplaceInstallPlan::Supported {
|
||||
spec, ..
|
||||
} if candidate.catalog_id.as_str().is_empty() => {
|
||||
PluginNextStep::SourceInstall { spec: spec.clone() }
|
||||
}
|
||||
_ => PluginNextStep::MarketplaceInstall {
|
||||
catalog_id: candidate.catalog_id.as_str().to_string(),
|
||||
},
|
||||
};
|
||||
out.push(PluginKeywordMatch {
|
||||
name: candidate.name.clone(),
|
||||
id: candidate.id.as_str().to_string(),
|
||||
next_step,
|
||||
keywords,
|
||||
domains,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One matcher-driven hit for a live draft. Already-active plugins never
|
||||
/// match. `/plugin install` is returned only when a catalog entry carries a
|
||||
/// real install spec.
|
||||
#[must_use]
|
||||
pub fn match_plugin_for_draft(
|
||||
draft: &str,
|
||||
registry: &PluginRegistry,
|
||||
marketplace: &[MarketplaceCandidate],
|
||||
) -> Option<PluginKeywordMatch> {
|
||||
let candidates = idle_and_catalog_keyword_matches(registry, marketplace);
|
||||
match_plugin_for_draft_among(draft, &candidates)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn match_plugin_for_draft_among(
|
||||
draft: &str,
|
||||
candidates: &[PluginKeywordMatch],
|
||||
) -> Option<PluginKeywordMatch> {
|
||||
let keyword_candidates = candidates
|
||||
.iter()
|
||||
.map(|candidate| crate::plugins::matcher::KeywordCandidate {
|
||||
name: candidate.name.as_str(),
|
||||
domains: &candidate.domains,
|
||||
keywords: &candidate.keywords,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let idx = crate::plugins::matcher::match_plugin_keyword(draft, &keyword_candidates)?;
|
||||
candidates.get(idx).cloned()
|
||||
}
|
||||
|
||||
/// Append-only user-turn fragment. Never part of the pinned system prefix.
|
||||
/// Bounded, omitted when nothing matches.
|
||||
#[must_use]
|
||||
pub fn recommended_plugins_user_fragment(
|
||||
draft: &str,
|
||||
registry: &PluginRegistry,
|
||||
marketplace: &[MarketplaceCandidate],
|
||||
) -> Option<String> {
|
||||
let candidates = idle_and_catalog_keyword_matches(registry, marketplace);
|
||||
if candidates.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let matched = match_plugin_for_draft_among(draft, &candidates)?;
|
||||
let mut listed = vec![matched];
|
||||
listed.truncate(MAX_RECOMMENDED_PLUGINS);
|
||||
let body = listed
|
||||
.iter()
|
||||
.map(|plugin| format!("- {} ({})", plugin.name, plugin.id))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
Some(format!(
|
||||
"<recommended_plugins>\n{RECOMMENDED_PLUGINS_INTRO}\n\n{body}\n</recommended_plugins>"
|
||||
))
|
||||
}
|
||||
|
||||
/// Resolve a model-requested plugin name against installed and catalog
|
||||
/// entries. Fails closed (None) when the name is unknown.
|
||||
#[must_use]
|
||||
pub fn lookup_reviewable_plugin(
|
||||
name: &str,
|
||||
registry: &PluginRegistry,
|
||||
marketplace: &[MarketplaceCandidate],
|
||||
) -> Option<PluginKeywordMatch> {
|
||||
let needle = name.trim();
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
idle_and_catalog_keyword_matches(registry, marketplace)
|
||||
.into_iter()
|
||||
.find(|candidate| {
|
||||
candidate.name.eq_ignore_ascii_case(needle) || candidate.id.eq_ignore_ascii_case(needle)
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn recommend_plugins_for_task(
|
||||
task: &str,
|
||||
registry: &PluginRegistry,
|
||||
marketplace: &[MarketplaceCandidate],
|
||||
options: RecommendOptions,
|
||||
) -> Vec<PluginTaskRecommendation> {
|
||||
let installed = registry.list();
|
||||
let installed_names = installed
|
||||
.iter()
|
||||
.map(|plugin| plugin.name().to_ascii_lowercase())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut entries = Vec::new();
|
||||
for plugin in &installed {
|
||||
entries.push(index_entry_from_installed(plugin));
|
||||
}
|
||||
for candidate in marketplace {
|
||||
if candidate.has_errors() {
|
||||
continue;
|
||||
}
|
||||
if installed_names.contains(&candidate.name.to_ascii_lowercase()) {
|
||||
continue;
|
||||
}
|
||||
entries.push(index_entry_from_marketplace(candidate));
|
||||
}
|
||||
recommend_from_entries(task, &entries, &installed, options)
|
||||
}
|
||||
|
||||
fn index_entry_from_installed(plugin: &LoadedPlugin) -> (String, RegistryEntry) {
|
||||
let mut keywords = plugin.manifest.plugin.keywords.clone();
|
||||
keywords.push(plugin.name().to_string());
|
||||
let mut description_parts = plugin
|
||||
.manifest
|
||||
.plugin
|
||||
.description
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
for skill in &plugin.skill_snapshots {
|
||||
description_parts.push(skill.name.clone());
|
||||
description_parts.push(skill.description.clone());
|
||||
keywords.push(skill.name.clone());
|
||||
keywords.extend(skill.aliases.iter().cloned());
|
||||
}
|
||||
(
|
||||
format!("installed:{}", plugin.name()),
|
||||
RegistryEntry {
|
||||
source: plugin.id.as_str().to_string(),
|
||||
description: (!description_parts.is_empty()).then(|| description_parts.join(" ")),
|
||||
keywords,
|
||||
domains: plugin.inventory.network_hosts.clone(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn index_entry_from_marketplace(candidate: &MarketplaceCandidate) -> (String, RegistryEntry) {
|
||||
let mut keywords = candidate.keywords.clone();
|
||||
keywords.push(candidate.name.clone());
|
||||
if let Some(display) = &candidate.display_name {
|
||||
keywords.push(display.clone());
|
||||
}
|
||||
keywords.extend(candidate.categories.iter().cloned());
|
||||
(
|
||||
format!(
|
||||
"marketplace:{}:{}",
|
||||
candidate.catalog_id.as_str(),
|
||||
candidate.name
|
||||
),
|
||||
RegistryEntry {
|
||||
source: format!(
|
||||
"marketplace:{}:{}",
|
||||
candidate.catalog_id.as_str(),
|
||||
candidate.name
|
||||
),
|
||||
description: candidate.description.clone(),
|
||||
keywords,
|
||||
domains: Vec::new(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn recommend_from_entries(
|
||||
task: &str,
|
||||
entries: &[(String, RegistryEntry)],
|
||||
installed: &[&LoadedPlugin],
|
||||
options: RecommendOptions,
|
||||
) -> Vec<PluginTaskRecommendation> {
|
||||
if options.limit == 0 {
|
||||
return Vec::new();
|
||||
}
|
||||
let index = RegistryDocument {
|
||||
skills: entries.iter().cloned().collect::<BTreeMap<_, _>>(),
|
||||
};
|
||||
let ranked = recommend_remote_skills(task, &index, options.limit.saturating_mul(2));
|
||||
let mut out = Vec::new();
|
||||
let mut seen_names = BTreeSet::new();
|
||||
for recommendation in ranked {
|
||||
if recommendation.score() < options.min_score {
|
||||
continue;
|
||||
}
|
||||
let (source, name, next_step) =
|
||||
match recommendation.entry.source.strip_prefix("marketplace:") {
|
||||
Some(rest) => {
|
||||
let Some((catalog_id, name)) = rest.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
(
|
||||
PluginMatchSource::Marketplace {
|
||||
catalog_id: catalog_id.to_string(),
|
||||
},
|
||||
name.to_string(),
|
||||
PluginNextStep::MarketplaceInstall {
|
||||
catalog_id: catalog_id.to_string(),
|
||||
},
|
||||
)
|
||||
}
|
||||
None => {
|
||||
let Some(plugin) = installed
|
||||
.iter()
|
||||
.find(|plugin| plugin.id.as_str() == recommendation.entry.source)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let next_step = if plugin.active() {
|
||||
PluginNextStep::AlreadyActive
|
||||
} else if !plugin.trusted() {
|
||||
PluginNextStep::Trust
|
||||
} else if !plugin.enabled {
|
||||
PluginNextStep::Enable
|
||||
} else {
|
||||
PluginNextStep::Inspect
|
||||
};
|
||||
(
|
||||
PluginMatchSource::Installed {
|
||||
id: plugin.id.as_str().to_string(),
|
||||
},
|
||||
plugin.name().to_string(),
|
||||
next_step,
|
||||
)
|
||||
}
|
||||
};
|
||||
if !options.include_active && next_step == PluginNextStep::AlreadyActive {
|
||||
continue;
|
||||
}
|
||||
let name_key = name.to_ascii_lowercase();
|
||||
if !seen_names.insert(name_key) {
|
||||
continue;
|
||||
}
|
||||
out.push(PluginTaskRecommendation {
|
||||
name,
|
||||
source,
|
||||
matched_terms: recommendation.matched_terms.clone(),
|
||||
score: recommendation.score(),
|
||||
next_step,
|
||||
});
|
||||
if out.len() >= options.limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::plugins::marketplace::types::{
|
||||
CatalogProvenance, CatalogTier, MarketplaceCandidate, MarketplaceCandidateId,
|
||||
MarketplaceCatalogId, MarketplaceInstallPlan, MarketplaceSourceSpec,
|
||||
};
|
||||
use crate::test_support::{EnvVarGuard, lock_test_env};
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keyword_bundle(
|
||||
root: &std::path::Path,
|
||||
name: &str,
|
||||
description: &str,
|
||||
keywords: &[&str],
|
||||
) {
|
||||
let bundle = root.join(".codewhale/plugins").join(name);
|
||||
fs::create_dir_all(&bundle).unwrap();
|
||||
let keyword_list = keywords
|
||||
.iter()
|
||||
.map(|keyword| format!("\"{keyword}\""))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{description}\"\nkeywords = [{keyword_list}]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn marketplace_candidate(catalog: &str, name: &str, keywords: &[&str]) -> MarketplaceCandidate {
|
||||
MarketplaceCandidate {
|
||||
id: MarketplaceCandidateId::new(&MarketplaceCatalogId::new(catalog), name),
|
||||
catalog_id: MarketplaceCatalogId::new(catalog),
|
||||
name: name.to_string(),
|
||||
display_name: Some(format!("{name} plugin")),
|
||||
description: Some(format!("{name} integration")),
|
||||
version: None,
|
||||
author: None,
|
||||
homepage: None,
|
||||
repository: None,
|
||||
license: None,
|
||||
keywords: keywords.iter().map(|value| (*value).to_string()).collect(),
|
||||
categories: Vec::new(),
|
||||
source: MarketplaceSourceSpec::GitHub {
|
||||
owner: "example".to_string(),
|
||||
repo: name.to_string(),
|
||||
git_ref: None,
|
||||
sha: None,
|
||||
},
|
||||
install_plan: MarketplaceInstallPlan::Supported {
|
||||
spec: format!("github:example/{name}"),
|
||||
source_kind: "github".to_string(),
|
||||
},
|
||||
declared_components: None,
|
||||
compatibility: None,
|
||||
provenance: CatalogProvenance {
|
||||
tier: CatalogTier::Community,
|
||||
publisher: None,
|
||||
source_url: None,
|
||||
},
|
||||
when: None,
|
||||
diagnostics: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_match_ranks_installed_supabase_plugin() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(
|
||||
root.path(),
|
||||
"supabase",
|
||||
"Hosted Postgres and auth",
|
||||
&["supabase", "postgres"],
|
||||
);
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
|
||||
let recs = recommend_plugins_for_task(
|
||||
"add supabase auth to this app",
|
||||
®istry,
|
||||
&[],
|
||||
RecommendOptions::proactive(),
|
||||
);
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert_eq!(recs[0].name, "supabase");
|
||||
assert!(recs[0].score >= PROACTIVE_MIN_SCORE);
|
||||
assert_eq!(recs[0].next_step, PluginNextStep::Trust);
|
||||
assert_eq!(recs[0].command(), "/plugin trust supabase");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_fills_in_a_missing_plugin() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
let catalog = [marketplace_candidate("official", "supabase", &["supabase"])];
|
||||
|
||||
let recs = recommend_plugins_for_task(
|
||||
"wire up supabase row level security",
|
||||
®istry,
|
||||
&catalog,
|
||||
RecommendOptions::proactive(),
|
||||
);
|
||||
assert_eq!(recs.len(), 1);
|
||||
assert_eq!(recs[0].name, "supabase");
|
||||
assert_eq!(
|
||||
recs[0].next_step,
|
||||
PluginNextStep::MarketplaceInstall {
|
||||
catalog_id: "official".to_string()
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
recs[0].command(),
|
||||
"/plugin marketplace install official supabase"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_active_plugins_are_skipped_for_proactive_toasts() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
|
||||
let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path())
|
||||
.as_ref()
|
||||
.clone();
|
||||
registry.trust("supabase").unwrap();
|
||||
registry.enable("supabase").unwrap();
|
||||
|
||||
let recs = recommend_plugins_for_task(
|
||||
"add supabase auth",
|
||||
®istry,
|
||||
&[],
|
||||
RecommendOptions::proactive(),
|
||||
);
|
||||
assert!(recs.is_empty(), "{recs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_prompts_do_not_match_on_description_alone() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(
|
||||
root.path(),
|
||||
"notes",
|
||||
"Create and organize spreadsheet notes",
|
||||
&[],
|
||||
);
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
|
||||
let recs = recommend_plugins_for_task(
|
||||
"fix the failing test",
|
||||
®istry,
|
||||
&[],
|
||||
RecommendOptions::proactive(),
|
||||
);
|
||||
assert!(recs.is_empty(), "{recs:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommended_plugins_fragment_present_for_matching_idle_plugin() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
|
||||
let fragment =
|
||||
recommended_plugins_user_fragment("add supabase auth to login", ®istry, &[])
|
||||
.expect("idle plugin should produce a fragment");
|
||||
assert!(fragment.starts_with("<recommended_plugins>"));
|
||||
assert!(fragment.contains("- supabase ("));
|
||||
assert!(fragment.contains("</recommended_plugins>"));
|
||||
assert!(
|
||||
recommended_plugins_user_fragment("fix the failing test", ®istry, &[]).is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn matcher_driven_cta_skips_already_active_plugins() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(root.path(), "supabase", "Hosted Postgres", &["supabase"]);
|
||||
let mut registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path())
|
||||
.as_ref()
|
||||
.clone();
|
||||
registry.trust("supabase").unwrap();
|
||||
registry.enable("supabase").unwrap();
|
||||
|
||||
assert!(
|
||||
match_plugin_for_draft("add supabase auth", ®istry, &[]).is_none(),
|
||||
"active plugins must not produce a live CTA"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -484,21 +484,6 @@ pub fn models_for_provider(
|
||||
}
|
||||
}
|
||||
|
||||
/// Every built-in provider that carries at least one merged-catalog row.
|
||||
#[must_use]
|
||||
#[allow(dead_code)]
|
||||
pub fn all_catalog_providers() -> Vec<ApiProvider> {
|
||||
let mut seen = Vec::new();
|
||||
for offering in &merged_snapshot().offerings {
|
||||
if let Some(provider) = ApiProvider::parse(&offering.provider)
|
||||
&& !seen.contains(&provider)
|
||||
{
|
||||
seen.push(provider);
|
||||
}
|
||||
}
|
||||
seen
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -20,6 +20,13 @@ pub struct RemoteSkillRecommendation<'a> {
|
||||
score: usize,
|
||||
}
|
||||
|
||||
impl RemoteSkillRecommendation<'_> {
|
||||
#[must_use]
|
||||
pub fn score(&self) -> usize {
|
||||
self.score
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank up to `limit` remote skills for `query`.
|
||||
///
|
||||
/// Explicit registry keywords and domains outrank name and description
|
||||
|
||||
@@ -1587,6 +1587,10 @@ fn plugin_skills_are_qualified_and_denied_until_trusted_and_enabled() {
|
||||
let rendered = super::render_skills_block(®istry, "en", tmp.path()).unwrap();
|
||||
assert!(rendered.contains("reviewed plugin snapshot: demo"));
|
||||
assert!(rendered.contains("use load_skill"));
|
||||
assert!(
|
||||
rendered.contains("hello"),
|
||||
"plugin skill descriptions must reach the model catalogue like native skills: {rendered}"
|
||||
);
|
||||
assert!(
|
||||
!rendered.contains(&plugin_root.display().to_string()),
|
||||
"model prompt must not expose mutable plugin files after snapshot review"
|
||||
|
||||
@@ -7,6 +7,7 @@ fn sample_pr() -> GhPullRequest {
|
||||
base: "main".to_string(),
|
||||
head: "feat/cool".to_string(),
|
||||
url: "https://github.com/example/repo/pull/123".to_string(),
|
||||
head_sha: "abc123def456abc123def456abc123def456abc1".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +31,7 @@ fn format_pr_prompt_handles_empty_body_and_unknown_branches() {
|
||||
base: String::new(),
|
||||
head: String::new(),
|
||||
url: String::new(),
|
||||
head_sha: String::new(),
|
||||
};
|
||||
let prompt = format_pr_prompt(7, &pr, "(diff body)");
|
||||
assert!(prompt.contains("(PR #7)"));
|
||||
|
||||
@@ -48,6 +48,7 @@ pub mod project;
|
||||
pub mod read_media;
|
||||
pub mod registry;
|
||||
pub mod remember;
|
||||
pub mod request_plugin_install;
|
||||
mod resource_admission;
|
||||
pub mod revert_turn;
|
||||
pub mod review;
|
||||
|
||||
@@ -1173,6 +1173,15 @@ impl ToolRegistryBuilder {
|
||||
self.with_tool(Arc::new(NotifyTool))
|
||||
}
|
||||
|
||||
/// Include `request_plugin_install` — model-callable review request.
|
||||
/// Never installs; the TUI surfaces `/plugin trust` or catalog install
|
||||
/// for the human.
|
||||
#[must_use]
|
||||
pub fn with_request_plugin_install_tool(self) -> Self {
|
||||
use super::request_plugin_install::RequestPluginInstallTool;
|
||||
self.with_tool(Arc::new(RequestPluginInstallTool))
|
||||
}
|
||||
|
||||
/// Include MCP tools from a connected pool as first-class registry
|
||||
/// citizens. Each MCP tool is wrapped in a lightweight adapter that
|
||||
/// implements `ToolSpec`, so the unified `ToolRegistryBuilder` flow
|
||||
@@ -1318,7 +1327,9 @@ impl ToolRegistryBuilder {
|
||||
builder = builder.with_vision_tools(vision_config, vision_client);
|
||||
}
|
||||
|
||||
builder.with_notify_tool()
|
||||
builder
|
||||
.with_notify_tool()
|
||||
.with_request_plugin_install_tool()
|
||||
}
|
||||
|
||||
/// Include the full child-inherited Agent surface under resolved
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
//! Model-callable plugin review request. Never installs, trusts, or enables.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::spec::{
|
||||
ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
|
||||
};
|
||||
use crate::plugins::recommend::{load_marketplace_candidates, lookup_reviewable_plugin};
|
||||
|
||||
pub const REQUEST_PLUGIN_INSTALL_TOOL_NAME: &str = "request_plugin_install";
|
||||
|
||||
pub struct RequestPluginInstallTool;
|
||||
|
||||
#[async_trait]
|
||||
impl ToolSpec for RequestPluginInstallTool {
|
||||
fn name(&self) -> &'static str {
|
||||
REQUEST_PLUGIN_INSTALL_TOOL_NAME
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Ask the human to review installing or trusting a plugin that is \
|
||||
already installed-but-idle or listed in a marketplace catalog they \
|
||||
added. Does not install, trust, or enable anything. Fails if the \
|
||||
plugin name is unknown. Pass `name` and a short `reason`."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Plugin name as shown in <recommended_plugins> or /plugin suggest."
|
||||
},
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Short reason this plugin fits the current task."
|
||||
}
|
||||
},
|
||||
"required": ["name", "reason"]
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Vec<ToolCapability> {
|
||||
vec![ToolCapability::ReadOnly]
|
||||
}
|
||||
|
||||
fn approval_requirement(&self) -> ApprovalRequirement {
|
||||
ApprovalRequirement::Auto
|
||||
}
|
||||
|
||||
async fn execute(&self, input: Value, ctx: &ToolContext) -> Result<ToolResult, ToolError> {
|
||||
let name = required_str(&input, "name")?.trim();
|
||||
let reason = required_str(&input, "reason")?.trim();
|
||||
if name.is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_plugin_install: name must not be empty",
|
||||
));
|
||||
}
|
||||
if reason.is_empty() {
|
||||
return Err(ToolError::invalid_input(
|
||||
"request_plugin_install: reason must not be empty",
|
||||
));
|
||||
}
|
||||
let Some(registry) = ctx.plugin_registry.as_ref() else {
|
||||
return Err(ToolError::not_available(
|
||||
"request_plugin_install: plugin registry is not available",
|
||||
));
|
||||
};
|
||||
let marketplace = load_marketplace_candidates(registry.state_path());
|
||||
let Some(matched) = lookup_reviewable_plugin(name, registry, &marketplace) else {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"request_plugin_install: unknown plugin `{name}`"
|
||||
)));
|
||||
};
|
||||
let command = matched.command();
|
||||
let payload = json!({
|
||||
"completed": false,
|
||||
"installed": false,
|
||||
"plugin": matched.name,
|
||||
"plugin_id": matched.id,
|
||||
"command": command,
|
||||
"reason": reason,
|
||||
});
|
||||
let mut result = ToolResult::success(format!(
|
||||
"Review requested for {}. Run `{command}` — nothing was installed, trusted, or enabled. Reason: {reason}",
|
||||
matched.name
|
||||
));
|
||||
result.metadata = Some(payload);
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::test_support::{EnvVarGuard, lock_test_env};
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_keyword_bundle(root: &std::path::Path, name: &str) {
|
||||
let bundle = root.join(".codewhale/plugins").join(name);
|
||||
fs::create_dir_all(&bundle).unwrap();
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
format!(
|
||||
"schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\ndescription = \"{name}\"\nkeywords = [\"{name}\"]\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn request_plugin_install_does_not_mutate_disk() {
|
||||
let _lock = lock_test_env();
|
||||
let root = TempDir::new().unwrap();
|
||||
let _home = EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
write_keyword_bundle(root.path(), "supabase");
|
||||
let registry = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv()
|
||||
.registry_for_workspace(root.path());
|
||||
let bundle = root.path().join(".codewhale/plugins/supabase/plugin.toml");
|
||||
let before = fs::read(&bundle).unwrap();
|
||||
let ctx = ToolContext::new(root.path()).with_plugin_registry(Arc::clone(®istry));
|
||||
|
||||
let result = RequestPluginInstallTool
|
||||
.execute(
|
||||
json!({"name": "supabase", "reason": "needs hosted auth"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.expect("known idle plugin");
|
||||
assert!(result.success);
|
||||
assert!(result.content.contains("/plugin trust supabase"));
|
||||
let meta = result.metadata.expect("metadata");
|
||||
assert_eq!(meta["installed"], json!(false));
|
||||
assert_eq!(meta["command"], json!("/plugin trust supabase"));
|
||||
assert_eq!(fs::read(&bundle).unwrap(), before);
|
||||
|
||||
let err = RequestPluginInstallTool
|
||||
.execute(
|
||||
json!({"name": "not-a-real-plugin", "reason": "guess"}),
|
||||
&ctx,
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(err.to_string().to_lowercase().contains("unknown"), "{err}");
|
||||
assert_eq!(fs::read(&bundle).unwrap(), before);
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,15 @@ the following schema:\n\
|
||||
}\n\
|
||||
If a field is unknown, use an empty string or null. Prioritize correctness and missing tests.";
|
||||
|
||||
/// The system prompt shared by every structured review path (`review`
|
||||
/// tool and `codewhale review --pr`). Callers parse the reply with
|
||||
/// [`ReviewOutput::from_str`], which falls back to freeform text when a
|
||||
/// model ignores the JSON contract.
|
||||
#[must_use]
|
||||
pub fn review_system_prompt() -> &'static str {
|
||||
REVIEW_SYSTEM_PROMPT
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReviewIssue {
|
||||
#[serde(default)]
|
||||
|
||||
@@ -1742,7 +1742,7 @@ impl ShellManager {
|
||||
Ok(())
|
||||
} else {
|
||||
// Do not disclose whether the id exists in another session.
|
||||
Err(anyhow!("Task {task_id} not found"))
|
||||
Err(anyhow!("Job {task_id} not found"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3039,7 +3039,7 @@ impl ShellManager {
|
||||
stderr: snapshot.stderr_tail.clone(),
|
||||
});
|
||||
}
|
||||
Err(anyhow!("Task {task_id} not found"))
|
||||
Err(anyhow!("Job {task_id} not found"))
|
||||
}
|
||||
|
||||
pub fn inspect_job_for_session(
|
||||
|
||||
@@ -1187,13 +1187,6 @@ impl ToolContext {
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the sandbox policy.
|
||||
#[allow(dead_code)]
|
||||
pub fn with_sandbox_policy(mut self, policy: SandboxPolicy) -> Self {
|
||||
self.sandbox_policy = policy;
|
||||
self
|
||||
}
|
||||
|
||||
/// Set feature flags for tool execution.
|
||||
pub fn with_features(mut self, features: Features) -> Self {
|
||||
self.features = features;
|
||||
|
||||
@@ -6611,6 +6611,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"todo_write",
|
||||
@@ -6638,6 +6639,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"review",
|
||||
@@ -6670,6 +6672,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"review",
|
||||
@@ -6709,6 +6712,7 @@ fn every_named_role_has_one_complete_capability_based_surface() {
|
||||
"read",
|
||||
"read_media",
|
||||
"remember",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"revert_turn",
|
||||
@@ -7689,6 +7693,7 @@ async fn read_only_roles_expose_and_dispatch_lowercase_bash_only() {
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"todo_write",
|
||||
|
||||
@@ -713,6 +713,10 @@ pub struct ViewportState {
|
||||
/// WorkflowPanel rect above the composer (#4121), for mouse toggle/cancel.
|
||||
pub last_workflow_panel_area: Option<Rect>,
|
||||
pub last_workflow_cancel_area: Option<Rect>,
|
||||
/// Live plugin CTA row above the composer, plus review/dismiss hitboxes.
|
||||
pub last_plugin_cta_area: Option<Rect>,
|
||||
pub last_plugin_cta_review_area: Option<Rect>,
|
||||
pub last_plugin_cta_dismiss_area: Option<Rect>,
|
||||
pub last_transcript_top: usize,
|
||||
pub last_transcript_visible: usize,
|
||||
pub last_transcript_total: usize,
|
||||
@@ -745,6 +749,9 @@ impl Default for ViewportState {
|
||||
last_approval_area: None,
|
||||
last_workflow_panel_area: None,
|
||||
last_workflow_cancel_area: None,
|
||||
last_plugin_cta_area: None,
|
||||
last_plugin_cta_review_area: None,
|
||||
last_plugin_cta_dismiss_area: None,
|
||||
last_transcript_top: 0,
|
||||
last_transcript_visible: 0,
|
||||
last_transcript_total: 0,
|
||||
@@ -1302,6 +1309,13 @@ pub struct App {
|
||||
pub context_pressure_warning_dismissed: Option<crate::context_budget::PressureLevel>,
|
||||
/// Last on-disk plugin catalog stamp we already nudged `/plugin reload` for.
|
||||
pub plugin_reload_nudge_stamp: Option<crate::plugins::PluginCatalogStamp>,
|
||||
/// Plugin names already toasted for this session's prompt matching.
|
||||
pub plugin_prompt_suggest_names: HashSet<String>,
|
||||
pub plugin_prompt_suggest_count: u8,
|
||||
/// Last idle catalog fingerprint poll, so disk changes can surface between turns.
|
||||
pub last_plugin_catalog_poll: Option<Instant>,
|
||||
/// Live composer plugin CTA (debounce + one match, never auto-install).
|
||||
pub plugin_cta: crate::tui::plugin_suggestions::PluginCtaState,
|
||||
pub model: String,
|
||||
/// Persisted model selections by provider name. Loaded from settings so
|
||||
/// `/model` and the picker can surface saved provider-specific choices.
|
||||
|
||||
@@ -766,6 +766,10 @@ impl App {
|
||||
last_status_message_seen: None,
|
||||
context_pressure_warning_dismissed: None,
|
||||
plugin_reload_nudge_stamp: None,
|
||||
plugin_prompt_suggest_names: HashSet::new(),
|
||||
plugin_prompt_suggest_count: 0,
|
||||
last_plugin_catalog_poll: None,
|
||||
plugin_cta: crate::tui::plugin_suggestions::PluginCtaState::default(),
|
||||
model,
|
||||
provider_models,
|
||||
enabled_provider_models,
|
||||
|
||||
@@ -12,11 +12,9 @@
|
||||
//! The indicator mirrors state the Work strip and the `/jobs` surface already
|
||||
//! read — it introduces no new registry and takes no lock in the render path:
|
||||
//!
|
||||
//! - **Background shells + durable tasks**: `App::task_panel`, the merged
|
||||
//! snapshot `refresh_active_task_panel` builds from the shell manager's
|
||||
//! `list_jobs()` and the TaskManager (`/jobs` surface). The event loop
|
||||
//! refreshes it every ~2.5s, so the chip follows shell/task lifecycle with
|
||||
//! the same cadence as the Work panel.
|
||||
//! - **Durable tasks**: `App::task_panel` entries that are not live shells.
|
||||
//! Background shells are first-class work-strip rows (`▾ Shells N`) and
|
||||
//! are not mirrored here.
|
||||
//! - **Sub-agents**: the union of `App::subagent_cache` entries still in
|
||||
//! `Running` state and `App::agent_progress` keys — the same live projection
|
||||
//! `running_agent_count` uses, so spawn/completion events update the chip
|
||||
@@ -167,10 +165,12 @@ pub fn pending_work_from_app(app: &App) -> PendingWork {
|
||||
if !matches!(entry.status.as_str(), "running" | "queued") {
|
||||
continue;
|
||||
}
|
||||
let (kind, raw_label) = match entry.prompt_summary.strip_prefix("shell: ") {
|
||||
Some(command) => (PendingItemKind::Shell, command),
|
||||
None => (PendingItemKind::Task, entry.id.as_str()),
|
||||
};
|
||||
// Live shells belong on the work strip (`▾ Shells N`), not this
|
||||
// composer crumb. A dual surface hid the PTY behind hourglasses.
|
||||
if entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_") {
|
||||
continue;
|
||||
}
|
||||
let (kind, raw_label) = (PendingItemKind::Task, entry.id.as_str());
|
||||
items.push(PendingItem {
|
||||
kind,
|
||||
label: truncate_label(raw_label),
|
||||
@@ -414,7 +414,11 @@ mod tests {
|
||||
.insert("agent_live".to_string(), "Agent 1".to_string());
|
||||
|
||||
let work = pending_work_from_app(&app);
|
||||
assert_eq!(work.count(PendingItemKind::Shell), 1, "shell job counted");
|
||||
assert_eq!(
|
||||
work.count(PendingItemKind::Shell),
|
||||
0,
|
||||
"shells are work-strip rows, not crumb items"
|
||||
);
|
||||
assert_eq!(work.count(PendingItemKind::Task), 1, "durable task counted");
|
||||
assert_eq!(
|
||||
work.count(PendingItemKind::Agent),
|
||||
@@ -422,7 +426,10 @@ mod tests {
|
||||
"running agent counted"
|
||||
);
|
||||
let line = work.render_line(400).unwrap();
|
||||
assert!(line.contains("cargo test"), "shell command labeled: {line}");
|
||||
assert!(
|
||||
!line.contains("cargo test"),
|
||||
"shell command must not occupy the crumb: {line}"
|
||||
);
|
||||
assert!(line.contains("run"), "task id labeled: {line}");
|
||||
assert!(line.contains("Agent 1"), "agent label shown: {line}");
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ pub(crate) enum EscapeAction {
|
||||
CancelRequest,
|
||||
PauseCommand,
|
||||
DiscardQueuedDraft,
|
||||
DismissPluginCta,
|
||||
ClearInput,
|
||||
Noop,
|
||||
}
|
||||
@@ -35,6 +36,8 @@ pub(crate) fn next_escape_action(app: &App, slash_menu_open: bool) -> EscapeActi
|
||||
|| matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
|
||||
{
|
||||
EscapeAction::CancelRequest
|
||||
} else if app.plugin_cta.phase.is_visible() {
|
||||
EscapeAction::DismissPluginCta
|
||||
} else if !app.input.is_empty() {
|
||||
EscapeAction::ClearInput
|
||||
} else {
|
||||
|
||||
@@ -294,13 +294,6 @@ pub fn create_worktree(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List worktrees from the cache (refresh first if needed).
|
||||
#[must_use]
|
||||
pub fn list_worktrees(workspace: &Path) -> Vec<WorktreeEntry> {
|
||||
refresh_if_stale(workspace);
|
||||
cached_status().worktrees
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -79,6 +79,7 @@ pub mod paste;
|
||||
pub mod paste_burst;
|
||||
pub mod persistence_actor;
|
||||
pub mod phase_strip;
|
||||
pub(crate) mod plugin_suggestions;
|
||||
pub mod prompt_suggestion;
|
||||
pub mod provider_picker;
|
||||
pub mod scrolling;
|
||||
|
||||
@@ -314,6 +314,28 @@ fn handle_workflow_panel_mouse(app: &mut App, mouse: MouseEvent) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn handle_plugin_cta_mouse(app: &mut App, mouse: MouseEvent) -> Option<Vec<ViewEvent>> {
|
||||
if !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) {
|
||||
return None;
|
||||
}
|
||||
if !mouse_hits_rect(mouse, app.viewport.last_plugin_cta_area) {
|
||||
return None;
|
||||
}
|
||||
if mouse_hits_rect(mouse, app.viewport.last_plugin_cta_dismiss_area) {
|
||||
let _ = app.dismiss_plugin_cta();
|
||||
return Some(Vec::new());
|
||||
}
|
||||
// Review button, or the rest of the CTA line, runs the existing review
|
||||
// command. Never auto-installs: the slash command is the human path.
|
||||
if let Some(command) = app.accept_plugin_cta_command() {
|
||||
return Some(apply_sidebar_row_action(
|
||||
app,
|
||||
crate::tui::app::SidebarRowAction::Command(command),
|
||||
));
|
||||
}
|
||||
Some(Vec::new())
|
||||
}
|
||||
|
||||
/// Handle mouse events within the composer area.
|
||||
/// Returns true if the event was consumed.
|
||||
pub(crate) fn handle_composer_mouse(app: &mut App, mouse: MouseEvent) -> bool {
|
||||
@@ -494,6 +516,10 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec<ViewEv
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
if let Some(events) = handle_plugin_cta_mouse(app, mouse) {
|
||||
return events;
|
||||
}
|
||||
|
||||
// Composer mouse events take priority over transcript.
|
||||
if handle_composer_mouse(app, mouse) {
|
||||
return Vec::new();
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
//! In-context plugin reminders: prompt matching, live composer CTA, and idle
|
||||
//! catalog polling.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Widget};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::localization::{MessageId, tr};
|
||||
use crate::plugins::recommend::{
|
||||
PluginNextStep, RecommendOptions, load_marketplace_candidates, match_plugin_for_draft,
|
||||
recommend_plugins_for_task,
|
||||
};
|
||||
use crate::tui::app::{App, StatusToastLevel};
|
||||
|
||||
const MAX_PROMPT_SUGGESTS_PER_SESSION: u8 = 2;
|
||||
const CATALOG_POLL_INTERVAL: Duration = Duration::from_secs(2);
|
||||
const CTA_DEBOUNCE: Duration = Duration::from_millis(200);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginCtaPhase {
|
||||
Hidden,
|
||||
Matched { name: String, command: String },
|
||||
}
|
||||
|
||||
impl PluginCtaPhase {
|
||||
#[must_use]
|
||||
pub fn is_visible(&self) -> bool {
|
||||
matches!(self, Self::Matched { .. })
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn matched_name(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Hidden => None,
|
||||
Self::Matched { name, .. } => Some(name.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginCtaState {
|
||||
pub phase: PluginCtaPhase,
|
||||
pub dismissed: HashSet<String>,
|
||||
debounce_at: Option<Instant>,
|
||||
last_draft: String,
|
||||
}
|
||||
|
||||
impl Default for PluginCtaState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
phase: PluginCtaPhase::Hidden,
|
||||
dismissed: HashSet::new(),
|
||||
debounce_at: None,
|
||||
last_draft: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl App {
|
||||
/// When the user sends a task that matches an installed-but-idle plugin
|
||||
/// or a locally added marketplace candidate, toast the next review step
|
||||
/// once. Never installs, trusts, or enables anything.
|
||||
pub fn maybe_nudge_plugin_for_prompt(&mut self, input: &str) -> bool {
|
||||
if self.plugin_prompt_suggest_count >= MAX_PROMPT_SUGGESTS_PER_SESSION {
|
||||
return false;
|
||||
}
|
||||
let marketplace = load_marketplace_candidates(self.plugin_registry.state_path());
|
||||
let recommendations = recommend_plugins_for_task(
|
||||
input,
|
||||
self.plugin_registry.as_ref(),
|
||||
&marketplace,
|
||||
RecommendOptions::proactive(),
|
||||
);
|
||||
let Some(recommendation) = recommendations.into_iter().next() else {
|
||||
return false;
|
||||
};
|
||||
if self
|
||||
.plugin_prompt_suggest_names
|
||||
.contains(&recommendation.name)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let message_id = match recommendation.next_step {
|
||||
PluginNextStep::Trust => MessageId::PluginPromptSuggestTrust,
|
||||
PluginNextStep::Enable => MessageId::PluginPromptSuggestEnable,
|
||||
PluginNextStep::MarketplaceInstall { .. } => MessageId::PluginPromptSuggestMarketplace,
|
||||
PluginNextStep::AlreadyActive
|
||||
| PluginNextStep::Inspect
|
||||
| PluginNextStep::SourceInstall { .. } => return false,
|
||||
};
|
||||
let mut message = tr(self.ui_locale, message_id).replace("{name}", &recommendation.name);
|
||||
if let PluginNextStep::MarketplaceInstall { catalog_id } = &recommendation.next_step {
|
||||
message = message.replace("{catalog}", catalog_id);
|
||||
}
|
||||
self.plugin_prompt_suggest_names.insert(recommendation.name);
|
||||
self.plugin_prompt_suggest_count = self.plugin_prompt_suggest_count.saturating_add(1);
|
||||
self.push_status_toast(message, StatusToastLevel::Info, Some(8_000));
|
||||
true
|
||||
}
|
||||
|
||||
/// Cheap idle poll so on-disk plugin changes can surface between turns,
|
||||
/// not only on send. Fingerprints directories; never auto-reloads.
|
||||
pub fn maybe_poll_plugin_catalog_idle(&mut self) {
|
||||
let now = Instant::now();
|
||||
if self
|
||||
.last_plugin_catalog_poll
|
||||
.is_some_and(|seen| now.duration_since(seen) < CATALOG_POLL_INTERVAL)
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.last_plugin_catalog_poll = Some(now);
|
||||
if let Some(message) = crate::plugins::plugin_reload_nudge(
|
||||
self.plugin_registry.as_ref(),
|
||||
&mut self.plugin_reload_nudge_stamp,
|
||||
) {
|
||||
self.push_status_toast(message, StatusToastLevel::Warning, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Arm a short debounce whenever the composer draft changes.
|
||||
pub fn notify_plugin_cta_text_changed(&mut self) {
|
||||
if self.input == self.plugin_cta.last_draft {
|
||||
return;
|
||||
}
|
||||
self.plugin_cta.last_draft = self.input.clone();
|
||||
self.plugin_cta.debounce_at = Some(Instant::now() + CTA_DEBOUNCE);
|
||||
}
|
||||
|
||||
/// Recompute the live CTA after the debounce window. One match at a
|
||||
/// time; already-active plugins stay hidden; a dismissed name stays
|
||||
/// dismissed for the rest of this session. Never auto-installs.
|
||||
pub fn handle_plugin_cta_debounce_expired(&mut self) {
|
||||
self.plugin_cta.debounce_at = None;
|
||||
self.plugin_cta.last_draft = self.input.clone();
|
||||
let marketplace = load_marketplace_candidates(self.plugin_registry.state_path());
|
||||
let Some(matched) =
|
||||
match_plugin_for_draft(&self.input, self.plugin_registry.as_ref(), &marketplace)
|
||||
else {
|
||||
if self.plugin_cta.phase.is_visible() {
|
||||
self.plugin_cta.phase = PluginCtaPhase::Hidden;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
};
|
||||
if self
|
||||
.plugin_cta
|
||||
.dismissed
|
||||
.contains(&matched.name.to_ascii_lowercase())
|
||||
{
|
||||
if self.plugin_cta.phase.is_visible() {
|
||||
self.plugin_cta.phase = PluginCtaPhase::Hidden;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
let command = matched.command();
|
||||
let new_phase = PluginCtaPhase::Matched {
|
||||
name: matched.name,
|
||||
command,
|
||||
};
|
||||
if self.plugin_cta.phase != new_phase {
|
||||
self.plugin_cta.phase = new_phase;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Poll draft changes and fire the CTA debounce without a dedicated timer
|
||||
/// task. The event loop already ticks this often.
|
||||
pub fn maybe_poll_plugin_cta(&mut self) {
|
||||
self.notify_plugin_cta_text_changed();
|
||||
let Some(at) = self.plugin_cta.debounce_at else {
|
||||
return;
|
||||
};
|
||||
if Instant::now() < at {
|
||||
return;
|
||||
}
|
||||
self.handle_plugin_cta_debounce_expired();
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn plugin_cta_row_height(&self) -> u16 {
|
||||
u16::from(self.plugin_cta.phase.is_visible())
|
||||
}
|
||||
|
||||
/// Hide the CTA for this plugin name for the rest of the session.
|
||||
pub fn dismiss_plugin_cta(&mut self) -> bool {
|
||||
let Some(name) = self.plugin_cta.phase.matched_name().map(str::to_string) else {
|
||||
return false;
|
||||
};
|
||||
self.plugin_cta.dismissed.insert(name.to_ascii_lowercase());
|
||||
self.plugin_cta.phase = PluginCtaPhase::Hidden;
|
||||
self.needs_redraw = true;
|
||||
true
|
||||
}
|
||||
|
||||
/// Human-initiated review: return the slash command so the TUI can run
|
||||
/// the existing `/plugin trust` / marketplace-install / `/plugin install`
|
||||
/// path. Never runs it here.
|
||||
#[must_use]
|
||||
pub fn accept_plugin_cta_command(&mut self) -> Option<String> {
|
||||
let (command, name) = match &self.plugin_cta.phase {
|
||||
PluginCtaPhase::Matched { command, name } => (command.clone(), name.clone()),
|
||||
PluginCtaPhase::Hidden => return None,
|
||||
};
|
||||
self.plugin_cta.dismissed.insert(name.to_ascii_lowercase());
|
||||
self.plugin_cta.phase = PluginCtaPhase::Hidden;
|
||||
self.needs_redraw = true;
|
||||
Some(command)
|
||||
}
|
||||
|
||||
/// Model-requested review: show the live CTA and a toast. Does not run
|
||||
/// the command, so nothing is installed, trusted, or enabled.
|
||||
pub fn surface_plugin_review_request(&mut self, name: &str, command: &str) {
|
||||
if name.trim().is_empty() || command.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
self.plugin_cta.phase = PluginCtaPhase::Matched {
|
||||
name: name.to_string(),
|
||||
command: command.to_string(),
|
||||
};
|
||||
self.push_status_toast(command.to_string(), StatusToastLevel::Info, Some(8_000));
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// Draw the one-line live CTA above the composer. No-op when hidden.
|
||||
pub fn draw_plugin_cta(app: &mut App, area: Rect, buf: &mut Buffer) {
|
||||
app.viewport.last_plugin_cta_area = None;
|
||||
app.viewport.last_plugin_cta_review_area = None;
|
||||
app.viewport.last_plugin_cta_dismiss_area = None;
|
||||
let PluginCtaPhase::Matched { name, .. } = &app.plugin_cta.phase else {
|
||||
return;
|
||||
};
|
||||
let name = name.clone();
|
||||
if area.height == 0 || area.width == 0 {
|
||||
return;
|
||||
}
|
||||
let prompt = tr(app.ui_locale, MessageId::PluginCtaInstallPrompt).replace("{name}", &name);
|
||||
let review = tr(app.ui_locale, MessageId::PluginCtaReview);
|
||||
let dismiss = tr(app.ui_locale, MessageId::PluginCtaDismiss);
|
||||
let review_label = format!("[{review}]");
|
||||
let dismiss_label = format!("[{dismiss}]");
|
||||
let review_w = review_label.width() as u16;
|
||||
let dismiss_w = dismiss_label.width() as u16;
|
||||
let gap = 1u16;
|
||||
let right_w = review_w.saturating_add(gap).saturating_add(dismiss_w);
|
||||
let bg = Style::default().bg(app.ui_theme.composer_bg);
|
||||
Block::default().style(bg).render(area, buf);
|
||||
let left_budget = if area.width > right_w.saturating_add(1) {
|
||||
area.width - right_w - 1
|
||||
} else {
|
||||
area.width
|
||||
};
|
||||
let left = Line::from(vec![Span::styled(
|
||||
prompt,
|
||||
Style::default().fg(app.ui_theme.text_hint),
|
||||
)]);
|
||||
buf.set_line(area.x, area.y, &left, left_budget);
|
||||
if area.width <= right_w {
|
||||
app.viewport.last_plugin_cta_area = Some(area);
|
||||
return;
|
||||
}
|
||||
let review_x = area.x + area.width - right_w;
|
||||
let dismiss_x = review_x + review_w + gap;
|
||||
buf.set_stringn(
|
||||
review_x,
|
||||
area.y,
|
||||
&review_label,
|
||||
usize::from(review_w),
|
||||
Style::default().fg(app.ui_theme.accent_action),
|
||||
);
|
||||
buf.set_stringn(
|
||||
dismiss_x,
|
||||
area.y,
|
||||
&dismiss_label,
|
||||
usize::from(dismiss_w),
|
||||
Style::default().fg(app.ui_theme.text_hint),
|
||||
);
|
||||
app.viewport.last_plugin_cta_area = Some(area);
|
||||
app.viewport.last_plugin_cta_review_area = Some(Rect::new(review_x, area.y, review_w, 1));
|
||||
app.viewport.last_plugin_cta_dismiss_area = Some(Rect::new(dismiss_x, area.y, dismiss_w, 1));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::localization::Locale;
|
||||
use crate::tui::app::TuiOptions;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn app_with_supabase_plugin() -> (App, TempDir, crate::test_support::EnvVarGuard) {
|
||||
let root = TempDir::new().unwrap();
|
||||
let home =
|
||||
crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path().join("home"));
|
||||
let bundle = root.path().join(".codewhale/plugins/supabase");
|
||||
fs::create_dir_all(&bundle).unwrap();
|
||||
fs::write(
|
||||
bundle.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"supabase\"\nversion = \"1.0.0\"\ndescription = \"Hosted Postgres and auth\"\nkeywords = [\"supabase\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let temp = TempDir::new().unwrap();
|
||||
let options = TuiOptions {
|
||||
config_path: Some(temp.path().join("config.toml")),
|
||||
skills_dir: temp.path().join("skills"),
|
||||
memory_path: temp.path().join("memory.md"),
|
||||
notes_path: temp.path().join("notes.txt"),
|
||||
mcp_config_path: temp.path().join("mcp.json"),
|
||||
..crate::test_support::test_tui_options(root.path())
|
||||
};
|
||||
let discovery = crate::plugins::PluginDiscoveryContext::capture_pre_dotenv();
|
||||
let registry = discovery.registry_for_workspace(root.path());
|
||||
let mut app = App::new_with_plugin_registry(options, &Config::default(), registry);
|
||||
app.ui_locale = Locale::En;
|
||||
(app, root, home)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sending_a_supabase_prompt_toasts_trust_for_an_installed_idle_plugin() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
|
||||
assert!(app.maybe_nudge_plugin_for_prompt("add supabase auth to login"));
|
||||
assert_eq!(app.status_toasts.len(), 1);
|
||||
assert!(
|
||||
app.status_toasts[0].text.contains("/plugin trust supabase"),
|
||||
"{}",
|
||||
app.status_toasts[0].text
|
||||
);
|
||||
assert!(!app.maybe_nudge_plugin_for_prompt("add supabase auth to login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cta_shows_for_a_matching_idle_plugin() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
app.input = "add supabase auth to login".to_string();
|
||||
app.handle_plugin_cta_debounce_expired();
|
||||
assert_eq!(
|
||||
app.plugin_cta.phase.matched_name(),
|
||||
Some("supabase"),
|
||||
"{:?}",
|
||||
app.plugin_cta.phase
|
||||
);
|
||||
assert_eq!(app.plugin_cta_row_height(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cta_hides_when_the_plugin_is_already_active() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
let registry = std::sync::Arc::make_mut(&mut app.plugin_registry);
|
||||
registry.trust("supabase").unwrap();
|
||||
registry.enable("supabase").unwrap();
|
||||
app.input = "add supabase auth to login".to_string();
|
||||
app.handle_plugin_cta_debounce_expired();
|
||||
assert!(
|
||||
!app.plugin_cta.phase.is_visible(),
|
||||
"{:?}",
|
||||
app.plugin_cta.phase
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_cta_dismiss_stays_dismissed_for_that_name_this_session() {
|
||||
let _lock = crate::test_support::lock_test_env();
|
||||
let (mut app, _root, _home) = app_with_supabase_plugin();
|
||||
app.input = "add supabase auth to login".to_string();
|
||||
app.handle_plugin_cta_debounce_expired();
|
||||
assert!(app.dismiss_plugin_cta());
|
||||
assert!(!app.plugin_cta.phase.is_visible());
|
||||
app.handle_plugin_cta_debounce_expired();
|
||||
assert!(
|
||||
!app.plugin_cta.phase.is_visible(),
|
||||
"dismissed names must not reappear this session: {:?}",
|
||||
app.plugin_cta.phase
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -53,7 +53,7 @@ pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String {
|
||||
}
|
||||
}
|
||||
lines.push(
|
||||
"Controls: /jobs show <id>, /jobs poll <id>, /jobs wait <id>, /jobs stdin <id> <input>, /jobs cancel <id>, /jobs cancel-all."
|
||||
"Controls: /jobs show <id>, /jobs poll <id>, /jobs wait <id>, /jobs stdin <id> <input>, /jobs cancel <id|all>."
|
||||
.to_string(),
|
||||
);
|
||||
lines.join("\n")
|
||||
@@ -177,6 +177,8 @@ mod tests {
|
||||
assert!(formatted.contains("shell_dead"));
|
||||
assert!(formatted.contains("stale"));
|
||||
assert!(formatted.contains("/jobs poll <id>"));
|
||||
assert!(formatted.contains("/jobs cancel <id|all>"));
|
||||
assert!(!formatted.contains("cancel-all"));
|
||||
assert!(formatted.contains("task=task_1"));
|
||||
}
|
||||
}
|
||||
@@ -524,6 +524,7 @@ pub(crate) fn prepare_user_dispatch(
|
||||
message: QueuedMessage,
|
||||
) -> Result<UserDispatchPrepare> {
|
||||
let _ = app.maybe_nudge_for_planning_prompt(&message.display);
|
||||
let _ = app.maybe_nudge_plugin_for_prompt(&message.display);
|
||||
|
||||
// Plan paused-command changes without touching App or the engine pause
|
||||
// gate. Route selection can await and client preflight can fail; neither
|
||||
|
||||
@@ -1136,6 +1136,8 @@ pub(crate) async fn run_event_loop(
|
||||
// #1830/#2317: service any already-arrived terminal keys before a
|
||||
// potentially long engine batch so composer/modal input stays live.
|
||||
collect_pending_terminal_events(&terminal_input, &mut pending_terminal_events)?;
|
||||
app.maybe_poll_plugin_catalog_idle();
|
||||
app.maybe_poll_plugin_cta();
|
||||
|
||||
if drain_remote_control_events(app, config, &engine_handle).await? {
|
||||
app.needs_redraw = true;
|
||||
@@ -1488,6 +1490,22 @@ pub(crate) async fn run_event_loop(
|
||||
.retain(|(tool_id, _, _)| tool_id != &id);
|
||||
}
|
||||
handle_tool_call_complete(app, &id, &name, &result);
|
||||
if name
|
||||
== crate::tools::request_plugin_install::REQUEST_PLUGIN_INSTALL_TOOL_NAME
|
||||
&& let Ok(output) = &result
|
||||
&& output.success
|
||||
&& let Some(meta) = output.metadata.as_ref()
|
||||
{
|
||||
let plugin = meta
|
||||
.get("plugin")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("");
|
||||
let command = meta
|
||||
.get("command")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("");
|
||||
app.surface_plugin_review_request(plugin, command);
|
||||
}
|
||||
if flush_gate_receipts_for(app, Some(&id)) {
|
||||
transcript_batch_updated = true;
|
||||
}
|
||||
@@ -5060,6 +5078,10 @@ pub(crate) async fn run_event_loop(
|
||||
Some("Queued edit canceled; follow-up restored".to_string());
|
||||
}
|
||||
}
|
||||
EscapeAction::DismissPluginCta => {
|
||||
app.backtrack.reset();
|
||||
let _ = app.dismiss_plugin_cta();
|
||||
}
|
||||
EscapeAction::ClearInput => {
|
||||
app.backtrack.reset();
|
||||
app.edit_in_progress = false;
|
||||
|
||||
@@ -888,13 +888,19 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
.map(|panel| panel.desired_height(shell_area.width))
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let plugin_cta_height = if mini && !mini_cfg.keep_input {
|
||||
0
|
||||
} else {
|
||||
app.plugin_cta_row_height()
|
||||
};
|
||||
let auxiliary_budget = body_height
|
||||
.saturating_sub(
|
||||
top_work_strip_height
|
||||
.saturating_add(MIN_CHAT_HEIGHT)
|
||||
.saturating_add(composer_height)
|
||||
.saturating_add(footer_height)
|
||||
.saturating_add(activity_height),
|
||||
.saturating_add(activity_height)
|
||||
.saturating_add(plugin_cta_height),
|
||||
)
|
||||
.saturating_sub(indicator_height);
|
||||
// Queued-only previews author the direct controls in row two (and fall
|
||||
@@ -936,14 +942,16 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
Constraint::Length(indicator_height), // Background-work chip (#5286, 0 if idle)
|
||||
Constraint::Length(session_boot_height), // MCP+plugin boot receipt (0 if quiet)
|
||||
Constraint::Length(activity_height), // Activity band above the composer
|
||||
Constraint::Length(plugin_cta_height), // Live plugin CTA (0 unless matched)
|
||||
Constraint::Length(composer_height), // Composer
|
||||
Constraint::Length(footer_height), // Identity band below the composer
|
||||
])
|
||||
.split(body_area);
|
||||
let session_boot_slot = 5;
|
||||
let activity_slot = 6;
|
||||
let composer_slot = 7;
|
||||
let footer_slot = 8;
|
||||
let plugin_cta_slot = 7;
|
||||
let composer_slot = 8;
|
||||
let footer_slot = 9;
|
||||
|
||||
let (work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar {
|
||||
// Mini mode without the side rail: the transcript takes the whole
|
||||
@@ -1075,6 +1083,15 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
crate::tui::phase_strip::render_activity(body_chunks[activity_slot], buf, app);
|
||||
}
|
||||
|
||||
if plugin_cta_height > 0 {
|
||||
let buf = f.buffer_mut();
|
||||
crate::tui::plugin_suggestions::draw_plugin_cta(app, body_chunks[plugin_cta_slot], buf);
|
||||
} else {
|
||||
app.viewport.last_plugin_cta_area = None;
|
||||
app.viewport.last_plugin_cta_review_area = None;
|
||||
app.viewport.last_plugin_cta_dismiss_area = None;
|
||||
}
|
||||
|
||||
// Render composer
|
||||
let cursor_pos = {
|
||||
let composer_widget = ComposerWidget::new(
|
||||
@@ -1188,6 +1205,13 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(
|
||||
app.ui_theme.footer_bg,
|
||||
);
|
||||
}
|
||||
if plugin_cta_height > 0 {
|
||||
column.paint_matching(
|
||||
body_chunks[plugin_cta_slot],
|
||||
f.buffer_mut(),
|
||||
app.ui_theme.composer_bg,
|
||||
);
|
||||
}
|
||||
column.paint_matching(
|
||||
body_chunks[composer_slot],
|
||||
f.buffer_mut(),
|
||||
|
||||
@@ -678,7 +678,15 @@ pub(crate) fn handle_shell_job_action(app: &mut App, action: crate::tui::app::Sh
|
||||
match action {
|
||||
crate::tui::app::ShellJobAction::List => {
|
||||
let jobs = manager.list_jobs_for_session(&active_session_id);
|
||||
add_shell_job_message(app, format_shell_job_list(&jobs));
|
||||
let mut text = format_shell_job_list(&jobs);
|
||||
if let Ok(cloud) =
|
||||
crate::cloud_dispatch::CloudJobStore::from_env().and_then(|store| store.list())
|
||||
&& !cloud.is_empty()
|
||||
{
|
||||
text.push_str("\n\n");
|
||||
text.push_str(&crate::cloud_dispatch::format_job_list(&cloud));
|
||||
}
|
||||
add_shell_job_message(app, text);
|
||||
}
|
||||
crate::tui::app::ShellJobAction::Show { id } => {
|
||||
match manager.inspect_job_for_session(&active_session_id, &id) {
|
||||
|
||||
@@ -8,7 +8,10 @@ use ratatui::layout::Rect;
|
||||
use crate::settings::InlineDiffMode;
|
||||
use crate::tools::canonical_action::canonical_action_alias;
|
||||
use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus};
|
||||
use crate::tui::app::{AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction};
|
||||
use crate::tui::app::{
|
||||
AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction, TaskPanelEntry,
|
||||
TaskPanelEntryKind,
|
||||
};
|
||||
use crate::tui::history::{
|
||||
FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell,
|
||||
};
|
||||
@@ -495,7 +498,7 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
|
||||
|
||||
update_session_instance_scope(app);
|
||||
|
||||
let rows = match graph {
|
||||
let mut rows = match graph {
|
||||
Some(graph) => graph_rows(
|
||||
&mut app.work_surface,
|
||||
&graph,
|
||||
@@ -522,6 +525,7 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
|
||||
)]
|
||||
}),
|
||||
};
|
||||
rows = with_live_shell_rows(app, rows);
|
||||
app.work_surface.latest_rows = rows.clone();
|
||||
if let Some(opened) = app.work_surface.opened.as_ref()
|
||||
&& !rows.iter().any(|row| &row.id == opened)
|
||||
@@ -538,8 +542,8 @@ pub(super) fn project(app: &mut App) -> Vec<WorkRow> {
|
||||
|
||||
/// Projection used by the live surface. The full Work catalog remains intact
|
||||
/// for explicit inspectors, while persistent chrome stays literal: current
|
||||
/// sub-agents, then plan-step to-dos. Tool operations, coordination receipts,
|
||||
/// file activity, and generic graph headings never enter this list.
|
||||
/// sub-agents, live shells, then plan-step to-dos. Tool operations, coordination
|
||||
/// receipts, file activity, and generic graph headings never enter this list.
|
||||
///
|
||||
/// On Top, the strip is actionable work only: running / queued / needs-input
|
||||
/// agents, plus plan-step to-dos. Quietly completed or cancelled workers
|
||||
@@ -573,6 +577,7 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
let mut todos = Vec::new();
|
||||
let mut live_agents = Vec::new();
|
||||
let mut settled_agents = 0usize;
|
||||
let mut shells = Vec::new();
|
||||
for row in rows {
|
||||
if todo_ids.contains(&row.id.0) {
|
||||
todos.push(row);
|
||||
@@ -582,10 +587,12 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
} else {
|
||||
live_agents.push(row);
|
||||
}
|
||||
} else if row.id.0.starts_with("shell:") {
|
||||
shells.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(todos.len() + live_agents.len() + 1);
|
||||
let mut out = Vec::with_capacity(todos.len() + live_agents.len() + shells.len() + 2);
|
||||
if !live_agents.is_empty() || settled_agents > 0 {
|
||||
// Honest live/settled split — failed/interrupted stay as strip rows
|
||||
// (needs attention) and are never counted as "running".
|
||||
@@ -610,6 +617,7 @@ pub(super) fn project_visible(app: &mut App) -> Vec<WorkRow> {
|
||||
out.push(agents_section_heading(&header));
|
||||
out.extend(live_agents);
|
||||
}
|
||||
push_shell_group(&mut out, shells);
|
||||
out.extend(todos);
|
||||
app.work_surface.latest_rows = out.clone();
|
||||
out
|
||||
@@ -655,8 +663,8 @@ fn plan_step_row_ids(app: &App) -> HashSet<String> {
|
||||
///
|
||||
/// - `Tasks` — the full live projection ([`project_visible`]).
|
||||
/// - `Agents` — the full sub-agent register under the `▾ Subagents N` header,
|
||||
/// followed by the durable to-do checklist: opening the register never hides
|
||||
/// the list.
|
||||
/// then live shells, then the durable to-do checklist: opening the register
|
||||
/// never hides the list.
|
||||
/// - `Pinned` — the goal, the sub-agent group, then the plan-step to-dos.
|
||||
/// - `Context` — empty: session facts are a line list, not work rows, and
|
||||
/// render outside the row machinery.
|
||||
@@ -681,14 +689,17 @@ pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
|
||||
let todo_ids = plan_step_row_ids(app);
|
||||
let mut agents = Vec::new();
|
||||
let mut todos = Vec::new();
|
||||
let mut shells = Vec::new();
|
||||
for row in rows {
|
||||
if row.id.0.starts_with("worker:") {
|
||||
agents.push(row);
|
||||
} else if row.id.0.starts_with("shell:") {
|
||||
shells.push(row);
|
||||
} else if todo_ids.contains(&row.id.0) {
|
||||
todos.push(row);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(agents.len() + todos.len() + 2);
|
||||
let mut out = Vec::with_capacity(agents.len() + todos.len() + shells.len() + 3);
|
||||
if !agents.is_empty() {
|
||||
out.push(agents_section_heading(&format!(
|
||||
"Subagents {}",
|
||||
@@ -696,6 +707,7 @@ pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
|
||||
)));
|
||||
out.extend(agents);
|
||||
}
|
||||
push_shell_group(&mut out, shells);
|
||||
if !todos.is_empty() {
|
||||
out.push(section_heading("tasks", "Tasks", "Durable to-do checklist"));
|
||||
out.extend(todos);
|
||||
@@ -708,14 +720,17 @@ pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
|
||||
let todo_ids = plan_step_row_ids(app);
|
||||
let mut todos = Vec::new();
|
||||
let mut agents = Vec::new();
|
||||
let mut shells = Vec::new();
|
||||
for row in rows {
|
||||
if todo_ids.contains(&row.id.0) {
|
||||
todos.push(row);
|
||||
} else if row.id.0.starts_with("worker:") {
|
||||
agents.push(row);
|
||||
} else if row.id.0.starts_with("shell:") {
|
||||
shells.push(row);
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(todos.len() + agents.len() + 2);
|
||||
let mut out = Vec::with_capacity(todos.len() + agents.len() + shells.len() + 3);
|
||||
// On Top the goal is already the strip title; a side column
|
||||
// repeats it as its first row so the durable goal home survives
|
||||
// in every placement.
|
||||
@@ -743,6 +758,7 @@ pub(super) fn visible_rows_for_panel(app: &mut App) -> Vec<WorkRow> {
|
||||
)));
|
||||
out.extend(agents);
|
||||
}
|
||||
push_shell_group(&mut out, shells);
|
||||
out.extend(todos);
|
||||
app.work_surface.latest_rows = out.clone();
|
||||
out
|
||||
@@ -2018,6 +2034,150 @@ fn agents_section_heading(label: &str) -> WorkRow {
|
||||
}
|
||||
}
|
||||
|
||||
fn with_live_shell_rows(app: &App, mut rows: Vec<WorkRow>) -> Vec<WorkRow> {
|
||||
rows.retain(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells");
|
||||
let shells = shell_work_rows(app);
|
||||
if shells.is_empty() {
|
||||
return rows;
|
||||
}
|
||||
let mut out = Vec::with_capacity(rows.len() + shells.len() + 1);
|
||||
push_shell_group(&mut out, shells);
|
||||
out.extend(rows);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_shell_group(out: &mut Vec<WorkRow>, shells: Vec<WorkRow>) {
|
||||
if shells.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push(shells_section_heading(&shells));
|
||||
out.extend(shells);
|
||||
}
|
||||
|
||||
fn is_live_shell_entry(entry: &TaskPanelEntry) -> bool {
|
||||
if entry.kind != TaskPanelEntryKind::Background {
|
||||
return false;
|
||||
}
|
||||
if !matches!(entry.status.as_str(), "running" | "queued") {
|
||||
return false;
|
||||
}
|
||||
entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_")
|
||||
}
|
||||
|
||||
fn shell_work_rows(app: &App) -> Vec<WorkRow> {
|
||||
app.task_panel
|
||||
.iter()
|
||||
.filter(|entry| is_live_shell_entry(entry))
|
||||
.map(|entry| {
|
||||
let command = entry
|
||||
.prompt_summary
|
||||
.strip_prefix("shell: ")
|
||||
.unwrap_or(entry.prompt_summary.as_str())
|
||||
.trim();
|
||||
let status = if entry.stale {
|
||||
"stale"
|
||||
} else {
|
||||
entry.status.as_str()
|
||||
};
|
||||
let elapsed_secs = entry.duration_ms.map(|ms| ms / 1_000);
|
||||
let objective = if command.is_empty() {
|
||||
entry.id.clone()
|
||||
} else {
|
||||
command.to_string()
|
||||
};
|
||||
WorkRow {
|
||||
id: WorkRowId(format!("shell:{}", entry.id)),
|
||||
mark: agent_mark(WorkBucket::Active),
|
||||
label: entry.id.clone(),
|
||||
detail: format!("{status} · {}", entry.id),
|
||||
tone: WorkTone::Live,
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::InspectWork {
|
||||
title: format!("Shell {}", entry.id),
|
||||
body: shell_inspector_body(app, entry, command, status),
|
||||
stop_action: Some(Box::new(SidebarRowAction::Command(format!(
|
||||
"/jobs cancel {}",
|
||||
entry.id
|
||||
)))),
|
||||
}),
|
||||
agent: Some(AgentRowFacts {
|
||||
role_label: "shell".to_string(),
|
||||
status: status.to_string(),
|
||||
objective,
|
||||
elapsed_secs,
|
||||
model: None,
|
||||
tokens: None,
|
||||
todos_remaining: None,
|
||||
}),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn shells_section_heading(shells: &[WorkRow]) -> WorkRow {
|
||||
let ids: Vec<&str> = shells
|
||||
.iter()
|
||||
.filter_map(|row| row.id.0.strip_prefix("shell:"))
|
||||
.collect();
|
||||
let listing = if ids.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(
|
||||
"{}\n\n",
|
||||
ids.iter()
|
||||
.map(|id| format!("- {id}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
)
|
||||
};
|
||||
WorkRow {
|
||||
id: WorkRowId("section:shells".to_string()),
|
||||
mark: "▾",
|
||||
label: format!("Shells {}", shells.len()),
|
||||
detail: "Watch live output · cancel by the id on the row".to_string(),
|
||||
tone: WorkTone::Heading,
|
||||
selectable: true,
|
||||
primary_action: Some(SidebarRowAction::InspectWork {
|
||||
title: format!("Shells {}", shells.len()),
|
||||
body: format!(
|
||||
"Running shells ({})\n\n{listing}Open a row to watch output. Cancel with /jobs cancel <id> or /jobs cancel all.",
|
||||
shells.len()
|
||||
),
|
||||
stop_action: Some(Box::new(SidebarRowAction::Command(
|
||||
"/jobs cancel all".to_string(),
|
||||
))),
|
||||
}),
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_inspector_body(app: &App, entry: &TaskPanelEntry, command: &str, status: &str) -> String {
|
||||
let mut body = format!(
|
||||
"Job: {}\nStatus: {status}\nCommand: {command}\n\nCancel: /jobs cancel {}\n",
|
||||
entry.id, entry.id
|
||||
);
|
||||
let session = app.current_session_id.as_deref().unwrap_or("");
|
||||
if let Some(manager) = app.runtime_services.shell_manager.as_ref()
|
||||
&& let Ok(mut manager) = manager.try_lock()
|
||||
&& let Ok(detail) = manager.inspect_job_for_session(session, &entry.id)
|
||||
{
|
||||
if !detail.stdout.is_empty() {
|
||||
body.push_str("\nSTDOUT:\n");
|
||||
body.push_str(&detail.stdout);
|
||||
}
|
||||
if !detail.stderr.is_empty() {
|
||||
body.push_str("\nSTDERR:\n");
|
||||
body.push_str(&detail.stderr);
|
||||
}
|
||||
if detail.stdout.is_empty() && detail.stderr.is_empty() {
|
||||
body.push_str("\n(no output yet — reopen this row to watch the shell)");
|
||||
}
|
||||
} else {
|
||||
body.push_str("\nOpen this row to watch live output.");
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn graph_node_row(snapshot: &WorkGraphSnapshot, node: &WorkNode) -> WorkRow {
|
||||
let (mark, tone) = match node.state {
|
||||
NodeState::Ready => (crate::tui::glyphs::READY, WorkTone::Muted),
|
||||
@@ -3171,4 +3331,108 @@ mod tests {
|
||||
"Work · Needs input: operation choose a release target · 1 blocked"
|
||||
);
|
||||
}
|
||||
|
||||
fn running_shell_entry(id: &str, command: &str) -> crate::tui::app::TaskPanelEntry {
|
||||
crate::tui::app::TaskPanelEntry {
|
||||
id: id.to_string(),
|
||||
status: "running".to_string(),
|
||||
prompt_summary: format!("shell: {command}"),
|
||||
duration_ms: Some(42_000),
|
||||
kind: crate::tui::app::TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
current_tool: None,
|
||||
role: None,
|
||||
files_touched: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_shells_are_first_class_work_strip_rows() {
|
||||
let mut app = test_app();
|
||||
app.work_surface.placement = WorkSurfacePlacement::Top;
|
||||
app.work_surface.effective_placement = WorkSurfacePlacement::Top;
|
||||
app.task_panel.push(running_shell_entry(
|
||||
"shell_a1b2c3d4",
|
||||
"cd /Volumes/VIXinSSD/ShannonNet",
|
||||
));
|
||||
app.subagent_cache
|
||||
.push(running_agent("doc-scout-spec-arch"));
|
||||
|
||||
let rows = project_visible(&mut app);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.any(|row| row.id.0 == "section:agents" && row.label.contains("Subagents")),
|
||||
"subagent visibility must not regress: {rows:?}"
|
||||
);
|
||||
let heading = rows
|
||||
.iter()
|
||||
.find(|row| row.id.0 == "section:shells")
|
||||
.expect("Shells group heading");
|
||||
assert_eq!(heading.label, "Shells 1");
|
||||
assert!(heading.selectable);
|
||||
let shell = rows
|
||||
.iter()
|
||||
.find(|row| row.id.0 == "shell:shell_a1b2c3d4")
|
||||
.expect("navigable shell row");
|
||||
assert_eq!(shell.label, "shell_a1b2c3d4");
|
||||
let Some(SidebarRowAction::InspectWork {
|
||||
title,
|
||||
body,
|
||||
stop_action,
|
||||
}) = shell.primary_action.as_ref()
|
||||
else {
|
||||
panic!(
|
||||
"shell row must open live output, got {:?}",
|
||||
shell.primary_action
|
||||
);
|
||||
};
|
||||
assert!(title.contains("shell_a1b2c3d4"), "{title}");
|
||||
assert!(body.contains("Job: shell_a1b2c3d4"), "{body}");
|
||||
assert!(body.contains("/jobs cancel shell_a1b2c3d4"), "{body}");
|
||||
assert!(matches!(
|
||||
stop_action.as_deref(),
|
||||
Some(SidebarRowAction::Command(command)) if command == "/jobs cancel shell_a1b2c3d4"
|
||||
));
|
||||
let facts = shell
|
||||
.agent
|
||||
.as_ref()
|
||||
.expect("shell uses the same row columns");
|
||||
assert_eq!(facts.role_label, "shell");
|
||||
assert_eq!(facts.status, "running");
|
||||
assert!(
|
||||
facts.objective.contains("ShannonNet"),
|
||||
"{}",
|
||||
facts.objective
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn durable_tasks_are_not_promoted_as_shell_rows() {
|
||||
let mut app = test_app();
|
||||
app.work_surface.placement = WorkSurfacePlacement::Top;
|
||||
app.work_surface.effective_placement = WorkSurfacePlacement::Top;
|
||||
app.task_panel.push(crate::tui::app::TaskPanelEntry {
|
||||
id: "run".to_string(),
|
||||
status: "running".to_string(),
|
||||
prompt_summary: "background confirmation test".to_string(),
|
||||
duration_ms: Some(99_000),
|
||||
kind: crate::tui::app::TaskPanelEntryKind::Background,
|
||||
stale: false,
|
||||
elapsed_since_output_ms: None,
|
||||
owner_agent_id: None,
|
||||
owner_agent_name: None,
|
||||
current_tool: None,
|
||||
role: None,
|
||||
files_touched: 0,
|
||||
});
|
||||
let rows = project_visible(&mut app);
|
||||
assert!(
|
||||
rows.iter()
|
||||
.all(|row| !row.id.0.starts_with("shell:") && row.id.0 != "section:shells"),
|
||||
"non-shell task_panel entries must not become Shells rows: {rows:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,6 @@ impl OperationIntent {
|
||||
acceptance: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_acceptance(mut self, acceptance: Vec<AcceptanceRequirement>) -> Self {
|
||||
self.acceptance = acceptance;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// One authoritative owner snapshot. `seq` must be monotonic within the
|
||||
|
||||
@@ -720,43 +720,6 @@ impl WorkRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile restored Fleet and Lane bindings from their durable owners.
|
||||
/// Missing records fail toward `Stale`; process probes never override the
|
||||
/// replayed ledger/registry state.
|
||||
pub fn reconcile_workspace_owner_bindings(
|
||||
&self,
|
||||
session_id: &str,
|
||||
workspace: &Path,
|
||||
) -> Result<usize, String> {
|
||||
let (base, revision) = {
|
||||
let active = lock_unpoisoned(&self.graph);
|
||||
if active.session_id.as_deref() != Some(session_id) {
|
||||
return Err(format!(
|
||||
"workspace owner reconciliation does not match active session {session_id}"
|
||||
));
|
||||
}
|
||||
let base = active
|
||||
.snapshot
|
||||
.clone()
|
||||
.ok_or_else(|| "workspace owner reconciliation has no active graph".to_string())?;
|
||||
let revision = base.revision;
|
||||
(base, revision)
|
||||
};
|
||||
let (next, changed) = reconcile_workspace_snapshot(base, session_id, workspace)?;
|
||||
if changed == 0 {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut active = lock_unpoisoned(&self.graph);
|
||||
if active.session_id.as_deref() != Some(session_id)
|
||||
|| active.snapshot.as_ref().map(|graph| graph.revision) != Some(revision)
|
||||
{
|
||||
return Err("Work Graph changed during workspace owner reconciliation".to_string());
|
||||
}
|
||||
active.snapshot = Some(next);
|
||||
active.pending_publish = true;
|
||||
Ok(changed)
|
||||
}
|
||||
|
||||
pub fn clear(&self, session_id: Option<&str>) -> bool {
|
||||
let Some(mut todos) = retry_lock(&self.todos, 100) else {
|
||||
return false;
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ Concretely:
|
||||
(which changes the project-context pack, a directory listing, a skills scan)
|
||||
cannot move the pinned prefix under the model's feet mid-turn.
|
||||
- **History only grows.** Volatile facts the model must see (LSP diagnostics,
|
||||
steer input, subagent completions) are appended to the message list, never
|
||||
spliced into the frozen prefix. Workspace drift is delivered the same way:
|
||||
steer input, subagent completions, `<recommended_plugins>` on a matching
|
||||
user turn) are appended to the message list, never spliced into the frozen
|
||||
prefix. Workspace drift is delivered the same way:
|
||||
at the start of each **new user turn** (never mid-tool-loop) the engine
|
||||
recomposes the volatile contributors and, if anything differs from what the
|
||||
model last saw, appends **one** `<context_update>` user-role message with a
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# Daytona cloud-agent dispatch
|
||||
|
||||
Local `cw` / Codewhale can offload a coding agent to Daytona the way Cursor
|
||||
sends a cloud agent: the remote job raises a branch and is intended to open a
|
||||
PR against an explicit forge. Local stays responsive; spend and push never
|
||||
happen silently.
|
||||
|
||||
## One obvious offload
|
||||
|
||||
```sh
|
||||
codewhale dispatch "open a PR that fixes the flake" --remote github
|
||||
codewhale dispatch --confirm cloud_<id>
|
||||
```
|
||||
|
||||
Same action in the TUI:
|
||||
|
||||
```
|
||||
/dispatch open a PR that fixes the flake --remote github
|
||||
/dispatch confirm cloud_<id>
|
||||
```
|
||||
|
||||
`codewhale cloud-agent` and `/cloud-agent` are aliases. `--confirm` /
|
||||
`/dispatch confirm` is required. A proposal is written first; nothing creates a
|
||||
Daytona sandbox or pushes a branch until that confirmation.
|
||||
|
||||
Cloud jobs are first-class on the existing jobs surface (`kind=cloud`):
|
||||
|
||||
```
|
||||
/jobs list
|
||||
/dispatch list
|
||||
/dispatch show <id>
|
||||
/dispatch cancel <id>
|
||||
codewhale dispatch --list
|
||||
```
|
||||
|
||||
## Remotes
|
||||
|
||||
Forges are explicit: `github`, `cnb`, `gitee`.
|
||||
|
||||
CWC already treats a remote *named* `github` as authoritative GitHub and
|
||||
`origin` as the CNB mirror when that URL is `cnb.cool`. Codewhale uses the same
|
||||
rule:
|
||||
|
||||
| Remote name | URL host | Forge |
|
||||
| --- | --- | --- |
|
||||
| `github` | any | `github` |
|
||||
| `cnb` | any | `cnb` |
|
||||
| `gitee` | any | `gitee` |
|
||||
| `origin` or other | `github.com` | `github` |
|
||||
| `origin` or other | `cnb.cool` | `cnb` |
|
||||
| `origin` or other | `gitee.com` | `gitee` |
|
||||
|
||||
If more than one forge is present, pass `--remote` / `--remote` on `/dispatch`.
|
||||
Do not assume `origin` is GitHub.
|
||||
|
||||
## Enable Daytona (fail-closed)
|
||||
|
||||
This slice does **not** fake a successful remote PR. Without credentials,
|
||||
dispatch refuses and records a `refused` cloud job.
|
||||
|
||||
1. Create an API key at [app.daytona.io/dashboard/keys](https://app.daytona.io/dashboard/keys).
|
||||
2. Export it in the process environment (never commit it, never put it in
|
||||
`models.toml` or `config.toml`):
|
||||
|
||||
```sh
|
||||
export DAYTONA_API_KEY=...
|
||||
# optional
|
||||
export DAYTONA_API_URL=https://app.daytona.io/api
|
||||
```
|
||||
|
||||
3. Or store the same key in the Codewhale secret slot `daytona` (OS keyring /
|
||||
`$CODEWHALE_HOME` secrets file). The CWC alias `CWC_DAYTONA_TOKEN` is also
|
||||
accepted so an already-configured control-plane shell works.
|
||||
|
||||
`daytona` CLI being installed is **not** a credential. `codewhale dispatch
|
||||
--status` / bare `/dispatch` report CLI presence separately.
|
||||
|
||||
## Confirmation and fail-closed rules
|
||||
|
||||
- No `--confirm` / `/dispatch confirm`: write a `proposed` job, exit success,
|
||||
do not call Daytona, do not push.
|
||||
- Confirm + no credentials: write a `refused` job, exit failure, no sandbox.
|
||||
- Confirm + credentials: create a Daytona sandbox labeled with the job id and
|
||||
forge. This slice does **not** claim a GitHub/CNB/Gitee PR URL. A missing
|
||||
forge token fails closed the same way — it does not invent a PR.
|
||||
|
||||
## Leftover
|
||||
|
||||
- Live watch / log tail of a running sandbox.
|
||||
- Cancel that tears down a paid Daytona sandbox.
|
||||
- Auto-decide heuristics (Codewhale may propose; it must not confirm itself).
|
||||
- The remote agent runner that actually raises the branch and opens the PR.
|
||||
@@ -295,6 +295,9 @@ Operate keeps that direct tool surface and its approval, sandbox, shell,
|
||||
ask-rule, and repository protections. Its difference is orchestration emphasis:
|
||||
Codewhale prefers Fleet workers for independent, parallel, background, or
|
||||
long-running work, while small or tightly coupled work can remain in the parent.
|
||||
Heavy work can also be proposed to a Daytona cloud agent with `codewhale
|
||||
dispatch` or `/dispatch` (explicit confirmation; remotes are `github` / `cnb` /
|
||||
`gitee`). See [DAYTONA_CLOUD_DISPATCH.md](DAYTONA_CLOUD_DISPATCH.md).
|
||||
|
||||
For trusted workspaces where you intentionally want actions to proceed without
|
||||
approval prompts, select the Full Access permission posture with `Shift+Tab`.
|
||||
|
||||
@@ -81,6 +81,17 @@ locale, Chinese included.** `web/app/[locale]/page.tsx`,
|
||||
`getChrome(locale)`. `web/lib/i18n/dictionaries/zh/` now exists (it used to
|
||||
be inline TSX), and nav/footer link sets are generated once in
|
||||
`web/lib/i18n/links.ts` so every locale gets the identical route shape.
|
||||
|
||||
**Website/docs translation pipeline (General Translation CLI, 2026-08-28).**
|
||||
Runtime stays the dictionaries above — do not add `gt-next` beside them.
|
||||
`web/gt-catalog/[locale].json` is the local JSON interchange (`en` + live
|
||||
`zh` first). `npm run i18n:gt -- export` writes catalogs from dictionaries;
|
||||
`check` (hooked from `check:locales`) requires them to match; `import`
|
||||
writes reviewed JSON back to website dictionary TS only. `translate` is
|
||||
fail-closed unless BYOK `GT_API_KEY` and `GT_PROJECT_ID` are set in the
|
||||
environment — never commit those values, never point this config at
|
||||
`crates/tui/locales`, and never wrap model completions. `gt generate` is
|
||||
not used: it is a framework JSX scanner, not a JSON-catalog tool.
|
||||
Reference shape: **`ChromeDict` 52 keys, `HomeDict` 62 keys.** Bilingual
|
||||
secondary nav labels, the masthead seal and issue line, the ticker live
|
||||
label, and the per-locale `Intl` date tag are dictionary values — no locale
|
||||
|
||||
+18
-5
@@ -8,11 +8,24 @@ the trust/enable lifecycle — this document covers how bits get onto disk in
|
||||
the first place.
|
||||
|
||||
`/plugin suggest <task>` is a local, read-only companion: it ranks already
|
||||
installed bundles by their validated name, description, bundled skill names,
|
||||
and declared hosts. It explains the match and gives the next review/enable
|
||||
step, but never installs, trusts, or enables a bundle. Codewhale deliberately
|
||||
does not treat arbitrary remote archives as a plugin marketplace; a remote
|
||||
catalog needs publisher and provenance policy before it can make suggestions.
|
||||
installed bundles (name, keywords, description, bundled skill names, declared
|
||||
hosts) and any marketplace catalogs you have added with `/plugin marketplace add`.
|
||||
It explains the match and gives the next review, enable, or catalog-install
|
||||
step, but never installs, trusts, or enables a bundle on its own.
|
||||
|
||||
Sending a task also surfaces one quiet toast when the prompt strongly matches
|
||||
an installed-but-idle plugin or a catalog candidate you do not have yet — for
|
||||
example a prompt about Supabase suggesting `/plugin trust supabase` or
|
||||
`/plugin marketplace install <catalog> supabase`. Description-only matches do
|
||||
not toast. While you type, a one-line composer CTA (`Install {name} plugin?`)
|
||||
offers the same review command after a short debounce; it never auto-installs,
|
||||
hides when the plugin is already active, and stays dismissed for that name
|
||||
this session. Matching idle or catalog plugins are also appended on send as
|
||||
an `<recommended_plugins>` user-turn block (not the pinned system prefix);
|
||||
the model can call `request_plugin_install` to surface review for the human
|
||||
without changing disk. Codewhale does not invent a remote plugin URL; missing
|
||||
plugins are suggested only from catalogs you added. On-disk bundle changes
|
||||
still toast `/plugin reload` on send and between turns.
|
||||
|
||||
## Sources
|
||||
|
||||
|
||||
@@ -258,8 +258,10 @@ Then run `/plugin enable example` again. Trust and enablement are separate:
|
||||
|
||||
(`/plugin install`, `update`, and `uninstall` place, replace, and remove the
|
||||
bits themselves and always drop into this same review — see
|
||||
[PLUGINS.md](PLUGINS.md). `/plugin suggest` only ranks what is already
|
||||
installed.)
|
||||
[PLUGINS.md](PLUGINS.md). `/plugin suggest` ranks installed bundles and
|
||||
any locally added marketplace catalogs; sending a matching task can toast the
|
||||
same next step without installing anything, and a live composer CTA plus an
|
||||
append-only `<recommended_plugins>` user block offer the same review path.)
|
||||
|
||||
Trust, enable, disable, revoke, and reload rebuild the current workspace's
|
||||
Skills, MCP, Commands, Agent profiles, and Hooks immediately. Each persisted
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
"sourceCandidate": {
|
||||
"version": "0.9.11",
|
||||
"providerCount": 45,
|
||||
"toolCount": 74,
|
||||
"toolCount": 75,
|
||||
"sandboxBackends": [
|
||||
"seatbelt (macOS, when available)",
|
||||
"bubblewrap (Linux, opt-in when installed)"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "One-way numeric ceilings and exact structural identities for the provider-free runtime contract. Decreases pass; increases or identity changes fail. Lock in decreases with: python3 scripts/check-runtime-contract-budget.py --update The v0.9.8 child-receipt restore grew every production tool surface by 1496 schema bytes / 374 estimated tokens (agent tool). The v0.9.8 workshop read/tool-result byte fields then grew every production tool surface by 371 schema bytes / 93 estimated tokens. Both raises are explicit maintainer decisions; identities stay on the pre-raise digests only if the name set is unchanged \u2014 re-measure on Linux CI if Lint reports identity drift. The v0.9.8 pinned session prefix added the <context_update> sentence to the base prompt (5848 -> 6084 bytes, every representative stage re-hashed), and the host-side Workflow/Goal verbs plus honest child posture grew the tool catalog (active 16531 -> 16602 bytes, full 71473 -> 72371); both are explicit v0.9.8 maintainer decisions measured from the release train. The v0.9.9 configured-skills change hides only custom configured-root paths, preserves discoverable default-root paths, normalizes Windows prompt separators, and trims 50 redundant skills-prompt bytes. The skill/memory/goal/handoff identities were re-measured without raising any ceiling. Explicit maintainer decision for #5473/#5492. The v0.9.10 full surfaces intentionally add the safe read_media tool; their measured schemas remain below the prior byte/token ceilings. Representative prompt byte metrics now use the same host-independent normalized text as their identities; the normalized base is 6089 bytes. The v0.9.11 model-visible sub-agent surface intentionally retires six legacy agents/* tools in favor of the canonical agent tool; all affected schema and prompt metrics decrease.",
|
||||
"_comment": "One-way numeric ceilings and exact structural identities for the provider-free runtime contract. Decreases pass; increases or identity changes fail. Lock in decreases with: python3 scripts/check-runtime-contract-budget.py --update The v0.9.8 child-receipt restore grew every production tool surface by 1496 schema bytes / 374 estimated tokens (agent tool). The v0.9.8 workshop read/tool-result byte fields then grew every production tool surface by 371 schema bytes / 93 estimated tokens. Both raises are explicit maintainer decisions; identities stay on the pre-raise digests only if the name set is unchanged \u2014 re-measure on Linux CI if Lint reports identity drift. The v0.9.8 pinned session prefix added the <context_update> sentence to the base prompt (5848 -> 6084 bytes, every representative stage re-hashed), and the host-side Workflow/Goal verbs plus honest child posture grew the tool catalog (active 16531 -> 16602 bytes, full 71473 -> 72371); both are explicit v0.9.8 maintainer decisions measured from the release train. The v0.9.9 configured-skills change hides only custom configured-root paths, preserves discoverable default-root paths, normalizes Windows prompt separators, and trims 50 redundant skills-prompt bytes. The skill/memory/goal/handoff identities were re-measured without raising any ceiling. Explicit maintainer decision for #5473/#5492. The v0.9.10 full surfaces intentionally add the safe read_media tool; their measured schemas remain below the prior byte/token ceilings. Representative prompt byte metrics now use the same host-independent normalized text as their identities; the normalized base is 6089 bytes. The v0.9.11 model-visible sub-agent surface intentionally retires six legacy agents/* tools in favor of the canonical agent tool; all affected schema and prompt metrics decrease. The v0.9.12 plugin prompt-match slice intentionally adds the request_plugin_install tool to the full tool surfaces (plan full: +518 schema bytes / +130 estimated tokens / 29 -> 30 tools) so a strong prompt match can surface the human review CTA; explicit maintainer decision for #5663/#5579.",
|
||||
"document_kind": "codewhale.runtime_contract_budget",
|
||||
"representative_context": {
|
||||
"fixture_id": "representative-v1",
|
||||
@@ -100,9 +100,9 @@
|
||||
"tools": 7
|
||||
},
|
||||
"full": {
|
||||
"bytes": 64848,
|
||||
"identity_sha256": "37d9a2329fee3f5b8a69c20d97e048fc11191cc37490a62bcd738f6d995fed81",
|
||||
"tokens_est": 16212,
|
||||
"bytes": 65461,
|
||||
"identity_sha256": "b5ceb72d89dc1e0e4161bd360ed2a05c433ce7b60b2300987165406c33d155af",
|
||||
"tokens_est": 16366,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Run",
|
||||
@@ -130,6 +130,7 @@
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"revert_turn",
|
||||
@@ -155,7 +156,7 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 50
|
||||
"tools": 51
|
||||
}
|
||||
},
|
||||
"operate": {
|
||||
@@ -175,9 +176,9 @@
|
||||
"tools": 7
|
||||
},
|
||||
"full": {
|
||||
"bytes": 64848,
|
||||
"identity_sha256": "37d9a2329fee3f5b8a69c20d97e048fc11191cc37490a62bcd738f6d995fed81",
|
||||
"tokens_est": 16212,
|
||||
"bytes": 65461,
|
||||
"identity_sha256": "b5ceb72d89dc1e0e4161bd360ed2a05c433ce7b60b2300987165406c33d155af",
|
||||
"tokens_est": 16366,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Run",
|
||||
@@ -205,6 +206,7 @@
|
||||
"project_map",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"retrieve_tool_result",
|
||||
"revert_turn",
|
||||
@@ -230,7 +232,7 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 50
|
||||
"tools": 51
|
||||
}
|
||||
},
|
||||
"plan": {
|
||||
@@ -250,9 +252,9 @@
|
||||
"tools": 7
|
||||
},
|
||||
"full": {
|
||||
"bytes": 37907,
|
||||
"identity_sha256": "28cf9153a4285ae29959162279193f0251a8090b33e84d380953916fc8c03ad6",
|
||||
"tokens_est": 9477,
|
||||
"bytes": 38425,
|
||||
"identity_sha256": "ac8af1f4988199825be7b00b054c258724a44074b1d4e6de6c92ade7c1cffe63",
|
||||
"tokens_est": 9607,
|
||||
"tool_names": [
|
||||
"Git",
|
||||
"Web",
|
||||
@@ -272,6 +274,7 @@
|
||||
"notify",
|
||||
"read",
|
||||
"read_media",
|
||||
"request_plugin_install",
|
||||
"request_user_input",
|
||||
"review",
|
||||
"send_later",
|
||||
@@ -284,7 +287,7 @@
|
||||
"workflow",
|
||||
"write"
|
||||
],
|
||||
"tools": 29
|
||||
"tools": 30
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -25,3 +25,10 @@ MAINTAINER_GITHUB_PAT=
|
||||
# Set to 1 once the Gitee mirror at gitee.com/Hmbown/...
|
||||
# exists. Until then leave blank to hide Gitee links.
|
||||
NEXT_PUBLIC_GITEE_ENABLED=
|
||||
|
||||
# Optional BYOK for website/docs machine translation via the General
|
||||
# Translation CLI (`npm run i18n:gt -- translate`). Never commit values.
|
||||
# Check/export/import are local and do not need these. Runtime does not
|
||||
# call GT. Fail-closed without both. Never wrap model completions.
|
||||
# GT_API_KEY=
|
||||
# GT_PROJECT_ID=
|
||||
+4
-1
@@ -12,7 +12,10 @@ site.
|
||||
- Localization migrations are one-way. New or touched pages use the shared
|
||||
dictionary/content spine; do not add page-local language booleans, `isZh`
|
||||
copy forks, or hard-coded translated page clones. A compatibility exception
|
||||
must name the external contract that still requires it.
|
||||
must name the external contract that still requires it. Website/docs
|
||||
machine translation uses `npm run i18n:gt` (local JSON catalogs into the
|
||||
same dictionaries). Do not add `gt-next` beside that path, and do not
|
||||
point GT at TUI locale packs.
|
||||
- Real-session media is owned by `lib/media-manifest.ts`. `pending` is the
|
||||
truthful state until the complete asset set passes manifest tests.
|
||||
- `AGENT.md` (singular) documents maintainer-owned community automation. Do not
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function LegalIndexPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
permanentRedirect(`/${locale}/legal/terms`);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import Link from "next/link";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
import { LEGAL_UPDATED, PRIVACY_SECTIONS } from "@/lib/legal-copy";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return buildPageMetadata({
|
||||
path: "/legal/privacy",
|
||||
locale,
|
||||
title: isZh ? "隐私政策 · Codewhale" : "Privacy policy · Codewhale",
|
||||
description: isZh
|
||||
? "Shannon Labs 如何在你使用 Codewhale 时处理信息。"
|
||||
: "How Shannon Labs handles information when you use Codewhale.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PrivacyPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return (
|
||||
<div className="portal-home">
|
||||
<article className="legal-doc">
|
||||
<p className="legal-doc-kicker">{isZh ? "法律" : "Legal"}</p>
|
||||
<h1>{isZh ? "隐私政策" : "Privacy policy"}</h1>
|
||||
<p className="legal-doc-updated">
|
||||
{isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED}
|
||||
{isZh ? "。以下为具有约束力的英文文本。" : "."}
|
||||
</p>
|
||||
<p>This policy explains how Shannon Labs handles information when you use Codewhale.</p>
|
||||
{PRIVACY_SECTIONS.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2>{section.title}</h2>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
<p className="legal-doc-nav">
|
||||
<Link href={`/${locale}/legal/terms`}>{isZh ? "服务条款" : "Terms of service"}</Link>
|
||||
<Link href={`/${locale}/pricing`}>{isZh ? "价格" : "Pricing"}</Link>
|
||||
<Link href={`/${locale}`}>{isZh ? "返回首页" : "Back home"}</Link>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import Link from "next/link";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
import { LEGAL_UPDATED, TERMS_SECTIONS } from "@/lib/legal-copy";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return buildPageMetadata({
|
||||
path: "/legal/terms",
|
||||
locale,
|
||||
title: isZh ? "服务条款 · Codewhale" : "Terms of service · Codewhale",
|
||||
description: isZh
|
||||
? "Shannon Labs 产品 Codewhale 的服务条款。"
|
||||
: "Terms that govern your use of Codewhale, a Shannon Labs product.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function TermsPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return (
|
||||
<div className="portal-home">
|
||||
<article className="legal-doc">
|
||||
<p className="legal-doc-kicker">{isZh ? "法律" : "Legal"}</p>
|
||||
<h1>{isZh ? "服务条款" : "Terms of service"}</h1>
|
||||
<p className="legal-doc-updated">
|
||||
{isZh ? "生效并最近更新于" : "Effective and last updated"} {LEGAL_UPDATED}
|
||||
{isZh ? "。以下为具有约束力的英文文本。" : "."}
|
||||
</p>
|
||||
<p>
|
||||
These terms govern your use of Codewhale, a Shannon Labs product. By creating an
|
||||
account or using the service, you agree to them.
|
||||
</p>
|
||||
{TERMS_SECTIONS.map((section) => (
|
||||
<section key={section.title}>
|
||||
<h2>{section.title}</h2>
|
||||
<p>{section.body}</p>
|
||||
</section>
|
||||
))}
|
||||
<p className="legal-doc-nav">
|
||||
<Link href={`/${locale}/legal/privacy`}>{isZh ? "隐私政策" : "Privacy policy"}</Link>
|
||||
<Link href={`/${locale}/pricing`}>{isZh ? "价格" : "Pricing"}</Link>
|
||||
<Link href={`/${locale}`}>{isZh ? "返回首页" : "Back home"}</Link>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import Link from "next/link";
|
||||
import { APP_LOGIN_URL } from "@/lib/i18n/links";
|
||||
import { buildPageMetadata } from "@/lib/page-meta";
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return buildPageMetadata({
|
||||
path: "/pricing",
|
||||
locale,
|
||||
title: isZh ? "价格 · Codewhale" : "Pricing · Codewhale",
|
||||
description: isZh
|
||||
? "Codewhale 开源运行时免费。托管会员计费已实现但尚未对公众开放。"
|
||||
: "The Codewhale open-source runtime is free. Hosted membership billing is built but not for sale yet.",
|
||||
});
|
||||
}
|
||||
|
||||
export default async function PricingPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
const isZh = locale === "zh";
|
||||
return (
|
||||
<div className="portal-home">
|
||||
<section className="portal-section">
|
||||
<div className="portal-container pricing-page">
|
||||
<p className="legal-doc-kicker">{isZh ? "价格" : "Pricing"}</p>
|
||||
<h1>{isZh ? "现在没有可以付款的套餐。" : "There is nothing to pay for yet."}</h1>
|
||||
<p className="portal-lede">
|
||||
{isZh
|
||||
? "开源运行时免费,自带模型密钥(BYOK)。托管计算的 $10/月会员已经写进产品,但生产环境的计费开关仍是休眠状态——没有结账按钮,也就不会误收一笔钱。"
|
||||
: "The open-source runtime is free. Bring your own model key. The $10/month Member plan for hosted compute is implemented in the product, but production billing is dormant — so there is no checkout button that could charge anyone by accident."}
|
||||
</p>
|
||||
<ul className="pricing-list">
|
||||
<li>
|
||||
<strong>{isZh ? "开源 / 自托管" : "Open source / self-hosted"}</strong>
|
||||
<span>{isZh ? "免费。在你的机器上运行,使用你自己的模型密钥。" : "Free. Run on your machine with your own model keys."}</span>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{isZh ? "托管会员" : "Hosted Member"}</strong>
|
||||
<span>
|
||||
{isZh
|
||||
? "$10/月、每期 $10 可结转的托管计算额度——已实现,尚未对公众销售。"
|
||||
: "$10/month with $10 of rollover hosted-compute credits each paid period — built, not for sale to the public."}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p className="pricing-note">
|
||||
{isZh
|
||||
? "模型用量由你连接的提供商计费,Codewhale 不加价。我们不会在计费休眠期间展示一个会失败的“立即购买”。"
|
||||
: "Model tokens are billed by the provider you connect. Codewhale does not mark them up. We will not show a Buy button that 503s while billing is dormant."}
|
||||
</p>
|
||||
<div className="portal-actions">
|
||||
<Link className="portal-button portal-button-primary" href={`/${locale}/install`}>
|
||||
{isZh ? "安装 →" : "Install →"}
|
||||
</Link>
|
||||
<a className="portal-button portal-button-secondary" href={APP_LOGIN_URL}>
|
||||
{isZh ? "打开应用" : "Open the app"}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function PrivacyAliasPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
permanentRedirect(`/${locale}/legal/privacy`);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { permanentRedirect } from "next/navigation";
|
||||
|
||||
export default async function TermsAliasPage({ params }: { params: Promise<{ locale: string }> }) {
|
||||
const { locale } = await params;
|
||||
permanentRedirect(`/${locale}/legal/terms`);
|
||||
}
|
||||
@@ -1231,6 +1231,78 @@ a.body-link:hover { background-size: 100% 6px; color: var(--ink); }
|
||||
background: var(--paper-deep);
|
||||
}
|
||||
|
||||
.legal-doc {
|
||||
width: var(--container);
|
||||
margin-inline: auto;
|
||||
padding-block: clamp(3.25rem, 6vw, 4.75rem);
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.legal-doc-kicker {
|
||||
margin: 0 0 0.75rem;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-muted, color-mix(in srgb, var(--ink) 62%, var(--paper)));
|
||||
}
|
||||
|
||||
.legal-doc h1 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-family: var(--font-display), serif;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.legal-doc-updated,
|
||||
.legal-doc p,
|
||||
.legal-doc section p {
|
||||
margin: 0 0 1.15rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.legal-doc section h2 {
|
||||
margin: 1.75rem 0 0.5rem;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.legal-doc-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem 1.5rem;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.legal-doc-nav a {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2rem;
|
||||
}
|
||||
|
||||
.pricing-page {
|
||||
max-width: 42rem;
|
||||
}
|
||||
|
||||
.pricing-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.pricing-list li {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.pricing-list li:last-child {
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.pricing-note {
|
||||
margin: 0 0 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ---------- read-only settings reference ---------- */
|
||||
.settings-preview {
|
||||
background: color-mix(in srgb, var(--paper-deep) 52%, var(--paper));
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { SITE_URL } from "@/lib/page-meta";
|
||||
|
||||
// Public, indexable routes (locale-prefixed). /admin and /api are
|
||||
// intentionally excluded; see app/robots.ts.
|
||||
const PATHS = ["", "/install", "/constitution", "/models", "/runtime", "/docs", "/docs/configuration", "/docs/constitution", "/docs/fleet", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/contribute", "/community"];
|
||||
const PATHS = ["", "/install", "/constitution", "/models", "/runtime", "/docs", "/docs/configuration", "/docs/constitution", "/docs/fleet", "/docs/guide", "/docs/hooks", "/docs/mcp", "/docs/modes", "/docs/runtime-api", "/docs/sandbox", "/docs/subagents", "/docs/tools", "/docs/troubleshooting", "/docs/vocabulary", "/docs/web", "/docs/work", "/faq", "/roadmap", "/feed", "/digest", "/contribute", "/community", "/pricing", "/legal/terms", "/legal/privacy"];
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return PATHS.flatMap((path) =>
|
||||
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { GITEE_ENABLED, type Locale } from "@/lib/i18n/config";
|
||||
import { getChrome } from "@/lib/i18n/dictionaries";
|
||||
import {
|
||||
footerLegalLinks,
|
||||
footerProductLinks,
|
||||
footerProjectLinks,
|
||||
REPO_RELEASES_URL,
|
||||
@@ -20,6 +21,7 @@ export function Footer({ locale = "en" }: { locale?: Locale }) {
|
||||
const homeHref = `/${locale}`;
|
||||
const product = footerProductLinks(locale, chrome);
|
||||
const project = footerProjectLinks(locale, chrome);
|
||||
const legal = footerLegalLinks(locale, chrome);
|
||||
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
@@ -52,6 +54,7 @@ export function Footer({ locale = "en" }: { locale?: Locale }) {
|
||||
<a href={REPO_RELEASES_URL}>{chrome.footerReleasesLink}</a>
|
||||
</p>
|
||||
<div>
|
||||
{legal.map((item) => <Link key={item.href} href={item.href}>{item.label}</Link>)}
|
||||
{GITEE_ENABLED && <a href="https://gitee.com/Hmbown/CodeWhale">Gitee</a>}
|
||||
<a href="https://cnb.cool/codewhale.net/codewhale">CNB</a>
|
||||
<a href="https://npmmirror.com/package/codewhale">npmmirror</a>
|
||||
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"chrome": {
|
||||
"navDocs": "Docs",
|
||||
"navStart": "Start",
|
||||
"navInstall": "Install",
|
||||
"navFaq": "FAQ",
|
||||
"navCommunity": "Community",
|
||||
"navContribute": "Contribute",
|
||||
"navDocsSecondary": "文档",
|
||||
"navStartSecondary": "指引",
|
||||
"navInstallSecondary": "安装",
|
||||
"navFaqSecondary": "问答",
|
||||
"navCommunitySecondary": "社区",
|
||||
"navContributeSecondary": "贡献",
|
||||
"skipToContent": "Skip to main content",
|
||||
"navPrimaryAria": "Primary",
|
||||
"navHomeAria": "Codewhale home",
|
||||
"installCta": "Install →",
|
||||
"authSignIn": "Sign in",
|
||||
"authRegister": "Register",
|
||||
"authGroupAria": "Account",
|
||||
"wordmarkSeal": "深",
|
||||
"wordmarkTag": "any model, on your machine",
|
||||
"issueLabel": "Issue {date}",
|
||||
"dateLocale": "en-US",
|
||||
"starsAria": "GitHub stars",
|
||||
"githubFallback": "GitHub",
|
||||
"tickerLiveLabel": "实 时",
|
||||
"tickerLiveTag": "LIVE",
|
||||
"tickerMerged": "merged",
|
||||
"tickerOpened": "opened",
|
||||
"tickerClosed": "closed",
|
||||
"tickerReleased": "released",
|
||||
"tickerFirstContribution": "first contribution",
|
||||
"tickerBy": "by {handle}",
|
||||
"tickerAria": "Recent repository activity",
|
||||
"traceLabel": "reasoning trace",
|
||||
"traceTabsAria": "Session excerpts",
|
||||
"menuOpen": "Open menu",
|
||||
"menuClose": "Close menu",
|
||||
"themeAuto": "auto",
|
||||
"themeLight": "light",
|
||||
"themeDark": "dark",
|
||||
"themeAria": "Docs theme: {mode} (click to cycle)",
|
||||
"themeTitle": "Docs theme · auto / light / dark",
|
||||
"footerTagline": "Codewhale dives into the deep so you don't have to — docs, source, and community for the open-source runtime.",
|
||||
"footerProduct": "Product",
|
||||
"footerProject": "Project",
|
||||
"footerDocs": "Docs",
|
||||
"footerGuide": "Getting started",
|
||||
"footerInstall": "Install",
|
||||
"footerModels": "Models",
|
||||
"footerRuntime": "Runtime",
|
||||
"footerFaq": "FAQ",
|
||||
"footerIssues": "Issues",
|
||||
"footerContribute": "Contribute",
|
||||
"footerLicense": "MIT license",
|
||||
"footerPricing": "Pricing",
|
||||
"footerTerms": "Terms",
|
||||
"footerPrivacy": "Privacy",
|
||||
"footerCanonicalSource": "Canonical source: ",
|
||||
"footerReleases": " · Releases: ",
|
||||
"footerReleasesLink": "GitHub Releases",
|
||||
"footerSecurity": "Security",
|
||||
"switcherLabel": "Language",
|
||||
"switcherSwitchTo": "Switch to {label}",
|
||||
"partialBadge": "(partial)"
|
||||
},
|
||||
"home": {
|
||||
"metaTitle": "Codewhale — dives into the deep so you don't have to.",
|
||||
"metaDescription": "Codewhale dives into the deep so you don't have to — an open-source terminal coding agent. Bring your own model. Runs on your machine. Rust, MIT.",
|
||||
"kicker": "Open source · Bring your own model · Runs in your terminal",
|
||||
"heroTitleA": "Codewhale dives into the deep",
|
||||
"heroTitleB": "so you don't have to.",
|
||||
"heroIntro": "{brand} is an open-source coding agent for your terminal. Give it a model and a task. It reads your code, edits files, runs the checks, and stops when the job is done or it needs you. Use any model, or a different one for each role.",
|
||||
"install": "Install",
|
||||
"docs": "Docs",
|
||||
"copy": "Copy",
|
||||
"copied": "Copied ✓",
|
||||
"installEyebrow": "one-line install",
|
||||
"installRequirement": "needs Node 18+ — no Rust toolchain",
|
||||
"installOtherWays": "other ways →",
|
||||
"latestRelease": "Latest release {tag}",
|
||||
"releaseUnavailable": "Release status unavailable",
|
||||
"currentSource": "Source",
|
||||
"sourceCandidate": "Unreleased",
|
||||
"providerRoutes": "{count} providers",
|
||||
"publishedRelease": "released",
|
||||
"figcaptionSourceCandidate": "unreleased",
|
||||
"shotSession": "Session",
|
||||
"screenshotAlt": "Codewhale terminal session in Operate mode: the whale, the composer, and the status footer",
|
||||
"figcaption": "Codewhale session · Operate mode · permissions: Ask",
|
||||
"proofHeading": "A coding agent in your terminal. Any model. On your machine.",
|
||||
"proofBody": "Use the model you already have — hosted, through a gateway, or local. Pick a mode: Plan, Work, or Operate. Pick how much it does without asking: Ask, Auto-Review, or Full Access.",
|
||||
"sealDecides": "法",
|
||||
"decidesEyebrow": "How it decides",
|
||||
"decidesHeading": "The reasoning, in its own words",
|
||||
"decidesLede": "Session excerpts. Each shows the project rule the model applied and what it did next.",
|
||||
"sealWorkflow": "行",
|
||||
"workflowHeading": "From task to verified change.",
|
||||
"workflow": [
|
||||
[
|
||||
"Inspect",
|
||||
"Read the repository, its instructions, and the task."
|
||||
],
|
||||
[
|
||||
"Act",
|
||||
"Edit files, asking first where you told it to."
|
||||
],
|
||||
[
|
||||
"Verify",
|
||||
"Run the checks and read the result."
|
||||
],
|
||||
[
|
||||
"Report",
|
||||
"Say what changed and what passed."
|
||||
]
|
||||
],
|
||||
"receiptAria": "Example run summary",
|
||||
"receiptInspect": "repository and instructions",
|
||||
"receiptAct": "edit files under the permissions you set",
|
||||
"receiptReport": "checks passed · summary saved",
|
||||
"sealStart": "起",
|
||||
"startHeading": "New here? Four steps.",
|
||||
"startLede": "Install → first session, no key → connect a provider → set up a fleet.",
|
||||
"startGuideLink": "Read the getting-started guide →",
|
||||
"startVocabularyLink": "Look up a term →",
|
||||
"sealBoundaries": "界",
|
||||
"boundariesHeadingA": "Your model.",
|
||||
"boundariesHeadingB": "Your boundaries.",
|
||||
"boundariesBody": "You choose the model, the mode, and how much it may do without asking. The provider and model never change unless you change them. Preview features are marked preview.",
|
||||
"hostedGatewayLocal": "Hosted, gateway, and local models",
|
||||
"planActOperateDesc": "From read-only planning to autonomous operation",
|
||||
"askAutoReviewDesc": "How much it does before asking you",
|
||||
"tuiExecWebDesc": "Interactive or scripted",
|
||||
"sealSurfaces": "面",
|
||||
"surfacesHeading": "Use it where the work happens.",
|
||||
"surfaces": [
|
||||
[
|
||||
"TUI",
|
||||
"Interactive terminal work"
|
||||
],
|
||||
[
|
||||
"codewhale exec",
|
||||
"Scripts and CI"
|
||||
],
|
||||
[
|
||||
"Web client",
|
||||
"Browser client, localhost only"
|
||||
],
|
||||
[
|
||||
"Runtime API + MCP",
|
||||
"Local integrations"
|
||||
],
|
||||
[
|
||||
"Fleet",
|
||||
"Several agents on one job"
|
||||
]
|
||||
],
|
||||
"runtimeLink": "Runtime surfaces and what is stable →",
|
||||
"installBandHeading": "Start with one command.",
|
||||
"binaries": "Binaries",
|
||||
"chinaMirrors": "China mirrors",
|
||||
"installGuideLink": "Read the install guide →",
|
||||
"sealCommunity": "众",
|
||||
"communityHeading": "Built in public",
|
||||
"communityBody": "MIT license. Contributors work on the runtime, providers, platforms, docs, and tests.",
|
||||
"communityLinksAria": "Community links",
|
||||
"contribute": "Contribute"
|
||||
},
|
||||
"docs-guide": {
|
||||
"metaTitle": "Getting started · Codewhale Docs",
|
||||
"metaDescription": "The full path from install to your ideal fleet: install, a first keyless session, provider connection, and fleet setup.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Getting started",
|
||||
"overviewLead": "Four steps from one install command to a fleet set up for your work.",
|
||||
"sessionTitle": "Watch a real session",
|
||||
"sessionLead": "A recording of a real session will go here. There is no recording yet, so nothing is shown.",
|
||||
"nextTitle": "Where next",
|
||||
"sourceNote": "Source documents: docs/GUIDE.md, docs/KEYBINDINGS.md · Step copy lives in web/lib/content/getting-started.ts; update docs-map.ts when changing."
|
||||
},
|
||||
"docs-shell": {
|
||||
"metaTitle": "Docs · Codewhale",
|
||||
"metaDescription": "Codewhale documentation: install, user guide, configuration, providers, core concepts, tools, MCP, skills, sandbox, runtime API, troubleshooting.",
|
||||
"portalMark": "Codewhale documentation",
|
||||
"heroTitle": "Find the guidance you need.",
|
||||
"heroLead": "Start with the guide and install pages, or go straight to vocabulary, modes, permissions, tools, providers, Fleet, hooks, MCP, and the Runtime API. Each page links to its source document in the repository.",
|
||||
"installCta": "Install Codewhale",
|
||||
"sourceDocsCta": "Browse source docs ↗"
|
||||
},
|
||||
"docs-hooks": {
|
||||
"metaTitle": "Hooks · Codewhale Docs",
|
||||
"metaDescription": "The shipped lifecycle hooks: mutable message_submit, tool_call_before decisions, turn_end, and sub-agent observer events.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Hooks",
|
||||
"overviewLead": "Hooks attach your own commands to Codewhale's lifecycle: inject context before a message is submitted, enforce policy before a tool call, and audit turns or sub-agent activity. This page describes what currently ships; docs/rfcs/1364-hooks-lifecycle.md is the design RFC for this surface, and docs/CONFIGURATION.md carries the full configuration schema.",
|
||||
"configIntro": "Hooks are configured under {hooksTable} entries in config.toml; run {hooksCommand} in the TUI to see every configured hook grouped by event — name, command preview, timeout, and condition — plus the global {enabledKey} state.",
|
||||
"events": [
|
||||
[
|
||||
"message_submit (mutable)",
|
||||
"Runs before a submitted message is added to history or sent to the model. The hook receives JSON on stdin; exit 0 with stdout JSON carrying a non-empty text field replaces the submitted text, and exit 2 blocks the submission before the turn starts. Multiple hooks run serially in config order, each receiving the previous hook's output. Hooks marked background = true are observer-only and cannot transform or block."
|
||||
],
|
||||
[
|
||||
"tool_call_before (decision)",
|
||||
"Runs before each tool call executes. Beyond the exit-2 hard deny (which always wins), a foreground hook may print a JSON decision on stdout with exit 0: allow / deny / ask, plus updatedInput to rewrite the tool input and additionalContext appended to the tool result the model sees. When several hooks match, precedence is deny > ask > allow; tool_name conditions support * globs (mcp__* matches every MCP tool). Full Access does not open tool-approval prompts, so ask does not downgrade that posture."
|
||||
],
|
||||
[
|
||||
"turn_end (observer)",
|
||||
"Fires after each model turn ends, once usage, cost, notifications, receipts, and queue-recovery state have settled. The stdin JSON carries fields such as status, duration_ms, usage, totals, and queued_message_count. Stdout is ignored and failures are warn-only — the hook cannot block input, mutate the transcript, or change the next queued follow-up."
|
||||
],
|
||||
[
|
||||
"subagent_spawn / subagent_complete (observer)",
|
||||
"Observe sub-agent start and completion with bounded JSON metadata on stdin (agent_id, status, truncated prompt/result previews). Failures are warn-only and never block scheduling or change prompts or results; use the transcript handle returned by agent when full detail is needed."
|
||||
]
|
||||
],
|
||||
"projectTitle": "Project-local hooks",
|
||||
"projectLead": "Repositories can ship policy in <workspace>/.codewhale/hooks.toml. Because project hooks are executable shell configuration, Codewhale loads them only after the workspace is trusted through the trust prompt or a trust_level = \"trusted\" entry in user-owned config — session /trust on and legacy .deepseek/trusted markers do not enable project hooks by themselves. Once trusted, project hooks are appended after the global hooks from config.toml, so they run last and win updatedInput ties. A malformed trusted project file logs a warning and startup falls back to global hooks only.",
|
||||
"sourceNote": "Source documents: docs/rfcs/1364-hooks-lifecycle.md (design RFC), docs/CONFIGURATION.md (configuration schema) · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-troubleshooting": {
|
||||
"metaTitle": "Troubleshooting · Codewhale Docs",
|
||||
"metaDescription": "Quick triage for common issues: hung turns, the offline queue, crash recovery, schema errors, MCP failures, and Docker notes.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Troubleshooting",
|
||||
"overviewLead": "Start with quick triage: confirm the binary and config (codewhale --version, ~/.codewhale/config.toml), enable verbose logs with RUST_LOG=deepseek_cli=debug when needed (RUST_LOG=deepseek_cli::client=debug for HTTP retries/reconnects), and capture the current state of ~/.codewhale/sessions and ~/.codewhale/tasks.",
|
||||
"incidents": [
|
||||
[
|
||||
"Turn hangs or the stream stops",
|
||||
"If a foreground shell command is still running, press Ctrl+B to move it to the background (the turn keeps running and the command becomes a background job under /jobs); use Esc or Ctrl+C to cancel the turn itself. Inspect deepseek_cli::client retry logs and endpoint connectivity, and after a restart confirm the previously in-flight turn shows as interrupted rather than running."
|
||||
],
|
||||
[
|
||||
"Network outage / offline behavior",
|
||||
"New prompts queue while offline, persisted to ~/.codewhale/sessions/checkpoints/offline_queue.json. Inspect with /queue list, restore connectivity, then re-send queued entries (/queue edit <n> plus Enter, or the normal input flow); the queue file clears when the queue empties."
|
||||
],
|
||||
[
|
||||
"Crash recovery",
|
||||
"The checkpoint lives at ~/.codewhale/sessions/checkpoints/latest.json; startup begins a fresh session unless --resume/--continue is supplied. Resume explicitly with codewhale --resume <id> or Ctrl+R in the TUI; if the checkpoint schema is newer than the binary supports, upgrade the binary or remove the stale checkpoint."
|
||||
],
|
||||
[
|
||||
"Persistent state schema errors",
|
||||
"Errors like schema vX is newer than supported vY affect sessions, runtime thread/turn/item records, and tasks. Confirm the binary version, back up the state directory before editing, then either run a newer compatible binary or archive the incompatible records and regenerate state."
|
||||
],
|
||||
[
|
||||
"MCP / tool execution failures",
|
||||
"Validate the ~/.codewhale/mcp.json schema and server command paths, confirm the server process starts manually, and check sandbox denials in TUI history/logs. Use /mcp validate for diagnostics, temporarily disable a failing server to isolate the issue, and re-enable after verification."
|
||||
]
|
||||
],
|
||||
"dockerTitle": "Docker notes",
|
||||
"dockerLead": "Each release publishes a multi-arch Linux image to GitHub Container Registry. The default image is a conservative runtime image: it runs as the non-root codewhale user (UID/GID 1000:1000), grants no passwordless sudo, and keeps user state in a volume mounted at /home/codewhale/.codewhale. Pin a release tag instead of latest for reproducible installs.",
|
||||
"dockerToolboxNote": "When a project needs apt-get, compiler toolchains, or package managers inside the container, do not change the default image contract — build an explicit toolbox image from docs/examples/Dockerfile.toolbox, and use one named state volume per project so sessions, config, and the offline queue do not bleed across workspaces. Never bake API keys or SSH private keys into custom images.",
|
||||
"sourceNote": "Source documents: docs/OPERATIONS_RUNBOOK.md, docs/DOCKER.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-configuration": {
|
||||
"metaTitle": "Configuration · Codewhale Docs",
|
||||
"metaDescription": "Where config.toml is read from, the per-project overlay, credential precedence, and legacy path migration.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Configuration",
|
||||
"overviewLead": "Codewhale reads its configuration from ~/.codewhale/config.toml (the legacy ~/.deepseek/config.toml is still read as a fallback). The --config flag and the CODEWHALE_CONFIG_PATH environment variable can point elsewhere; --config wins when both are set, and environment variable overrides are applied after the file is loaded.",
|
||||
"auditLead": "Inside the TUI, {auditCommand} shows which documented keys can change in the current session, which can also be persisted, and which stay file-only or restart-only — treat its “Command / reason” column as the source of truth before editing by hand.",
|
||||
"overlayTitle": "Per-project overlay",
|
||||
"overlayLead": "When a workspace contains a regular-file <workspace>/.codewhale/config.toml, the safe values it declares are merged on top of the global config (legacy <workspace>/.deepseek/config.toml files are still read when the Codewhale path is absent; symlinked project configs are rejected). This lets a repository suggest a model or tighten the local safety posture without touching the user's global config. Pass --no-project-config to skip the overlay for one launch.",
|
||||
"overlayLimits": "The overlay is intentionally narrow: it supports model, reasoning_effort, approval_policy and sandbox_mode (tightening values only), notes_path, max_subagents (clamped to 1..=20), and allow_shell (false applies, true is ignored). Credentials, endpoints, provider selection, MCP config, hooks, skills, and instructions = [...] stay user-global — a repo-local config.toml that declares api_key, base_url, or provider is ignored, so a cloned repository cannot pick arbitrary local files into the prompt.",
|
||||
"credentialsTitle": "Credential lookup",
|
||||
"credentialsLead": "After any explicit {apiKey}, credentials resolve in config → keyring → env order. {authStatus} inspects the active provider's config file, OS keyring backend, environment variable, winning source, and last-four label without printing the key itself. Hosted, generic OpenAI-compatible, self-hosted, or native Anthropic routes are selected with {providerConfig} or {providerFlag}; the full registry lives on the Models & providers page and in docs/PROVIDERS.md.",
|
||||
"legacyTitle": "Legacy .deepseek/ paths",
|
||||
"legacyLead": "Codewhale was renamed from DeepSeek-TUI. To avoid breaking existing installs, the runtime reads state from the new ~/.codewhale/ location but falls back to ~/.deepseek/ when only the legacy directory exists, and always writes to ~/.codewhale/ — read-with-fallback, write-to-new. State-dir resolution is consolidated in resolve_state_dir / ensure_state_dir in crates/config/src/lib.rs, and every legacy path reference carries an audited keep decision.",
|
||||
"sourceNote": "Source documents: docs/CONFIGURATION.md, docs/LEGACY_PATHS.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-constitution": {
|
||||
"metaTitle": "Constitution and /constitution · Codewhale Docs",
|
||||
"metaDescription": "User-global constitution, repo-local law, project instructions, and runtime boundaries.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Constitution and /constitution",
|
||||
"overviewTitleAside": "宪章与 /constitution",
|
||||
"overviewLead": "Codewhale gives the agent an accountable address, then a legal system for context conflicts. {constitutionCommand} is the primary personal constitution surface: guided setup stores structured user-global data in {homeConfig} and renders it as model-facing prose. Repos can still add local law via {repoConfig}; runtime policy separately encodes modes, approval, sandbox, cost, and tool boundaries.",
|
||||
"principles": [
|
||||
[
|
||||
"userGlobal",
|
||||
"Use /constitution for standing personal law across projects. It is structured data rendered to prose, not a raw prompt editor."
|
||||
],
|
||||
[
|
||||
"repoLocal",
|
||||
".codewhale/constitution.json is optional project policy for protected invariants, branch rules, verification, and escalation."
|
||||
],
|
||||
[
|
||||
"runtime",
|
||||
"Constitution text may express preferences, but approval, sandbox, shell, network, trust, and MCP permissions remain enforced config."
|
||||
]
|
||||
],
|
||||
"authorityNote": "Standard project instructions still live in AGENTS.md; memory and handoffs rank below constitutions and project instructions; the full base-prompt Markdown override is an expert escape hatch, not the normal setup path. See {configDocs}.",
|
||||
"configDocsLabel": "configuration docs",
|
||||
"sourceNote": "Source document: docs/ARCHITECTURE.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-fleet": {
|
||||
"metaTitle": "Fleet & Workflow · Codewhale Docs",
|
||||
"metaDescription": "The durable Agent roster and member-selection layer, plus the optional Workflow orchestration overlay.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Fleet & Workflow",
|
||||
"overviewLead": "Fleet is the durable roster: who is available and which member is selected. It is not an execution or authority engine. Runtime launches and tracks the selected member as a headless codewhale exec run, owns retry and remote placement, and writes the durable receipts and ledger projection.",
|
||||
"runTitle": "Run a fleet",
|
||||
"runLead": "The Runtime's Fleet-run projection lives in the workspace's .codewhale/fleet.jsonl ledger, with worker logs under .codewhale/fleet/. codewhale fleet resume <run-id> asks Runtime to replay the ledger and reconcile stale leases; it is idempotent after a manager exit, laptop sleep, or runtime restart.",
|
||||
"statusLead": "Two similarly named status surfaces exist: in the TUI, {fleetStatusTui} (or {subagents}) shows the sub-agents attached to the current interactive session; in a shell, {fleetStatusShell} reads the durable Fleet ledger.",
|
||||
"profilesTitle": "Roles and /fleet setup",
|
||||
"profilesLead": "/fleet setup opens a progressive wizard for authoring a reusable roster member: one focused choice at a time — semantic role, model (inherit or a concrete configured route), thinking tier, then an exact identity/route review before save. Profiles live in project scope (.codewhale/agents/<role>.toml) or personal scope ($CODEWHALE_HOME/agents/<role>.toml); a same-id project profile wins. Runtime separately owns trust, filesystem/network reach, secrets, approvals, sandboxing, and tools, so profile storage scope never widens execution authority.",
|
||||
"workflowTitle": "Workflow orchestration",
|
||||
"workflowLead": "Ordinary multi-agent work does not need Workflow: send normal messages in Operate and let Codewhale prefer background workers when parallelism, isolation, or duration makes delegation useful. Use Workflow when ordered phases, gates, shared budgets, replay, or deterministic fan-in matter. A Workflow script coordinates only: it selects Fleet members but has no filesystem or shell; Runtime launches the real workers under live authority policy. Scripts use a declarative compile-only JS subset that lowers to a typed WorkflowSpec validated and executed by Rust; import, fetch, process, eval, and async/await are rejected.",
|
||||
"workflowLimits": "Default validation bounds: up to 1,000 worker agents per Workflow run, Workflow IR structural nesting no deeper than 5, loops must declare max_iterations, and dynamic expand nodes must declare max_children plus a template. Runtime child delegation is a separate execution budget: it defaults to 3 levels and has an opt-in hard ceiling of 8. These are population and shape limits, not launch concurrency: Runtime admits at most 16 live workers for one run and queues the rest. Omitted or zero max_steps stays unbounded; only a positive value adds a model-turn ceiling.",
|
||||
"sourceNote": "Source documents: docs/FLEET.md, docs/WORKFLOW_AUTHORING.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-mcp": {
|
||||
"metaTitle": "MCP · Codewhale Docs",
|
||||
"metaDescription": "Consume external tool servers over the Model Context Protocol, or expose Codewhale itself as an MCP server.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewLead": "Codewhale can load additional tools via MCP (Model Context Protocol). MCP servers can be local stdio processes that the TUI starts, or remote URL-based servers that speak Streamable HTTP with legacy SSE fallback. A successfully connected server registers its tools into the model catalog; a failed or disabled server is never presented as an available tool.",
|
||||
"overviewConfig": "The config file defaults to {configPath} (the legacy {legacyConfigPath} is still read when the Codewhale file is absent), overridable with {configPathOption} or {configEnvVar}. The {serversKey} key used by other clients is accepted too.",
|
||||
"setupTitle": "Setup and management",
|
||||
"setupLead": "Bootstrap a starter config with {initCommand}; inside the TUI, {mcpCommand} opens a compact manager showing each server's enabled state, transport, command or URL, timeouts, and connection errors. Common commands:",
|
||||
"setupReload": "Config edits made from the TUI are written immediately, but the model-visible MCP tool pool is not hot-reloaded — the manager marks it restart-required. /mcp validate and /mcp reload reconnect to refresh the on-screen snapshot.",
|
||||
"authTitle": "Remote authentication",
|
||||
"authLead": "URL-based servers can use static headers, env-derived env_headers, bearer_token_env_var, or OAuth. Precedence is conservative: headers and env_headers apply first; bearer_token_env_var adds an Authorization header only when one is not already set; OAuth login tokens likewise never override an explicit header. Avoid committing literal Authorization headers — prefer env_headers, bearer_token_env_var, or OAuth login so secrets stay outside the MCP file.",
|
||||
"toolsTitle": "Tool naming and safety",
|
||||
"toolsLead": "Discovered MCP tools are exposed to the model as {toolNamePattern} — a server named {gitServer} with a {statusTool} tool becomes {gitStatusTool}. MCP tools flow through the same approval framework as built-in tools: read-only MCP helpers can run without prompts when policy permits, side-effectful MCP tools require approval, and Full Access does not bypass hard policy holds.",
|
||||
"toolsTrust": "Only configure MCP servers you trust, and treat MCP server configuration as equivalent to running code on your machine. Reviewed local plugin bundles can also contribute MCP servers: they reuse the same MCP manager, approval, and network-policy paths, appear under namespaced <plugin>-<server> identities, and are held to a stricter boundary than hand-written mcp.json.",
|
||||
"serverTitle": "Codewhale as an MCP server",
|
||||
"serverLead": "{serveMcp} runs Codewhale as an stdio MCP server so other sessions (or any MCP client) can call its tools; {mcpServerCommand} is the equivalent dispatcher entrypoint. {addSelfCommand} resolves the current binary path and writes the server into your MCP config. Keep the modes distinct: {serveHttp} is the runtime HTTP/SSE API, a separate surface.",
|
||||
"sourceNote": "Source document: docs/MCP.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-modes": {
|
||||
"metaTitle": "Modes · Codewhale Docs",
|
||||
"metaDescription": "Plan, Work, Operate modes and independent permission postures.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Modes",
|
||||
"overviewLead": "A mode decides how Codewhale handles the work. A permission posture decides how it handles consequential tool calls. They are separate controls.",
|
||||
"modes": [
|
||||
[
|
||||
"Plan",
|
||||
"Read-only investigation and planning. Codewhale can inspect the workspace, but it cannot run shell commands or edit files."
|
||||
],
|
||||
[
|
||||
"Act",
|
||||
"Normal interactive coding. Codewhale can inspect, edit, and use tools; shell availability and approval prompts follow the active configuration and permission posture."
|
||||
],
|
||||
[
|
||||
"Operate",
|
||||
"Multitask coordination from the same composer. The parent can inspect, edit, and use shell or MCP tools under the same permission posture, sandbox, and safety rules as Act. Fleet workers are preferred for independent, parallel, background, or long-running work, but delegation is not required for every executable step. Workflow is optional unless the work needs ordered phases, gates, or deterministic fan-in."
|
||||
]
|
||||
],
|
||||
"switchingTitle": "Switch modes",
|
||||
"switchingLead": "When the composer is idle, press {tab} to cycle Plan → Act → Operate. When a completion menu is open, Tab accepts the completion; during an active turn, it can queue the current draft as the next follow-up.",
|
||||
"switchingCommandLead": "Run /mode to open the picker, or switch directly:",
|
||||
"permissionsTitle": "Permission postures",
|
||||
"permissionsLead": "Plan is always Read Only. When the composer is idle in Act or Operate, press {shiftTab} to cycle Ask → Auto-Review → Full Access. Run {configCommand} to inspect or edit the current session permission; project or managed policy may lock or tighten it.",
|
||||
"postures": [
|
||||
[
|
||||
"Ask",
|
||||
"Ask before tools that can make consequential changes."
|
||||
],
|
||||
[
|
||||
"Auto-Review",
|
||||
"Review tool risk automatically and ask when a decision needs you."
|
||||
],
|
||||
[
|
||||
"Full Access",
|
||||
"Run tools without approval prompts and enable trusted-workspace access. Repository rules and managed constraints still apply; use it only in a workspace you trust."
|
||||
]
|
||||
],
|
||||
"sourceNote": "Source document: docs/MODES.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-runtime-api": {
|
||||
"metaTitle": "Runtime API · Codewhale Docs",
|
||||
"metaDescription": "Local HTTP/SSE, JSON-RPC stdio, and ACP entrypoints for integrations, bridges, and automation.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Runtime API",
|
||||
"overviewLead": "codewhale app-server is the canonical local runtime API and control plane. Local SDKs, mobile/remote-control clients, and editor integrations talk to it instead of screen-scraping terminal output. The engine runs as a local-only process: every API binds to localhost by default — no hosted relay, no provider-token custody, no secret leakage. codewhale serve --http / --mobile remain compatibility aliases for app-server --http / --mobile and launch the identical server; new integrations should target app-server.",
|
||||
"entries": [
|
||||
[
|
||||
"http",
|
||||
"The full /v1/* HTTP/SSE runtime API (canonical entry), default 127.0.0.1:7878."
|
||||
],
|
||||
[
|
||||
"mobile",
|
||||
"The runtime API plus the /mobile phone control page."
|
||||
],
|
||||
[
|
||||
"stdio",
|
||||
"Newline-delimited JSON-RPC 2.0 control transport with no listener, for local SDKs and probes."
|
||||
],
|
||||
[
|
||||
"web",
|
||||
"The loopback-only browser client, embedded in the binary and opened in the default browser."
|
||||
],
|
||||
[
|
||||
"doctor",
|
||||
"Machine-readable health and capability report."
|
||||
],
|
||||
[
|
||||
"acp",
|
||||
"ACP (Agent Client Protocol) stdio adapter for editors such as Zed."
|
||||
],
|
||||
[
|
||||
"exec",
|
||||
"The one-shot headless worker (stream-json, fleet subprocess, CI primitive) — not part of this API, but it shares the same runtime and event vocabulary."
|
||||
]
|
||||
],
|
||||
"stdioTitle": "Probe without model tokens",
|
||||
"stdioLead": "The stdio control transport can be probed without spending model tokens. capabilities returns the advertised method families (thread/*, app/*, prompt/*) and the full method list; the method set is pinned by a drift test in crates/app-server/src/lib.rs, so SDK and local integration clients can rely on it not changing silently.",
|
||||
"interruptNote": "A live turn can be asked to stop with thread/interrupt (or POST /v1/threads/{id}/turns/{turn_id}/interrupt over HTTP); when no turn is streaming the reply carries interrupted: false — not an error, just nothing to stop.",
|
||||
"securityTitle": "Security boundary",
|
||||
"securityLead": "The runtime API token is read from {authToken}, then {runtimeTokenEnv}, then {legacyTokenEnv}; {insecureFlag} is only accepted with a loopback bind. Cross-origin browser requests are rejected by the CORS allow-list. Before selecting a non-loopback bind — especially {mobileFlag} — read the full deployment and authentication contract in docs/RUNTIME_API.md.",
|
||||
"sourceNote": "Source document: docs/RUNTIME_API.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-sandbox": {
|
||||
"metaTitle": "Sandbox & Approval · Codewhale Docs",
|
||||
"metaDescription": "The honest boundary: macOS Seatbelt, opt-in Linux bubblewrap, platform gaps, and approval policy.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Sandbox & Approval",
|
||||
"overviewLead": "Codewhale can launch shell commands proposed by a model. Approval policy, workspace-aware tools, and an operating-system command wrapper are separate controls: an approval is not a sandbox, and selecting workspace-write does not prove the current platform has an OS wrapper available. This page describes only behavior wired into the command execution path.",
|
||||
"platforms": [
|
||||
[
|
||||
"macOS · Seatbelt",
|
||||
"Codewhale probes /usr/bin/sandbox-exec; when the probe succeeds and the policy requests a sandbox, the child command is wrapped in a generated Seatbelt profile: broad filesystem reads, policy-limited writes, and network only when the policy enables it. A failed probe is reported honestly as no OS sandbox."
|
||||
],
|
||||
[
|
||||
"Linux · opt-in bubblewrap",
|
||||
"Linux command sandboxing is opt-in: set prefer_bwrap = true and keep /usr/bin/bwrap executable. The child gets a read-only root view with writable mounts derived from the resolved policy; the network namespace is isolated by default and --share-net is added only when the policy enables network access. Without the opt-in, Codewhale reports none."
|
||||
],
|
||||
[
|
||||
"Windows · no OS sandbox",
|
||||
"The Windows command path currently reports no OS sandbox. Host permissions and approval policy still apply, but they are not a Codewhale OS command sandbox."
|
||||
],
|
||||
[
|
||||
"External OpenSandbox execution",
|
||||
"With sandbox_backend = \"opensandbox\", shell execution is sent to the configured OpenSandbox-compatible HTTP endpoint instead of starting a local child. Isolation guarantees belong to the configured service and its operator."
|
||||
]
|
||||
],
|
||||
"policiesTitle": "Policies and fallbacks",
|
||||
"policiesLead": "The local {sandboxMode} values are {readOnly}, {workspaceWrite}, {dangerFullAccess}, and {externalSandbox}. The first two are enforced by Seatbelt or bubblewrap only when that wrapper is selected and available; {dangerFullAccess} deliberately bypasses the local OS wrapper; {externalSandbox} declares that execution is already externally isolated. When no wrapper is selected, the shell command runs without Codewhale OS isolation — approval rules and workspace-aware native file tools remain separate controls.",
|
||||
"diagnosticsTitle": "Diagnostics and limits",
|
||||
"diagnosticsLead": "codewhale setup --status, codewhale doctor, codewhale doctor --json, and the diagnostics tool report the locally available wrapper after applying the resolved bubblewrap preference. Denial attribution is intentionally conservative: a child command's generic Permission denied is not by itself proof that Codewhale's sandbox blocked it, and unsandboxed command failures are never labeled sandbox denials.",
|
||||
"diagnosticsLimits": "The limitations are stated just as plainly: availability is checked before launch, yet the selected wrapper can still fail because of host policy, container restrictions, or a race after the probe; bubblewrap ignores a configured writable root that is missing or not a directory; and no sandbox protects against kernel vulnerabilities or all resource-exhaustion and side-channel attacks.",
|
||||
"sourceNote": "Source document: docs/SANDBOX.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-subagents": {
|
||||
"metaTitle": "Sub-Agents · Codewhale Docs",
|
||||
"metaDescription": "The agent tool, Fleet roles, context forking, worktree isolation, and concurrency caps.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Sub-Agents",
|
||||
"overviewLead": "A parent session launches one focused sub-agent through the agent tool and immediately gets back an agent_id, a compact receipt, and a transcript handle while the worker runs in the background. Sub-agents inherit the parent's tool registry by default, but they are leaf workers: they do not receive agent or nested lifecycle tools. agent launches detached background work — cancelling the parent turn stops the parent's wait path, but it does not kill already-opened child runs.",
|
||||
"overviewFleetNote": "For work that must survive process restarts, sleep, or remote execution, prefer Fleet or a Workflow-backed fleet run over a short in-session agent call.",
|
||||
"roles": [
|
||||
[
|
||||
"worker",
|
||||
"Flexible multi-step execution of the parent's brief; writes and shell allowed. The default role."
|
||||
],
|
||||
[
|
||||
"scout",
|
||||
"Read-only, maps the relevant code fast — “find every call site of Foo.”"
|
||||
],
|
||||
[
|
||||
"planner",
|
||||
"Analyse and produce a strategy without executing — “design the migration; don't run it.”"
|
||||
],
|
||||
[
|
||||
"reviewer",
|
||||
"Read-and-grade with severity scores — “audit this PR for bugs.”"
|
||||
],
|
||||
[
|
||||
"builder",
|
||||
"Land a specific change with minimal edits; writes and shell allowed."
|
||||
],
|
||||
[
|
||||
"verifier",
|
||||
"Run tests and validation gates and report the outcome; no code edits."
|
||||
],
|
||||
[
|
||||
"consultant",
|
||||
"Read-only high-reasoning counsel for judgement calls and design critique."
|
||||
],
|
||||
[
|
||||
"custom",
|
||||
"An explicit narrow tool allowlist for locked-down dispatch."
|
||||
]
|
||||
],
|
||||
"forkTitle": "Context forking",
|
||||
"forkLead": "{agentTool} starts fresh by default: the child gets its role prompt plus the task you pass. When the task depends on decisions, files, todos, or plan state already in the parent transcript, use {forkContext} — the runtime keeps the parent's request prefix byte-identical where available (preserving prefix-cache reuse), appends a structured state snapshot, then adds the sub-agent role instructions and task at the tail. Use fresh sessions for independent exploration and forked sessions for continuation, review, summarization, or compaction work.",
|
||||
"worktreeTitle": "Worktree isolation",
|
||||
"worktreeLead": "Launch parallel edit lanes with {worktreeFlag}: Codewhale creates a fresh git worktree and branch for the child (default {branchPattern}, checked out beside the parent repo under {worktreeDir}) so the parent checkout stays clean. Isolation is not write authority: a prompt-only worker starts read-only, and a writer also declares {writeAuthority} plus at least one normalized {writeRoots}, {exactFiles}, or {coordinationContracts} value. Overlapping shared write claims fail before any mutation.",
|
||||
"capacityTitle": "Concurrency caps",
|
||||
"capacityLead": "The sub-agent capacity source of truth is crates/tui/src/config/subagent_limits.rs: default configured concurrency is 64, maximum configured concurrency is 128, and maximum admitted running-plus-queued work is 1024. These are capacity ceilings, not advice to dispatch every slot — a manager should use the smallest useful fan-out, keep a single fan-in owner, and verify worker receipts before reporting combined completion.",
|
||||
"sourceNote": "Source document: docs/SUBAGENTS.md · Update docs-map.ts when changing."
|
||||
},
|
||||
"docs-web": {
|
||||
"metaTitle": "Browser Client · Codewhale Docs",
|
||||
"metaDescription": "The loopback-only embedded browser client: one-time bootstrap, session cookie, and the local trust boundary.",
|
||||
"bodyClassName": "text-ink-soft leading-relaxed",
|
||||
"overviewTitle": "Browser Client",
|
||||
"overviewLead": "{webCommand} opens Codewhale's embedded browser client over the canonical Runtime API. It is a local surface: the server always binds to {loopbackHost}, cannot be rebound to a LAN address, and cannot run with Runtime authentication disabled. The default address is {defaultUrl}; on a port collision, pick another loopback port with {portExample}. Stop the process with Ctrl+C and the browser session ends with it.",
|
||||
"overviewBody": "The current client provides a responsive thread and search rail, Runtime-owned session facts, transcript and tool receipts, and a composer. It can create, select, rename, and archive threads; start or steer turns; interrupt work; resolve approvals; and answer Runtime user-input requests. The browser is another view of the same local Runtime — it does not create a second cloud account, copy provider credentials into browser storage, or weaken the configured approval and sandbox policies.",
|
||||
"authTitle": "Authentication boundary",
|
||||
"authLead": "The browser-launch URL carries a random, short-lived, one-time bootstrap capability — never the Runtime bearer token. A loopback request exchanges it for an HttpOnly, SameSite=Strict, process-local session cookie and immediately invalidates the capability. Reused, expired, malformed, and non-loopback bootstrap attempts fail closed. The Runtime token is never placed in rendered HTML, browser storage, URL queries or fragments, or browser-launch arguments. Cookie-authenticated state-changing requests must also present the exact local web origin; cross-origin browser requests are rejected.",
|
||||
"localTitle": "Local means local",
|
||||
"localLead": "{webCommand} accepts only {portFlag} — there is no {hostFlag} and no insecure-auth option on this command. Do not treat it as a public website or expose its port through router forwarding, a public reverse proxy, or a tunnel. The separate {mobileCommand} and {httpFlag} modes carry different deployment and authentication contracts; read the Runtime API documentation before operating either one, especially before selecting a non-loopback bind.",
|
||||
"troubleshootingTitle": "Troubleshooting",
|
||||
"troubleshootingLead": "If port 7878 is occupied, pass an unused --port. If the browser cannot be opened, the command exits with an error rather than leaving a reusable bootstrap capability behind; check the OS default-browser setup and start again. If the page loads but a provider is unavailable, inspect codewhale doctor and /provider — the web command does not configure or move provider credentials. If a session expired, restart codewhale web to mint a new process-local session; reusing an old bootstrap URL is expected to fail.",
|
||||
"sourceNote": "Source document: docs/WEB.md · Update docs-map.ts when changing."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
{
|
||||
"chrome": {
|
||||
"navDocs": "文档",
|
||||
"navStart": "指引",
|
||||
"navInstall": "安装",
|
||||
"navFaq": "常见问题",
|
||||
"navCommunity": "社区",
|
||||
"navContribute": "贡献",
|
||||
"navDocsSecondary": "Docs",
|
||||
"navStartSecondary": "Start",
|
||||
"navInstallSecondary": "Install",
|
||||
"navFaqSecondary": "FAQ",
|
||||
"navCommunitySecondary": "Community",
|
||||
"navContributeSecondary": "Contribute",
|
||||
"skipToContent": "跳转到主要内容",
|
||||
"navPrimaryAria": "主导航",
|
||||
"navHomeAria": "Codewhale 首页",
|
||||
"installCta": "安装 →",
|
||||
"authSignIn": "登录",
|
||||
"authRegister": "注册",
|
||||
"authGroupAria": "账户",
|
||||
"wordmarkSeal": "深",
|
||||
"wordmarkTag": "任意模型,本机运行",
|
||||
"issueLabel": "第 {date} 期",
|
||||
"dateLocale": "zh-CN",
|
||||
"starsAria": "GitHub 星标数",
|
||||
"githubFallback": "GitHub",
|
||||
"tickerLiveLabel": "实 时",
|
||||
"tickerLiveTag": "LIVE",
|
||||
"tickerMerged": "已合并",
|
||||
"tickerOpened": "已开启",
|
||||
"tickerClosed": "已关闭",
|
||||
"tickerReleased": "已发布",
|
||||
"tickerFirstContribution": "首次贡献",
|
||||
"tickerBy": "作者 {handle}",
|
||||
"tickerAria": "仓库近期动态",
|
||||
"traceLabel": "推理痕迹",
|
||||
"traceTabsAria": "会话片段",
|
||||
"menuOpen": "打开菜单",
|
||||
"menuClose": "关闭菜单",
|
||||
"themeAuto": "自动",
|
||||
"themeLight": "浅色",
|
||||
"themeDark": "深色",
|
||||
"themeAria": "文档主题:{mode}(点击切换)",
|
||||
"themeTitle": "文档主题 · 自动 / 浅色 / 深色",
|
||||
"footerTagline": "Codewhale 潜入深海,你不必亲自下潜——开源运行时的文档、源码与社区。",
|
||||
"footerProduct": "产品",
|
||||
"footerProject": "项目",
|
||||
"footerDocs": "文档",
|
||||
"footerGuide": "新手指引",
|
||||
"footerInstall": "安装",
|
||||
"footerModels": "模型",
|
||||
"footerRuntime": "运行时",
|
||||
"footerFaq": "常见问题",
|
||||
"footerIssues": "议题",
|
||||
"footerContribute": "参与贡献",
|
||||
"footerLicense": "MIT 许可证",
|
||||
"footerPricing": "价格",
|
||||
"footerTerms": "服务条款",
|
||||
"footerPrivacy": "隐私政策",
|
||||
"footerCanonicalSource": "官方源码:",
|
||||
"footerReleases": " · 发布:",
|
||||
"footerReleasesLink": "GitHub 发布页",
|
||||
"footerSecurity": "安全",
|
||||
"switcherLabel": "语言",
|
||||
"switcherSwitchTo": "切换到 {label}",
|
||||
"partialBadge": "(部分)"
|
||||
},
|
||||
"home": {
|
||||
"metaTitle": "Codewhale — 一入码门深似海,它替你潜。",
|
||||
"metaDescription": "Codewhale 潜入深海,你不必亲自下潜——开源的终端编程智能体。模型自带,跑在你自己的机器上。Rust 编写,MIT 许可。",
|
||||
"kicker": "开源 · 自带模型 · 运行在你的终端",
|
||||
"heroTitleA": "一入码门深似海,",
|
||||
"heroTitleB": "Codewhale 替你潜。",
|
||||
"heroIntro": "{brand} 是一个跑在终端里的开源编程智能体。给它一个模型和一个任务。它会读你的代码、改文件、跑检查,活干完了或需要你拿主意时就停下来。模型随便用,也可以给每个角色各配一个。",
|
||||
"install": "安装",
|
||||
"docs": "文档",
|
||||
"copy": "复制",
|
||||
"copied": "已复制 ✓",
|
||||
"installEyebrow": "一行安装",
|
||||
"installRequirement": "需要 Node 18+,无需 Rust 工具链",
|
||||
"installOtherWays": "其他方式 →",
|
||||
"latestRelease": "最新发布 {tag}",
|
||||
"releaseUnavailable": "发布状态暂不可用",
|
||||
"currentSource": "源码",
|
||||
"sourceCandidate": "未发布",
|
||||
"providerRoutes": "{count} 个提供商",
|
||||
"publishedRelease": "已发布",
|
||||
"figcaptionSourceCandidate": "未发布",
|
||||
"shotSession": "会话",
|
||||
"screenshotAlt": "Codewhale 终端会话,Operate 模式:鲸鱼、输入区与状态栏",
|
||||
"figcaption": "Codewhale 会话 · Operate 模式 · 权限:Ask",
|
||||
"proofHeading": "终端里的编程智能体。任意模型。本机运行。",
|
||||
"proofBody": "用你手头已有的模型——托管、网关或本地都行。选一个模式:Plan、Work 或 Operate。再选它不问你就能做多少:Ask、Auto-Review 或 Full Access。",
|
||||
"sealDecides": "法",
|
||||
"decidesEyebrow": "它如何决策",
|
||||
"decidesHeading": "推理过程,原话呈现",
|
||||
"decidesLede": "会话摘录。每一段都写明模型依据了哪条项目规则,以及接着做了什么。",
|
||||
"sealWorkflow": "行",
|
||||
"workflowHeading": "从任务到验证过的改动。",
|
||||
"workflow": [
|
||||
[
|
||||
"检查",
|
||||
"读取仓库、项目说明与任务。"
|
||||
],
|
||||
[
|
||||
"执行",
|
||||
"修改文件,你要求先问的地方会先问。"
|
||||
],
|
||||
[
|
||||
"验证",
|
||||
"运行检查,核对结果。"
|
||||
],
|
||||
[
|
||||
"报告",
|
||||
"说明改了什么、通过了什么。"
|
||||
]
|
||||
],
|
||||
"receiptAria": "运行摘要示例",
|
||||
"receiptInspect": "仓库与项目说明",
|
||||
"receiptAct": "在你设定的权限内修改文件",
|
||||
"receiptReport": "检查通过 · 摘要已保存",
|
||||
"sealStart": "起",
|
||||
"startHeading": "第一次用?四步。",
|
||||
"startLede": "安装 → 首次会话,无需密钥 → 接入提供商 → 配置 Fleet。",
|
||||
"startGuideLink": "阅读新手指引 →",
|
||||
"startVocabularyLink": "查名词 →",
|
||||
"sealBoundaries": "界",
|
||||
"boundariesHeadingA": "你的模型。",
|
||||
"boundariesHeadingB": "你的边界。",
|
||||
"boundariesBody": "模型、模式、以及它不问你能做多少,都由你来选。你不改,提供商和模型就不会变。预览功能会标注预览。",
|
||||
"hostedGatewayLocal": "托管、网关与本地模型",
|
||||
"planActOperateDesc": "从只读规划到自主执行",
|
||||
"askAutoReviewDesc": "它在问你之前能做多少",
|
||||
"tuiExecWebDesc": "交互式或脚本化",
|
||||
"sealSurfaces": "面",
|
||||
"surfacesHeading": "活在哪里干,就在哪里用。",
|
||||
"surfaces": [
|
||||
[
|
||||
"TUI",
|
||||
"交互式终端工作"
|
||||
],
|
||||
[
|
||||
"codewhale exec",
|
||||
"脚本与 CI"
|
||||
],
|
||||
[
|
||||
"Web 客户端",
|
||||
"浏览器客户端,仅限本机"
|
||||
],
|
||||
[
|
||||
"运行时 API + MCP",
|
||||
"本地集成"
|
||||
],
|
||||
[
|
||||
"Fleet",
|
||||
"多个智能体协作一件事"
|
||||
]
|
||||
],
|
||||
"runtimeLink": "运行时界面与稳定程度 →",
|
||||
"installBandHeading": "从一条命令开始。",
|
||||
"binaries": "预编译包",
|
||||
"chinaMirrors": "中国镜像",
|
||||
"installGuideLink": "阅读安装指南 →",
|
||||
"sealCommunity": "众",
|
||||
"communityHeading": "公开构建",
|
||||
"communityBody": "MIT 许可。贡献者的工作覆盖运行时、提供商、平台、文档与测试。",
|
||||
"communityLinksAria": "社区链接",
|
||||
"contribute": "参与贡献"
|
||||
},
|
||||
"docs-guide": {
|
||||
"metaTitle": "新手指引 · Codewhale 文档",
|
||||
"metaDescription": "从安装到配置理想 Fleet 的完整路径:安装、无需密钥的首次会话、连接提供商、设置 Fleet。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "新手指引",
|
||||
"overviewLead": "从一条安装命令到配置好你的 Fleet,四步走完。",
|
||||
"sessionTitle": "看一次真实会话",
|
||||
"sessionLead": "这里将放一段真实会话的录像。目前还没有录制,所以什么也不显示。",
|
||||
"nextTitle": "接下来",
|
||||
"sourceNote": "来源文档:docs/GUIDE.md, docs/KEYBINDINGS.md · 步骤文案来自 web/lib/content/getting-started.ts;更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-shell": {
|
||||
"metaTitle": "文档 · Codewhale",
|
||||
"metaDescription": "Codewhale 文档:安装、使用指南、配置、提供商、核心概念、工具、MCP、技能、沙箱、运行时 API、排障。",
|
||||
"portalMark": "Codewhale 文档",
|
||||
"heroTitle": "查找准确的使用说明。",
|
||||
"heroLead": "从新手指引和安装开始,或直接查看名词、模式、权限、工具、提供商、Fleet、钩子、MCP 与运行时 API。每页都链接到仓库中的源文档。",
|
||||
"installCta": "安装 Codewhale",
|
||||
"sourceDocsCta": "浏览源文档 ↗"
|
||||
},
|
||||
"docs-hooks": {
|
||||
"metaTitle": "钩子 · Codewhale 文档",
|
||||
"metaDescription": "已发布的生命周期钩子:可变 message_submit、tool_call_before 决策、turn_end 与子 Agent 观察事件。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "钩子",
|
||||
"overviewLead": "钩子让你把自己的命令挂进 Codewhale 的生命周期:在消息提交前注入上下文、在工具调用前执行策略、在回合结束或子 Agent 启停时做审计。本页描述当前已发布的行为;docs/rfcs/1364-hooks-lifecycle.md 是这组能力的设计 RFC,完整配置 schema 见 docs/CONFIGURATION.md。",
|
||||
"configIntro": "钩子配置在 config.toml 的 {hooksTable} 条目下;TUI 里运行 {hooksCommand} 可以按事件分组查看每个钩子的名称、命令预览、超时和条件,以及 {enabledKey} 的全局开关状态。",
|
||||
"events": [
|
||||
[
|
||||
"message_submit(可变)",
|
||||
"在用户消息进入历史或发给模型之前运行。钩子从 stdin 收到 JSON;exit 0 且 stdout 打印含非空 text 字段的 JSON 时替换提交文本;exit 2 在回合开始前阻止提交。多个钩子按配置顺序串行执行,每个钩子收到上一个钩子的输出文本。标记 background = true 的钩子只能观察,不能改写或阻止。"
|
||||
],
|
||||
[
|
||||
"tool_call_before(决策)",
|
||||
"在每次工具调用执行前运行。除 exit 2 硬拒绝(始终生效)外,前台钩子可在 exit 0 时用 stdout JSON 给出决策:allow / deny / ask,并可附带 updatedInput 改写工具输入、additionalContext 追加进给模型的工具结果。多个钩子命中时优先级为 deny > ask > allow;tool_name 条件支持 * 通配(如 mcp__* 匹配所有 MCP 工具)。Full Access 不打开工具审批提示,因此 ask 不会降低该姿态。"
|
||||
],
|
||||
[
|
||||
"turn_end(观察)",
|
||||
"在每个模型回合结束后触发,此时用量、成本、通知、收据和队列恢复状态都已更新。stdin 收到包含 status、duration_ms、usage、totals、queued_message_count 等字段的 JSON。stdout 被忽略,失败只记警告——不能阻止输入、改写 transcript 或改变下一个排队消息。"
|
||||
],
|
||||
[
|
||||
"subagent_spawn / subagent_complete(观察)",
|
||||
"观察子 Agent 的启动与完成,stdin 收到有界的 JSON 元数据(agent_id、状态、截断后的 prompt/result 预览)。失败只记警告,不阻塞调度、不改 prompt 或结果;需要完整细节时使用 agent 返回的 transcript 句柄。"
|
||||
]
|
||||
],
|
||||
"projectTitle": "项目级钩子",
|
||||
"projectLead": "仓库可以在 <workspace>/.codewhale/hooks.toml 中携带策略。因为项目钩子是可执行的 shell 配置,Codewhale 只有在工作区通过信任提示或用户配置中的 trust_level = \"trusted\" 被信任后才加载它们——会话内的 /trust on 和旧版 .deepseek/trusted 标记都不会单独启用项目钩子。受信任后,项目钩子追加在 config.toml 的全局钩子之后运行,因此对 updatedInput 而言最后生效。格式错误但已受信任的项目文件会记警告并回退到只用全局钩子。",
|
||||
"sourceNote": "来源文档:docs/rfcs/1364-hooks-lifecycle.md(设计 RFC), docs/CONFIGURATION.md(配置 schema)· 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-troubleshooting": {
|
||||
"metaTitle": "排障 · Codewhale 文档",
|
||||
"metaDescription": "常见问题的快速分诊:挂起的回合、离线队列、崩溃恢复、schema 错误、MCP 故障与 Docker 说明。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "排障",
|
||||
"overviewLead": "先快速分诊:确认二进制与配置(codewhale --version、~/.codewhale/config.toml),需要更详细日志时用 RUST_LOG=deepseek_cli=debug 启动(HTTP 重试/重连用 RUST_LOG=deepseek_cli::client=debug),并看一眼 ~/.codewhale/sessions 与 ~/.codewhale/tasks 的当前状态。",
|
||||
"incidents": [
|
||||
[
|
||||
"回合挂起或流停止",
|
||||
"前台 shell 命令还在跑时按 Ctrl+B 把它移到后台(回合继续,命令变成 /jobs 下的后台任务);想取消回合本身用 Esc 或 Ctrl+C。检查 deepseek_cli::client 的重试日志和端点连通性,重启后确认此前在途的回合被标记为中断,而不是停在运行态。"
|
||||
],
|
||||
[
|
||||
"网络中断 / 离线行为",
|
||||
"离线时新提示词会排队,队列持久化在 ~/.codewhale/sessions/checkpoints/offline_queue.json。用 /queue list 查看,恢复连接后重新发送(/queue edit <n> 加回车,或走正常输入流程),队列清空后文件随之清除。"
|
||||
],
|
||||
[
|
||||
"崩溃恢复",
|
||||
"检查点保存在 ~/.codewhale/sessions/checkpoints/latest.json;除非传入 --resume/--continue,启动会开新会话。用 codewhale --resume <id> 或 TUI 里的 Ctrl+R 显式恢复;若检查点 schema 比二进制新,升级二进制或移除过期检查点。"
|
||||
],
|
||||
[
|
||||
"持久状态 schema 错误",
|
||||
"形如 schema vX is newer than supported vY 的错误涉及 sessions、运行时 thread/turn/item 记录和 tasks。先确认二进制版本,编辑前备份状态目录,然后用更新的兼容二进制运行,或归档不兼容记录并重建状态。"
|
||||
],
|
||||
[
|
||||
"MCP / 工具执行失败",
|
||||
"校验 ~/.codewhale/mcp.json 的 schema 和服务器命令路径,手动确认服务器进程能启动,并在 TUI 历史/日志中检查沙箱拒绝。用 /mcp validate 诊断,可暂时禁用出问题的服务器隔离原因,验证后再启用。"
|
||||
]
|
||||
],
|
||||
"dockerTitle": "Docker 说明",
|
||||
"dockerLead": "每个发布都会向 GitHub Container Registry 推送多架构 Linux 镜像。默认镜像是保守的运行时镜像:以非 root 的 codewhale 用户(UID/GID 1000:1000)运行,不授予免密 sudo,用户状态放在挂载到 /home/codewhale/.codewhale 的卷里。可复现的安装请固定发布标签而不是 latest。",
|
||||
"dockerToolboxNote": "需要在容器内使用 apt-get、编译工具链或包管理器时,不要改默认镜像约定——基于 docs/examples/Dockerfile.toolbox 构建显式的 toolbox 镜像,并为每个项目使用独立的命名状态卷,避免会话、配置和离线队列跨工作区串扰。不要把 API 密钥或 SSH 私钥烘进自定义镜像。",
|
||||
"sourceNote": "来源文档:docs/OPERATIONS_RUNBOOK.md, docs/DOCKER.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-configuration": {
|
||||
"metaTitle": "配置 · Codewhale 文档",
|
||||
"metaDescription": "config.toml 的查找顺序、项目级覆盖、凭据优先级和旧版路径迁移。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "配置",
|
||||
"overviewLead": "Codewhale 从 ~/.codewhale/config.toml 读取配置(旧版 ~/.deepseek/config.toml 仍作为回退读取)。--config 标志和 CODEWHALE_CONFIG_PATH 环境变量可以指定别的路径,两者同时设置时 --config 优先;文件加载之后再应用环境变量覆盖。",
|
||||
"auditLead": "在 TUI 里运行 {auditCommand} 可以查看哪些文档化的键能在当前会话修改、哪些能持久化、哪些只能改文件或需要重启——改动前以它输出的“Command / reason”列为准。",
|
||||
"overlayTitle": "项目级覆盖",
|
||||
"overlayLead": "当工作区包含常规文件 <workspace>/.codewhale/config.toml 时,其中声明的安全取值会合并到全局配置之上(旧版 <workspace>/.deepseek/config.toml 在新路径缺失时仍会读取;符号链接的项目配置会被拒绝)。这让仓库可以建议模型或收紧本地安全姿态,而不动用户的全局配置。单次启动可用 --no-project-config 跳过覆盖。",
|
||||
"overlayLimits": "覆盖层有意保持狭窄:支持 model、reasoning_effort、approval_policy 与 sandbox_mode(只能收紧)、notes_path、max_subagents(夹紧到 1..=20)、allow_shell(false 生效,true 被忽略)。凭据、端点、提供商选择、MCP 配置、hooks、skills 和 instructions = [...] 始终属于用户全局配置——仓库里的 config.toml 声明 api_key、base_url 或 provider 会被忽略,克隆的仓库无法借此选择任意本地文件进入提示词。",
|
||||
"credentialsTitle": "凭据查找",
|
||||
"credentialsLead": "在显式 {apiKey} 之后,凭据按 config → keyring → env 的顺序解析。{authStatus} 可以查看当前提供商的配置文件、系统 keyring 后端、环境变量、生效来源和末四位标签,而不会打印密钥本身。托管、OpenAI 兼容、自托管或 Anthropic 原生路由用 {providerConfig} 或 {providerFlag} 选择;完整注册表见模型与提供商页和 docs/PROVIDERS.md。",
|
||||
"legacyTitle": "旧版 .deepseek/ 路径",
|
||||
"legacyLead": "Codewhale 由 DeepSeek-TUI 更名而来。为了不破坏既有安装,运行时从新的 ~/.codewhale/ 位置读取状态,但在只有旧目录存在时回退到 ~/.deepseek/,并且始终写入 ~/.codewhale/——读取带回退、写入新位置。状态目录解析集中在 crates/config/src/lib.rs 的 resolve_state_dir / ensure_state_dir 中,每一处旧路径引用都有审计过的保留决定。",
|
||||
"sourceNote": "来源文档:docs/CONFIGURATION.md, docs/LEGACY_PATHS.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-constitution": {
|
||||
"metaTitle": "宪章与 /constitution · Codewhale 文档",
|
||||
"metaDescription": "用户全局宪章、仓库本地法、项目说明和运行时边界。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "宪章与 /constitution",
|
||||
"overviewTitleAside": "Constitution",
|
||||
"overviewLead": "Codewhale 先给 Agent 一个可追责的地址,再给上下文冲突一套法律。{constitutionCommand} 是管理个人常驻宪章的主入口: 它把结构化的用户全局设置保存在 {homeConfig}, 再渲染成模型可读的 prose block。仓库仍可通过 {repoConfig} 增加本地 law;runtime policy 独立负责模式、审批、沙箱、成本和工具边界。",
|
||||
"principles": [
|
||||
[
|
||||
"userGlobal",
|
||||
"用 /constitution 管理跨项目个人常驻法。它是结构化数据渲染成 prose,不是裸 prompt 编辑器。"
|
||||
],
|
||||
[
|
||||
"repoLocal",
|
||||
".codewhale/constitution.json 是可选项目 law,用于不变量、分支规则、验证和升级条件。"
|
||||
],
|
||||
[
|
||||
"runtime",
|
||||
"宪章文本可以表达偏好;审批、沙箱、Shell、网络、信任和 MCP 权限仍由运行时配置强制执行。"
|
||||
]
|
||||
],
|
||||
"authorityNote": "普通项目说明仍放在 AGENTS.md;记忆和交接低于宪章与项目说明;完整 base prompt Markdown 覆盖只是专家逃生口,不是普通设置路径。详见 {configDocs}。",
|
||||
"configDocsLabel": "配置文档",
|
||||
"sourceNote": "来源文档:docs/ARCHITECTURE.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-fleet": {
|
||||
"metaTitle": "Fleet 与 Workflow · Codewhale 文档",
|
||||
"metaDescription": "持久 Agent 花名册与成员选择层,以及可选的 Workflow 编排层。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "Fleet 与 Workflow",
|
||||
"overviewLead": "Fleet 是持久花名册:记录有哪些成员以及选中了哪一位。它不是执行或权限引擎。Runtime 将选定成员作为无头 codewhale exec 运行来启动和跟踪,负责重试与远程放置,并写入持久收据和台账投影。",
|
||||
"runTitle": "运行一次 Fleet",
|
||||
"runLead": "Runtime 的 Fleet 运行投影存放在工作区的 .codewhale/fleet.jsonl 台账中,worker 日志在 .codewhale/fleet/ 下。codewhale fleet resume <run-id> 会让 Runtime 重放台账并调和过期租约;该操作幂等,可在管理进程退出、笔记本睡眠或运行时重启后安全执行。",
|
||||
"statusLead": "注意两个同名状态面:TUI 里的 {fleetStatusTui}(或 {subagents})只显示当前交互会话的子 Agent;shell 里的 {fleetStatusShell} 才读取持久 Fleet 台账。",
|
||||
"profilesTitle": "角色与 /fleet setup",
|
||||
"profilesLead": "/fleet setup 打开渐进式向导来编写可复用的花名册成员:依次选择语义角色、模型(继承或具体已配置路由)和思考档位,再核对准确的身份与路由后保存。档案可写在项目级(.codewhale/agents/<role>.toml)或个人级($CODEWHALE_HOME/agents/<role>.toml);同 ID 的项目档案优先。Runtime 另行负责信任、文件系统/网络范围、密钥、审批、沙箱和工具,因此档案存储范围不会扩大执行权限。",
|
||||
"workflowTitle": "Workflow 编排",
|
||||
"workflowLead": "普通多 Agent 工作不需要 Workflow:在 Operate 里直接发消息,需要并行、隔离或长时间工作时让 Codewhale 优先委派后台 worker 即可。只有当工作需要有序阶段、门禁、共享预算、回放或确定性汇总时才用 Workflow。Workflow 脚本只负责协调:它选择 Fleet 成员,但没有自己的文件系统或 shell;Runtime 在实时权限策略下启动真正的 worker。脚本使用编译专用的声明式 JS 子集,降低到类型化 WorkflowSpec 后由 Rust 校验与执行;import、fetch、process、eval、async/await 会被拒绝。",
|
||||
"workflowLimits": "默认校验边界:每次 Workflow 运行最多 1,000 个 worker Agent、最多 8 层递归 Fleet 环(默认 3 层)、循环必须声明 max_iterations、动态 expand 节点必须声明 max_children 和模板。这些是数量上限而非并发要求:Runtime 每个运行最多接纳 16 个存活 worker,其余排队。省略 max_steps 或设为 0 都保持无界;只有正值才增加模型轮次上限。",
|
||||
"sourceNote": "来源文档:docs/FLEET.md, docs/WORKFLOW_AUTHORING.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-mcp": {
|
||||
"metaTitle": "MCP · Codewhale 文档",
|
||||
"metaDescription": "通过 Model Context Protocol 消费外部工具服务器,或把 Codewhale 作为 MCP 服务器暴露。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewLead": "Codewhale 可以通过 MCP(Model Context Protocol)加载额外的工具。MCP 服务器可以是由 TUI 启动的本地 stdio 进程,也可以是远程 URL 服务器(Streamable HTTP,带旧版 SSE 回退)。连接成功的服务器会把工具注册进模型目录;失败或被禁用的服务器不会作为可用工具呈现给模型。",
|
||||
"overviewConfig": "配置文件默认在 {configPath}(新文件缺失时仍读取旧版 {legacyConfigPath}),可用 {configPathOption} 或 {configEnvVar} 覆盖。也兼容其他客户端使用的 {serversKey} 键名。",
|
||||
"setupTitle": "配置与管理",
|
||||
"setupLead": "用 {initCommand} 生成初始配置;TUI 内的 {mcpCommand} 打开紧凑管理器,显示每个服务器的启用状态、传输方式、命令或 URL、超时和连接错误。常用命令:",
|
||||
"setupReload": "在 TUI 里做的配置编辑会立即写盘,但模型可见的 MCP 工具池不会热加载——管理器会把它标记为需要重启。/mcp validate 和 /mcp reload 会重新连接以刷新界面快照。",
|
||||
"authTitle": "远程认证",
|
||||
"authLead": "URL 服务器可以使用静态 headers、从环境变量派生的 env_headers、bearer_token_env_var 或 OAuth。优先级是保守的:先应用 headers 和 env_headers;bearer_token_env_var 只在尚未设置 Authorization 时添加;OAuth 登录获取的令牌同样不会覆盖已有的显式 header。应避免提交字面量 Authorization header——优先用 env_headers、bearer_token_env_var 或 OAuth 登录,让秘密留在 MCP 文件之外。",
|
||||
"toolsTitle": "工具命名与安全",
|
||||
"toolsLead": "发现的 MCP 工具以 {toolNamePattern} 的形式暴露给模型——例如名为 {gitServer} 的服务器的 {statusTool} 工具会变成 {gitStatusTool}。MCP 工具和内置工具走同一套审批框架:只读的 MCP 辅助工具在策略允许时可免提示运行,有副作用的 MCP 工具需要审批,Full Access 也不会绕过硬策略拦截。",
|
||||
"toolsTrust": "只配置你信任的 MCP 服务器,并把 MCP 服务器配置视为等同于在本机运行代码。经过审查的本地插件包也可以贡献 MCP 服务器:它们复用同一个 MCP 管理器、审批和网络策略路径,以 <plugin>-<server> 的命名空间身份出现,边界比手写的 mcp.json 更严格。",
|
||||
"serverTitle": "把 Codewhale 作为 MCP 服务器",
|
||||
"serverLead": "{serveMcp} 会把 Codewhale 作为 stdio MCP 服务器运行,让其他会话(或任何 MCP 客户端)调用它的工具;{mcpServerCommand} 是 dispatcher 暴露的等价入口。{addSelfCommand} 会自动解析当前二进制路径并把服务器写进你的 MCP 配置。注意区分:{serveHttp} 是运行时 HTTP/SSE API,是另一种模式。",
|
||||
"sourceNote": "来源文档:docs/MCP.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-modes": {
|
||||
"metaTitle": "模式 · Codewhale 文档",
|
||||
"metaDescription": "Plan、Work、Operate 三种运行模式与独立的权限姿态。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "模式",
|
||||
"overviewLead": "模式决定 Codewhale 如何组织工作;权限姿态决定它如何处理具有后果的工具调用。两者相互独立。",
|
||||
"modes": [
|
||||
[
|
||||
"Plan",
|
||||
"用于只读调查与规划。Codewhale 可以检查工作区,但不能执行 Shell 命令或修改文件。"
|
||||
],
|
||||
[
|
||||
"Act",
|
||||
"用于常规交互式编码。Codewhale 可以检查、编辑并使用工具;Shell 是否可用以及何时请求批准,取决于当前配置和权限姿态。"
|
||||
],
|
||||
[
|
||||
"Operate",
|
||||
"用于从同一个输入区协调多项任务。父回合可以直接检查、编辑并使用 Shell 或 MCP 工具,其权限姿态、沙箱和安全规则与 Act 相同。独立、并行、后台或长时间工作会优先交给 Fleet worker,但并非所有可执行步骤都必须委派。只有需要有序阶段、门禁或确定性汇总时才需要 Workflow。"
|
||||
]
|
||||
],
|
||||
"switchingTitle": "切换模式",
|
||||
"switchingLead": "输入区空闲时,按 {tab} 循环 Plan → Act → Operate。补全菜单打开时,Tab 接受补全;回合运行时,它可以把当前草稿排入下一个跟进消息。",
|
||||
"switchingCommandLead": "运行 /mode 打开模式选择器,或使用以下命令直接切换:",
|
||||
"permissionsTitle": "权限姿态",
|
||||
"permissionsLead": "Plan 始终为只读。在 Act 或 Operate 中且输入区空闲时,按 {shiftTab} 循环 Ask → Auto-Review → Full Access。运行 {configCommand} 可查看或编辑当前会话权限;项目或托管策略可能会锁定或收紧它。",
|
||||
"postures": [
|
||||
[
|
||||
"Ask",
|
||||
"在可能产生重要后果的工具执行前询问你。"
|
||||
],
|
||||
[
|
||||
"Auto-Review",
|
||||
"自动评估工具风险,只在确实需要你决定时询问。"
|
||||
],
|
||||
[
|
||||
"Full Access",
|
||||
"无需批准提示即可运行工具,并启用受信任工作区访问。仓库规则和托管约束仍然有效;仅在你信任的工作区中使用。"
|
||||
]
|
||||
],
|
||||
"sourceNote": "来源文档:docs/MODES.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-runtime-api": {
|
||||
"metaTitle": "运行时 API · Codewhale 文档",
|
||||
"metaDescription": "面向集成、桥接和自动化的本地 HTTP/SSE、JSON-RPC stdio 与 ACP 入口。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "运行时 API",
|
||||
"overviewLead": "codewhale app-server 是 canonical 的本地运行时 API 与控制平面。本地 SDK、移动/远控客户端和编辑器集成直接与它对话,而不是抓终端输出。引擎只作为本地进程运行:所有 API 默认绑定 localhost——没有托管中继,不托管 provider 令牌,不泄露秘密。codewhale serve --http / --mobile 保留为 app-server --http / --mobile 的兼容别名,启动的是同一个服务器;新集成应面向 app-server。",
|
||||
"entries": [
|
||||
[
|
||||
"http",
|
||||
"完整 /v1/* HTTP/SSE 运行时 API(canonical 入口),默认 127.0.0.1:7878。"
|
||||
],
|
||||
[
|
||||
"mobile",
|
||||
"运行时 API 加 /mobile 手机控制页。"
|
||||
],
|
||||
[
|
||||
"stdio",
|
||||
"换行分隔的 JSON-RPC 2.0 控制传输,无监听端口,适合本地 SDK 和探针。"
|
||||
],
|
||||
[
|
||||
"web",
|
||||
"仅回环的浏览器客户端,内嵌于二进制并打开默认浏览器。"
|
||||
],
|
||||
[
|
||||
"doctor",
|
||||
"机器可读的健康与能力报告。"
|
||||
],
|
||||
[
|
||||
"acp",
|
||||
"面向 Zed 等编辑器的 ACP(Agent Client Protocol)stdio 适配器。"
|
||||
],
|
||||
[
|
||||
"exec",
|
||||
"一次性无头 worker(stream-json、Fleet 子进程、CI 原语)——不属于本 API,但共享同一运行时与事件词汇。"
|
||||
]
|
||||
],
|
||||
"stdioTitle": "零成本探测",
|
||||
"stdioLead": "stdio 控制传输可以不花模型 token 地探测。capabilities 返回声明的方法族(thread/*、app/*、prompt/*)和完整方法列表;方法集由 crates/app-server/src/lib.rs 中的漂移测试固定,SDK 和本地集成可以放心依赖它不会悄悄变化。",
|
||||
"interruptNote": "进行中的回合可以用 thread/interrupt(或 HTTP 的 POST /v1/threads/{id}/turns/{turn_id}/interrupt)请求中断;没有正在流式输出的回合时返回 interrupted: false——这不是错误,只是没有可停的东西。",
|
||||
"securityTitle": "安全边界",
|
||||
"securityLead": "运行时 API 令牌按 {authToken}、{runtimeTokenEnv}、{legacyTokenEnv} 的顺序读取;{insecureFlag} 只允许与回环绑定一起使用。浏览器侧的跨源请求会被 CORS 允许列表拒绝。选择非回环绑定(尤其是 {mobileFlag})之前,请阅读 docs/RUNTIME_API.md 的完整部署与认证约定。",
|
||||
"sourceNote": "来源文档:docs/RUNTIME_API.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-sandbox": {
|
||||
"metaTitle": "沙箱与审批 · Codewhale 文档",
|
||||
"metaDescription": "macOS Seatbelt、Linux 可选 bubblewrap、平台缺口和审批策略的真实边界。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "沙箱与审批",
|
||||
"overviewLead": "Codewhale 可以启动由模型提出的 shell 命令。审批策略、感知工作区的文件工具和操作系统命令包装器是三个独立的控制:一次审批不是沙箱,选择 workspace-write 也不代表当前平台有可用的 OS 包装器。本页只描述已经接入命令执行路径的行为。",
|
||||
"platforms": [
|
||||
[
|
||||
"macOS · Seatbelt",
|
||||
"Codewhale 探测 /usr/bin/sandbox-exec;探测成功且策略要求沙箱时,子命令会被包上运行时生成的 Seatbelt profile:广泛的文件系统读取、按策略限制的写入、仅在策略允许时放行网络。探测失败则如实报告无 OS 沙箱。"
|
||||
],
|
||||
[
|
||||
"Linux · 可选 bubblewrap",
|
||||
"Linux 命令沙箱是显式启用的:设置 prefer_bwrap = true,且 /usr/bin/bwrap 是可执行文件时才选用。子命令得到只读根视图,writable 挂载来自解析后的策略;默认隔离网络命名空间,仅在策略开启 network_access 时加 --share-net。未启用或未安装 bwrap 时报告 none。"
|
||||
],
|
||||
[
|
||||
"Windows · 无 OS 沙箱",
|
||||
"Windows 命令路径目前报告无 OS 沙箱。主机权限和审批策略仍然有效,但它们不是 Codewhale 的 OS 命令沙箱。"
|
||||
],
|
||||
[
|
||||
"外部 OpenSandbox 执行",
|
||||
"配置 sandbox_backend = \"opensandbox\" 后,shell 执行会发往配置的 OpenSandbox 兼容 HTTP 端点,而不是启动本地子进程。隔离保证属于所配置的服务及其运营者。"
|
||||
]
|
||||
],
|
||||
"policiesTitle": "策略与回退",
|
||||
"policiesLead": "本地 {sandboxMode} 取值为 {readOnly}、{workspaceWrite}、{dangerFullAccess} 或 {externalSandbox}。前两者只在选中且可用的 Seatbelt 或 bubblewrap 包装器下被强制执行;{dangerFullAccess} 有意绕过本地 OS 包装器;{externalSandbox} 声明执行已被外部隔离。没有选中包装器时,shell 命令在没有 Codewhale OS 隔离的情况下运行——审批规则和感知工作区的原生文件工具仍是独立的控制。",
|
||||
"diagnosticsTitle": "诊断与限制",
|
||||
"diagnosticsLead": "codewhale setup --status、codewhale doctor、codewhale doctor --json 和 diagnostics 工具会报告应用 bubblewrap 偏好后本地可用的包装器。拒绝归因是保守的:子命令的通用 Permission denied 本身并不能证明是 Codewhale 的沙箱拦截了它,未沙箱化的命令失败永远不会被标记为沙箱拒绝。",
|
||||
"diagnosticsLimits": "限制同样如实说明:可用性在启动前检查,选中的包装器仍可能因主机策略、容器限制或竞态而失败;bubblewrap 会忽略缺失或不是目录的可写根;没有任何沙箱能防御内核漏洞或所有资源耗尽与侧信道攻击。",
|
||||
"sourceNote": "来源文档:docs/SANDBOX.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-subagents": {
|
||||
"metaTitle": "子 Agent · Codewhale 文档",
|
||||
"metaDescription": "agent 工具、Fleet 角色、上下文分叉、worktree 隔离和并发上限。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "子 Agent",
|
||||
"overviewLead": "父会话通过 agent 工具启动一个有明确职责的子 Agent,并立即拿回 agent_id、compact 收据和 transcript 句柄;子 Agent 在后台运行。子 Agent 默认继承父级的工具注册表,但它们是叶子 worker:不会再拿到 agent 或嵌套生命周期工具。agent 启动的是分离的后台工作——取消父回合会停止父级的等待路径,但不会杀死已经启动的子运行。",
|
||||
"overviewFleetNote": "对于必须跨进程重启、睡眠或远程执行存活的工作,优先选择 Fleet 或 Workflow 支撑的 Fleet 运行,而不是会话内的短寿命 agent 调用。",
|
||||
"roles": [
|
||||
[
|
||||
"worker",
|
||||
"灵活执行父级交代的多步任务;可写、可用 shell。默认角色。"
|
||||
],
|
||||
[
|
||||
"scout",
|
||||
"只读,快速摸清相关代码——例如“找出 Foo 的所有调用点”。"
|
||||
],
|
||||
[
|
||||
"planner",
|
||||
"分析并产出策略,不执行——“设计迁移方案,不要动手”。"
|
||||
],
|
||||
[
|
||||
"reviewer",
|
||||
"只读审查并按严重度打分——“审一遍这个 PR 的 bug”。"
|
||||
],
|
||||
[
|
||||
"builder",
|
||||
"以最小改动落地一个明确的变更;可写、可用 shell。"
|
||||
],
|
||||
[
|
||||
"verifier",
|
||||
"运行测试和校验并汇报结果,不写代码。"
|
||||
],
|
||||
[
|
||||
"consultant",
|
||||
"只读的高推理力度顾问,用于判断类问题和设计评审。"
|
||||
],
|
||||
[
|
||||
"custom",
|
||||
"手工指定狭窄的工具白名单,用于锁定的派发。"
|
||||
]
|
||||
],
|
||||
"forkTitle": "上下文分叉",
|
||||
"forkLead": "{agentTool} 默认开启全新会话:子 Agent 只拿到角色提示词和你给的任务。当任务依赖父 transcript 里已有的决定、文件、待办或计划状态时,用 {forkContext}——运行时在可用时保持父级前缀逐字节一致(保留前缀缓存复用),追加一份结构化状态快照,再把子 Agent 的角色说明和任务放在末尾。独立探索用新会话,延续、审查、总结或压缩类工作用分叉会话。",
|
||||
"worktreeTitle": "Worktree 隔离",
|
||||
"worktreeLead": "并行编辑通道用 {worktreeFlag} 启动:Codewhale 为子 Agent 创建新的 git worktree 和分支(默认 {branchPattern},检出在父仓库旁的 {worktreeDir} 下),父检出保持干净。隔离不等于写权限:只带 prompt 的 worker 从只读开始;要写代码的子 Agent 还需声明 {writeAuthority} 和至少一个规范化的 {writeRoots}、{exactFiles} 或 {coordinationContracts}值;重叠的共享写声明会在任何改动之前失败。",
|
||||
"capacityTitle": "并发上限",
|
||||
"capacityLead": "子 Agent 容量的权威来源是 crates/tui/src/config/subagent_limits.rs:默认配置并发 64,最大配置并发 128,运行加排队的最大准入 1024。这些是容量上限,不是建议把每个槽位都派出去——管理者应使用最小的有效扇出,保持单一汇总负责人,并在汇报整体完成前验证 worker 收据。",
|
||||
"sourceNote": "来源文档:docs/SUBAGENTS.md · 更新时请同步修改 docs-map.ts。"
|
||||
},
|
||||
"docs-web": {
|
||||
"metaTitle": "浏览器客户端 · Codewhale 文档",
|
||||
"metaDescription": "仅回环的内嵌浏览器客户端:一次性引导、会话 Cookie 与本地信任边界。",
|
||||
"bodyClassName": "text-ink-soft leading-[1.9] tracking-wide",
|
||||
"overviewTitle": "浏览器客户端",
|
||||
"overviewLead": "{webCommand} 在 canonical 运行时 API 之上打开 Codewhale 内嵌的浏览器客户端。它是一个纯本地界面:服务器始终绑定 {loopbackHost},无法改绑到局域网地址,也无法在关闭运行时认证的情况下运行。默认地址是 {defaultUrl};端口冲突时用 {portExample} 换一个回环端口。Ctrl+C 停止进程,浏览器会话随之结束。",
|
||||
"overviewBody": "当前客户端提供响应式的线程与搜索侧栏、由运行时持有的会话事实、transcript 与工具收据,以及输入区。它可以创建、选择、重命名和归档线程;发起或引导回合;中断工作;处理审批;回答运行时的用户输入请求。浏览器只是同一个本地运行时的另一视图——不会创建第二个云账号,不会把 provider 凭据复制进浏览器存储,也不会削弱已配置的审批与沙箱策略。",
|
||||
"authTitle": "认证边界",
|
||||
"authLead": "启动 URL 携带的是一个随机、短寿命、一次性的引导凭证——绝不是运行时 bearer 令牌。一次回环请求把它换成 HttpOnly、SameSite=Strict、进程本地的会话 Cookie,并立即使该凭证失效。重用、过期、畸形或非回环的引导尝试都会失败关闭。运行时令牌不会出现在渲染的 HTML、浏览器存储、URL 查询或片段、或浏览器启动参数中。携带 Cookie 的状态变更请求还必须出示精确的本地 web 源;跨源浏览器请求会被拒绝。",
|
||||
"localTitle": "本地就是本地",
|
||||
"localLead": "{webCommand} 只接受 {portFlag}——没有 {hostFlag},也没有关闭认证的选项。不要把它当公开网站,也不要通过路由器转发、公开反向代理或隧道暴露它的端口。单独的 {mobileCommand} 和 {httpFlag} 模式有不同的部署与认证约定,操作它们(尤其是选择非回环绑定)之前请阅读运行时 API 文档。",
|
||||
"troubleshootingTitle": "常见问题",
|
||||
"troubleshootingLead": "端口 7878 被占用时用 --port 换一个。浏览器无法打开时命令会报错退出,而不会留下可重用的引导凭证;检查系统默认浏览器设置后重新启动。页面能打开但 provider 不可用时,查 codewhale doctor 和 /provider——web 命令不配置也不迁移 provider 凭据。会话过期后重启 codewhale web 以签发新的进程本地会话;重用旧的引导 URL 本来就会失败。",
|
||||
"sourceNote": "来源文档:docs/WEB.md · 更新时请同步修改 docs-map.ts。"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"defaultLocale": "en",
|
||||
"locales": ["zh"],
|
||||
"files": {
|
||||
"json": {
|
||||
"include": ["gt-catalog/[locale].json"]
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-1
@@ -17,6 +17,7 @@ import { contentLocalesForPath } from "./i18n/content-locales";
|
||||
import { getChrome, getHome } from "./i18n/dictionaries";
|
||||
import {
|
||||
currentNavHref,
|
||||
footerLegalLinks,
|
||||
footerProductLinks,
|
||||
footerProjectLinks,
|
||||
navLinks as buildNavLinks,
|
||||
@@ -88,7 +89,13 @@ describe("sitemap and hreflang preservation", () => {
|
||||
});
|
||||
|
||||
it("keeps sitemap and hreflang output aligned with real translation coverage", () => {
|
||||
expect(sitemapEntries).toHaveLength(78);
|
||||
expect(sitemapEntries).toHaveLength(84);
|
||||
for (const path of ["/pricing", "/legal/terms", "/legal/privacy"]) {
|
||||
expect(
|
||||
sitemapEntries.some((entry) => entry.url === `${SITE_URL}/en${path}`),
|
||||
path,
|
||||
).toBe(true);
|
||||
}
|
||||
for (const [path, expectedLocales] of [
|
||||
["/", locales],
|
||||
["/docs/guide", contentLocalesForPath("/docs/guide")],
|
||||
@@ -199,6 +206,19 @@ describe("navigation parity and accessibility", () => {
|
||||
`/${locale}/contribute`,
|
||||
"https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
|
||||
]);
|
||||
const legal = footerLegalLinks(locale, getChrome(locale));
|
||||
expect(legal.map((l) => l.href), `${locale} footer legal`).toEqual([
|
||||
`/${locale}/pricing`,
|
||||
`/${locale}/legal/terms`,
|
||||
`/${locale}/legal/privacy`,
|
||||
]);
|
||||
// Labels come from the dictionary, not hardcoded English, so every
|
||||
// routed locale renders the footer legal links in its own language.
|
||||
expect(legal.map((l) => l.label), `${locale} footer legal labels`).toEqual([
|
||||
getChrome(locale).footerPricing,
|
||||
getChrome(locale).footerTerms,
|
||||
getChrome(locale).footerPrivacy,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -281,8 +301,15 @@ describe("navigation parity and accessibility", () => {
|
||||
label: "MIT license",
|
||||
href: "https://github.com/Hmbown/CodeWhale/blob/main/LICENSE",
|
||||
});
|
||||
// zh gets the footer legal labels from its dictionary, not English.
|
||||
expect(footerLegalLinks("zh", getChrome("zh")).map((l) => l.label)).toEqual([
|
||||
"价格",
|
||||
"服务条款",
|
||||
"隐私政策",
|
||||
]);
|
||||
expect(footer).toContain("footerProductLinks(locale, chrome)");
|
||||
expect(footer).toContain("footerProjectLinks(locale, chrome)");
|
||||
expect(footer).toContain("footerLegalLinks(locale, chrome)");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ export interface RepoFacts {
|
||||
}
|
||||
|
||||
export const FACTS: RepoFacts = {
|
||||
"generatedAt": "2026-08-25T09:50:23.220Z",
|
||||
"generatedAt": "2026-08-29T03:01:38.446Z",
|
||||
"sourceRevision": null,
|
||||
"sourceCommittedAt": null,
|
||||
"version": "0.9.11",
|
||||
@@ -287,7 +287,7 @@ export const FACTS: RepoFacts = {
|
||||
],
|
||||
"defaultModel": "deepseek-v4-pro",
|
||||
"nodeEngines": ">=18",
|
||||
"toolCount": 74,
|
||||
"toolCount": 75,
|
||||
"license": "MIT",
|
||||
"latestPublishedRelease": {
|
||||
"tag": "v0.9.11",
|
||||
|
||||
@@ -82,6 +82,9 @@ export const chrome: ChromeDict = {
|
||||
footerIssues: "Issues",
|
||||
footerContribute: "المساهمة",
|
||||
footerLicense: "رخصة MIT",
|
||||
footerPricing: "الأسعار",
|
||||
footerTerms: "شروط الخدمة",
|
||||
footerPrivacy: "الخصوصية",
|
||||
footerCanonicalSource: "المصدر القانوني: ",
|
||||
footerReleases: " · الإصدارات: ",
|
||||
footerReleasesLink: "إصدارات GitHub",
|
||||
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user