Add API tokens, CLI server API and shared core crate
- Personal API tokens (full/read scope, expiry, revoke) and CLI password login - Cargo workspace: codecraft-core (types, providers, permission rules) shared by server and the upcoming terminal client; server re-exports the old paths - CLI server API: /cli/config, /cli/skill, /cli/tool (server MCP tools), /llm/stream model gateway with budgets and usage per user - Theme/language preferences mirrored to the server; personal memory endpoint - Statistics: provider and mode tables stacked; CLI usage labelled - cli/: work in progress (terminal client is being rewritten as a local TUI) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fca1c41087
commit
aa1f8cf016
40 files changed
+2454
-814
No files matched your search
@@ -5,3 +5,4 @@ data
|
|||||||
workspace
|
workspace
|
||||||
.env
|
.env
|
||||||
.git
|
.git
|
||||||
|
target
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/target/
|
||||||
/server/target/
|
/server/target/
|
||||||
/web/node_modules/
|
/web/node_modules/
|
||||||
/web/dist/
|
/web/dist/
|
||||||
|
|||||||
Generated
+204
-2
@@ -61,6 +61,56 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyhow"
|
name = "anyhow"
|
||||||
version = "1.0.104"
|
version = "1.0.104"
|
||||||
@@ -165,7 +215,7 @@ dependencies = [
|
|||||||
"sha1",
|
"sha1",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-tungstenite",
|
"tokio-tungstenite 0.29.0",
|
||||||
"tower",
|
"tower",
|
||||||
"tower-layer",
|
"tower-layer",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
@@ -376,6 +426,46 @@ dependencies = [
|
|||||||
"inout",
|
"inout",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cmake"
|
name = "cmake"
|
||||||
version = "0.1.58"
|
version = "0.1.58"
|
||||||
@@ -404,6 +494,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"calamine",
|
"calamine",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"codecraft-core",
|
||||||
"croner",
|
"croner",
|
||||||
"eventsource-stream",
|
"eventsource-stream",
|
||||||
"futures",
|
"futures",
|
||||||
@@ -435,6 +526,40 @@ dependencies = [
|
|||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codecraft-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"futures-util",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tokio",
|
||||||
|
"tokio-tungstenite 0.28.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "codecraft-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"async-trait",
|
||||||
|
"chrono",
|
||||||
|
"eventsource-stream",
|
||||||
|
"futures",
|
||||||
|
"globset",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"shell-words",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"uuid",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "codepage"
|
name = "codepage"
|
||||||
version = "0.1.3"
|
version = "0.1.3"
|
||||||
@@ -444,6 +569,12 @@ dependencies = [
|
|||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.9"
|
version = "4.6.9"
|
||||||
@@ -1470,6 +1601,12 @@ version = "2.12.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itoa"
|
name = "itoa"
|
||||||
version = "1.0.18"
|
version = "1.0.18"
|
||||||
@@ -1772,6 +1909,12 @@ version = "1.21.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -2907,6 +3050,22 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||||
|
dependencies = [
|
||||||
|
"futures-util",
|
||||||
|
"log",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tungstenite 0.28.0",
|
||||||
|
"webpki-roots 0.26.11",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-tungstenite"
|
name = "tokio-tungstenite"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -2916,7 +3075,7 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"log",
|
"log",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tungstenite",
|
"tungstenite 0.29.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3124,6 +3283,25 @@ version = "0.2.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tungstenite"
|
||||||
|
version = "0.28.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"data-encoding",
|
||||||
|
"http",
|
||||||
|
"httparse",
|
||||||
|
"log",
|
||||||
|
"rand 0.9.5",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"sha1",
|
||||||
|
"thiserror",
|
||||||
|
"utf-8",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tungstenite"
|
name = "tungstenite"
|
||||||
version = "0.29.0"
|
version = "0.29.0"
|
||||||
@@ -3213,6 +3391,12 @@ dependencies = [
|
|||||||
"utf_types",
|
"utf_types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "utf_types"
|
name = "utf_types"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
@@ -3391,6 +3575,24 @@ dependencies = [
|
|||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "0.26.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
|
||||||
|
dependencies = [
|
||||||
|
"webpki-roots 1.0.11",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "webpki-roots"
|
||||||
|
version = "1.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||||
|
dependencies = [
|
||||||
|
"rustls-pki-types",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "winapi-util"
|
name = "winapi-util"
|
||||||
version = "0.1.11"
|
version = "0.1.11"
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "3"
|
||||||
|
members = ["core", "server", "cli"]
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = 3
|
||||||
|
|
||||||
|
# The command line client is small and self-contained.
|
||||||
|
[profile.release.package.codecraft-cli]
|
||||||
|
opt-level = "z"
|
||||||
|
strip = true
|
||||||
+12
-6
@@ -12,12 +12,18 @@ RUN npm run build
|
|||||||
FROM rust:1-bookworm AS server
|
FROM rust:1-bookworm AS server
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY config.example.toml ./
|
COPY config.example.toml ./
|
||||||
COPY server/Cargo.toml server/Cargo.lock ./server/
|
COPY Cargo.toml Cargo.lock ./
|
||||||
# Cache dependencies separately from the sources.
|
COPY core/Cargo.toml ./core/
|
||||||
RUN mkdir -p server/src && echo 'fn main(){}' > server/src/main.rs \
|
COPY server/Cargo.toml ./server/
|
||||||
&& cd server && cargo build --release && rm -rf src
|
COPY cli/Cargo.toml ./cli/
|
||||||
|
# Cache dependencies separately from the sources (stub crates first).
|
||||||
|
RUN mkdir -p core/src server/src cli/src \
|
||||||
|
&& echo '' > core/src/lib.rs && echo 'fn main(){}' > server/src/main.rs && echo 'fn main(){}' > cli/src/main.rs \
|
||||||
|
&& cargo build --release -p codecraft && rm -rf core/src server/src cli/src
|
||||||
|
COPY core/src ./core/src
|
||||||
COPY server/src ./server/src
|
COPY server/src ./server/src
|
||||||
RUN cd server && touch src/main.rs && cargo build --release
|
COPY cli/src ./cli/src
|
||||||
|
RUN touch core/src/lib.rs server/src/main.rs && cargo build --release -p codecraft
|
||||||
|
|
||||||
# --- runtime ------------------------------------------------------------------
|
# --- runtime ------------------------------------------------------------------
|
||||||
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
# Node is included so agents can work on JS projects and run npx-based MCP servers.
|
||||||
@@ -34,7 +40,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
# Language servers for diagnostics after edits (rust-analyzer/gopls/clangd can be
|
||||||
# installed by agents: rustup component add rust-analyzer, go install …).
|
# installed by agents: rustup component add rust-analyzer, go install …).
|
||||||
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
RUN npm install -g typescript@5 typescript-language-server pyright && npm cache clean --force
|
||||||
COPY --from=server /src/server/target/release/codecraft /usr/local/bin/codecraft
|
COPY --from=server /src/target/release/codecraft /usr/local/bin/codecraft
|
||||||
COPY --from=web /web/dist /app/web
|
COPY --from=web /web/dist /app/web
|
||||||
COPY server/ghidra /app/ghidra
|
COPY server/ghidra /app/ghidra
|
||||||
COPY server/resources /app/resources
|
COPY server/resources /app/resources
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "codecraft-cli"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Command line client for CodeCraft"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "codecraft"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive", "env"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
tokio = { version = "1", features = ["macros", "rt-multi-thread", "io-std", "io-util", "fs", "time", "signal"] }
|
||||||
|
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect", "rustls-tls-webpki-roots"] }
|
||||||
+674
@@ -0,0 +1,674 @@
|
|||||||
|
//! `codecraft` – command line client for CodeCraft.
|
||||||
|
//!
|
||||||
|
//! Authenticates with a personal API token (Settings → Account → API tokens).
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow, bail};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::io::{IsTerminal, Write};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio::io::{AsyncBufReadExt, BufReader};
|
||||||
|
use tokio_tungstenite::tungstenite::{Message as WsMessage, client::IntoClientRequest};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "codecraft", version, about = "Command line client for CodeCraft", propagate_version = true)]
|
||||||
|
struct Cli {
|
||||||
|
/// Server URL (overrides the saved login)
|
||||||
|
#[arg(long, env = "CODECRAFT_URL", global = true)]
|
||||||
|
url: Option<String>,
|
||||||
|
/// API token (overrides the saved login)
|
||||||
|
#[arg(long, env = "CODECRAFT_TOKEN", global = true, hide_env_values = true)]
|
||||||
|
token: Option<String>,
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Cmd,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Save server URL and API token (Settings → Account → API tokens)
|
||||||
|
Login { url: String, token: String },
|
||||||
|
/// Remove the saved login
|
||||||
|
Logout,
|
||||||
|
/// Server, user, running sessions and costs this month
|
||||||
|
Status,
|
||||||
|
/// List sessions
|
||||||
|
Sessions {
|
||||||
|
/// Show at most N sessions
|
||||||
|
#[arg(short = 'n', long, default_value_t = 20)]
|
||||||
|
limit: usize,
|
||||||
|
},
|
||||||
|
/// Print a session's conversation
|
||||||
|
Show { session: String },
|
||||||
|
/// Export a session as Markdown or JSON
|
||||||
|
Export {
|
||||||
|
session: String,
|
||||||
|
#[arg(long, default_value = "md")]
|
||||||
|
format: String,
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Start a new session with a prompt and follow it
|
||||||
|
Run {
|
||||||
|
prompt: String,
|
||||||
|
/// Working directory on the server (default: your folder)
|
||||||
|
#[arg(long)]
|
||||||
|
cwd: Option<String>,
|
||||||
|
/// Mode: build, plan, craft, …
|
||||||
|
#[arg(long, default_value = "build")]
|
||||||
|
agent: String,
|
||||||
|
/// Model "provider/model"
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
/// Permission mode: default, auto, accept_edits, bypass
|
||||||
|
#[arg(long)]
|
||||||
|
mode: Option<String>,
|
||||||
|
/// Reasoning effort: low, medium, high, max
|
||||||
|
#[arg(long)]
|
||||||
|
effort: Option<String>,
|
||||||
|
/// Don't wait – print the session id and exit
|
||||||
|
#[arg(long)]
|
||||||
|
detach: bool,
|
||||||
|
},
|
||||||
|
/// Send a prompt to an existing session and follow it
|
||||||
|
Send {
|
||||||
|
session: String,
|
||||||
|
prompt: String,
|
||||||
|
#[arg(long)]
|
||||||
|
effort: Option<String>,
|
||||||
|
},
|
||||||
|
/// Interactive chat (new session, or continue one)
|
||||||
|
Chat {
|
||||||
|
session: Option<String>,
|
||||||
|
#[arg(long)]
|
||||||
|
cwd: Option<String>,
|
||||||
|
#[arg(long, default_value = "build")]
|
||||||
|
agent: String,
|
||||||
|
#[arg(long)]
|
||||||
|
model: Option<String>,
|
||||||
|
},
|
||||||
|
/// Follow a running session (answer permissions and questions)
|
||||||
|
Attach { session: String },
|
||||||
|
/// Stop a running session
|
||||||
|
Stop { session: String },
|
||||||
|
/// Automations
|
||||||
|
Jobs {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: Option<JobCmd>,
|
||||||
|
},
|
||||||
|
/// Files in your folders
|
||||||
|
Files {
|
||||||
|
#[command(subcommand)]
|
||||||
|
cmd: FileCmd,
|
||||||
|
},
|
||||||
|
/// Costs and tokens of the last days
|
||||||
|
Costs {
|
||||||
|
#[arg(long, default_value_t = 30)]
|
||||||
|
days: u32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum JobCmd {
|
||||||
|
/// List automations (default)
|
||||||
|
List,
|
||||||
|
/// Start an automation now
|
||||||
|
Run { id: String },
|
||||||
|
/// Enable an automation
|
||||||
|
On { id: String },
|
||||||
|
/// Disable an automation
|
||||||
|
Off { id: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum FileCmd {
|
||||||
|
/// List a folder
|
||||||
|
Ls { path: Option<String> },
|
||||||
|
/// Download a file
|
||||||
|
Get {
|
||||||
|
path: String,
|
||||||
|
#[arg(short, long)]
|
||||||
|
output: Option<PathBuf>,
|
||||||
|
},
|
||||||
|
/// Upload a file (chunked, large files supported)
|
||||||
|
Put { local: PathBuf, remote: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- config ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn config_path() -> PathBuf {
|
||||||
|
let base = std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from).unwrap_or_else(|| PathBuf::from(std::env::var_os("HOME").unwrap_or_default()).join(".config"));
|
||||||
|
base.join("codecraft").join("cli.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_login(cli: &Cli) -> Result<(String, String)> {
|
||||||
|
let saved: Value = std::fs::read_to_string(config_path()).ok().and_then(|t| serde_json::from_str(&t).ok()).unwrap_or(Value::Null);
|
||||||
|
let url = cli.url.clone().or_else(|| saved["url"].as_str().map(String::from));
|
||||||
|
let token = cli.token.clone().or_else(|| saved["token"].as_str().map(String::from));
|
||||||
|
match (url, token) {
|
||||||
|
(Some(u), Some(t)) => Ok((u.trim_end_matches('/').to_string(), t)),
|
||||||
|
_ => bail!("not logged in – run `codecraft login <url> <token>` (create a token under Settings → Account → API tokens)"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- output ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Style {
|
||||||
|
on: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Style {
|
||||||
|
fn paint(&self, code: &str, s: &str) -> String {
|
||||||
|
if self.on { format!("\x1b[{code}m{s}\x1b[0m") } else { s.to_string() }
|
||||||
|
}
|
||||||
|
fn dim(&self, s: &str) -> String {
|
||||||
|
self.paint("2", s)
|
||||||
|
}
|
||||||
|
fn bold(&self, s: &str) -> String {
|
||||||
|
self.paint("1", s)
|
||||||
|
}
|
||||||
|
fn accent(&self, s: &str) -> String {
|
||||||
|
self.paint("33", s)
|
||||||
|
}
|
||||||
|
fn red(&self, s: &str) -> String {
|
||||||
|
self.paint("31", s)
|
||||||
|
}
|
||||||
|
fn green(&self, s: &str) -> String {
|
||||||
|
self.paint("32", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn short(id: &str) -> &str {
|
||||||
|
&id[id.len().saturating_sub(8)..]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fmt_time(ms: i64) -> String {
|
||||||
|
let secs = (now_ms() - ms) / 1000;
|
||||||
|
match secs {
|
||||||
|
s if s < 60 => "just now".into(),
|
||||||
|
s if s < 3600 => format!("{} min ago", s / 60),
|
||||||
|
s if s < 86_400 => format!("{} h ago", s / 3600),
|
||||||
|
s => format!("{} d ago", s / 86_400),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as i64).unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
struct Api {
|
||||||
|
base: String,
|
||||||
|
token: String,
|
||||||
|
http: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Api {
|
||||||
|
fn new(base: String, token: String) -> Self {
|
||||||
|
let http = reqwest::Client::builder().user_agent(concat!("codecraft-cli/", env!("CARGO_PKG_VERSION"))).build().expect("http client");
|
||||||
|
Self { base, token, http }
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn send(&self, req: reqwest::RequestBuilder) -> Result<reqwest::Response> {
|
||||||
|
let resp = req.bearer_auth(&self.token).send().await.context("cannot reach the server")?;
|
||||||
|
if resp.status().is_success() {
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let msg = serde_json::from_str::<Value>(&body).ok().and_then(|v| v["error"].as_str().map(String::from)).unwrap_or(body);
|
||||||
|
if status.as_u16() == 401 {
|
||||||
|
bail!("unauthorized – the token is invalid or expired (codecraft login …)");
|
||||||
|
}
|
||||||
|
bail!("{} {}", status.as_u16(), msg.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get(&self, path: &str) -> Result<Value> {
|
||||||
|
Ok(self.send(self.http.get(format!("{}/api{path}", self.base))).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post(&self, path: &str, body: Value) -> Result<Value> {
|
||||||
|
Ok(self.send(self.http.post(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn patch(&self, path: &str, body: Value) -> Result<Value> {
|
||||||
|
Ok(self.send(self.http.patch(format!("{}/api{path}", self.base)).json(&body)).await?.json().await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a full or shortened (suffix) session id.
|
||||||
|
async fn session_id(&self, id: &str) -> Result<String> {
|
||||||
|
if id.len() >= 24 {
|
||||||
|
return Ok(id.to_string());
|
||||||
|
}
|
||||||
|
let list = self.get("/sessions").await?;
|
||||||
|
let hits: Vec<String> = list.as_array().into_iter().flatten().filter_map(|s| s["id"].as_str()).filter(|s| s.ends_with(id)).map(String::from).collect();
|
||||||
|
match hits.len() {
|
||||||
|
1 => Ok(hits[0].clone()),
|
||||||
|
0 => bail!("no session matches '{id}'"),
|
||||||
|
_ => bail!("'{id}' is ambiguous – use more characters"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- following a session -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Read one line from stdin (async, so events keep flowing).
|
||||||
|
async fn read_line(prompt: &str) -> Result<Option<String>> {
|
||||||
|
print!("{prompt}");
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
let mut line = String::new();
|
||||||
|
let n = BufReader::new(tokio::io::stdin()).read_line(&mut line).await?;
|
||||||
|
Ok(if n == 0 { None } else { Some(line.trim_end_matches(['\r', '\n']).to_string()) })
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer_permission(api: &Api, st: &Style, ev: &Value) -> Result<()> {
|
||||||
|
println!("\n{} {} {}", st.accent("? Permission"), st.bold(ev["tool"].as_str().unwrap_or("")), ev["title"].as_str().unwrap_or(""));
|
||||||
|
if let Some(cmd) = ev["input"]["command"].as_str() {
|
||||||
|
println!(" {}", st.dim(&format!("$ {cmd}")));
|
||||||
|
}
|
||||||
|
if let Some(diff) = ev["diff"].as_str() {
|
||||||
|
for l in diff.lines().take(60) {
|
||||||
|
let line = if l.starts_with('+') && !l.starts_with("+++") {
|
||||||
|
st.green(l)
|
||||||
|
} else if l.starts_with('-') && !l.starts_with("---") {
|
||||||
|
st.red(l)
|
||||||
|
} else {
|
||||||
|
st.dim(l)
|
||||||
|
};
|
||||||
|
println!(" {line}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loop {
|
||||||
|
let Some(ans) = read_line(" [y]es once / [a]lways / [n]o (n: <feedback>) > ").await? else { return Ok(()) };
|
||||||
|
let ans = ans.trim();
|
||||||
|
let (reply, message) = match ans.chars().next().map(|c| c.to_ascii_lowercase()) {
|
||||||
|
Some('y') | Some('j') => ("once", None),
|
||||||
|
Some('a') => ("always", None),
|
||||||
|
Some('n') => ("reject", ans.split_once(':').map(|(_, m)| m.trim().to_string()).filter(|m| !m.is_empty())),
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
api.post(&format!("/permissions/{}", ev["id"].as_str().unwrap_or("")), json!({"reply": reply, "message": message})).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn answer_question(api: &Api, st: &Style, ev: &Value) -> Result<()> {
|
||||||
|
let mut answers = serde_json::Map::new();
|
||||||
|
for q in ev["questions"].as_array().into_iter().flatten() {
|
||||||
|
let text = q["question"].as_str().unwrap_or("");
|
||||||
|
println!("\n{} {}", st.accent("? Question"), st.bold(text));
|
||||||
|
let options: Vec<&str> = q["options"].as_array().into_iter().flatten().filter_map(|o| o["label"].as_str()).collect();
|
||||||
|
for (i, o) in options.iter().enumerate() {
|
||||||
|
println!(" {}. {o}", i + 1);
|
||||||
|
}
|
||||||
|
let Some(ans) = read_line(" answer (number or text) > ").await? else { return Ok(()) };
|
||||||
|
let value = ans.trim().parse::<usize>().ok().and_then(|n| options.get(n.wrapping_sub(1)).map(|s| s.to_string())).unwrap_or_else(|| ans.trim().to_string());
|
||||||
|
answers.insert(text.to_string(), Value::String(value));
|
||||||
|
}
|
||||||
|
api.post(&format!("/questions/{}", ev["id"].as_str().unwrap_or("")), json!({"answers": answers})).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream a session's events until it is idle. `wait_start`: the turn was just
|
||||||
|
/// submitted, so an initial "idle" state is not the end yet.
|
||||||
|
async fn follow(api: &Api, st: &Style, session: &str, wait_start: bool) -> Result<()> {
|
||||||
|
let ws_url = format!("{}/api/ws", api.base.replacen("http", "ws", 1));
|
||||||
|
let mut req = ws_url.into_client_request()?;
|
||||||
|
req.headers_mut().insert("Authorization", format!("Bearer {}", api.token).parse()?);
|
||||||
|
let (mut ws, _) = tokio_tungstenite::connect_async(req).await.context("cannot open the event stream")?;
|
||||||
|
|
||||||
|
// Requests that were already pending before we connected.
|
||||||
|
for ev in api.get("/pending").await?.as_array().into_iter().flatten() {
|
||||||
|
if ev["root_session_id"] == session || ev["session_id"] == session {
|
||||||
|
handle_interaction(api, st, ev).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut started = !wait_start || api.get(&format!("/sessions/{session}")).await?["busy"] == true;
|
||||||
|
let mut in_text = false;
|
||||||
|
let mut in_thinking = false;
|
||||||
|
let idle_check = tokio::time::sleep(std::time::Duration::from_secs(5));
|
||||||
|
tokio::pin!(idle_check);
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = ws.next() => {
|
||||||
|
let Some(msg) = msg else { break };
|
||||||
|
let text = match msg? {
|
||||||
|
WsMessage::Text(t) => t.to_string(),
|
||||||
|
WsMessage::Ping(p) => { ws.send(WsMessage::Pong(p)).await?; continue }
|
||||||
|
WsMessage::Close(_) => break,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let ev: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
|
||||||
|
let sid = ev["session_id"].as_str().or(ev["session"]["id"].as_str()).unwrap_or("");
|
||||||
|
let root = ev["root_session_id"].as_str().unwrap_or(sid);
|
||||||
|
let mine = sid == session;
|
||||||
|
match ev["type"].as_str().unwrap_or("") {
|
||||||
|
"text_delta" if mine => {
|
||||||
|
if in_thinking { println!(); in_thinking = false; }
|
||||||
|
print!("{}", ev["text"].as_str().unwrap_or(""));
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
in_text = true;
|
||||||
|
}
|
||||||
|
"reasoning_delta" if mine => {
|
||||||
|
if !in_thinking { print!("{}", st.dim("\n[thinking] ")); in_thinking = true; }
|
||||||
|
print!("{}", st.dim(ev["text"].as_str().unwrap_or("")));
|
||||||
|
std::io::stdout().flush()?;
|
||||||
|
}
|
||||||
|
"tool_started" if mine => {
|
||||||
|
if in_text || in_thinking { println!(); in_text = false; in_thinking = false; }
|
||||||
|
let input = &ev["input"];
|
||||||
|
let detail = input["description"].as_str().or(input["command"].as_str()).or(input["path"].as_str()).or(input["pattern"].as_str()).or(input["query"].as_str()).unwrap_or("");
|
||||||
|
println!("{} {} {}", st.accent("▸"), st.bold(ev["name"].as_str().unwrap_or("")), st.dim(detail));
|
||||||
|
}
|
||||||
|
"permission_request" | "question_request" if root == session || mine => {
|
||||||
|
if in_text { println!(); in_text = false; }
|
||||||
|
handle_interaction(api, st, &ev).await?;
|
||||||
|
}
|
||||||
|
"error" if mine => {
|
||||||
|
println!("\n{} {}", st.red("error:"), ev["message"].as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
"waiting" if mine => {
|
||||||
|
if let Some(r) = ev["reason"].as_str() { println!("{}", st.dim(&format!("… waiting ({r})"))); }
|
||||||
|
}
|
||||||
|
"session_status" if mine => {
|
||||||
|
if ev["busy"] == true { started = true; }
|
||||||
|
else if started { if in_text { println!(); } break; }
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ = &mut idle_check, if !started => {
|
||||||
|
// The turn may have finished before we connected.
|
||||||
|
if api.get(&format!("/sessions/{session}")).await?["busy"] != true { break; }
|
||||||
|
started = true;
|
||||||
|
}
|
||||||
|
_ = tokio::signal::ctrl_c() => {
|
||||||
|
println!("\n{}", st.dim("detached – the session keeps running (codecraft stop <id> to stop it)"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_interaction(api: &Api, st: &Style, ev: &Value) -> Result<()> {
|
||||||
|
if !std::io::stdin().is_terminal() {
|
||||||
|
println!("{}", st.accent("? waiting for an answer in the web UI (stdin is not a terminal)"));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match ev["type"].as_str() {
|
||||||
|
Some("permission_request") => answer_permission(api, st, ev).await,
|
||||||
|
Some("question_request") => answer_question(api, st, ev).await,
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn create_session(api: &Api, cwd: Option<String>, agent: &str, model: Option<String>, mode: Option<String>) -> Result<String> {
|
||||||
|
let s = api.post("/sessions", json!({"cwd": cwd, "agent": agent, "model": model, "permission_mode": mode})).await?;
|
||||||
|
s["id"].as_str().map(String::from).ok_or_else(|| anyhow!("unexpected answer from the server"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- commands ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let st = Style { on: std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none() };
|
||||||
|
if let Err(e) = run(cli, &st).await {
|
||||||
|
eprintln!("{} {e:#}", st.red("error:"));
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run(cli: Cli, st: &Style) -> Result<()> {
|
||||||
|
if let Cmd::Login { url, token } = &cli.cmd {
|
||||||
|
let api = Api::new(url.trim_end_matches('/').to_string(), token.clone());
|
||||||
|
let info = api.get("/info").await?;
|
||||||
|
let user = info["user"]["username"].as_str().ok_or_else(|| anyhow!("the token was not accepted"))?;
|
||||||
|
let path = config_path();
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap())?;
|
||||||
|
std::fs::write(&path, serde_json::to_string_pretty(&json!({"url": api.base, "token": token}))?)?;
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
|
||||||
|
}
|
||||||
|
println!("Logged in to {} as {} (CodeCraft {}). Saved to {}", api.base, st.bold(user), info["version"].as_str().unwrap_or("?"), path.display());
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Cmd::Logout = cli.cmd {
|
||||||
|
let _ = std::fs::remove_file(config_path());
|
||||||
|
println!("Logged out.");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let (url, token) = load_login(&cli)?;
|
||||||
|
let api = Api::new(url, token);
|
||||||
|
|
||||||
|
match cli.cmd {
|
||||||
|
Cmd::Login { .. } | Cmd::Logout => unreachable!(),
|
||||||
|
Cmd::Status => {
|
||||||
|
let info = api.get("/info").await?;
|
||||||
|
let sessions = api.get("/sessions").await?;
|
||||||
|
let busy: Vec<&Value> = sessions.as_array().into_iter().flatten().filter(|s| s["busy"] == true).collect();
|
||||||
|
let stats = api.get(&format!("/stats?from={}", month_start_ms())).await.unwrap_or(Value::Null);
|
||||||
|
println!("{} {} · {}", st.bold("CodeCraft"), info["version"].as_str().unwrap_or("?"), api.base);
|
||||||
|
println!("User: {}{}", info["user"]["username"].as_str().unwrap_or("?"), if info["user"]["role"] == "admin" { " (admin)" } else { "" });
|
||||||
|
println!("Running: {} session(s)", busy.len());
|
||||||
|
for s in busy {
|
||||||
|
println!(" {} {}", st.dim(short(s["id"].as_str().unwrap_or(""))), s["title"].as_str().unwrap_or(""));
|
||||||
|
}
|
||||||
|
if let Some(cost) = stats["total"]["cost"].as_f64() {
|
||||||
|
println!("30 days: {cost:.2} {} · {} requests", stats["currency"].as_str().unwrap_or(""), stats["total"]["requests"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Sessions { limit } => {
|
||||||
|
let list = api.get("/sessions").await?;
|
||||||
|
for s in list.as_array().into_iter().flatten().take(limit) {
|
||||||
|
let busy = if s["busy"] == true { st.accent(" ● running") } else { String::new() };
|
||||||
|
println!(
|
||||||
|
"{} {:<40} {}{}",
|
||||||
|
st.dim(short(s["id"].as_str().unwrap_or(""))),
|
||||||
|
truncate(s["title"].as_str().filter(|t| !t.is_empty()).unwrap_or("(untitled)"), 40),
|
||||||
|
st.dim(&format!("{} · {} · {}", s["agent"].as_str().unwrap_or(""), s["cwd"].as_str().unwrap_or(""), fmt_time(s["updated_at"].as_i64().unwrap_or(0)))),
|
||||||
|
busy
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Show { session } => {
|
||||||
|
let id = api.session_id(&session).await?;
|
||||||
|
let s = api.get(&format!("/sessions/{id}")).await?;
|
||||||
|
println!("{} {}", st.bold(s["session"]["title"].as_str().unwrap_or("")), st.dim(s["session"]["cwd"].as_str().unwrap_or("")));
|
||||||
|
for m in s["messages"].as_array().into_iter().flatten() {
|
||||||
|
for p in m["parts"].as_array().into_iter().flatten() {
|
||||||
|
match (m["role"].as_str(), p["type"].as_str()) {
|
||||||
|
(Some("user"), Some("text")) => println!("\n{} {}", st.accent("you ›"), p["text"].as_str().unwrap_or("")),
|
||||||
|
(_, Some("text")) => println!("\n{}", p["text"].as_str().unwrap_or("")),
|
||||||
|
(_, Some("tool_call")) => println!("{} {}", st.accent("▸"), st.bold(p["name"].as_str().unwrap_or(""))),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Export { session, format, output } => {
|
||||||
|
let id = api.session_id(&session).await?;
|
||||||
|
let resp = api.send(api.http.get(format!("{}/api/sessions/{id}/export?format={format}", api.base))).await?;
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
match output {
|
||||||
|
Some(p) => {
|
||||||
|
std::fs::write(&p, &bytes)?;
|
||||||
|
println!("Saved {}", p.display());
|
||||||
|
}
|
||||||
|
None => std::io::stdout().write_all(&bytes)?,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Run { prompt, cwd, agent, model, mode, effort, detach } => {
|
||||||
|
let id = create_session(&api, cwd, &agent, model, mode).await?;
|
||||||
|
api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?;
|
||||||
|
if detach {
|
||||||
|
println!("{id}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
println!("{}", st.dim(&format!("session {} · {}/#/s/{id}", short(&id), api.base)));
|
||||||
|
follow(&api, st, &id, true).await?;
|
||||||
|
}
|
||||||
|
Cmd::Send { session, prompt, effort } => {
|
||||||
|
let id = api.session_id(&session).await?;
|
||||||
|
api.post(&format!("/sessions/{id}/prompt"), json!({"text": prompt, "effort": effort})).await?;
|
||||||
|
follow(&api, st, &id, true).await?;
|
||||||
|
}
|
||||||
|
Cmd::Chat { session, cwd, agent, model } => {
|
||||||
|
let id = match session {
|
||||||
|
Some(s) => api.session_id(&s).await?,
|
||||||
|
None => create_session(&api, cwd, &agent, model, None).await?,
|
||||||
|
};
|
||||||
|
println!("{}", st.dim(&format!("chat in session {} – empty line or Ctrl+D to quit", short(&id))));
|
||||||
|
loop {
|
||||||
|
let Some(line) = read_line(&format!("\n{} ", st.accent("you ›"))).await? else { break };
|
||||||
|
if line.trim().is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
api.post(&format!("/sessions/{id}/prompt"), json!({"text": line})).await?;
|
||||||
|
follow(&api, st, &id, true).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Cmd::Attach { session } => {
|
||||||
|
let id = api.session_id(&session).await?;
|
||||||
|
follow(&api, st, &id, false).await?;
|
||||||
|
}
|
||||||
|
Cmd::Stop { session } => {
|
||||||
|
let id = api.session_id(&session).await?;
|
||||||
|
api.post(&format!("/sessions/{id}/abort"), json!({})).await?;
|
||||||
|
println!("Stopped {}.", short(&id));
|
||||||
|
}
|
||||||
|
Cmd::Jobs { cmd } => match cmd.unwrap_or(JobCmd::List) {
|
||||||
|
JobCmd::List => {
|
||||||
|
for j in api.get("/jobs").await?.as_array().into_iter().flatten() {
|
||||||
|
let on = if j["enabled"] == true { st.green("on ") } else { st.dim("off") };
|
||||||
|
println!(
|
||||||
|
"{} {} {:<30} {} {}",
|
||||||
|
st.dim(short(j["id"].as_str().unwrap_or(""))),
|
||||||
|
on,
|
||||||
|
truncate(j["spec"]["name"].as_str().unwrap_or("?"), 30),
|
||||||
|
j["spec"]["schedule"].as_str().unwrap_or("?"),
|
||||||
|
st.dim(&format!("last: {}", j["last_status"].as_str().unwrap_or("-")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JobCmd::Run { id } => {
|
||||||
|
let id = job_id(&api, &id).await?;
|
||||||
|
let r = api.post(&format!("/jobs/{id}/run"), json!({})).await?;
|
||||||
|
let sid = r["session_id"].as_str().unwrap_or("");
|
||||||
|
println!("{}", st.dim(&format!("started session {}", short(sid))));
|
||||||
|
follow(&api, st, sid, true).await?;
|
||||||
|
}
|
||||||
|
JobCmd::On { id } => {
|
||||||
|
let id = job_id(&api, &id).await?;
|
||||||
|
api.patch(&format!("/jobs/{id}"), json!({"enabled": true})).await?;
|
||||||
|
println!("Enabled.");
|
||||||
|
}
|
||||||
|
JobCmd::Off { id } => {
|
||||||
|
let id = job_id(&api, &id).await?;
|
||||||
|
api.patch(&format!("/jobs/{id}"), json!({"enabled": false})).await?;
|
||||||
|
println!("Disabled.");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Cmd::Files { cmd } => match cmd {
|
||||||
|
FileCmd::Ls { path } => {
|
||||||
|
let q = path.map(|p| format!("?path={}", urlencode(&p))).unwrap_or_default();
|
||||||
|
let l = api.get(&format!("/files/list{q}")).await?;
|
||||||
|
println!("{}", st.dim(l["path"].as_str().unwrap_or("")));
|
||||||
|
for e in l["entries"].as_array().into_iter().flatten() {
|
||||||
|
let name = e["name"].as_str().unwrap_or("");
|
||||||
|
if e["is_dir"] == true {
|
||||||
|
println!(" {}/", st.bold(name));
|
||||||
|
} else {
|
||||||
|
println!(" {name:<40} {}", st.dim(&human(e["size"].as_u64().unwrap_or(0))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FileCmd::Get { path, output } => {
|
||||||
|
let resp = api.send(api.http.get(format!("{}/api/files/raw?path={}&download=true", api.base, urlencode(&path)))).await?;
|
||||||
|
let bytes = resp.bytes().await?;
|
||||||
|
let out = output.unwrap_or_else(|| PathBuf::from(path.rsplit('/').next().unwrap_or("download")));
|
||||||
|
std::fs::write(&out, &bytes)?;
|
||||||
|
println!("Saved {} ({})", out.display(), human(bytes.len() as u64));
|
||||||
|
}
|
||||||
|
FileCmd::Put { local, remote } => {
|
||||||
|
let data = tokio::fs::read(&local).await.with_context(|| format!("cannot read {}", local.display()))?;
|
||||||
|
let remote = if remote.ends_with('/') { format!("{remote}{}", local.file_name().and_then(|n| n.to_str()).unwrap_or("upload")) } else { remote };
|
||||||
|
const CHUNK: usize = 8 * 1024 * 1024;
|
||||||
|
let mut offset = 0usize;
|
||||||
|
loop {
|
||||||
|
let end = (offset + CHUNK).min(data.len());
|
||||||
|
let url = format!("{}/api/files/upload?path={}&offset={offset}", api.base, urlencode(&remote));
|
||||||
|
api.send(api.http.put(url).body(data[offset..end].to_vec())).await?;
|
||||||
|
offset = end;
|
||||||
|
if std::io::stderr().is_terminal() {
|
||||||
|
eprint!("\r{} / {}", human(offset as u64), human(data.len() as u64));
|
||||||
|
}
|
||||||
|
if offset >= data.len() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eprintln!();
|
||||||
|
println!("Uploaded to {remote}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Cmd::Costs { days } => {
|
||||||
|
let from = now_ms() - days as i64 * 86_400_000;
|
||||||
|
let s = api.get(&format!("/stats?from={from}")).await?;
|
||||||
|
let cur = s["currency"].as_str().unwrap_or("");
|
||||||
|
let t = &s["total"];
|
||||||
|
println!("{} (last {days} days)", st.bold("Costs"));
|
||||||
|
println!(" total {:.2} {cur}", t["cost"].as_f64().unwrap_or(0.0));
|
||||||
|
println!(" requests {}", t["requests"]);
|
||||||
|
println!(" tokens {} in · {} out", t["input_tokens"], t["output_tokens"]);
|
||||||
|
if let Some(models) = s["by_model"].as_array() {
|
||||||
|
println!(" {}", st.dim("by model:"));
|
||||||
|
for m in models {
|
||||||
|
println!(" {:<40} {:>8.2} {cur}", truncate(m["key"].as_str().or(m["model"].as_str()).unwrap_or("?"), 40), m["cost"].as_f64().unwrap_or(0.0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn job_id(api: &Api, id: &str) -> Result<String> {
|
||||||
|
if id.len() >= 24 {
|
||||||
|
return Ok(id.to_string());
|
||||||
|
}
|
||||||
|
let hits: Vec<String> = api.get("/jobs").await?.as_array().into_iter().flatten().filter_map(|j| j["id"].as_str()).filter(|j| j.ends_with(id)).map(String::from).collect();
|
||||||
|
match hits.len() {
|
||||||
|
1 => Ok(hits[0].clone()),
|
||||||
|
0 => bail!("no automation matches '{id}'"),
|
||||||
|
_ => bail!("'{id}' is ambiguous"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn month_start_ms() -> i64 {
|
||||||
|
now_ms() - 30 * 86_400_000
|
||||||
|
}
|
||||||
|
|
||||||
|
fn truncate(s: &str, n: usize) -> String {
|
||||||
|
if s.chars().count() <= n { s.to_string() } else { format!("{}…", s.chars().take(n - 1).collect::<String>()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn human(n: u64) -> String {
|
||||||
|
match n {
|
||||||
|
n if n < 1024 => format!("{n} B"),
|
||||||
|
n if n < 1024 * 1024 => format!("{:.1} KB", n as f64 / 1024.0),
|
||||||
|
n if n < 1024 * 1024 * 1024 => format!("{:.1} MB", n as f64 / 1048576.0),
|
||||||
|
n => format!("{:.1} GB", n as f64 / 1073741824.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn urlencode(s: &str) -> String {
|
||||||
|
s.bytes()
|
||||||
|
.map(|b| match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' | b'/' => (b as char).to_string(),
|
||||||
|
_ => format!("%{b:02X}"),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[package]
|
||||||
|
name = "codecraft-core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Shared core of CodeCraft: data types, model providers, permission rules"
|
||||||
|
license = "MIT"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
async-trait = "0.1"
|
||||||
|
eventsource-stream = "0.2"
|
||||||
|
futures = "0.3"
|
||||||
|
globset = "0.4"
|
||||||
|
regex = "1"
|
||||||
|
reqwest = { version = "0.13", default-features = false, features = ["json", "stream", "rustls"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
shell-words = "1"
|
||||||
|
tokio = { version = "1", features = ["sync", "time", "macros", "rt"] }
|
||||||
|
tokio-util = "0.7"
|
||||||
|
uuid = { version = "1", features = ["v4", "v7"] }
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
//! Provider, model and permission configuration shared by server and CLI.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ProviderKind {
|
||||||
|
Anthropic,
|
||||||
|
Openai,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
pub struct ProviderConfig {
|
||||||
|
pub kind: ProviderKind,
|
||||||
|
#[serde(default)]
|
||||||
|
pub base_url: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub api_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_header: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub auth_prefix: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub models: Vec<ModelEntry>,
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub enabled: bool,
|
||||||
|
/// Max. simultaneous requests to this provider/host (None = unlimited).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_concurrency: Option<usize>,
|
||||||
|
/// Monthly budget in the configured currency (tracked from recorded costs).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub monthly_budget: Option<f64>,
|
||||||
|
/// Optional admin key to read the provider's own cost report
|
||||||
|
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
||||||
|
#[serde(default, skip_serializing_if = "String::is_empty")]
|
||||||
|
pub admin_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum ModelEntry {
|
||||||
|
Id(String),
|
||||||
|
Full(ModelConfig),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct ModelConfig {
|
||||||
|
pub id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub context: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub max_output: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub reasoning: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub vision: bool,
|
||||||
|
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
|
||||||
|
#[serde(default)]
|
||||||
|
pub effort: Option<String>,
|
||||||
|
/// Prices in currency units per 1M tokens (used for cost statistics).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub input_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub output_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_read_price: Option<f64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cache_write_price: Option<f64>,
|
||||||
|
/// Max. simultaneous requests to this model (None = unlimited).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_concurrency: Option<usize>,
|
||||||
|
/// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens).
|
||||||
|
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
||||||
|
pub options: serde_json::Map<String, serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelConfig {
|
||||||
|
/// Cost of one request. Cache prices default to the input price.
|
||||||
|
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
||||||
|
let inp = self.input_price.unwrap_or(0.0);
|
||||||
|
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
||||||
|
per(u.input_tokens, inp)
|
||||||
|
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
||||||
|
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
||||||
|
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModelEntry {
|
||||||
|
pub fn resolved(&self) -> ModelConfig {
|
||||||
|
match self {
|
||||||
|
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
|
||||||
|
ModelEntry::Full(m) => m.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
|
pub struct PermissionConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub bash_rules: BTreeMap<String, String>,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub tools: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace `${NAME}` with the environment variable (empty if unset).
|
||||||
|
pub fn expand_env(s: &str) -> String {
|
||||||
|
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
||||||
|
re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default())
|
||||||
|
.into_owned()
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//! Shared core of CodeCraft (server and command line client): message types,
|
||||||
|
//! model providers (Anthropic, OpenAI-compatible) and permission rules.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod permission;
|
||||||
|
pub mod provider;
|
||||||
|
pub mod types;
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
//! Permission evaluation: config rules, session permission modes and
|
||||||
|
//! "always allow" approvals given in the UI.
|
||||||
|
|
||||||
|
use crate::config::PermissionConfig;
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Decision {
|
||||||
|
Allow,
|
||||||
|
Ask,
|
||||||
|
Deny,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse(s: &str) -> Option<Decision> {
|
||||||
|
match s {
|
||||||
|
"allow" => Some(Decision::Allow),
|
||||||
|
"ask" => Some(Decision::Ask),
|
||||||
|
"deny" => Some(Decision::Deny),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn glob_match(pattern: &str, text: &str) -> bool {
|
||||||
|
globset::GlobBuilder::new(pattern)
|
||||||
|
.literal_separator(false)
|
||||||
|
.build()
|
||||||
|
.map(|g| g.compile_matcher().is_match(text))
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a shell command into simple commands on && || ; | and newlines.
|
||||||
|
/// Returns None when the command contains constructs we cannot judge safely
|
||||||
|
/// (command substitution, backticks).
|
||||||
|
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
|
||||||
|
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
let mut cur = String::new();
|
||||||
|
let mut chars = cmd.chars().peekable();
|
||||||
|
let (mut sq, mut dq) = (false, false);
|
||||||
|
while let Some(c) = chars.next() {
|
||||||
|
match c {
|
||||||
|
'\'' if !dq => {
|
||||||
|
sq = !sq;
|
||||||
|
cur.push(c)
|
||||||
|
}
|
||||||
|
'"' if !sq => {
|
||||||
|
dq = !dq;
|
||||||
|
cur.push(c)
|
||||||
|
}
|
||||||
|
'\\' if !sq => {
|
||||||
|
cur.push(c);
|
||||||
|
if let Some(n) = chars.next() {
|
||||||
|
cur.push(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
';' | '\n' | '|' | '&' if !sq && !dq => {
|
||||||
|
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
|
||||||
|
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
|
||||||
|
chars.next();
|
||||||
|
}
|
||||||
|
// Redirections like 2>&1 are not separators.
|
||||||
|
if c == '&' && cur.ends_with('>') {
|
||||||
|
cur.push(c);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
parts.push(std::mem::take(&mut cur));
|
||||||
|
}
|
||||||
|
_ => cur.push(c),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(cur);
|
||||||
|
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix stored for "always allow" on a bash command: the first two words.
|
||||||
|
pub fn bash_prefix(cmd: &str) -> String {
|
||||||
|
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Session permission modes.
|
||||||
|
pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"];
|
||||||
|
|
||||||
|
/// Commands that only read. Checked per simple command (after splitting).
|
||||||
|
const SAFE_COMMANDS: &[&str] = &[
|
||||||
|
"ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat",
|
||||||
|
"du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename",
|
||||||
|
"dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps",
|
||||||
|
"jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more",
|
||||||
|
];
|
||||||
|
|
||||||
|
const SAFE_GIT: &[&str] = &[
|
||||||
|
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Strip harmless redirections so they don't count as file writes.
|
||||||
|
fn strip_redirections(cmd: &str) -> String {
|
||||||
|
cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A simple command that only reads.
|
||||||
|
pub fn is_safe_command(part: &str) -> bool {
|
||||||
|
let cleaned = strip_redirections(part);
|
||||||
|
if cleaned.contains('>') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let words: Vec<String> = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect());
|
||||||
|
// Leading VAR=value assignments are not safe (could change behaviour); keep it strict.
|
||||||
|
let Some(cmd) = words.first() else { return true };
|
||||||
|
let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
|
||||||
|
if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match cmd.as_str() {
|
||||||
|
c if SAFE_COMMANDS.contains(&c) => true,
|
||||||
|
"find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")),
|
||||||
|
"sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")),
|
||||||
|
"git" => match args.first() {
|
||||||
|
Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"),
|
||||||
|
Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")),
|
||||||
|
Some(&"stash") => args.get(1) == Some(&"list"),
|
||||||
|
Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"),
|
||||||
|
Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"),
|
||||||
|
_ => false,
|
||||||
|
},
|
||||||
|
"cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")),
|
||||||
|
"npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")),
|
||||||
|
"pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commands that always need confirmation unless everything is allowed.
|
||||||
|
pub fn is_dangerous(cmd: &str) -> bool {
|
||||||
|
let c = format!(" {} ", cmd.replace('\n', " "));
|
||||||
|
const PATTERNS: &[&str] = &[
|
||||||
|
" rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean",
|
||||||
|
" git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ",
|
||||||
|
" mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ",
|
||||||
|
" systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ",
|
||||||
|
];
|
||||||
|
PATTERNS.iter().any(|p| c.contains(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the rules need to know about a tool.
|
||||||
|
pub trait ToolInfo {
|
||||||
|
fn name(&self) -> &str;
|
||||||
|
fn read_only(&self) -> bool;
|
||||||
|
/// Changes files (write, edit …): auto-approved in accept_edits/auto mode.
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Evaluator<'a> {
|
||||||
|
pub cfg: &'a PermissionConfig,
|
||||||
|
pub mode: &'a str,
|
||||||
|
/// Session approvals: tool names, or "bash:<prefix>".
|
||||||
|
pub approved: &'a HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Evaluator<'_> {
|
||||||
|
pub fn evaluate<T: ToolInfo + ?Sized>(&self, tool: &T, input: &Value) -> Decision {
|
||||||
|
let name = tool.name();
|
||||||
|
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
|
||||||
|
|
||||||
|
if name == "bash" {
|
||||||
|
let cmd = input["command"].as_str().unwrap_or("");
|
||||||
|
let bash = self.bash_decision(cmd);
|
||||||
|
if bash == Some(Decision::Deny) || base == Decision::Deny {
|
||||||
|
return Decision::Deny;
|
||||||
|
}
|
||||||
|
if self.mode == "bypass" {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
if let Some(d) = bash {
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
// Read-only commands never need confirmation.
|
||||||
|
if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
if base == Decision::Deny {
|
||||||
|
return Decision::Deny;
|
||||||
|
}
|
||||||
|
if self.mode == "bypass" || self.approved.contains(name) {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
// In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...).
|
||||||
|
if self.mode == "auto" && tool.read_only() {
|
||||||
|
return Decision::Allow;
|
||||||
|
}
|
||||||
|
base
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_rule(&self, name: &str) -> Option<Decision> {
|
||||||
|
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
|
||||||
|
return Some(d);
|
||||||
|
}
|
||||||
|
// Glob rules, most specific (longest) first.
|
||||||
|
let mut globs: Vec<(&String, &String)> =
|
||||||
|
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
|
||||||
|
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
|
||||||
|
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
|
||||||
|
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
|
||||||
|
// Deny rules are checked against the full command as well as each part.
|
||||||
|
let deny_hit = |text: &str| {
|
||||||
|
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
|
||||||
|
};
|
||||||
|
if deny_hit(cmd.trim()) {
|
||||||
|
return Some(Decision::Deny);
|
||||||
|
}
|
||||||
|
let parts = split_command(cmd)?;
|
||||||
|
if parts.iter().any(|p| deny_hit(p)) {
|
||||||
|
return Some(Decision::Deny);
|
||||||
|
}
|
||||||
|
let all_allowed = !parts.is_empty()
|
||||||
|
&& parts.iter().all(|p| {
|
||||||
|
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|
||||||
|
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
|
||||||
|
});
|
||||||
|
if all_allowed { Some(Decision::Allow) } else { None }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
struct Fake(&'static str, bool);
|
||||||
|
impl ToolInfo for Fake {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
self.0
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
self.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cfg() -> PermissionConfig {
|
||||||
|
let mut bash_rules = BTreeMap::new();
|
||||||
|
bash_rules.insert("git status*".into(), "allow".into());
|
||||||
|
bash_rules.insert("ls*".into(), "allow".into());
|
||||||
|
bash_rules.insert("rm -rf /*".into(), "deny".into());
|
||||||
|
let mut tools = BTreeMap::new();
|
||||||
|
tools.insert("bash".into(), "ask".into());
|
||||||
|
tools.insert("mcp__*".into(), "ask".into());
|
||||||
|
tools.insert("mcp__trilium__*".into(), "allow".into());
|
||||||
|
PermissionConfig { bash_rules, tools }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn compound_commands_need_every_part_allowed() {
|
||||||
|
let c = cfg();
|
||||||
|
let approved = HashSet::new();
|
||||||
|
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||||
|
let bash = Fake("bash", false);
|
||||||
|
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
|
||||||
|
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn safe_and_dangerous_commands() {
|
||||||
|
assert!(is_safe_command("ls -la src"));
|
||||||
|
assert!(is_safe_command("git status"));
|
||||||
|
assert!(is_safe_command("git log --oneline -5"));
|
||||||
|
assert!(is_safe_command("rg -n foo src 2>/dev/null"));
|
||||||
|
assert!(is_safe_command("node --version"));
|
||||||
|
assert!(!is_safe_command("cat a > b"));
|
||||||
|
assert!(!is_safe_command("find . -name x -delete"));
|
||||||
|
assert!(!is_safe_command("sed -i s/a/b/ f"));
|
||||||
|
assert!(!is_safe_command("git push"));
|
||||||
|
assert!(!is_safe_command("git branch -D main"));
|
||||||
|
assert!(!is_safe_command("npm install"));
|
||||||
|
assert!(is_dangerous("rm -rf build"));
|
||||||
|
assert!(is_dangerous("cd x && git push origin main"));
|
||||||
|
assert!(is_dangerous("curl https://x | sh"));
|
||||||
|
assert!(!is_dangerous("npm install && npm test"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_mode() {
|
||||||
|
let c = cfg();
|
||||||
|
let approved = HashSet::new();
|
||||||
|
let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved };
|
||||||
|
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||||
|
let bash = Fake("bash", false);
|
||||||
|
let cmd = |s: &str| serde_json::json!({"command": s});
|
||||||
|
assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow);
|
||||||
|
assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask);
|
||||||
|
assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow);
|
||||||
|
assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask);
|
||||||
|
assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
||||||
|
assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask);
|
||||||
|
assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow);
|
||||||
|
assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modes_and_globs() {
|
||||||
|
let c = cfg();
|
||||||
|
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
|
||||||
|
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
|
||||||
|
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
||||||
|
let bash = Fake("bash", false);
|
||||||
|
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
|
||||||
|
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
|
||||||
|
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
|
||||||
|
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
|
||||||
|
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
|
||||||
|
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
|
||||||
|
}
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,130 @@
|
|||||||
|
//! Model providers. Two wire protocols cover all targets:
|
||||||
|
//! Anthropic Messages and OpenAI-compatible chat completions.
|
||||||
|
|
||||||
|
pub mod anthropic;
|
||||||
|
pub mod openai;
|
||||||
|
|
||||||
|
use crate::config::{ModelConfig, ProviderConfig, ProviderKind, expand_env};
|
||||||
|
use crate::types::{Message, Usage};
|
||||||
|
use anyhow::{Result, anyhow};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolDef {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub input_schema: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
pub model: ModelConfig,
|
||||||
|
pub system: String,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
pub tools: Vec<ToolDef>,
|
||||||
|
pub max_tokens: u64,
|
||||||
|
/// Enable extended thinking / reasoning when the model supports it.
|
||||||
|
pub reasoning: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum StreamEvent {
|
||||||
|
TextDelta(String),
|
||||||
|
ReasoningDelta(String),
|
||||||
|
ReasoningSignature(String),
|
||||||
|
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
|
||||||
|
Usage(Usage),
|
||||||
|
Done { stop_reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait Provider: Send + Sync {
|
||||||
|
async fn stream(
|
||||||
|
&self,
|
||||||
|
req: ChatRequest,
|
||||||
|
tx: mpsc::Sender<StreamEvent>,
|
||||||
|
cancel: CancellationToken,
|
||||||
|
) -> Result<()>;
|
||||||
|
|
||||||
|
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connection settings shared by both protocol implementations.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Endpoint {
|
||||||
|
pub base_url: String,
|
||||||
|
pub api_key: String,
|
||||||
|
pub auth_header: String,
|
||||||
|
pub auth_prefix: String,
|
||||||
|
pub headers: BTreeMap<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Endpoint {
|
||||||
|
pub fn from_config(p: &ProviderConfig) -> Self {
|
||||||
|
let (default_header, default_prefix) = match p.kind {
|
||||||
|
ProviderKind::Anthropic => ("x-api-key", ""),
|
||||||
|
ProviderKind::Openai => ("Authorization", "Bearer "),
|
||||||
|
};
|
||||||
|
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
|
||||||
|
"https://api.anthropic.com/v1".to_string()
|
||||||
|
} else {
|
||||||
|
expand_env(&p.base_url)
|
||||||
|
};
|
||||||
|
Self {
|
||||||
|
base_url: base.trim_end_matches('/').to_string(),
|
||||||
|
api_key: expand_env(&p.api_key),
|
||||||
|
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
|
||||||
|
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
|
||||||
|
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
||||||
|
if !self.api_key.is_empty() {
|
||||||
|
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
||||||
|
}
|
||||||
|
for (k, v) in &self.headers {
|
||||||
|
rb = rb.header(k, v);
|
||||||
|
}
|
||||||
|
rb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared HTTP client for model requests.
|
||||||
|
pub fn http_client() -> reqwest::Client {
|
||||||
|
reqwest::Client::builder()
|
||||||
|
.connect_timeout(std::time::Duration::from_secs(20))
|
||||||
|
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
|
||||||
|
.build()
|
||||||
|
.expect("http client")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create the provider for a provider configuration.
|
||||||
|
pub fn build(client: reqwest::Client, pc: &ProviderConfig) -> std::sync::Arc<dyn Provider> {
|
||||||
|
let ep = Endpoint::from_config(pc);
|
||||||
|
match pc.kind {
|
||||||
|
ProviderKind::Anthropic => std::sync::Arc::new(anthropic::Anthropic::new(client, ep)),
|
||||||
|
ProviderKind::Openai => std::sync::Arc::new(openai::OpenAi::new(client, ep)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn an HTTP error response into a readable error.
|
||||||
|
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
|
||||||
|
let status = resp.status();
|
||||||
|
let body = resp.text().await.unwrap_or_default();
|
||||||
|
let detail = serde_json::from_str::<Value>(&body)
|
||||||
|
.ok()
|
||||||
|
.and_then(|v| {
|
||||||
|
v.pointer("/error/message")
|
||||||
|
.or_else(|| v.pointer("/0/error/message"))
|
||||||
|
.or_else(|| v.get("message"))
|
||||||
|
.and_then(|m| m.as_str().map(String::from))
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| body.chars().take(500).collect());
|
||||||
|
anyhow!("provider returned HTTP {status}: {detail}")
|
||||||
|
}
|
||||||
File renamed without changes.
@@ -0,0 +1,222 @@
|
|||||||
|
//! Provider-neutral message model and the event stream sent to clients.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Role {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct ImageData {
|
||||||
|
pub media_type: String,
|
||||||
|
/// Base64-encoded bytes.
|
||||||
|
pub data: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Part {
|
||||||
|
Text {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Image {
|
||||||
|
image: ImageData,
|
||||||
|
},
|
||||||
|
Reasoning {
|
||||||
|
text: String,
|
||||||
|
/// Provider-specific signature needed to replay thinking blocks (Anthropic).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
signature: Option<String>,
|
||||||
|
},
|
||||||
|
ToolCall {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
/// Opaque provider data that must be echoed back (e.g. Gemini thought signatures).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
extra: Option<Value>,
|
||||||
|
},
|
||||||
|
ToolResult {
|
||||||
|
call_id: String,
|
||||||
|
/// Tool name, kept for providers that need it (Gemini via OpenAI compat) and the UI.
|
||||||
|
#[serde(default)]
|
||||||
|
name: String,
|
||||||
|
content: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
images: Vec<ImageData>,
|
||||||
|
#[serde(default)]
|
||||||
|
is_error: bool,
|
||||||
|
/// Structured data for the UI (diffs, todo lists, ...). Never sent to the model.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
meta: Option<Value>,
|
||||||
|
},
|
||||||
|
/// Marks the start of a compacted history; the text is the summary.
|
||||||
|
Summary {
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub id: String,
|
||||||
|
pub role: Role,
|
||||||
|
pub parts: Vec<Part>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub model: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub usage: Option<Usage>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Message {
|
||||||
|
pub fn new(role: Role, parts: Vec<Part>) -> Self {
|
||||||
|
Self {
|
||||||
|
id: new_id(),
|
||||||
|
role,
|
||||||
|
parts,
|
||||||
|
model: None,
|
||||||
|
usage: None,
|
||||||
|
created_at: now_ms(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Usage {
|
||||||
|
pub input_tokens: u64,
|
||||||
|
pub output_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_read_tokens: u64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub cache_write_tokens: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Usage {
|
||||||
|
/// Total tokens of context the last request consumed.
|
||||||
|
pub fn context_tokens(&self) -> u64 {
|
||||||
|
self.input_tokens + self.cache_read_tokens + self.cache_write_tokens + self.output_tokens
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub cwd: String,
|
||||||
|
/// "provider/model"
|
||||||
|
pub model: String,
|
||||||
|
/// Active agent mode (build, plan, craft, ...).
|
||||||
|
pub agent: String,
|
||||||
|
/// Permission mode: default | accept_edits | bypass
|
||||||
|
pub permission_mode: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_id: Option<String>,
|
||||||
|
/// User who owns the session (subagent sessions inherit it).
|
||||||
|
#[serde(default)]
|
||||||
|
pub owner_id: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub todos: Value,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_usage: Option<Usage>,
|
||||||
|
/// Craft mode: clean room (true) or deep (false); None = server default.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub clean_room: Option<bool>,
|
||||||
|
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phase: Option<String>,
|
||||||
|
/// Isolated git worktree of this session: {repo, path, branch, base}.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub worktree: Option<Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn clean_room(&self, default: bool) -> bool {
|
||||||
|
self.clean_room.unwrap_or(default)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Question forwarded from an agent (or an MCP server) to the user.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Question {
|
||||||
|
pub question: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub header: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub options: Vec<QuestionOption>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub multi_select: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct QuestionOption {
|
||||||
|
pub label: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Events broadcast to all connected clients. Clients filter by session id.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum Event {
|
||||||
|
SessionUpdated { session: Session },
|
||||||
|
SessionDeleted { session_id: String },
|
||||||
|
SessionStatus { session_id: String, busy: bool },
|
||||||
|
MessageAdded { session_id: String, message: Message },
|
||||||
|
/// Streaming deltas for the assistant message currently being generated.
|
||||||
|
TextDelta { session_id: String, message_id: String, text: String },
|
||||||
|
ReasoningDelta { session_id: String, message_id: String, text: String },
|
||||||
|
ToolStarted { session_id: String, message_id: String, call_id: String, name: String, input: Value },
|
||||||
|
ToolProgress { session_id: String, call_id: String, output: String },
|
||||||
|
PermissionRequest { id: String, session_id: String, root_session_id: String, tool: String, title: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] diff: Option<String> },
|
||||||
|
PermissionResolved { id: String, session_id: String },
|
||||||
|
QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec<Question> },
|
||||||
|
QuestionResolved { id: String, session_id: String },
|
||||||
|
Error { session_id: String, message: String },
|
||||||
|
/// The session waits for a free model or subagent slot (reason None = no longer waiting).
|
||||||
|
/// reason: "model:<provider/model>" or "subagent:<name>".
|
||||||
|
Waiting { session_id: String, reason: Option<String> },
|
||||||
|
McpStatus { servers: Value },
|
||||||
|
/// A model request started: timing and prompt size for the activity line.
|
||||||
|
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
||||||
|
/// Messages waiting for a busy session (full list).
|
||||||
|
QueueUpdated { session_id: String, items: Value },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Event {
|
||||||
|
pub fn session_id(&self) -> Option<&str> {
|
||||||
|
use Event::*;
|
||||||
|
match self {
|
||||||
|
SessionUpdated { session } => Some(&session.id),
|
||||||
|
SessionDeleted { session_id }
|
||||||
|
| SessionStatus { session_id, .. }
|
||||||
|
| MessageAdded { session_id, .. }
|
||||||
|
| TextDelta { session_id, .. }
|
||||||
|
| ReasoningDelta { session_id, .. }
|
||||||
|
| ToolStarted { session_id, .. }
|
||||||
|
| ToolProgress { session_id, .. }
|
||||||
|
| PermissionRequest { session_id, .. }
|
||||||
|
| PermissionResolved { session_id, .. }
|
||||||
|
| QuestionRequest { session_id, .. }
|
||||||
|
| QuestionResolved { session_id, .. }
|
||||||
|
| Error { session_id, .. }
|
||||||
|
| Waiting { session_id, .. }
|
||||||
|
| QueueUpdated { session_id, .. }
|
||||||
|
| StepStarted { session_id, .. } => Some(session_id),
|
||||||
|
McpStatus { .. } => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_id() -> String {
|
||||||
|
uuid::Uuid::now_v7().simple().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn now_ms() -> i64 {
|
||||||
|
chrono::Utc::now().timestamp_millis()
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ edition = "2024"
|
|||||||
description = "Web-based coding harness for AI models"
|
description = "Web-based coding harness for AI models"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
codecraft-core = { path = "../core" }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
argon2 = "0.6"
|
argon2 = "0.6"
|
||||||
sha2 = "0.11"
|
sha2 = "0.11"
|
||||||
|
|||||||
+10
-1
@@ -20,7 +20,7 @@ use serde_json::{Map, Value, json};
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
type AppState = Arc<App>;
|
pub type AppState = Arc<App>;
|
||||||
|
|
||||||
pub struct ApiError(pub StatusCode, pub String);
|
pub struct ApiError(pub StatusCode, pub String);
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ pub fn router(app: AppState) -> Router {
|
|||||||
// public
|
// public
|
||||||
.route("/info", get(auth::info))
|
.route("/info", get(auth::info))
|
||||||
.route("/auth/login", post(auth::login))
|
.route("/auth/login", post(auth::login))
|
||||||
|
.route("/auth/cli-login", post(auth::cli_login))
|
||||||
.route("/auth/logout", post(auth::logout))
|
.route("/auth/logout", post(auth::logout))
|
||||||
.route("/auth/setup", post(auth::setup))
|
.route("/auth/setup", post(auth::setup))
|
||||||
.route("/auth/oidc/start", get(auth::oidc_start))
|
.route("/auth/oidc/start", get(auth::oidc_start))
|
||||||
@@ -125,6 +126,14 @@ pub fn router(app: AppState) -> Router {
|
|||||||
.route("/images/{id}", delete(delete_image))
|
.route("/images/{id}", delete(delete_image))
|
||||||
.route("/images/{id}/file", get(image_file))
|
.route("/images/{id}/file", get(image_file))
|
||||||
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
.route("/account/git", get(get_git_settings).put(put_git_settings))
|
||||||
|
.route("/account/prefs", get(crate::cli_api::get_prefs).put(crate::cli_api::put_prefs))
|
||||||
|
.route("/account/memory", get(crate::cli_api::get_memory).put(crate::cli_api::put_memory))
|
||||||
|
.route("/cli/config", get(crate::cli_api::config))
|
||||||
|
.route("/cli/skill/{name}", get(crate::cli_api::skill))
|
||||||
|
.route("/cli/tool", post(crate::cli_api::tool))
|
||||||
|
.route("/llm/stream", post(crate::cli_api::llm_stream))
|
||||||
|
.route("/account/tokens", get(auth::list_api_tokens).post(auth::create_api_token))
|
||||||
|
.route("/account/tokens/{id}", delete(auth::delete_api_token))
|
||||||
.route("/jobs", get(list_jobs).post(register_job))
|
.route("/jobs", get(list_jobs).post(register_job))
|
||||||
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
.route("/jobs/{id}", axum::routing::patch(update_job).delete(delete_job))
|
||||||
.route("/jobs/{id}/run", post(run_job))
|
.route("/jobs/{id}/run", post(run_job))
|
||||||
|
|||||||
+92
-2
@@ -133,12 +133,57 @@ fn record_failure(app: &App, keys: &[String]) {
|
|||||||
|
|
||||||
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
pub fn current_user(app: &App, headers: &HeaderMap) -> Option<User> {
|
||||||
let token = token_from_headers(headers)?;
|
let token = token_from_headers(headers)?;
|
||||||
app.store.user_by_token(&sha256_hex(&token)).ok().flatten()
|
let hash = sha256_hex(&token);
|
||||||
|
if token.starts_with(API_TOKEN_PREFIX) {
|
||||||
|
return app.store.user_by_api_token(&hash).ok().flatten();
|
||||||
|
}
|
||||||
|
app.store.user_by_token(&hash).ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Prefix of personal API tokens (CLI, scripts).
|
||||||
|
pub const API_TOKEN_PREFIX: &str = "cc_";
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct NewApiToken {
|
||||||
|
name: String,
|
||||||
|
/// Days until it expires; None = never.
|
||||||
|
days: Option<u32>,
|
||||||
|
/// "full" (default) or "read" (GET requests only).
|
||||||
|
scope: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_api_tokens(Extension(user): Extension<User>, State(app): State<Arc<App>>) -> ApiResult {
|
||||||
|
Ok(Json(serde_json::json!(app.store.api_tokens(&user.id)?)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a token; the plain value is returned once and only stored hashed.
|
||||||
|
pub async fn create_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Json(b): Json<NewApiToken>) -> ApiResult {
|
||||||
|
let name = b.name.trim();
|
||||||
|
if name.is_empty() || name.len() > 80 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "the token needs a name (max. 80 characters)".into()));
|
||||||
|
}
|
||||||
|
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||||||
|
let prefix: String = token.chars().take(10).collect();
|
||||||
|
let expires = b.days.filter(|d| *d > 0).map(|d| crate::types::now_ms() + d as i64 * 86_400_000);
|
||||||
|
let scope = match b.scope.as_deref() {
|
||||||
|
Some("read") => "read",
|
||||||
|
None | Some("full") => "full",
|
||||||
|
Some(other) => return Err(ApiError(StatusCode::BAD_REQUEST, format!("invalid scope '{other}'"))),
|
||||||
|
};
|
||||||
|
let id = app.store.api_token_add(&user.id, name, &sha256_hex(&token), &prefix, expires, scope)?;
|
||||||
|
app.audit(Some(&user), "api_token.create", format!("{name} ({scope})"), &client_ip(&headers));
|
||||||
|
Ok(Json(serde_json::json!({"id": id, "name": name, "token": token, "prefix": prefix, "expires_at": expires, "scope": scope})))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_api_token(Extension(user): Extension<User>, State(app): State<Arc<App>>, headers: HeaderMap, Path(id): Path<String>) -> ApiResult {
|
||||||
|
let ok = app.store.api_token_delete(&user.id, &id)?;
|
||||||
|
app.audit(Some(&user), "api_token.delete", &id, &client_ip(&headers));
|
||||||
|
Ok(Json(serde_json::json!({"ok": ok})))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paths reachable without a login.
|
/// Paths reachable without a login.
|
||||||
fn is_public(path: &str) -> bool {
|
fn is_public(path: &str) -> bool {
|
||||||
["/info", "/health", "/auth/login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
["/info", "/health", "/auth/login", "/auth/cli-login", "/auth/setup", "/auth/logout", "/auth/oidc/start", "/auth/oidc/callback"]
|
||||||
.iter()
|
.iter()
|
||||||
.any(|p| path.ends_with(p))
|
.any(|p| path.ends_with(p))
|
||||||
}
|
}
|
||||||
@@ -149,6 +194,17 @@ pub async fn require_user(State(app): State<Arc<App>>, mut req: Request, next: N
|
|||||||
}
|
}
|
||||||
match current_user(&app, req.headers()) {
|
match current_user(&app, req.headers()) {
|
||||||
Some(user) => {
|
Some(user) => {
|
||||||
|
// Read-only API tokens: no state-changing requests.
|
||||||
|
if let Some(token) = token_from_headers(req.headers()).filter(|t| t.starts_with(API_TOKEN_PREFIX)) {
|
||||||
|
let read_only = app.store.api_token_scope(&sha256_hex(&token)).as_deref() == Some("read");
|
||||||
|
if read_only && !matches!(*req.method(), axum::http::Method::GET | axum::http::Method::HEAD) {
|
||||||
|
return ApiError(StatusCode::FORBIDDEN, "this API token is read-only".into()).into_response();
|
||||||
|
}
|
||||||
|
// Tokens cannot manage tokens.
|
||||||
|
if req.uri().path().contains("/account/tokens") {
|
||||||
|
return ApiError(StatusCode::FORBIDDEN, "API tokens are managed in the web UI".into()).into_response();
|
||||||
|
}
|
||||||
|
}
|
||||||
req.extensions_mut().insert(user);
|
req.extensions_mut().insert(user);
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
}
|
}
|
||||||
@@ -232,6 +288,40 @@ pub async fn login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body):
|
|||||||
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
Ok(([(header::SET_COOKIE, cookie)], Json(json!(user))).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct CliLoginBody {
|
||||||
|
username: String,
|
||||||
|
password: String,
|
||||||
|
/// Shown in the token name ("CLI on <hostname>").
|
||||||
|
hostname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Command line login: checks the password (same rate limit as the web login)
|
||||||
|
/// and creates a personal API token for this machine.
|
||||||
|
pub async fn cli_login(State(app): State<Arc<App>>, headers: HeaderMap, Json(body): Json<CliLoginBody>) -> ApiResult {
|
||||||
|
let ip = client_ip(&headers);
|
||||||
|
let keys = [format!("ip:{ip}"), format!("user:{}", body.username.trim().to_lowercase())];
|
||||||
|
if locked(&app, &keys) {
|
||||||
|
return Err(ApiError(StatusCode::TOO_MANY_REQUESTS, "too many failed sign-ins – try again in 15 minutes".into()));
|
||||||
|
}
|
||||||
|
let found = app.store.user_by_name(body.username.trim())?;
|
||||||
|
let hash = found.as_ref().and_then(|(_, h)| h.clone());
|
||||||
|
let ok = verify_password(&body.password, hash.as_deref().unwrap_or("$argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHRzb21lc2FsdA$Zm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFyZm9vYmFy"));
|
||||||
|
let Some((user, _)) = found.filter(|(u, _)| ok && hash.is_some() && !u.disabled) else {
|
||||||
|
record_failure(&app, &keys);
|
||||||
|
app.store.audit(None, body.username.trim(), "login.failed", "cli", &ip);
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(400)).await;
|
||||||
|
return Err(ApiError(StatusCode::UNAUTHORIZED, "invalid username or password".into()));
|
||||||
|
};
|
||||||
|
let host: String = body.hostname.unwrap_or_default().chars().filter(|c| c.is_alphanumeric() || "-._".contains(*c)).take(40).collect();
|
||||||
|
let name = if host.is_empty() { "CLI".to_string() } else { format!("CLI on {host}") };
|
||||||
|
let token = format!("{API_TOKEN_PREFIX}{}", random_token(32));
|
||||||
|
let prefix: String = token.chars().take(10).collect();
|
||||||
|
app.store.api_token_add(&user.id, &name, &sha256_hex(&token), &prefix, None, "full")?;
|
||||||
|
app.audit(Some(&user), "login", &format!("cli ({name})"), &ip);
|
||||||
|
Ok(Json(json!({"token": token, "user": user, "token_name": name})))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
|
pub async fn logout(State(app): State<Arc<App>>, headers: HeaderMap) -> Response {
|
||||||
if let Some(t) = token_from_headers(&headers) {
|
if let Some(t) = token_from_headers(&headers) {
|
||||||
let _ = app.store.delete_token(&sha256_hex(&t));
|
let _ = app.store.delete_token(&sha256_hex(&t));
|
||||||
|
|||||||
@@ -0,0 +1,250 @@
|
|||||||
|
//! API for the local command line client (`codecraft`): the CLI runs the
|
||||||
|
//! agent loop and the file/shell tools on the user's machine; the server
|
||||||
|
//! provides the configuration (modes, models, rules, skills, theme), acts as
|
||||||
|
//! the model gateway (keys, budgets, statistics) and runs its MCP tools.
|
||||||
|
|
||||||
|
use crate::api::{ApiError, ApiResult, AppState};
|
||||||
|
use crate::provider::{ChatRequest, StreamEvent, ToolDef};
|
||||||
|
use crate::store::{UsageRecord, User};
|
||||||
|
use crate::tools::ToolCtx;
|
||||||
|
use crate::types::{Message, now_ms};
|
||||||
|
use axum::extract::{Path, State};
|
||||||
|
use axum::http::StatusCode;
|
||||||
|
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
|
||||||
|
use axum::{Extension, Json};
|
||||||
|
use futures::stream::Stream;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use tokio_util::sync::CancellationToken;
|
||||||
|
|
||||||
|
/// Everything the CLI needs to run like the web UI.
|
||||||
|
pub async fn config(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let cfg = app.config();
|
||||||
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
|
let agents: Vec<Value> = crate::prompts::agents(&app, &root, &root)
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| {
|
||||||
|
let mut v = json!(a);
|
||||||
|
v["prompt"] = json!(a.prompt);
|
||||||
|
v
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let commands: Vec<Value> = crate::prompts::commands(&app, &root, &root)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.description.len() < 400)
|
||||||
|
.map(|c| json!({"name": c.name, "description": c.description, "body": c.body, "agent": c.agent}))
|
||||||
|
.collect();
|
||||||
|
let skills: Vec<Value> = crate::prompts::skills(&app, &root, &root).into_iter().map(|s| json!({"name": s.name, "description": s.description})).collect();
|
||||||
|
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
|
||||||
|
let mut mcp_tools: Vec<ToolDef> = direct.iter().map(|t| t.def()).collect();
|
||||||
|
if !deferred.is_empty() {
|
||||||
|
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpSearch));
|
||||||
|
mcp_tools.push(crate::tools::Tool::def(&crate::mcp::McpCall));
|
||||||
|
}
|
||||||
|
let prefs: Value = app.store.user_setting(&user.id, "prefs")?;
|
||||||
|
let memory: String = app.store.user_setting(&user.id, "memory")?;
|
||||||
|
let default_model = if cfg.server.default_model.is_empty() { app.registry().first_model() } else { Some(cfg.server.default_model.clone()) };
|
||||||
|
Ok(Json(json!({
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"user": user,
|
||||||
|
"models": app.registry().models_json(),
|
||||||
|
"default_model": default_model,
|
||||||
|
"small_model": cfg.server.small_model,
|
||||||
|
"currency": cfg.server.currency,
|
||||||
|
"base_prompt": crate::prompts::BASE_PROMPT,
|
||||||
|
"auto_mode_prompt": crate::prompts::AUTO_MODE_PROMPT,
|
||||||
|
"agents": agents,
|
||||||
|
"commands": commands,
|
||||||
|
"skills": skills,
|
||||||
|
"permissions": cfg.permissions,
|
||||||
|
"mcp_tools": mcp_tools,
|
||||||
|
"deferred_mcp": app.mcp.deferred_summary(&cfg, Some(&user)).iter().map(|(s, n, names)| json!({"server": s, "tools": n, "examples": names})).collect::<Vec<_>>(),
|
||||||
|
"prefs": prefs,
|
||||||
|
"memory": memory,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A server skill with its supporting text files (templates).
|
||||||
|
pub async fn skill(Extension(user): Extension<User>, State(app): State<AppState>, Path(name): Path<String>) -> ApiResult {
|
||||||
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
|
let sk = crate::prompts::skills(&app, &root, &root).into_iter().find(|s| s.name == name).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "skill not found".into()))?;
|
||||||
|
let dir = sk.path.parent().map(PathBuf::from).unwrap_or_default();
|
||||||
|
let content = std::fs::read_to_string(&sk.path)?;
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for e in ignore::WalkBuilder::new(&dir).hidden(false).build().flatten() {
|
||||||
|
let p = e.path();
|
||||||
|
if !p.is_file() || p == sk.path || files.len() >= 50 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Ok(text) = std::fs::read_to_string(p) {
|
||||||
|
if text.len() <= 200_000 {
|
||||||
|
files.push(json!({"path": p.strip_prefix(&dir).unwrap_or(p), "content": text}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(Json(json!({"name": sk.name, "description": sk.description, "content": content, "files": files})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct LlmBody {
|
||||||
|
/// "provider/model"
|
||||||
|
model: String,
|
||||||
|
system: String,
|
||||||
|
messages: Vec<Message>,
|
||||||
|
#[serde(default)]
|
||||||
|
tools: Vec<ToolDef>,
|
||||||
|
max_tokens: Option<u64>,
|
||||||
|
#[serde(default)]
|
||||||
|
reasoning: bool,
|
||||||
|
effort: Option<String>,
|
||||||
|
/// Local session id (statistics group the requests by it).
|
||||||
|
session: Option<String>,
|
||||||
|
/// Mode / agent name (statistics).
|
||||||
|
agent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Model gateway: one model request, streamed as SSE (`data: <json>`).
|
||||||
|
/// Events: text, reasoning, signature, tool_call, usage, done, error.
|
||||||
|
pub async fn llm_stream(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<LlmBody>) -> Result<Sse<impl Stream<Item = Result<SseEvent, Infallible>>>, ApiError> {
|
||||||
|
let (provider, mut model) = app.registry().resolve(&b.model).map_err(|e| ApiError(StatusCode::BAD_REQUEST, format!("{e:#}")))?;
|
||||||
|
if let Some(e) = b.effort.filter(|e| !e.is_empty()) {
|
||||||
|
model.effort = Some(e);
|
||||||
|
}
|
||||||
|
let provider_name = b.model.split_once('/').map(|(p, _)| p.to_string()).unwrap_or_default();
|
||||||
|
crate::budget::check(&app, &provider_name, Some(&user.id)).map_err(|e| ApiError(StatusCode::PAYMENT_REQUIRED, format!("{e:#}")))?;
|
||||||
|
let max_tokens = b.max_tokens.or(model.max_output).unwrap_or(16_000);
|
||||||
|
let req = ChatRequest { model: model.clone(), system: b.system, messages: b.messages, tools: b.tools, max_tokens, reasoning: b.reasoning };
|
||||||
|
let session = format!("cli:{}", b.session.unwrap_or_else(|| "default".into()));
|
||||||
|
let agent = format!("cli:{}", b.agent.unwrap_or_else(|| "build".into()));
|
||||||
|
let spec = b.model.clone();
|
||||||
|
let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Result<SseEvent, Infallible>>(256);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let slot = app.registry().acquire(&spec).await;
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel(256);
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
let c2 = cancel.clone();
|
||||||
|
let handle = tokio::spawn(async move {
|
||||||
|
let _slot = slot;
|
||||||
|
provider.stream(req, tx, c2).await
|
||||||
|
});
|
||||||
|
let mut usage = crate::types::Usage::default();
|
||||||
|
let mut ttft = None;
|
||||||
|
while let Some(ev) = rx.recv().await {
|
||||||
|
let v = match ev {
|
||||||
|
StreamEvent::TextDelta(t) => json!({"type": "text", "text": t}),
|
||||||
|
StreamEvent::ReasoningDelta(t) => json!({"type": "reasoning", "text": t}),
|
||||||
|
StreamEvent::ReasoningSignature(s) => json!({"type": "signature", "signature": s}),
|
||||||
|
StreamEvent::ToolCall { id, name, input, extra } => json!({"type": "tool_call", "id": id, "name": name, "input": input, "extra": extra}),
|
||||||
|
StreamEvent::Usage(u) => {
|
||||||
|
usage = u.clone();
|
||||||
|
json!({"type": "usage", "usage": u})
|
||||||
|
}
|
||||||
|
StreamEvent::Done { stop_reason } => json!({"type": "done", "stop_reason": stop_reason}),
|
||||||
|
};
|
||||||
|
if ttft.is_none() && v["type"] != "usage" {
|
||||||
|
ttft = Some(started.elapsed().as_millis() as u64);
|
||||||
|
}
|
||||||
|
if out_tx.send(Ok(SseEvent::default().data(v.to_string()))).await.is_err() {
|
||||||
|
// Client went away: stop the model request.
|
||||||
|
cancel.cancel();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let result = handle.await;
|
||||||
|
let error = !matches!(result, Ok(Ok(())));
|
||||||
|
if let Ok(Err(e)) | Err(e) = result.map_err(anyhow::Error::from) {
|
||||||
|
let _ = out_tx.send(Ok(SseEvent::default().data(json!({"type": "error", "message": format!("{e:#}")}).to_string()))).await;
|
||||||
|
}
|
||||||
|
let (p, m) = spec.split_once('/').unwrap_or(("", spec.as_str()));
|
||||||
|
let _ = app.store.add_usage(&UsageRecord {
|
||||||
|
ts: now_ms(),
|
||||||
|
session_id: session.clone(),
|
||||||
|
root_session_id: session,
|
||||||
|
provider: p.into(),
|
||||||
|
model: m.into(),
|
||||||
|
agent,
|
||||||
|
input_tokens: usage.input_tokens,
|
||||||
|
output_tokens: usage.output_tokens,
|
||||||
|
cache_read_tokens: usage.cache_read_tokens,
|
||||||
|
cache_write_tokens: usage.cache_write_tokens,
|
||||||
|
ttft_ms: ttft.unwrap_or(0),
|
||||||
|
duration_ms: started.elapsed().as_millis() as u64,
|
||||||
|
cost: model.cost(&usage),
|
||||||
|
error,
|
||||||
|
user_id: Some(user.id.clone()),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
let stream = futures::stream::unfold(out_rx, |mut rx| async move { rx.recv().await.map(|ev| (ev, rx)) });
|
||||||
|
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ToolBody {
|
||||||
|
name: String,
|
||||||
|
input: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run one of the server's MCP tools for the CLI (permission was asked locally).
|
||||||
|
pub async fn tool(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<ToolBody>) -> ApiResult {
|
||||||
|
let cfg = app.config();
|
||||||
|
let (direct, deferred) = app.mcp.tools(&cfg, Some(&user));
|
||||||
|
let tool: std::sync::Arc<dyn crate::tools::Tool> = match b.name.as_str() {
|
||||||
|
"mcp_search" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpSearch),
|
||||||
|
"mcp_call" if !deferred.is_empty() => std::sync::Arc::new(crate::mcp::McpCall),
|
||||||
|
n => direct.into_iter().find(|t| t.name() == n).ok_or_else(|| ApiError(StatusCode::NOT_FOUND, format!("no MCP tool '{n}' for you")))?,
|
||||||
|
};
|
||||||
|
let root = app.user_roots(Some(&user.id)).into_iter().next().unwrap_or_else(|| app.workspace());
|
||||||
|
let ctx = ToolCtx {
|
||||||
|
app: app.clone(),
|
||||||
|
session_id: format!("cli-{}", user.id),
|
||||||
|
call_id: crate::types::new_id(),
|
||||||
|
cwd: root.clone(),
|
||||||
|
cancel: CancellationToken::new(),
|
||||||
|
agent: "build".into(),
|
||||||
|
roots: app.user_roots(Some(&user.id)),
|
||||||
|
sandbox: crate::sandbox::spec_for(&app, Some(&user.id)),
|
||||||
|
clean_room: false,
|
||||||
|
owner_id: Some(user.id.clone()),
|
||||||
|
};
|
||||||
|
app.audit(Some(&user), "cli.mcp", &b.name, "");
|
||||||
|
match tool.run(b.input, &ctx).await {
|
||||||
|
Ok(out) => Ok(Json(json!({"content": out.content, "is_error": out.is_error, "images": out.images}))),
|
||||||
|
Err(e) => Ok(Json(json!({"content": format!("Error: {e:#}"), "is_error": true}))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Interface preferences (theme) shared between web UI and CLI.
|
||||||
|
pub async fn get_prefs(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let v: Value = app.store.user_setting(&user.id, "prefs")?;
|
||||||
|
Ok(Json(if v.is_null() { json!({}) } else { v }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_prefs(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<Value>) -> ApiResult {
|
||||||
|
if !b.is_object() || b.to_string().len() > 4000 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid preferences".into()));
|
||||||
|
}
|
||||||
|
app.store.set_user_setting(&user.id, "prefs", &b)?;
|
||||||
|
Ok(Json(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Personal memory (applies to all projects, web and CLI).
|
||||||
|
pub async fn get_memory(Extension(user): Extension<User>, State(app): State<AppState>) -> ApiResult {
|
||||||
|
let m: String = app.store.user_setting(&user.id, "memory")?;
|
||||||
|
Ok(Json(json!({"memory": m})))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct MemoryBody {
|
||||||
|
memory: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn put_memory(Extension(user): Extension<User>, State(app): State<AppState>, Json(b): Json<MemoryBody>) -> ApiResult {
|
||||||
|
if b.memory.len() > 20_000 {
|
||||||
|
return Err(ApiError(StatusCode::BAD_REQUEST, "the memory is limited to 20,000 characters".into()));
|
||||||
|
}
|
||||||
|
app.store.set_user_setting(&user.id, "memory", &b.memory)?;
|
||||||
|
Ok(Json(json!({"memory": b.memory})))
|
||||||
|
}
|
||||||
+6
-116
@@ -8,6 +8,12 @@ use serde::{Deserialize, Serialize};
|
|||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
pub use codecraft_core::config::{ModelConfig, PermissionConfig, ProviderConfig, ProviderKind, expand_env};
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
|
pub const EXAMPLE_CONFIG: &str = include_str!("../../config.example.toml");
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
@@ -270,107 +276,6 @@ impl Default for ServerConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum ProviderKind {
|
|
||||||
Anthropic,
|
|
||||||
Openai,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
pub struct ProviderConfig {
|
|
||||||
pub kind: ProviderKind,
|
|
||||||
#[serde(default)]
|
|
||||||
pub base_url: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub api_key: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auth_header: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub auth_prefix: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub models: Vec<ModelEntry>,
|
|
||||||
#[serde(default = "yes")]
|
|
||||||
pub enabled: bool,
|
|
||||||
/// Max. simultaneous requests to this provider/host (None = unlimited).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_concurrency: Option<usize>,
|
|
||||||
/// Monthly budget in the configured currency (tracked from recorded costs).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub monthly_budget: Option<f64>,
|
|
||||||
/// Optional admin key to read the provider's own cost report
|
|
||||||
/// (Anthropic: sk-ant-admin01-…, organisation-wide).
|
|
||||||
#[serde(default, skip_serializing_if = "String::is_empty")]
|
|
||||||
pub admin_key: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn yes() -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
|
||||||
#[serde(untagged)]
|
|
||||||
pub enum ModelEntry {
|
|
||||||
Id(String),
|
|
||||||
Full(ModelConfig),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
|
||||||
pub struct ModelConfig {
|
|
||||||
pub id: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub name: Option<String>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub context: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub max_output: Option<u64>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub reasoning: bool,
|
|
||||||
#[serde(default)]
|
|
||||||
pub vision: bool,
|
|
||||||
/// Reasoning effort (Anthropic: low|medium|high|xhigh|max, OpenAI: low|medium|high).
|
|
||||||
#[serde(default)]
|
|
||||||
pub effort: Option<String>,
|
|
||||||
/// Prices in currency units per 1M tokens (used for cost statistics).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub input_price: Option<f64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub output_price: Option<f64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_read_price: Option<f64>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub cache_write_price: Option<f64>,
|
|
||||||
/// Max. simultaneous requests to this model (None = unlimited).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub max_concurrency: Option<usize>,
|
|
||||||
/// Extra request body fields sent as-is (e.g. temperature, reasoning_budget_tokens).
|
|
||||||
#[serde(default, skip_serializing_if = "serde_json::Map::is_empty")]
|
|
||||||
pub options: serde_json::Map<String, serde_json::Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelConfig {
|
|
||||||
/// Cost of one request. Cache prices default to the input price.
|
|
||||||
pub fn cost(&self, u: &crate::types::Usage) -> f64 {
|
|
||||||
let inp = self.input_price.unwrap_or(0.0);
|
|
||||||
let per = |tokens: u64, price: f64| tokens as f64 * price / 1_000_000.0;
|
|
||||||
per(u.input_tokens, inp)
|
|
||||||
+ per(u.output_tokens, self.output_price.unwrap_or(0.0))
|
|
||||||
+ per(u.cache_read_tokens, self.cache_read_price.unwrap_or(inp))
|
|
||||||
+ per(u.cache_write_tokens, self.cache_write_price.unwrap_or(inp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ModelEntry {
|
|
||||||
pub fn resolved(&self) -> ModelConfig {
|
|
||||||
match self {
|
|
||||||
ModelEntry::Id(id) => ModelConfig { id: id.clone(), ..Default::default() },
|
|
||||||
ModelEntry::Full(m) => m.clone(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||||
pub struct McpConfig {
|
pub struct McpConfig {
|
||||||
#[serde(rename = "type")]
|
#[serde(rename = "type")]
|
||||||
@@ -413,14 +318,6 @@ pub enum McpKind {
|
|||||||
Http,
|
Http,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
|
||||||
pub struct PermissionConfig {
|
|
||||||
#[serde(default)]
|
|
||||||
pub bash_rules: BTreeMap<String, String>,
|
|
||||||
#[serde(flatten)]
|
|
||||||
pub tools: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
|
||||||
pub struct AgentConfig {
|
pub struct AgentConfig {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -470,13 +367,6 @@ impl Default for CraftConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Replace `${NAME}` with the environment variable (empty if unset).
|
|
||||||
pub fn expand_env(s: &str) -> String {
|
|
||||||
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
|
|
||||||
re.replace_all(s, |c: ®ex::Captures| std::env::var(&c[1]).unwrap_or_default())
|
|
||||||
.into_owned()
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
pub fn parse(text: &str) -> Result<Self> {
|
pub fn parse(text: &str) -> Result<Self> {
|
||||||
let cfg: Config = toml::from_str(text).context("invalid config")?;
|
let cfg: Config = toml::from_str(text).context("invalid config")?;
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ pub async fn generate(app: &App, req: GenerateRequest) -> Result<Vec<ImageRecord
|
|||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
cost,
|
cost,
|
||||||
error: false,
|
error: false,
|
||||||
|
user_id: None,
|
||||||
});
|
});
|
||||||
Ok(records)
|
Ok(records)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
mod api;
|
mod api;
|
||||||
mod app;
|
mod app;
|
||||||
mod auth;
|
mod auth;
|
||||||
|
mod cli_api;
|
||||||
mod budget;
|
mod budget;
|
||||||
mod config;
|
mod config;
|
||||||
mod docs;
|
mod docs;
|
||||||
|
|||||||
@@ -640,6 +640,9 @@ impl Tool for McpCall {
|
|||||||
|
|
||||||
/// The session owner (MCP access is decided per user).
|
/// The session owner (MCP access is decided per user).
|
||||||
fn owner(ctx: &ToolCtx) -> Option<crate::store::User> {
|
fn owner(ctx: &ToolCtx) -> Option<crate::store::User> {
|
||||||
|
if let Some(id) = &ctx.owner_id {
|
||||||
|
return ctx.app.store.user(id).ok().flatten();
|
||||||
|
}
|
||||||
let s = ctx.app.session(&ctx.session_id).ok()?;
|
let s = ctx.app.session(&ctx.session_id).ok()?;
|
||||||
ctx.app.store.user(s.owner_id.as_deref()?).ok().flatten()
|
ctx.app.store.user(s.owner_id.as_deref()?).ok().flatten()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ pub async fn expand(app: &Arc<App>, session_id: &str, text: &str) -> Vec<Part> {
|
|||||||
roots: app.user_roots(session.owner_id.as_deref()),
|
roots: app.user_roots(session.owner_id.as_deref()),
|
||||||
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
|
sandbox: crate::sandbox::spec_for(app, session.owner_id.as_deref()),
|
||||||
clean_room: session.clean_room(app.config().craft.clean_room),
|
clean_room: session.clean_room(app.config().craft.clean_room),
|
||||||
|
owner_id: None,
|
||||||
};
|
};
|
||||||
let mut parts = Vec::new();
|
let mut parts = Vec::new();
|
||||||
for token in tokens.into_iter().take(MAX_MENTIONS) {
|
for token in tokens.into_iter().take(MAX_MENTIONS) {
|
||||||
|
|||||||
+2
-332
@@ -1,333 +1,3 @@
|
|||||||
//! Permission evaluation: config rules, session permission modes and
|
//! Permission rules (shared with the CLI via codecraft-core).
|
||||||
//! "always allow" approvals given in the UI.
|
|
||||||
|
|
||||||
use crate::config::PermissionConfig;
|
pub use codecraft_core::permission::*;
|
||||||
use crate::tools::Tool;
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::collections::HashSet;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Decision {
|
|
||||||
Allow,
|
|
||||||
Ask,
|
|
||||||
Deny,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse(s: &str) -> Option<Decision> {
|
|
||||||
match s {
|
|
||||||
"allow" => Some(Decision::Allow),
|
|
||||||
"ask" => Some(Decision::Ask),
|
|
||||||
"deny" => Some(Decision::Deny),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn glob_match(pattern: &str, text: &str) -> bool {
|
|
||||||
globset::GlobBuilder::new(pattern)
|
|
||||||
.literal_separator(false)
|
|
||||||
.build()
|
|
||||||
.map(|g| g.compile_matcher().is_match(text))
|
|
||||||
.unwrap_or(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Split a shell command into simple commands on && || ; | and newlines.
|
|
||||||
/// Returns None when the command contains constructs we cannot judge safely
|
|
||||||
/// (command substitution, backticks).
|
|
||||||
pub fn split_command(cmd: &str) -> Option<Vec<String>> {
|
|
||||||
if cmd.contains("$(") || cmd.contains('`') || cmd.contains("<(") || cmd.contains(">(") {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
let mut cur = String::new();
|
|
||||||
let mut chars = cmd.chars().peekable();
|
|
||||||
let (mut sq, mut dq) = (false, false);
|
|
||||||
while let Some(c) = chars.next() {
|
|
||||||
match c {
|
|
||||||
'\'' if !dq => {
|
|
||||||
sq = !sq;
|
|
||||||
cur.push(c)
|
|
||||||
}
|
|
||||||
'"' if !sq => {
|
|
||||||
dq = !dq;
|
|
||||||
cur.push(c)
|
|
||||||
}
|
|
||||||
'\\' if !sq => {
|
|
||||||
cur.push(c);
|
|
||||||
if let Some(n) = chars.next() {
|
|
||||||
cur.push(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
';' | '\n' | '|' | '&' if !sq && !dq => {
|
|
||||||
// "&" alone (background) and "&&", "||", "|", ";" all separate commands.
|
|
||||||
if (c == '|' || c == '&') && chars.peek() == Some(&c) {
|
|
||||||
chars.next();
|
|
||||||
}
|
|
||||||
// Redirections like 2>&1 are not separators.
|
|
||||||
if c == '&' && cur.ends_with('>') {
|
|
||||||
cur.push(c);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
parts.push(std::mem::take(&mut cur));
|
|
||||||
}
|
|
||||||
_ => cur.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
parts.push(cur);
|
|
||||||
Some(parts.into_iter().map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Prefix stored for "always allow" on a bash command: the first two words.
|
|
||||||
pub fn bash_prefix(cmd: &str) -> String {
|
|
||||||
cmd.split_whitespace().take(2).collect::<Vec<_>>().join(" ")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Session permission modes.
|
|
||||||
pub const MODES: &[&str] = &["default", "auto", "accept_edits", "bypass"];
|
|
||||||
|
|
||||||
/// Commands that only read. Checked per simple command (after splitting).
|
|
||||||
const SAFE_COMMANDS: &[&str] = &[
|
|
||||||
"ls", "ll", "cat", "head", "tail", "wc", "pwd", "echo", "printf", "which", "whereis", "type", "file", "stat",
|
|
||||||
"du", "df", "tree", "grep", "egrep", "fgrep", "rg", "ag", "sort", "uniq", "cut", "tr", "diff", "cmp", "basename",
|
|
||||||
"dirname", "realpath", "readlink", "date", "uname", "whoami", "id", "hostname", "nproc", "free", "uptime", "ps",
|
|
||||||
"jq", "yq", "md5sum", "sha1sum", "sha256sum", "column", "nl", "fold", "true", "false", "test", "[", "less", "more",
|
|
||||||
];
|
|
||||||
|
|
||||||
const SAFE_GIT: &[&str] = &[
|
|
||||||
"status", "diff", "log", "show", "rev-parse", "ls-files", "blame", "describe", "grep", "shortlog", "reflog", "remote",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Strip harmless redirections so they don't count as file writes.
|
|
||||||
fn strip_redirections(cmd: &str) -> String {
|
|
||||||
cmd.replace("2>&1", "").replace("2>/dev/null", "").replace(">/dev/null", "").replace("> /dev/null", "")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A simple command that only reads.
|
|
||||||
pub fn is_safe_command(part: &str) -> bool {
|
|
||||||
let cleaned = strip_redirections(part);
|
|
||||||
if cleaned.contains('>') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
let words: Vec<String> = shell_words::split(&cleaned).unwrap_or_else(|_| cleaned.split_whitespace().map(String::from).collect());
|
|
||||||
// Leading VAR=value assignments are not safe (could change behaviour); keep it strict.
|
|
||||||
let Some(cmd) = words.first() else { return true };
|
|
||||||
let args: Vec<&str> = words[1..].iter().map(String::as_str).collect();
|
|
||||||
if args.len() == 1 && matches!(args[0], "--version" | "-V" | "--help" | "-h") {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
match cmd.as_str() {
|
|
||||||
c if SAFE_COMMANDS.contains(&c) => true,
|
|
||||||
"find" => !args.iter().any(|a| matches!(*a, "-exec" | "-execdir" | "-delete" | "-ok" | "-okdir" | "-fprint" | "-fprintf" | "-fls")),
|
|
||||||
"sed" => !args.iter().any(|a| a.starts_with("-i") || a.starts_with("--in-place")),
|
|
||||||
"git" => match args.first() {
|
|
||||||
Some(sub) if SAFE_GIT.contains(sub) => !(*sub == "remote" && args.len() > 1 && args[1] != "-v"),
|
|
||||||
Some(&"branch") => !args.iter().any(|a| matches!(*a, "-d" | "-D" | "-m" | "-M" | "--delete" | "--move")),
|
|
||||||
Some(&"stash") => args.get(1) == Some(&"list"),
|
|
||||||
Some(&"tag") => args.len() == 1 || args.get(1) == Some(&"-l") || args.get(1) == Some(&"--list"),
|
|
||||||
Some(&"config") => args.iter().any(|a| *a == "--get" || *a == "--list" || *a == "-l"),
|
|
||||||
_ => false,
|
|
||||||
},
|
|
||||||
"cargo" => matches!(args.first(), Some(&"check") | Some(&"metadata") | Some(&"tree") | Some(&"--list")),
|
|
||||||
"npm" | "pnpm" | "yarn" => matches!(args.first(), Some(&"ls") | Some(&"list") | Some(&"outdated") | Some(&"view") | Some(&"why")),
|
|
||||||
"pip" | "pip3" => matches!(args.first(), Some(&"list") | Some(&"show") | Some(&"freeze")),
|
|
||||||
_ => false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Commands that always need confirmation unless everything is allowed.
|
|
||||||
pub fn is_dangerous(cmd: &str) -> bool {
|
|
||||||
let c = format!(" {} ", cmd.replace('\n', " "));
|
|
||||||
const PATTERNS: &[&str] = &[
|
|
||||||
" rm -r", " rm -f", " rm -R", " rm --recursive", " sudo ", " su ", " git push", " git reset --hard", " git clean",
|
|
||||||
" git checkout -- ", " git restore ", " git rebase", " git commit --amend", " chmod -R", " chown -R", " dd ",
|
|
||||||
" mkfs", " shutdown", " reboot", " poweroff", " kill ", " pkill ", " killall ", " docker ", " docker-compose ",
|
|
||||||
" systemctl ", " npm publish", " cargo publish", " | sh", " | bash", "|sh ", "|bash ", " > /dev/sd", " crontab ",
|
|
||||||
];
|
|
||||||
PATTERNS.iter().any(|p| c.contains(p))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct Evaluator<'a> {
|
|
||||||
pub cfg: &'a PermissionConfig,
|
|
||||||
pub mode: &'a str,
|
|
||||||
/// Session approvals: tool names, or "bash:<prefix>".
|
|
||||||
pub approved: &'a HashSet<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Evaluator<'_> {
|
|
||||||
pub fn evaluate(&self, tool: &dyn Tool, input: &Value) -> Decision {
|
|
||||||
let name = tool.name();
|
|
||||||
let base = self.tool_rule(name).unwrap_or(if tool.read_only() { Decision::Allow } else { Decision::Ask });
|
|
||||||
|
|
||||||
if name == "bash" {
|
|
||||||
let cmd = input["command"].as_str().unwrap_or("");
|
|
||||||
let bash = self.bash_decision(cmd);
|
|
||||||
if bash == Some(Decision::Deny) || base == Decision::Deny {
|
|
||||||
return Decision::Deny;
|
|
||||||
}
|
|
||||||
if self.mode == "bypass" {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
if let Some(d) = bash {
|
|
||||||
return d;
|
|
||||||
}
|
|
||||||
// Read-only commands never need confirmation.
|
|
||||||
if split_command(cmd).is_some_and(|parts| !parts.is_empty() && parts.iter().all(|p| is_safe_command(p))) {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
if self.mode == "auto" && !is_dangerous(cmd) && split_command(cmd).is_some() {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
if base == Decision::Deny {
|
|
||||||
return Decision::Deny;
|
|
||||||
}
|
|
||||||
if self.mode == "bypass" || self.approved.contains(name) {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
if (self.mode == "accept_edits" || self.mode == "auto") && tool.is_edit() {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
// In auto mode everything read-only passes (web_fetch, read-only MCP tools, ...).
|
|
||||||
if self.mode == "auto" && tool.read_only() {
|
|
||||||
return Decision::Allow;
|
|
||||||
}
|
|
||||||
base
|
|
||||||
}
|
|
||||||
|
|
||||||
fn tool_rule(&self, name: &str) -> Option<Decision> {
|
|
||||||
if let Some(d) = self.cfg.tools.get(name).and_then(|s| parse(s)) {
|
|
||||||
return Some(d);
|
|
||||||
}
|
|
||||||
// Glob rules, most specific (longest) first.
|
|
||||||
let mut globs: Vec<(&String, &String)> =
|
|
||||||
self.cfg.tools.iter().filter(|(k, _)| k.contains('*')).collect();
|
|
||||||
globs.sort_by_key(|(k, _)| std::cmp::Reverse(k.len()));
|
|
||||||
globs.into_iter().find(|(k, _)| glob_match(k, name)).and_then(|(_, v)| parse(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Some(Allow) only if every simple command is allowed; Some(Deny) if any is denied.
|
|
||||||
fn bash_decision(&self, cmd: &str) -> Option<Decision> {
|
|
||||||
// Deny rules are checked against the full command as well as each part.
|
|
||||||
let deny_hit = |text: &str| {
|
|
||||||
self.cfg.bash_rules.iter().any(|(p, d)| d == "deny" && glob_match(p, text))
|
|
||||||
};
|
|
||||||
if deny_hit(cmd.trim()) {
|
|
||||||
return Some(Decision::Deny);
|
|
||||||
}
|
|
||||||
let parts = split_command(cmd)?;
|
|
||||||
if parts.iter().any(|p| deny_hit(p)) {
|
|
||||||
return Some(Decision::Deny);
|
|
||||||
}
|
|
||||||
let all_allowed = !parts.is_empty()
|
|
||||||
&& parts.iter().all(|p| {
|
|
||||||
self.approved.contains(&format!("bash:{}", bash_prefix(p)))
|
|
||||||
|| self.cfg.bash_rules.iter().any(|(pat, d)| d == "allow" && glob_match(pat, p))
|
|
||||||
});
|
|
||||||
if all_allowed { Some(Decision::Allow) } else { None }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
struct Fake(&'static str, bool);
|
|
||||||
#[async_trait::async_trait]
|
|
||||||
impl Tool for Fake {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
self.0
|
|
||||||
}
|
|
||||||
fn description(&self) -> String {
|
|
||||||
String::new()
|
|
||||||
}
|
|
||||||
fn schema(&self) -> Value {
|
|
||||||
Value::Null
|
|
||||||
}
|
|
||||||
fn read_only(&self) -> bool {
|
|
||||||
self.1
|
|
||||||
}
|
|
||||||
async fn run(&self, _: Value, _: &crate::tools::ToolCtx) -> anyhow::Result<crate::tools::ToolOutput> {
|
|
||||||
unreachable!()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cfg() -> PermissionConfig {
|
|
||||||
let mut bash_rules = BTreeMap::new();
|
|
||||||
bash_rules.insert("git status*".into(), "allow".into());
|
|
||||||
bash_rules.insert("ls*".into(), "allow".into());
|
|
||||||
bash_rules.insert("rm -rf /*".into(), "deny".into());
|
|
||||||
let mut tools = BTreeMap::new();
|
|
||||||
tools.insert("bash".into(), "ask".into());
|
|
||||||
tools.insert("mcp__*".into(), "ask".into());
|
|
||||||
tools.insert("mcp__trilium__*".into(), "allow".into());
|
|
||||||
PermissionConfig { bash_rules, tools }
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn compound_commands_need_every_part_allowed() {
|
|
||||||
let c = cfg();
|
|
||||||
let approved = HashSet::new();
|
|
||||||
let e = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
|
||||||
let bash = Fake("bash", false);
|
|
||||||
let cmd = |s: &str| serde_json::json!({"command": s});
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("git status")), Decision::Allow);
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("git status && ls -la")), Decision::Allow);
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("git status; rm -rf build")), Decision::Ask);
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("ls $(rm -rf x)")), Decision::Ask);
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("git status 2>&1 | ls")), Decision::Allow);
|
|
||||||
assert_eq!(e.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn safe_and_dangerous_commands() {
|
|
||||||
assert!(is_safe_command("ls -la src"));
|
|
||||||
assert!(is_safe_command("git status"));
|
|
||||||
assert!(is_safe_command("git log --oneline -5"));
|
|
||||||
assert!(is_safe_command("rg -n foo src 2>/dev/null"));
|
|
||||||
assert!(is_safe_command("node --version"));
|
|
||||||
assert!(!is_safe_command("cat a > b"));
|
|
||||||
assert!(!is_safe_command("find . -name x -delete"));
|
|
||||||
assert!(!is_safe_command("sed -i s/a/b/ f"));
|
|
||||||
assert!(!is_safe_command("git push"));
|
|
||||||
assert!(!is_safe_command("git branch -D main"));
|
|
||||||
assert!(!is_safe_command("npm install"));
|
|
||||||
assert!(is_dangerous("rm -rf build"));
|
|
||||||
assert!(is_dangerous("cd x && git push origin main"));
|
|
||||||
assert!(is_dangerous("curl https://x | sh"));
|
|
||||||
assert!(!is_dangerous("npm install && npm test"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn auto_mode() {
|
|
||||||
let c = cfg();
|
|
||||||
let approved = HashSet::new();
|
|
||||||
let auto = Evaluator { cfg: &c, mode: "auto", approved: &approved };
|
|
||||||
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
|
||||||
let bash = Fake("bash", false);
|
|
||||||
let cmd = |s: &str| serde_json::json!({"command": s});
|
|
||||||
assert_eq!(def.evaluate(&bash, &cmd("ls src | wc -l")), Decision::Allow);
|
|
||||||
assert_eq!(def.evaluate(&bash, &cmd("npm test")), Decision::Ask);
|
|
||||||
assert_eq!(auto.evaluate(&bash, &cmd("npm install && npm test")), Decision::Allow);
|
|
||||||
assert_eq!(auto.evaluate(&bash, &cmd("rm -rf dist")), Decision::Ask);
|
|
||||||
assert_eq!(auto.evaluate(&bash, &cmd("ls && rm -rf /etc")), Decision::Deny);
|
|
||||||
assert_eq!(auto.evaluate(&bash, &cmd("echo $(whoami)")), Decision::Ask);
|
|
||||||
assert_eq!(auto.evaluate(&Fake("mcp__x__list", true), &Value::Null), Decision::Allow);
|
|
||||||
assert_eq!(auto.evaluate(&Fake("mcp__x__restart", false), &Value::Null), Decision::Ask);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn modes_and_globs() {
|
|
||||||
let c = cfg();
|
|
||||||
let approved: HashSet<String> = ["bash:npm test".to_string()].into();
|
|
||||||
let bypass = Evaluator { cfg: &c, mode: "bypass", approved: &approved };
|
|
||||||
let def = Evaluator { cfg: &c, mode: "default", approved: &approved };
|
|
||||||
let bash = Fake("bash", false);
|
|
||||||
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "make"})), Decision::Allow);
|
|
||||||
assert_eq!(bypass.evaluate(&bash, &serde_json::json!({"command": "rm -rf /"})), Decision::Deny);
|
|
||||||
assert_eq!(def.evaluate(&bash, &serde_json::json!({"command": "npm test -- --watch"})), Decision::Allow);
|
|
||||||
assert_eq!(def.evaluate(&Fake("mcp__trilium__search", false), &Value::Null), Decision::Allow);
|
|
||||||
assert_eq!(def.evaluate(&Fake("mcp__other__x", false), &Value::Null), Decision::Ask);
|
|
||||||
assert_eq!(def.evaluate(&Fake("read", true), &Value::Null), Decision::Allow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,7 +35,9 @@ pub struct Agent {
|
|||||||
pub path: Option<String>,
|
pub path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
pub const AUTO_MODE_PROMPT: &str = "\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.";
|
||||||
|
|
||||||
|
pub const BASE_PROMPT: &str = r#"You are CodeCraft, an autonomous coding agent working in the user's project through tools.
|
||||||
|
|
||||||
# Working style
|
# Working style
|
||||||
- Be direct and concise. Lead with what you did or found; no filler.
|
- Be direct and concise. Lead with what you did or found; no filler.
|
||||||
@@ -231,7 +233,7 @@ pub fn system_prompt(app: &App, agent: &Agent, root: &Path, cwd: &Path, model: &
|
|||||||
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
|
s.push_str("\n\n# Installing tools\nYou run without root (no apt/sudo). Install what you need in user space – it persists: Python packages with `pip install <pkg>` (user site) or a venv, CLI tools with `pipx install`, Rust with `curl https://sh.rustup.rs -sSf | sh -s -- -y` (then `~/.cargo/bin` is on PATH), Node packages with `npm i -g`, and other binaries (e.g. Godot) by downloading them to `~/.local/bin`. Check with `which`/`--version` first whether a tool is already installed.");
|
||||||
}
|
}
|
||||||
if permission_mode == "auto" && !agent.read_only {
|
if permission_mode == "auto" && !agent.read_only {
|
||||||
s.push_str("\n\n# Working autonomously\nThe user does not want to approve every step. Before you start a larger change (several files, installing dependencies, migrations, anything hard to undo), confirm the plan ONCE with ask_user in a short summary. After the user agrees, carry out the whole plan without asking again – reading, editing, building and testing are pre-approved. Only stop to ask when something unexpected changes the plan. Small, obvious fixes need no confirmation. Destructive or external actions (deleting, git push, deployments, changing other systems via MCP) still trigger an approval prompt automatically.");
|
s.push_str(AUTO_MODE_PROMPT);
|
||||||
}
|
}
|
||||||
let git = cwd.join(".git").exists();
|
let git = cwd.join(".git").exists();
|
||||||
s.push_str(&format!(
|
s.push_str(&format!(
|
||||||
|
|||||||
+5
-118
@@ -1,100 +1,13 @@
|
|||||||
//! Model providers. Two wire protocols cover all targets:
|
//! Model providers (implementations in codecraft-core) and the registry of
|
||||||
//! Anthropic Messages and OpenAI-compatible chat completions.
|
//! configured providers with their concurrency limits.
|
||||||
|
|
||||||
pub mod anthropic;
|
pub use codecraft_core::provider::*;
|
||||||
pub mod openai;
|
|
||||||
|
|
||||||
use crate::config::{Config, ModelConfig, ProviderConfig, ProviderKind, expand_env};
|
use crate::config::{Config, ModelConfig};
|
||||||
use crate::types::{Message, Usage};
|
|
||||||
use anyhow::{Result, anyhow};
|
use anyhow::{Result, anyhow};
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde::Serialize;
|
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tokio_util::sync::CancellationToken;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
pub struct ToolDef {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub input_schema: Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ChatRequest {
|
|
||||||
pub model: ModelConfig,
|
|
||||||
pub system: String,
|
|
||||||
pub messages: Vec<Message>,
|
|
||||||
pub tools: Vec<ToolDef>,
|
|
||||||
pub max_tokens: u64,
|
|
||||||
/// Enable extended thinking / reasoning when the model supports it.
|
|
||||||
pub reasoning: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub enum StreamEvent {
|
|
||||||
TextDelta(String),
|
|
||||||
ReasoningDelta(String),
|
|
||||||
ReasoningSignature(String),
|
|
||||||
ToolCall { id: String, name: String, input: Value, extra: Option<Value> },
|
|
||||||
Usage(Usage),
|
|
||||||
Done { stop_reason: String },
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait Provider: Send + Sync {
|
|
||||||
async fn stream(
|
|
||||||
&self,
|
|
||||||
req: ChatRequest,
|
|
||||||
tx: mpsc::Sender<StreamEvent>,
|
|
||||||
cancel: CancellationToken,
|
|
||||||
) -> Result<()>;
|
|
||||||
|
|
||||||
async fn list_models(&self) -> Result<Vec<ModelConfig>>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Connection settings shared by both protocol implementations.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Endpoint {
|
|
||||||
pub base_url: String,
|
|
||||||
pub api_key: String,
|
|
||||||
pub auth_header: String,
|
|
||||||
pub auth_prefix: String,
|
|
||||||
pub headers: BTreeMap<String, String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Endpoint {
|
|
||||||
fn from_config(p: &ProviderConfig) -> Self {
|
|
||||||
let (default_header, default_prefix) = match p.kind {
|
|
||||||
ProviderKind::Anthropic => ("x-api-key", ""),
|
|
||||||
ProviderKind::Openai => ("Authorization", "Bearer "),
|
|
||||||
};
|
|
||||||
let base = if p.base_url.is_empty() && p.kind == ProviderKind::Anthropic {
|
|
||||||
"https://api.anthropic.com/v1".to_string()
|
|
||||||
} else {
|
|
||||||
expand_env(&p.base_url)
|
|
||||||
};
|
|
||||||
Self {
|
|
||||||
base_url: base.trim_end_matches('/').to_string(),
|
|
||||||
api_key: expand_env(&p.api_key),
|
|
||||||
auth_header: p.auth_header.clone().unwrap_or_else(|| default_header.into()),
|
|
||||||
auth_prefix: p.auth_prefix.clone().unwrap_or_else(|| default_prefix.into()),
|
|
||||||
headers: p.headers.iter().map(|(k, v)| (k.clone(), expand_env(v))).collect(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn apply(&self, mut rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
|
|
||||||
if !self.api_key.is_empty() {
|
|
||||||
rb = rb.header(&self.auth_header, format!("{}{}", self.auth_prefix, self.api_key));
|
|
||||||
}
|
|
||||||
for (k, v) in &self.headers {
|
|
||||||
rb = rb.header(k, v);
|
|
||||||
}
|
|
||||||
rb
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub struct ProviderEntry {
|
pub struct ProviderEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -114,13 +27,6 @@ pub struct Slot {
|
|||||||
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
|
_permits: Vec<tokio::sync::OwnedSemaphorePermit>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn http_client() -> reqwest::Client {
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.connect_timeout(std::time::Duration::from_secs(20))
|
|
||||||
.user_agent(concat!("codecraft/", env!("CARGO_PKG_VERSION")))
|
|
||||||
.build()
|
|
||||||
.expect("http client")
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Registry {
|
impl Registry {
|
||||||
/// Build providers from config. Providers with an empty model list are queried
|
/// Build providers from config. Providers with an empty model list are queried
|
||||||
@@ -133,11 +39,7 @@ impl Registry {
|
|||||||
if !pc.enabled {
|
if !pc.enabled {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let ep = Endpoint::from_config(pc);
|
let provider: Arc<dyn Provider> = build(client.clone(), pc);
|
||||||
let provider: Arc<dyn Provider> = match pc.kind {
|
|
||||||
ProviderKind::Anthropic => Arc::new(anthropic::Anthropic::new(client.clone(), ep)),
|
|
||||||
ProviderKind::Openai => Arc::new(openai::OpenAi::new(client.clone(), ep)),
|
|
||||||
};
|
|
||||||
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
let mut models: Vec<ModelConfig> = pc.models.iter().map(|m| m.resolved()).collect();
|
||||||
if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) {
|
if let Some(n) = pc.max_concurrency.filter(|n| *n > 0) {
|
||||||
limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n)));
|
limits.insert(name.clone(), Arc::new(tokio::sync::Semaphore::new(n)));
|
||||||
@@ -236,18 +138,3 @@ impl Registry {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turn an HTTP error response into a readable error.
|
|
||||||
pub async fn http_error(resp: reqwest::Response) -> anyhow::Error {
|
|
||||||
let status = resp.status();
|
|
||||||
let body = resp.text().await.unwrap_or_default();
|
|
||||||
let detail = serde_json::from_str::<Value>(&body)
|
|
||||||
.ok()
|
|
||||||
.and_then(|v| {
|
|
||||||
v.pointer("/error/message")
|
|
||||||
.or_else(|| v.pointer("/0/error/message"))
|
|
||||||
.or_else(|| v.get("message"))
|
|
||||||
.and_then(|m| m.as_str().map(String::from))
|
|
||||||
})
|
|
||||||
.unwrap_or_else(|| body.chars().take(500).collect());
|
|
||||||
anyhow!("provider returned HTTP {status}: {detail}")
|
|
||||||
}
|
|
||||||
@@ -385,6 +385,7 @@ pub async fn run_session(app: Arc<App>, session_id: &str, cancel: CancellationTo
|
|||||||
roots: roots.clone(),
|
roots: roots.clone(),
|
||||||
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
sandbox: crate::sandbox::spec_for(&app, session.owner_id.as_deref()),
|
||||||
clean_room,
|
clean_room,
|
||||||
|
owner_id: None,
|
||||||
};
|
};
|
||||||
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
let by_name = |n: &str| tools.iter().find(|t| t.name() == n).cloned();
|
||||||
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
|
let all_read_only = calls.iter().all(|(_, n, _)| by_name(n).is_some_and(|t| t.read_only()));
|
||||||
@@ -454,6 +455,7 @@ async fn execute_call(
|
|||||||
roots: base.roots.clone(),
|
roots: base.roots.clone(),
|
||||||
sandbox: base.sandbox.clone(),
|
sandbox: base.sandbox.clone(),
|
||||||
clean_room: base.clean_room,
|
clean_room: base.clean_room,
|
||||||
|
owner_id: None,
|
||||||
};
|
};
|
||||||
// mcp_call is judged (and approved) as the MCP tool it calls.
|
// mcp_call is judged (and approved) as the MCP tool it calls.
|
||||||
let call_name = name;
|
let call_name = name;
|
||||||
@@ -853,6 +855,7 @@ async fn stream_step(
|
|||||||
duration_ms: duration,
|
duration_ms: duration,
|
||||||
cost: req.model.cost(usage),
|
cost: req.model.cost(usage),
|
||||||
error,
|
error,
|
||||||
|
user_id: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Err(e) = result {
|
if let Err(e) = result {
|
||||||
@@ -937,6 +940,7 @@ fn record_side_usage(app: &App, session: &Session, spec: &str, model: &crate::co
|
|||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
cost: model.cost(usage),
|
cost: model.cost(usage),
|
||||||
error: false,
|
error: false,
|
||||||
|
user_id: None,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+69
-3
@@ -97,6 +97,17 @@ impl Store {
|
|||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS queue_session ON queue(session_id, created_at);
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
prefix TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_used INTEGER,
|
||||||
|
expires_at INTEGER,
|
||||||
|
scope TEXT NOT NULL DEFAULT 'full'
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS jobs (
|
CREATE TABLE IF NOT EXISTS jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
owner_id TEXT NOT NULL,
|
owner_id TEXT NOT NULL,
|
||||||
@@ -317,6 +328,8 @@ pub struct UsageRecord {
|
|||||||
pub duration_ms: u64,
|
pub duration_ms: u64,
|
||||||
pub cost: f64,
|
pub cost: f64,
|
||||||
pub error: bool,
|
pub error: bool,
|
||||||
|
/// User to bill when there is no session (CLI requests); else the session owner.
|
||||||
|
pub user_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Aggregation SQL shared by all statistics queries.
|
/// Aggregation SQL shared by all statistics queries.
|
||||||
@@ -364,11 +377,11 @@ impl Store {
|
|||||||
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
|
||||||
ttft_ms, duration_ms, cost, error, user_id)
|
ttft_ms, duration_ms, cost, error, user_id)
|
||||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,
|
||||||
(SELECT owner_id FROM sessions WHERE id = ?3))",
|
COALESCE(?15, (SELECT owner_id FROM sessions WHERE id = ?3)))",
|
||||||
params![
|
params![
|
||||||
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
u.ts, u.session_id, u.root_session_id, u.provider, u.model, u.agent,
|
||||||
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
u.input_tokens as i64, u.output_tokens as i64, u.cache_read_tokens as i64,
|
||||||
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64
|
u.cache_write_tokens as i64, u.ttft_ms as i64, u.duration_ms as i64, u.cost, u.error as i64, u.user_id
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -474,7 +487,7 @@ impl Store {
|
|||||||
// Most expensive chats in the range.
|
// Most expensive chats in the range.
|
||||||
let top_sessions = {
|
let top_sessions = {
|
||||||
let mut stmt = conn.prepare(&format!(
|
let mut stmt = conn.prepare(&format!(
|
||||||
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), '(deleted)'), {AGG}
|
"SELECT u.root_session_id, COALESCE(json_extract(s.data, '$.title'), CASE WHEN u.root_session_id LIKE 'cli:%' THEN 'CLI (local)' ELSE '(deleted)' END), {AGG}
|
||||||
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
|
FROM usage u LEFT JOIN sessions s ON s.id = u.root_session_id
|
||||||
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
|
WHERE u.ts >= ?1 AND u.ts < ?2 AND (?3 IS NULL OR u.user_id = ?3)
|
||||||
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
GROUP BY 1 ORDER BY SUM(u.cost) DESC, COUNT(*) DESC LIMIT 15"
|
||||||
@@ -623,6 +636,59 @@ impl Store {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- personal API tokens ------------------------------------------------
|
||||||
|
|
||||||
|
pub fn api_token_add(&self, user_id: &str, name: &str, token_hash: &str, prefix: &str, expires_at: Option<i64>, scope: &str) -> Result<String> {
|
||||||
|
let id = crate::types::new_id();
|
||||||
|
self.conn.lock().unwrap().execute(
|
||||||
|
"INSERT INTO api_tokens (id, user_id, name, token_hash, prefix, created_at, expires_at, scope) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
|
||||||
|
params![id, user_id, name, token_hash, prefix, now_ms(), expires_at, scope],
|
||||||
|
)?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_tokens(&self, user_id: &str) -> Result<Vec<serde_json::Value>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let mut stmt = conn.prepare("SELECT id, name, prefix, created_at, last_used, expires_at, scope FROM api_tokens WHERE user_id = ?1 ORDER BY created_at")?;
|
||||||
|
let rows = stmt.query_map([user_id], |r| {
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"id": r.get::<_, String>(0)?, "name": r.get::<_, String>(1)?, "prefix": r.get::<_, String>(2)?,
|
||||||
|
"created_at": r.get::<_, i64>(3)?, "last_used": r.get::<_, Option<i64>>(4)?, "expires_at": r.get::<_, Option<i64>>(5)?, "scope": r.get::<_, String>(6)?,
|
||||||
|
}))
|
||||||
|
})?;
|
||||||
|
Ok(rows.collect::<std::result::Result<_, _>>()?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn api_token_delete(&self, user_id: &str, id: &str) -> Result<bool> {
|
||||||
|
Ok(self.conn.lock().unwrap().execute("DELETE FROM api_tokens WHERE id = ?1 AND user_id = ?2", [id, user_id])? > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scope ("full" / "read") of a personal API token.
|
||||||
|
pub fn api_token_scope(&self, token_hash: &str) -> Option<String> {
|
||||||
|
self.conn.lock().unwrap().query_row("SELECT scope FROM api_tokens WHERE token_hash = ?1", [token_hash], |r| r.get(0)).optional().ok().flatten()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The (enabled) user behind a valid personal API token.
|
||||||
|
pub fn user_by_api_token(&self, token_hash: &str) -> Result<Option<User>> {
|
||||||
|
let conn = self.conn.lock().unwrap();
|
||||||
|
let user = conn
|
||||||
|
.query_row(
|
||||||
|
&format!(
|
||||||
|
"SELECT {} FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||||||
|
WHERE t.token_hash = ?1 AND (t.expires_at IS NULL OR t.expires_at > ?2) AND u.disabled = 0",
|
||||||
|
USER_COLS.split(", ").map(|c| format!("u.{c}")).collect::<Vec<_>>().join(", ")
|
||||||
|
),
|
||||||
|
params![token_hash, now_ms()],
|
||||||
|
user_row,
|
||||||
|
)
|
||||||
|
.optional()?;
|
||||||
|
if user.is_some() {
|
||||||
|
// At most one write per minute per token.
|
||||||
|
conn.execute("UPDATE api_tokens SET last_used = ?2 WHERE token_hash = ?1 AND (last_used IS NULL OR last_used < ?2 - 60000)", params![token_hash, now_ms()])?;
|
||||||
|
}
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
/// The (enabled) user behind a valid login token.
|
/// The (enabled) user behind a valid login token.
|
||||||
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
|
pub fn user_by_token(&self, token_hash: &str) -> Result<Option<User>> {
|
||||||
let conn = self.conn.lock().unwrap();
|
let conn = self.conn.lock().unwrap();
|
||||||
|
|||||||
@@ -54,6 +54,8 @@ pub struct ToolCtx {
|
|||||||
pub sandbox: Option<crate::sandbox::SandboxSpec>,
|
pub sandbox: Option<crate::sandbox::SandboxSpec>,
|
||||||
/// Craft clean room active for this session (no page sources, no binaries).
|
/// Craft clean room active for this session (no page sources, no binaries).
|
||||||
pub clean_room: bool,
|
pub clean_room: bool,
|
||||||
|
/// User the call runs for when there is no session (CLI requests).
|
||||||
|
pub owner_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ToolCtx {
|
impl ToolCtx {
|
||||||
@@ -195,3 +197,16 @@ pub fn truncate(s: &str, max: usize) -> String {
|
|||||||
}
|
}
|
||||||
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
|
format!("{}\n\n[... output truncated: {} of {} bytes shown ...]", &s[..cut], cut, s.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Server tools answer the permission rules' questions about themselves.
|
||||||
|
impl codecraft_core::permission::ToolInfo for dyn Tool {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
Tool::name(self)
|
||||||
|
}
|
||||||
|
fn read_only(&self) -> bool {
|
||||||
|
Tool::read_only(self)
|
||||||
|
}
|
||||||
|
fn is_edit(&self) -> bool {
|
||||||
|
Tool::is_edit(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-221
@@ -1,222 +1,3 @@
|
|||||||
//! Provider-neutral message model and the event stream sent to clients.
|
//! Message and session types (shared with the CLI via codecraft-core).
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
pub use codecraft_core::types::*;
|
||||||
use serde_json::Value;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
|
||||||
#[serde(rename_all = "lowercase")]
|
|
||||||
pub enum Role {
|
|
||||||
User,
|
|
||||||
Assistant,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct ImageData {
|
|
||||||
pub media_type: String,
|
|
||||||
/// Base64-encoded bytes.
|
|
||||||
pub data: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum Part {
|
|
||||||
Text {
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
Image {
|
|
||||||
image: ImageData,
|
|
||||||
},
|
|
||||||
Reasoning {
|
|
||||||
text: String,
|
|
||||||
/// Provider-specific signature needed to replay thinking blocks (Anthropic).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
signature: Option<String>,
|
|
||||||
},
|
|
||||||
ToolCall {
|
|
||||||
id: String,
|
|
||||||
name: String,
|
|
||||||
input: Value,
|
|
||||||
/// Opaque provider data that must be echoed back (e.g. Gemini thought signatures).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
extra: Option<Value>,
|
|
||||||
},
|
|
||||||
ToolResult {
|
|
||||||
call_id: String,
|
|
||||||
/// Tool name, kept for providers that need it (Gemini via OpenAI compat) and the UI.
|
|
||||||
#[serde(default)]
|
|
||||||
name: String,
|
|
||||||
content: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
images: Vec<ImageData>,
|
|
||||||
#[serde(default)]
|
|
||||||
is_error: bool,
|
|
||||||
/// Structured data for the UI (diffs, todo lists, ...). Never sent to the model.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
meta: Option<Value>,
|
|
||||||
},
|
|
||||||
/// Marks the start of a compacted history; the text is the summary.
|
|
||||||
Summary {
|
|
||||||
text: String,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Message {
|
|
||||||
pub id: String,
|
|
||||||
pub role: Role,
|
|
||||||
pub parts: Vec<Part>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub model: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub usage: Option<Usage>,
|
|
||||||
pub created_at: i64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Message {
|
|
||||||
pub fn new(role: Role, parts: Vec<Part>) -> Self {
|
|
||||||
Self {
|
|
||||||
id: new_id(),
|
|
||||||
role,
|
|
||||||
parts,
|
|
||||||
model: None,
|
|
||||||
usage: None,
|
|
||||||
created_at: now_ms(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
|
||||||
pub struct Usage {
|
|
||||||
pub input_tokens: u64,
|
|
||||||
pub output_tokens: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cache_read_tokens: u64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub cache_write_tokens: u64,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Usage {
|
|
||||||
/// Total tokens of context the last request consumed.
|
|
||||||
pub fn context_tokens(&self) -> u64 {
|
|
||||||
self.input_tokens + self.cache_read_tokens + self.cache_write_tokens + self.output_tokens
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Session {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
pub cwd: String,
|
|
||||||
/// "provider/model"
|
|
||||||
pub model: String,
|
|
||||||
/// Active agent mode (build, plan, craft, ...).
|
|
||||||
pub agent: String,
|
|
||||||
/// Permission mode: default | accept_edits | bypass
|
|
||||||
pub permission_mode: String,
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub parent_id: Option<String>,
|
|
||||||
/// User who owns the session (subagent sessions inherit it).
|
|
||||||
#[serde(default)]
|
|
||||||
pub owner_id: Option<String>,
|
|
||||||
pub created_at: i64,
|
|
||||||
pub updated_at: i64,
|
|
||||||
#[serde(default)]
|
|
||||||
pub todos: Value,
|
|
||||||
#[serde(default)]
|
|
||||||
pub last_usage: Option<Usage>,
|
|
||||||
/// Craft mode: clean room (true) or deep (false); None = server default.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub clean_room: Option<bool>,
|
|
||||||
/// Craft workflow phase reported by the agent (study, specify, build, check, ready).
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub phase: Option<String>,
|
|
||||||
/// Isolated git worktree of this session: {repo, path, branch, base}.
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub worktree: Option<Value>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Session {
|
|
||||||
pub fn clean_room(&self, default: bool) -> bool {
|
|
||||||
self.clean_room.unwrap_or(default)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Question forwarded from an agent (or an MCP server) to the user.
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Question {
|
|
||||||
pub question: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub header: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub options: Vec<QuestionOption>,
|
|
||||||
#[serde(default)]
|
|
||||||
pub multi_select: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct QuestionOption {
|
|
||||||
pub label: String,
|
|
||||||
#[serde(default)]
|
|
||||||
pub description: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Events broadcast to all connected clients. Clients filter by session id.
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
|
||||||
pub enum Event {
|
|
||||||
SessionUpdated { session: Session },
|
|
||||||
SessionDeleted { session_id: String },
|
|
||||||
SessionStatus { session_id: String, busy: bool },
|
|
||||||
MessageAdded { session_id: String, message: Message },
|
|
||||||
/// Streaming deltas for the assistant message currently being generated.
|
|
||||||
TextDelta { session_id: String, message_id: String, text: String },
|
|
||||||
ReasoningDelta { session_id: String, message_id: String, text: String },
|
|
||||||
ToolStarted { session_id: String, message_id: String, call_id: String, name: String, input: Value },
|
|
||||||
ToolProgress { session_id: String, call_id: String, output: String },
|
|
||||||
PermissionRequest { id: String, session_id: String, root_session_id: String, tool: String, title: String, input: Value, #[serde(skip_serializing_if = "Option::is_none")] diff: Option<String> },
|
|
||||||
PermissionResolved { id: String, session_id: String },
|
|
||||||
QuestionRequest { id: String, session_id: String, root_session_id: String, source: String, questions: Vec<Question> },
|
|
||||||
QuestionResolved { id: String, session_id: String },
|
|
||||||
Error { session_id: String, message: String },
|
|
||||||
/// The session waits for a free model or subagent slot (reason None = no longer waiting).
|
|
||||||
/// reason: "model:<provider/model>" or "subagent:<name>".
|
|
||||||
Waiting { session_id: String, reason: Option<String> },
|
|
||||||
McpStatus { servers: Value },
|
|
||||||
/// A model request started: timing and prompt size for the activity line.
|
|
||||||
StepStarted { session_id: String, message_id: String, started_at: i64, prompt_tokens: u64, estimated: bool },
|
|
||||||
/// Messages waiting for a busy session (full list).
|
|
||||||
QueueUpdated { session_id: String, items: Value },
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Event {
|
|
||||||
pub fn session_id(&self) -> Option<&str> {
|
|
||||||
use Event::*;
|
|
||||||
match self {
|
|
||||||
SessionUpdated { session } => Some(&session.id),
|
|
||||||
SessionDeleted { session_id }
|
|
||||||
| SessionStatus { session_id, .. }
|
|
||||||
| MessageAdded { session_id, .. }
|
|
||||||
| TextDelta { session_id, .. }
|
|
||||||
| ReasoningDelta { session_id, .. }
|
|
||||||
| ToolStarted { session_id, .. }
|
|
||||||
| ToolProgress { session_id, .. }
|
|
||||||
| PermissionRequest { session_id, .. }
|
|
||||||
| PermissionResolved { session_id, .. }
|
|
||||||
| QuestionRequest { session_id, .. }
|
|
||||||
| QuestionResolved { session_id, .. }
|
|
||||||
| Error { session_id, .. }
|
|
||||||
| Waiting { session_id, .. }
|
|
||||||
| QueueUpdated { session_id, .. }
|
|
||||||
| StepStarted { session_id, .. } => Some(session_id),
|
|
||||||
McpStatus { .. } => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn new_id() -> String {
|
|
||||||
uuid::Uuid::now_v7().simple().to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn now_ms() -> i64 {
|
|
||||||
chrono::Utc::now().timestamp_millis()
|
|
||||||
}
|
|
||||||
+5
-1
@@ -14,6 +14,7 @@ import { Images } from "./components/Images";
|
|||||||
import { Jobs } from "./components/Jobs";
|
import { Jobs } from "./components/Jobs";
|
||||||
import { Spinner, useTitleActivity } from "./components/Activity";
|
import { Spinner, useTitleActivity } from "./components/Activity";
|
||||||
import { DialogHost } from "./dialogs";
|
import { DialogHost } from "./dialogs";
|
||||||
|
import { startPrefsSync } from "./prefsSync";
|
||||||
|
|
||||||
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
// The file manager (editor, git) is loaded on demand to keep the chat small.
|
||||||
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
const Files = lazy(() => import("./components/Files").then((m) => ({ default: m.Files })));
|
||||||
@@ -31,7 +32,10 @@ export function App() {
|
|||||||
try {
|
try {
|
||||||
const i = await get<Info>("/info");
|
const i = await get<Info>("/info");
|
||||||
setState(() => ({ me: i.user }));
|
setState(() => ({ me: i.user }));
|
||||||
if (i.user) await loadSessions();
|
if (i.user) {
|
||||||
|
await loadSessions();
|
||||||
|
void startPrefsSync();
|
||||||
|
}
|
||||||
setInfo(i);
|
setInfo(i);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e.message);
|
setError(e.message);
|
||||||
|
|||||||
@@ -253,15 +253,13 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
<h3>{t("stats.byModel")}</h3>
|
<h3>{t("stats.byModel")}</h3>
|
||||||
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
<AggTable rows={data.by_model} keyName="model" label={t("stats.model")} currency={cur} />
|
||||||
</div>
|
</div>
|
||||||
<div className="two-col">
|
<div className="panel">
|
||||||
<div className="panel">
|
<h3>{t("stats.byProvider")}</h3>
|
||||||
<h3>{t("stats.byProvider")}</h3>
|
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
||||||
<AggTable rows={data.by_provider} keyName="provider" label={t("stats.provider")} currency={cur} />
|
</div>
|
||||||
</div>
|
<div className="panel">
|
||||||
<div className="panel">
|
<h3>{t("stats.byAgent")}</h3>
|
||||||
<h3>{t("stats.byAgent")}</h3>
|
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
||||||
<AggTable rows={data.by_agent} keyName="agent" label={t("stats.agent")} currency={cur} />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
{isAdmin && !userFilter && data.by_user.length > 1 && (
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
@@ -271,7 +269,7 @@ export function Stats({ onMenu }: { onMenu: () => void }) {
|
|||||||
)}
|
)}
|
||||||
<div className="panel">
|
<div className="panel">
|
||||||
<h3>{t("stats.topSessions")}</h3>
|
<h3>{t("stats.topSessions")}</h3>
|
||||||
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => navigate(`/s/${r.session_id}`)} />
|
<AggTable rows={data.top_sessions} keyName="title" label={t("stats.session")} currency={cur} link={(r) => !r.session_id.startsWith("cli:") && navigate(`/s/${r.session_id}`)} />
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { post } from "../../api";
|
|||||||
import { useT } from "../../i18n";
|
import { useT } from "../../i18n";
|
||||||
import { useStore } from "../../store";
|
import { useStore } from "../../store";
|
||||||
import { GitAccount } from "./GitAccount";
|
import { GitAccount } from "./GitAccount";
|
||||||
|
import { ApiTokens } from "./ApiTokens";
|
||||||
|
|
||||||
export function Account() {
|
export function Account() {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
@@ -46,6 +47,7 @@ export function Account() {
|
|||||||
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
<div style={{ fontSize: 12, color: "var(--muted)", marginTop: 8 }}>{t("account.hint")}</div>
|
||||||
</div>
|
</div>
|
||||||
<GitAccount />
|
<GitAccount />
|
||||||
|
<ApiTokens />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { del, get, post } from "../../api";
|
||||||
|
import { fmtRelative, useT } from "../../i18n";
|
||||||
|
import { confirmDialog, toast } from "../../dialogs";
|
||||||
|
import { Icon } from "../Icon";
|
||||||
|
|
||||||
|
interface TokenInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string;
|
||||||
|
scope: "full" | "read";
|
||||||
|
created_at: number;
|
||||||
|
last_used?: number | null;
|
||||||
|
expires_at?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Personal API tokens for the CLI and scripts. */
|
||||||
|
export function ApiTokens() {
|
||||||
|
const t = useT();
|
||||||
|
const [list, setList] = useState<TokenInfo[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [scope, setScope] = useState<"full" | "read">("full");
|
||||||
|
const [days, setDays] = useState("");
|
||||||
|
const [created, setCreated] = useState<string>();
|
||||||
|
|
||||||
|
const reload = () => get<TokenInfo[]>("/account/tokens").then(setList).catch((e) => toast(e.message));
|
||||||
|
useEffect(() => {
|
||||||
|
void reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const create = async () => {
|
||||||
|
try {
|
||||||
|
const r = await post<{ token: string }>("/account/tokens", { name: name.trim(), scope, days: days ? Number(days) : undefined });
|
||||||
|
setCreated(r.token);
|
||||||
|
setName("");
|
||||||
|
void reload();
|
||||||
|
} catch (e: any) {
|
||||||
|
toast(e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="panel">
|
||||||
|
<h3>
|
||||||
|
<Icon name="lock" /> {t("tokens.title")}
|
||||||
|
</h3>
|
||||||
|
<div className="hint" style={{ marginBottom: 10 }}>
|
||||||
|
{t("tokens.hint")}
|
||||||
|
</div>
|
||||||
|
{created && (
|
||||||
|
<div className="notice ok token-created">
|
||||||
|
<div>{t("tokens.copyNow")}</div>
|
||||||
|
<div className="row" style={{ gap: 6, marginTop: 6 }}>
|
||||||
|
<code className="token-value">{created}</code>
|
||||||
|
<button
|
||||||
|
className="small"
|
||||||
|
onClick={() => navigator.clipboard.writeText(created).then(() => toast(t("tokens.copied"), "success"))}
|
||||||
|
>
|
||||||
|
<Icon name="copy" /> {t("tokens.copy")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<pre className="mono token-example">{`codecraft login ${location.origin}`}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{list.length > 0 && (
|
||||||
|
<table className="grid">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{t("tokens.name")}</th>
|
||||||
|
<th>{t("tokens.scope")}</th>
|
||||||
|
<th>{t("tokens.lastUsed")}</th>
|
||||||
|
<th>{t("tokens.expires")}</th>
|
||||||
|
<th />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{list.map((k) => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td>
|
||||||
|
{k.name} <code className="muted">{k.prefix}…</code>
|
||||||
|
</td>
|
||||||
|
<td>{t(k.scope === "read" ? "tokens.scopeRead" : "tokens.scopeFull")}</td>
|
||||||
|
<td>{k.last_used ? fmtRelative(k.last_used) : "–"}</td>
|
||||||
|
<td>{k.expires_at ? new Date(k.expires_at).toLocaleDateString() : t("tokens.never")}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
className="small ghost danger"
|
||||||
|
aria-label={t("tokens.revoke")}
|
||||||
|
title={t("tokens.revoke")}
|
||||||
|
onClick={async () => {
|
||||||
|
if (await confirmDialog({ message: t("tokens.revokeConfirm", { name: k.name }), danger: true })) {
|
||||||
|
await del(`/account/tokens/${k.id}`).catch((e) => toast(e.message));
|
||||||
|
void reload();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon name="trash" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<div className="row" style={{ gap: 6, marginTop: 10, flexWrap: "wrap" }}>
|
||||||
|
<input style={{ flex: 1, minWidth: 160 }} placeholder={t("tokens.namePlaceholder")} value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
|
<select value={scope} onChange={(e) => setScope(e.target.value as "full" | "read")}>
|
||||||
|
<option value="full">{t("tokens.scopeFull")}</option>
|
||||||
|
<option value="read">{t("tokens.scopeRead")}</option>
|
||||||
|
</select>
|
||||||
|
<select value={days} onChange={(e) => setDays(e.target.value)}>
|
||||||
|
<option value="">{t("tokens.noExpiry")}</option>
|
||||||
|
<option value="30">30 {t("tokens.days")}</option>
|
||||||
|
<option value="90">90 {t("tokens.days")}</option>
|
||||||
|
<option value="365">365 {t("tokens.days")}</option>
|
||||||
|
</select>
|
||||||
|
<button className="primary" disabled={!name.trim()} onClick={create}>
|
||||||
|
<Icon name="plus" /> {t("tokens.create")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -370,6 +370,24 @@ export const de: Messages = {
|
|||||||
"voice.stop": "Diktat beenden",
|
"voice.stop": "Diktat beenden",
|
||||||
"voice.denied": "Mikrofonzugriff verweigert – in den Browser-Einstellungen erlauben.",
|
"voice.denied": "Mikrofonzugriff verweigert – in den Browser-Einstellungen erlauben.",
|
||||||
"voice.error": "Spracherkennung fehlgeschlagen",
|
"voice.error": "Spracherkennung fehlgeschlagen",
|
||||||
|
"tokens.title": "API-Tokens",
|
||||||
|
"tokens.hint": "Für den Kommandozeilen-Client (codecraft) und Skripte: als „Authorization: Bearer <token>“ mitschicken. Nur-Lesen-Tokens können nur lesen (keine Prompts, keine Änderungen).",
|
||||||
|
"tokens.copyNow": "Token jetzt kopieren – er wird nur einmal angezeigt:",
|
||||||
|
"tokens.copy": "Kopieren",
|
||||||
|
"tokens.copied": "Kopiert",
|
||||||
|
"tokens.name": "Name",
|
||||||
|
"tokens.scope": "Rechte",
|
||||||
|
"tokens.lastUsed": "Zuletzt benutzt",
|
||||||
|
"tokens.expires": "Läuft ab",
|
||||||
|
"tokens.never": "nie",
|
||||||
|
"tokens.revoke": "Widerrufen",
|
||||||
|
"tokens.revokeConfirm": "Token „{name}“ widerrufen? Programme, die ihn nutzen, verlieren sofort den Zugriff.",
|
||||||
|
"tokens.namePlaceholder": "Name, z. B. Laptop-CLI",
|
||||||
|
"tokens.scopeFull": "Voller Zugriff",
|
||||||
|
"tokens.scopeRead": "Nur lesen",
|
||||||
|
"tokens.noExpiry": "Kein Ablauf",
|
||||||
|
"tokens.days": "Tage",
|
||||||
|
"tokens.create": "Token erstellen",
|
||||||
"new.title": "Neue Session",
|
"new.title": "Neue Session",
|
||||||
"new.directory": "Projektverzeichnis",
|
"new.directory": "Projektverzeichnis",
|
||||||
"new.up": "Hoch",
|
"new.up": "Hoch",
|
||||||
|
|||||||
@@ -369,6 +369,24 @@ export const en = {
|
|||||||
"voice.stop": "Stop dictation",
|
"voice.stop": "Stop dictation",
|
||||||
"voice.denied": "Microphone access was denied – allow it in the browser settings.",
|
"voice.denied": "Microphone access was denied – allow it in the browser settings.",
|
||||||
"voice.error": "Speech recognition failed",
|
"voice.error": "Speech recognition failed",
|
||||||
|
"tokens.title": "API tokens",
|
||||||
|
"tokens.hint": "For the command line client (codecraft) and scripts: send as “Authorization: Bearer <token>”. Read-only tokens can only read (no prompts, no changes).",
|
||||||
|
"tokens.copyNow": "Copy the token now – it is shown only once:",
|
||||||
|
"tokens.copy": "Copy",
|
||||||
|
"tokens.copied": "Copied",
|
||||||
|
"tokens.name": "Name",
|
||||||
|
"tokens.scope": "Rights",
|
||||||
|
"tokens.lastUsed": "Last used",
|
||||||
|
"tokens.expires": "Expires",
|
||||||
|
"tokens.never": "never",
|
||||||
|
"tokens.revoke": "Revoke",
|
||||||
|
"tokens.revokeConfirm": "Revoke the token “{name}”? Programs using it lose access immediately.",
|
||||||
|
"tokens.namePlaceholder": "Name, e.g. laptop CLI",
|
||||||
|
"tokens.scopeFull": "Full access",
|
||||||
|
"tokens.scopeRead": "Read only",
|
||||||
|
"tokens.noExpiry": "No expiry",
|
||||||
|
"tokens.days": "days",
|
||||||
|
"tokens.create": "Create token",
|
||||||
"new.title": "New session",
|
"new.title": "New session",
|
||||||
"new.directory": "Project directory",
|
"new.directory": "Project directory",
|
||||||
"new.up": "Up",
|
"new.up": "Up",
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ let current = detect();
|
|||||||
document.documentElement.lang = current;
|
document.documentElement.lang = current;
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
export function onLangChange(fn: () => void) {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => void listeners.delete(fn);
|
||||||
|
}
|
||||||
|
|
||||||
export function getLang() {
|
export function getLang() {
|
||||||
return current;
|
return current;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { get, put } from "./api";
|
||||||
|
import { getLang, onLangChange, setLang } from "./i18n";
|
||||||
|
import { applyTheme, getTheme, getThemeMode, onThemeChange, THEMES, type Theme, type ThemeMode } from "./theme";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Theme and language are stored per device (localStorage) and mirrored to the
|
||||||
|
* server, so the command line client uses the same colours and language and a
|
||||||
|
* new device starts with your settings.
|
||||||
|
*/
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
function push() {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void put("/account/prefs", { theme: getTheme(), theme_mode: getThemeMode(), lang: getLang() }).catch(() => undefined);
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startPrefsSync() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
// A device without own settings takes the server's.
|
||||||
|
const fresh = !localStorage.getItem("codecraft.theme") && !localStorage.getItem("codecraft.themeMode");
|
||||||
|
try {
|
||||||
|
const p = await get<{ theme?: string; theme_mode?: string; lang?: string }>("/account/prefs");
|
||||||
|
if (fresh && p.theme && THEMES.some((t) => t.id === p.theme)) {
|
||||||
|
applyTheme(p.theme as Theme, (p.theme_mode as ThemeMode) ?? "auto");
|
||||||
|
}
|
||||||
|
if (fresh && p.lang) setLang(p.lang);
|
||||||
|
if (!p.theme) push();
|
||||||
|
} catch {
|
||||||
|
/* offline or old server */
|
||||||
|
}
|
||||||
|
onThemeChange(push);
|
||||||
|
onLangChange(push);
|
||||||
|
}
|
||||||
@@ -3040,3 +3040,17 @@ button.danger-solid {
|
|||||||
color: var(--red);
|
color: var(--red);
|
||||||
animation: glow 1.2s ease-in-out infinite;
|
animation: glow 1.2s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.token-value {
|
||||||
|
word-break: break-all;
|
||||||
|
background: var(--bg0);
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.token-example {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-all;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user