Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| da078af73f | |||
| 01ea581f8a | |||
| 552aaebf16 | |||
| c2736ace90 | |||
| 2ff270ebfe | |||
| 5a59fdb64b |
@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
|
|
||||||
Each project can independently use one of:
|
Each project can independently use one of:
|
||||||
|
|
||||||
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||||
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|||||||
208
app/src-tauri/Cargo.lock
generated
208
app/src-tauri/Cargo.lock
generated
@@ -41,56 +41,6 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstream"
|
|
||||||
version = "0.6.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
|
||||||
dependencies = [
|
|
||||||
"anstyle",
|
|
||||||
"anstyle-parse",
|
|
||||||
"anstyle-query",
|
|
||||||
"anstyle-wincon",
|
|
||||||
"colorchoice",
|
|
||||||
"is_terminal_polyfill",
|
|
||||||
"utf8parse",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle"
|
|
||||||
version = "1.0.13"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anstyle-parse"
|
|
||||||
version = "0.2.7"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
|
||||||
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.102"
|
version = "1.0.102"
|
||||||
@@ -404,6 +354,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "byteorder-lite"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.1"
|
version = "1.11.1"
|
||||||
@@ -543,12 +499,6 @@ dependencies = [
|
|||||||
"windows-link 0.2.1",
|
"windows-link 0.2.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorchoice"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "combine"
|
name = "combine"
|
||||||
version = "4.6.7"
|
version = "4.6.7"
|
||||||
@@ -938,29 +888,6 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_filter"
|
|
||||||
version = "1.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"regex",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "env_logger"
|
|
||||||
version = "0.11.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
|
||||||
dependencies = [
|
|
||||||
"anstream",
|
|
||||||
"anstyle",
|
|
||||||
"env_filter",
|
|
||||||
"jiff",
|
|
||||||
"log",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "equivalent"
|
name = "equivalent"
|
||||||
version = "1.0.2"
|
version = "1.0.2"
|
||||||
@@ -1024,6 +951,16 @@ dependencies = [
|
|||||||
"simd-adler32",
|
"simd-adler32",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fern"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
|
||||||
|
dependencies = [
|
||||||
|
"chrono",
|
||||||
|
"log",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@@ -1745,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1862,6 +1799,19 @@ dependencies = [
|
|||||||
"icu_properties",
|
"icu_properties",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "image"
|
||||||
|
version = "0.25.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
|
||||||
|
dependencies = [
|
||||||
|
"bytemuck",
|
||||||
|
"byteorder-lite",
|
||||||
|
"moxcms",
|
||||||
|
"num-traits",
|
||||||
|
"png 0.18.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "indexmap"
|
name = "indexmap"
|
||||||
version = "1.9.3"
|
version = "1.9.3"
|
||||||
@@ -1929,12 +1879,6 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[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.17"
|
version = "1.0.17"
|
||||||
@@ -1964,30 +1908,6 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
|
|
||||||
dependencies = [
|
|
||||||
"jiff-static",
|
|
||||||
"log",
|
|
||||||
"portable-atomic",
|
|
||||||
"portable-atomic-util",
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "jiff-static"
|
|
||||||
version = "0.2.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.117",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jni"
|
name = "jni"
|
||||||
version = "0.21.1"
|
version = "0.21.1"
|
||||||
@@ -2265,6 +2185,16 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "moxcms"
|
||||||
|
version = "0.7.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
"pxfm",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "muda"
|
name = "muda"
|
||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
@@ -2280,7 +2210,7 @@ dependencies = [
|
|||||||
"objc2-core-foundation",
|
"objc2-core-foundation",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -2583,12 +2513,6 @@ version = "1.21.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell_polyfill"
|
|
||||||
version = "1.70.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "open"
|
name = "open"
|
||||||
version = "5.3.3"
|
version = "5.3.3"
|
||||||
@@ -2872,6 +2796,19 @@ dependencies = [
|
|||||||
"miniz_oxide",
|
"miniz_oxide",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "png"
|
||||||
|
version = "0.18.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.11.0",
|
||||||
|
"crc32fast",
|
||||||
|
"fdeflate",
|
||||||
|
"flate2",
|
||||||
|
"miniz_oxide",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "polling"
|
name = "polling"
|
||||||
version = "3.11.0"
|
version = "3.11.0"
|
||||||
@@ -2886,21 +2823,6 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic"
|
|
||||||
version = "1.13.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "portable-atomic-util"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
|
|
||||||
dependencies = [
|
|
||||||
"portable-atomic",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "potential_utf"
|
name = "potential_utf"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@@ -3009,6 +2931,15 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pxfm"
|
||||||
|
version = "0.1.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||||
|
dependencies = [
|
||||||
|
"num-traits",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.38.4"
|
version = "0.38.4"
|
||||||
@@ -4076,6 +4007,7 @@ dependencies = [
|
|||||||
"gtk",
|
"gtk",
|
||||||
"heck 0.5.0",
|
"heck 0.5.0",
|
||||||
"http",
|
"http",
|
||||||
|
"image",
|
||||||
"jni",
|
"jni",
|
||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
@@ -4143,7 +4075,7 @@ dependencies = [
|
|||||||
"ico",
|
"ico",
|
||||||
"json-patch",
|
"json-patch",
|
||||||
"plist",
|
"plist",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"semver",
|
"semver",
|
||||||
@@ -4728,7 +4660,7 @@ dependencies = [
|
|||||||
"objc2-core-graphics",
|
"objc2-core-graphics",
|
||||||
"objc2-foundation",
|
"objc2-foundation",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"png",
|
"png 0.17.16",
|
||||||
"serde",
|
"serde",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"windows-sys 0.60.2",
|
"windows-sys 0.60.2",
|
||||||
@@ -4741,7 +4673,7 @@ dependencies = [
|
|||||||
"bollard",
|
"bollard",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dirs",
|
"dirs",
|
||||||
"env_logger",
|
"fern",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"keyring",
|
"keyring",
|
||||||
"log",
|
"log",
|
||||||
@@ -4889,12 +4821,6 @@ version = "1.0.4"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "utf8parse"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.21.0"
|
version = "1.21.0"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ name = "triple-c"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
tauri = { version = "2", features = ["image-png"] }
|
tauri = { version = "2", features = ["image-png", "image-ico"] }
|
||||||
tauri-plugin-store = "2"
|
tauri-plugin-store = "2"
|
||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
@@ -26,7 +26,7 @@ uuid = { version = "1", features = ["v4"] }
|
|||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
fern = { version = "0.7", features = ["date-based"] }
|
||||||
tar = "0.4"
|
tar = "0.4"
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
|
|||||||
@@ -124,26 +124,15 @@ pub async fn start_project_container(
|
|||||||
let settings = state.settings_store.get();
|
let settings = state.settings_store.get();
|
||||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||||
|
|
||||||
// Get API key only if auth mode requires it
|
// Validate auth mode requirements
|
||||||
let api_key = match project.auth_mode {
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
AuthMode::ApiKey => {
|
|
||||||
let key = secure::get_api_key()?
|
|
||||||
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
|
|
||||||
Some(key)
|
|
||||||
}
|
|
||||||
AuthMode::Login => {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
AuthMode::Bedrock => {
|
|
||||||
let bedrock = project.bedrock_config.as_ref()
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
// Region can come from per-project or global
|
// Region can come from per-project or global
|
||||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
||||||
}
|
}
|
||||||
None
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Update status to starting
|
// Update status to starting
|
||||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||||
@@ -170,6 +159,7 @@ pub async fn start_project_container(
|
|||||||
&existing_id,
|
&existing_id,
|
||||||
&project,
|
&project,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
@@ -179,12 +169,12 @@ pub async fn start_project_container(
|
|||||||
docker::remove_container(&existing_id).await?;
|
docker::remove_container(&existing_id).await?;
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
@@ -195,12 +185,12 @@ pub async fn start_project_container(
|
|||||||
} else {
|
} else {
|
||||||
let new_id = docker::create_container(
|
let new_id = docker::create_container(
|
||||||
&project,
|
&project,
|
||||||
api_key.as_deref(),
|
|
||||||
&docker_socket,
|
&docker_socket,
|
||||||
&image_name,
|
&image_name,
|
||||||
aws_config_path.as_deref(),
|
aws_config_path.as_deref(),
|
||||||
&settings.global_aws,
|
&settings.global_aws,
|
||||||
settings.global_claude_instructions.as_deref(),
|
settings.global_claude_instructions.as_deref(),
|
||||||
|
&settings.global_custom_env_vars,
|
||||||
).await?;
|
).await?;
|
||||||
docker::start_container(&new_id).await?;
|
docker::start_container(&new_id).await?;
|
||||||
new_id
|
new_id
|
||||||
|
|||||||
@@ -2,24 +2,8 @@ use tauri::State;
|
|||||||
|
|
||||||
use crate::docker;
|
use crate::docker;
|
||||||
use crate::models::AppSettings;
|
use crate::models::AppSettings;
|
||||||
use crate::storage::secure;
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_api_key(key: String) -> Result<(), String> {
|
|
||||||
secure::store_api_key(&key)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn has_api_key() -> Result<bool, String> {
|
|
||||||
secure::has_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn delete_api_key() -> Result<(), String> {
|
|
||||||
secure::delete_api_key()
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||||
Ok(state.settings_store.get())
|
Ok(state.settings_store.get())
|
||||||
|
|||||||
@@ -30,6 +30,25 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
|
|||||||
parts.join(",")
|
parts.join(",")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Merge global and per-project custom environment variables.
|
||||||
|
/// Per-project variables override global variables with the same key.
|
||||||
|
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
||||||
|
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
|
||||||
|
for ev in global {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for ev in project {
|
||||||
|
let key = ev.key.trim().to_string();
|
||||||
|
if !key.is_empty() {
|
||||||
|
merged.insert(key, ev.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
merged.into_values().collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Merge global and per-project Claude instructions into a single string.
|
/// Merge global and per-project Claude instructions into a single string.
|
||||||
fn merge_claude_instructions(
|
fn merge_claude_instructions(
|
||||||
global_instructions: Option<&str>,
|
global_instructions: Option<&str>,
|
||||||
@@ -108,12 +127,12 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
|||||||
|
|
||||||
pub async fn create_container(
|
pub async fn create_container(
|
||||||
project: &Project,
|
project: &Project,
|
||||||
api_key: Option<&str>,
|
|
||||||
docker_socket_path: &str,
|
docker_socket_path: &str,
|
||||||
image_name: &str,
|
image_name: &str,
|
||||||
aws_config_path: Option<&str>,
|
aws_config_path: Option<&str>,
|
||||||
global_aws: &GlobalAwsSettings,
|
global_aws: &GlobalAwsSettings,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
) -> Result<String, String> {
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let container_name = project.container_name();
|
let container_name = project.container_name();
|
||||||
@@ -156,10 +175,6 @@ pub async fn create_container(
|
|||||||
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(key) = api_key {
|
|
||||||
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(ref token) = project.git_token {
|
if let Some(ref token) = project.git_token {
|
||||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||||
}
|
}
|
||||||
@@ -222,9 +237,10 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Custom environment variables
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||||
for env_var in &project.custom_env_vars {
|
for env_var in &merged_env {
|
||||||
let key = env_var.key.trim();
|
let key = env_var.key.trim();
|
||||||
if key.is_empty() {
|
if key.is_empty() {
|
||||||
continue;
|
continue;
|
||||||
@@ -236,7 +252,7 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
env_vars.push(format!("{}={}", key, env_var.value));
|
env_vars.push(format!("{}={}", key, env_var.value));
|
||||||
}
|
}
|
||||||
let custom_env_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
|
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||||
|
|
||||||
// Claude instructions (global + per-project)
|
// Claude instructions (global + per-project)
|
||||||
@@ -419,6 +435,7 @@ pub async fn container_needs_recreation(
|
|||||||
container_id: &str,
|
container_id: &str,
|
||||||
project: &Project,
|
project: &Project,
|
||||||
global_claude_instructions: Option<&str>,
|
global_claude_instructions: Option<&str>,
|
||||||
|
global_custom_env_vars: &[EnvVar],
|
||||||
) -> Result<bool, String> {
|
) -> Result<bool, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let info = docker
|
let info = docker
|
||||||
@@ -547,7 +564,8 @@ pub async fn container_needs_recreation(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Custom environment variables ──────────────────────────────────────
|
// ── Custom environment variables ──────────────────────────────────────
|
||||||
let expected_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
|
let expected_fingerprint = compute_env_fingerprint(&merged_env);
|
||||||
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
|
||||||
if container_fingerprint != expected_fingerprint {
|
if container_fingerprint != expected_fingerprint {
|
||||||
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
mod commands;
|
mod commands;
|
||||||
mod docker;
|
mod docker;
|
||||||
|
mod logging;
|
||||||
mod models;
|
mod models;
|
||||||
mod storage;
|
mod storage;
|
||||||
|
|
||||||
@@ -15,23 +16,43 @@ pub struct AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
env_logger::init();
|
logging::init();
|
||||||
|
|
||||||
|
let projects_store = match ProjectsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize projects store: {}", e);
|
||||||
|
panic!("Failed to initialize projects store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let settings_store = match SettingsStore::new() {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to initialize settings store: {}", e);
|
||||||
|
panic!("Failed to initialize settings store: {}", e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_store::Builder::default().build())
|
.plugin(tauri_plugin_store::Builder::default().build())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.manage(AppState {
|
.manage(AppState {
|
||||||
projects_store: ProjectsStore::new().expect("Failed to initialize projects store"),
|
projects_store,
|
||||||
settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
|
settings_store,
|
||||||
exec_manager: ExecSessionManager::new(),
|
exec_manager: ExecSessionManager::new(),
|
||||||
})
|
})
|
||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let icon = tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png"))
|
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||||
.expect("Failed to load window icon");
|
Ok(icon) => {
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
let _ = window.set_icon(icon);
|
let _ = window.set_icon(icon);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to load window icon: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.on_window_event(|window, event| {
|
.on_window_event(|window, event| {
|
||||||
@@ -58,9 +79,6 @@ pub fn run() {
|
|||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::set_api_key,
|
|
||||||
commands::settings_commands::has_api_key,
|
|
||||||
commands::settings_commands::delete_api_key,
|
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
commands::settings_commands::pull_image,
|
commands::settings_commands::pull_image,
|
||||||
|
|||||||
73
app/src-tauri/src/logging.rs
Normal file
73
app/src-tauri/src/logging.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
use std::fs;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
|
||||||
|
fn log_dir() -> Option<PathBuf> {
|
||||||
|
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialise logging to both stderr and a log file in the app data directory.
|
||||||
|
///
|
||||||
|
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
|
||||||
|
/// A panic hook is also installed so that unexpected crashes are captured in the
|
||||||
|
/// same log file before the process exits.
|
||||||
|
pub fn init() {
|
||||||
|
let log_file_path = log_dir().and_then(|dir| {
|
||||||
|
fs::create_dir_all(&dir).ok()?;
|
||||||
|
let path = dir.join("triple-c.log");
|
||||||
|
fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&path)
|
||||||
|
.ok()
|
||||||
|
.map(|file| (path, file))
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut dispatch = fern::Dispatch::new()
|
||||||
|
.format(|out, message, record| {
|
||||||
|
out.finish(format_args!(
|
||||||
|
"[{} {} {}] {}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
record.level(),
|
||||||
|
record.target(),
|
||||||
|
message
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.level(log::LevelFilter::Info)
|
||||||
|
.chain(std::io::stderr());
|
||||||
|
|
||||||
|
if let Some((_path, file)) = &log_file_path {
|
||||||
|
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(e) = dispatch.apply() {
|
||||||
|
eprintln!("Failed to initialise logger: {}", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Install a panic hook that writes to the log file so crashes are captured.
|
||||||
|
let crash_log_dir = log_dir();
|
||||||
|
std::panic::set_hook(Box::new(move |info| {
|
||||||
|
let msg = format!(
|
||||||
|
"[{} PANIC] {}\nBacktrace:\n{:?}",
|
||||||
|
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
|
||||||
|
info,
|
||||||
|
std::backtrace::Backtrace::force_capture(),
|
||||||
|
);
|
||||||
|
eprintln!("{}", msg);
|
||||||
|
if let Some(ref dir) = crash_log_dir {
|
||||||
|
let crash_path = dir.join("triple-c.log");
|
||||||
|
let _ = fs::OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.append(true)
|
||||||
|
.open(&crash_path)
|
||||||
|
.and_then(|mut f| {
|
||||||
|
use std::io::Write;
|
||||||
|
writeln!(f, "{}", msg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
if let Some((ref path, _)) = log_file_path {
|
||||||
|
log::info!("Logging to {}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use super::project::EnvVar;
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_global_instructions() -> Option<String> {
|
||||||
|
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum ImageSource {
|
pub enum ImageSource {
|
||||||
@@ -54,8 +60,10 @@ pub struct AppSettings {
|
|||||||
pub custom_image_name: Option<String>,
|
pub custom_image_name: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub global_aws: GlobalAwsSettings,
|
pub global_aws: GlobalAwsSettings,
|
||||||
#[serde(default)]
|
#[serde(default = "default_global_instructions")]
|
||||||
pub global_claude_instructions: Option<String>,
|
pub global_claude_instructions: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub global_custom_env_vars: Vec<EnvVar>,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub auto_check_updates: bool,
|
pub auto_check_updates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -72,7 +80,8 @@ impl Default for AppSettings {
|
|||||||
image_source: ImageSource::default(),
|
image_source: ImageSource::default(),
|
||||||
custom_image_name: None,
|
custom_image_name: None,
|
||||||
global_aws: GlobalAwsSettings::default(),
|
global_aws: GlobalAwsSettings::default(),
|
||||||
global_claude_instructions: None,
|
global_claude_instructions: default_global_instructions(),
|
||||||
|
global_custom_env_vars: Vec::new(),
|
||||||
auto_check_updates: true,
|
auto_check_updates: true,
|
||||||
dismissed_update_version: None,
|
dismissed_update_version: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,20 +46,21 @@ pub enum ProjectStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// How the project authenticates with Claude.
|
/// How the project authenticates with Claude.
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
/// persisted in the config volume)
|
||||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum AuthMode {
|
||||||
Login,
|
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||||
ApiKey,
|
#[serde(alias = "login", alias = "api_key")]
|
||||||
|
Anthropic,
|
||||||
Bedrock,
|
Bedrock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for AuthMode {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self::Login
|
Self::Anthropic
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,42 +1,3 @@
|
|||||||
const SERVICE_NAME: &str = "triple-c";
|
|
||||||
const API_KEY_USER: &str = "anthropic-api-key";
|
|
||||||
|
|
||||||
pub fn store_api_key(key: &str) -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
entry
|
|
||||||
.set_password(key)
|
|
||||||
.map_err(|e| format!("Failed to store API key: {}", e))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_api_key() -> Result<Option<String>, String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.get_password() {
|
|
||||||
Ok(key) => Ok(Some(key)),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(None),
|
|
||||||
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete_api_key() -> Result<(), String> {
|
|
||||||
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
|
|
||||||
.map_err(|e| format!("Keyring error: {}", e))?;
|
|
||||||
match entry.delete_credential() {
|
|
||||||
Ok(()) => Ok(()),
|
|
||||||
Err(keyring::Error::NoEntry) => Ok(()),
|
|
||||||
Err(e) => Err(format!("Failed to delete API key: {}", e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn has_api_key() -> Result<bool, String> {
|
|
||||||
match get_api_key() {
|
|
||||||
Ok(Some(_)) => Ok(true),
|
|
||||||
Ok(None) => Ok(false),
|
|
||||||
Err(e) => Err(e),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Store a per-project secret in the OS keychain.
|
/// Store a per-project secret in the OS keychain.
|
||||||
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
|
||||||
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
let service = format!("triple-c-project-{}-{}", project_id, key_name);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { useAppState } from "./store/appState";
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage } = useDocker();
|
const { checkDocker, checkImage } = useDocker();
|
||||||
const { checkApiKey, loadSettings } = useSettings();
|
const { loadSettings } = useSettings();
|
||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId } = useAppState(
|
||||||
@@ -25,7 +25,6 @@ export default function App() {
|
|||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) checkImage();
|
if (available) checkImage();
|
||||||
});
|
});
|
||||||
checkApiKey();
|
|
||||||
refresh();
|
refresh();
|
||||||
|
|
||||||
// Update detection
|
// Update detection
|
||||||
|
|||||||
80
app/src/components/projects/ClaudeInstructionsModal.tsx
Normal file
80
app/src/components/projects/ClaudeInstructionsModal.tsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
instructions: string;
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (instructions: string) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [instructions, setInstructions] = useState(initial);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
textareaRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
try { await onSave(instructions); } catch (err) {
|
||||||
|
console.error("Failed to update Claude instructions:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
|
||||||
|
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||||
|
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change Claude instructions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={instructions}
|
||||||
|
onChange={(e) => setInstructions(e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="Enter instructions for Claude Code in this project's container..."
|
||||||
|
disabled={disabled}
|
||||||
|
rows={14}
|
||||||
|
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-4">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
124
app/src/components/projects/EnvVarsModal.tsx
Normal file
124
app/src/components/projects/EnvVarsModal.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
envVars: EnvVar[];
|
||||||
|
disabled: boolean;
|
||||||
|
onSave: (vars: EnvVar[]) => Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
|
||||||
|
const [vars, setVars] = useState<EnvVar[]>(initial);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
|
||||||
|
const updated = [...vars];
|
||||||
|
updated[index] = { ...updated[index], [field]: value };
|
||||||
|
setVars(updated);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVar = async (index: number) => {
|
||||||
|
const updated = vars.filter((_, i) => i !== index);
|
||||||
|
setVars(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to remove environment variable:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addVar = async () => {
|
||||||
|
const updated = [...vars, { key: "", value: "" }];
|
||||||
|
setVars(updated);
|
||||||
|
try { await onSave(updated); } catch (err) {
|
||||||
|
console.error("Failed to add environment variable:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = async () => {
|
||||||
|
try { await onSave(vars); } catch (err) {
|
||||||
|
console.error("Failed to update environment variables:", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
|
||||||
|
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
|
||||||
|
|
||||||
|
{disabled && (
|
||||||
|
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change environment variables.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2 mb-4">
|
||||||
|
{vars.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
|
||||||
|
)}
|
||||||
|
{vars.map((ev, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
value={ev.key}
|
||||||
|
onChange={(e) => updateVar(i, "key", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="KEY"
|
||||||
|
disabled={disabled}
|
||||||
|
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={ev.value}
|
||||||
|
onChange={(e) => updateVar(i, "value", e.target.value)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
placeholder="value"
|
||||||
|
disabled={disabled}
|
||||||
|
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => removeVar(i)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<button
|
||||||
|
onClick={addVar}
|
||||||
|
disabled={disabled}
|
||||||
|
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
+ Add variable
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -47,7 +47,7 @@ const mockProject: Project = {
|
|||||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||||
container_id: null,
|
container_id: null,
|
||||||
status: "stopped",
|
status: "stopped",
|
||||||
auth_mode: "login",
|
auth_mode: "anthropic",
|
||||||
bedrock_config: null,
|
bedrock_config: null,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
ssh_key_path: null,
|
ssh_key_path: null,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod }
|
|||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
|
import EnvVarsModal from "./EnvVarsModal";
|
||||||
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -17,6 +19,8 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showConfig, setShowConfig] = useState(false);
|
const [showConfig, setShowConfig] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
@@ -165,22 +169,6 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClaudeInstructionsBlur = async () => {
|
|
||||||
try {
|
|
||||||
await update({ ...project, claude_instructions: claudeInstructions || null });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update Claude instructions:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEnvVarBlur = async () => {
|
|
||||||
try {
|
|
||||||
await update({ ...project, custom_env_vars: envVars });
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to update environment variables:", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBedrockRegionBlur = async () => {
|
const handleBedrockRegionBlur = async () => {
|
||||||
try {
|
try {
|
||||||
const current = project.bedrock_config ?? defaultBedrockConfig;
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
@@ -279,26 +267,15 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<div className="flex items-center gap-1 text-xs">
|
<div className="flex items-center gap-1 text-xs">
|
||||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||||
disabled={!isStopped}
|
disabled={!isStopped}
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
project.auth_mode === "login"
|
project.auth_mode === "anthropic"
|
||||||
? "bg-[var(--accent)] text-white"
|
? "bg-[var(--accent)] text-white"
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
} disabled:opacity-50`}
|
} disabled:opacity-50`}
|
||||||
>
|
>
|
||||||
/login
|
Anthropic
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className={`px-2 py-0.5 rounded transition-colors ${
|
|
||||||
project.auth_mode === "api_key"
|
|
||||||
? "bg-[var(--accent)] text-white"
|
|
||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
|
||||||
} disabled:opacity-50`}
|
|
||||||
>
|
|
||||||
API key
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||||
@@ -358,6 +335,11 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{/* Config panel */}
|
{/* Config panel */}
|
||||||
{showConfig && (
|
{showConfig && (
|
||||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{!isStopped && (
|
||||||
|
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
|
||||||
|
Container must be stopped to change settings.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Folder paths */}
|
{/* Folder paths */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
|
||||||
@@ -530,76 +512,29 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Environment Variables */}
|
{/* Environment Variables */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
{envVars.map((ev, i) => (
|
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
||||||
<div key={i} className="flex gap-1 mb-1">
|
</label>
|
||||||
<input
|
|
||||||
value={ev.key}
|
|
||||||
onChange={(e) => {
|
|
||||||
const vars = [...envVars];
|
|
||||||
vars[i] = { ...vars[i], key: e.target.value };
|
|
||||||
setEnvVars(vars);
|
|
||||||
}}
|
|
||||||
onBlur={handleEnvVarBlur}
|
|
||||||
placeholder="KEY"
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="w-1/3 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
value={ev.value}
|
|
||||||
onChange={(e) => {
|
|
||||||
const vars = [...envVars];
|
|
||||||
vars[i] = { ...vars[i], value: e.target.value };
|
|
||||||
setEnvVars(vars);
|
|
||||||
}}
|
|
||||||
onBlur={handleEnvVarBlur}
|
|
||||||
placeholder="value"
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
const vars = envVars.filter((_, j) => j !== i);
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
setEnvVars(vars);
|
|
||||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
|
||||||
console.error("Failed to remove environment variable:", err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
|
|
||||||
>
|
>
|
||||||
x
|
Edit
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
const vars = [...envVars, { key: "", value: "" }];
|
|
||||||
setEnvVars(vars);
|
|
||||||
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
|
|
||||||
console.error("Failed to add environment variable:", err);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={!isStopped}
|
|
||||||
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
+ Add variable
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Claude Instructions */}
|
{/* Claude Instructions */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
<textarea
|
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
||||||
value={claudeInstructions}
|
</label>
|
||||||
onChange={(e) => setClaudeInstructions(e.target.value)}
|
<button
|
||||||
onBlur={handleClaudeInstructionsBlur}
|
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||||
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
disabled={!isStopped}
|
>
|
||||||
rows={3}
|
Edit
|
||||||
className="w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
|
</button>
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bedrock config */}
|
{/* Bedrock config */}
|
||||||
@@ -734,6 +669,30 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{error && (
|
{error && (
|
||||||
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={envVars}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setEnvVars(vars);
|
||||||
|
await update({ ...project, custom_env_vars: vars });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showClaudeInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={claudeInstructions}
|
||||||
|
disabled={!isStopped}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setClaudeInstructions(instructions);
|
||||||
|
await update({ ...project, claude_instructions: instructions || null });
|
||||||
|
}}
|
||||||
|
onClose={() => setShowClaudeInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,68 +1,10 @@
|
|||||||
import { useState } from "react";
|
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
|
||||||
|
|
||||||
export default function ApiKeyInput() {
|
export default function ApiKeyInput() {
|
||||||
const { hasKey, saveApiKey, removeApiKey } = useSettings();
|
|
||||||
const [key, setKey] = useState("");
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!key.trim()) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
await saveApiKey(key.trim());
|
|
||||||
setKey("");
|
|
||||||
} catch (e) {
|
|
||||||
setError(String(e));
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
|
||||||
API Key (for projects using API key mode)
|
|
||||||
</label>
|
|
||||||
{hasKey ? (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-[var(--success)]">Key configured</span>
|
|
||||||
<button
|
|
||||||
onClick={async () => {
|
|
||||||
try { await removeApiKey(); } catch (e) { setError(String(e)); }
|
|
||||||
}}
|
|
||||||
className="text-xs text-[var(--error)] hover:underline"
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={key}
|
|
||||||
onChange={(e) => setKey(e.target.value)}
|
|
||||||
placeholder="sk-ant-..."
|
|
||||||
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSave()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving || !key.trim()}
|
|
||||||
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{saving ? "Saving..." : "Save Key"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export default function AwsSettings() {
|
|||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<p className="text-xs text-[var(--text-secondary)]">
|
<p className="text-xs text-[var(--text-secondary)]">
|
||||||
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
||||||
|
Changes here require a container rebuild to take effect.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* AWS Config Path */}
|
{/* AWS Config Path */}
|
||||||
|
|||||||
@@ -4,22 +4,24 @@ import DockerSettings from "./DockerSettings";
|
|||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
|
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||||
|
import type { EnvVar } from "../../lib/types";
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { appSettings, saveSettings } = useSettings();
|
const { appSettings, saveSettings } = useSettings();
|
||||||
const { appVersion, checkForUpdates } = useUpdates();
|
const { appVersion, checkForUpdates } = useUpdates();
|
||||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||||
|
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||||
|
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||||
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
|
|
||||||
// Sync local state when appSettings change
|
// Sync local state when appSettings change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||||
}, [appSettings?.global_claude_instructions]);
|
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||||
|
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
|
||||||
const handleInstructionsBlur = async () => {
|
|
||||||
if (!appSettings) return;
|
|
||||||
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCheckNow = async () => {
|
const handleCheckNow = async () => {
|
||||||
setCheckingUpdates(true);
|
setCheckingUpdates(true);
|
||||||
@@ -43,19 +45,43 @@ export default function SettingsPanel() {
|
|||||||
<ApiKeyInput />
|
<ApiKeyInput />
|
||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
|
{/* Global Claude Instructions */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
|
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<div className="flex items-center justify-between">
|
||||||
value={globalInstructions}
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
onChange={(e) => setGlobalInstructions(e.target.value)}
|
{globalInstructions ? "Configured" : "Not set"}
|
||||||
onBlur={handleInstructionsBlur}
|
</span>
|
||||||
placeholder="Instructions for Claude Code in all project containers..."
|
<button
|
||||||
rows={4}
|
onClick={() => setShowInstructionsModal(true)}
|
||||||
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono"
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
/>
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Environment Variables */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||||
|
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
|
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowEnvVarsModal(true)}
|
||||||
|
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Updates section */}
|
{/* Updates section */}
|
||||||
@@ -89,6 +115,34 @@ export default function SettingsPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showInstructionsModal && (
|
||||||
|
<ClaudeInstructionsModal
|
||||||
|
instructions={globalInstructions}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (instructions) => {
|
||||||
|
setGlobalInstructions(instructions);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowInstructionsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEnvVarsModal && (
|
||||||
|
<EnvVarsModal
|
||||||
|
envVars={globalEnvVars}
|
||||||
|
disabled={false}
|
||||||
|
onSave={async (vars) => {
|
||||||
|
setGlobalEnvVars(vars);
|
||||||
|
if (appSettings) {
|
||||||
|
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onClose={() => setShowEnvVarsModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
|
|||||||
import type { AppSettings } from "../lib/types";
|
import type { AppSettings } from "../lib/types";
|
||||||
|
|
||||||
export function useSettings() {
|
export function useSettings() {
|
||||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
|
const { appSettings, setAppSettings } = useAppState(
|
||||||
useShallow(s => ({
|
useShallow(s => ({
|
||||||
hasKey: s.hasKey,
|
|
||||||
setHasKey: s.setHasKey,
|
|
||||||
appSettings: s.appSettings,
|
appSettings: s.appSettings,
|
||||||
setAppSettings: s.setAppSettings,
|
setAppSettings: s.setAppSettings,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
const checkApiKey = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const has = await commands.hasApiKey();
|
|
||||||
setHasKey(has);
|
|
||||||
return has;
|
|
||||||
} catch {
|
|
||||||
setHasKey(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const saveApiKey = useCallback(
|
|
||||||
async (key: string) => {
|
|
||||||
await commands.setApiKey(key);
|
|
||||||
setHasKey(true);
|
|
||||||
},
|
|
||||||
[setHasKey],
|
|
||||||
);
|
|
||||||
|
|
||||||
const removeApiKey = useCallback(async () => {
|
|
||||||
await commands.deleteApiKey();
|
|
||||||
setHasKey(false);
|
|
||||||
}, [setHasKey]);
|
|
||||||
|
|
||||||
const loadSettings = useCallback(async () => {
|
const loadSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const settings = await commands.getSettings();
|
const settings = await commands.getSettings();
|
||||||
@@ -59,10 +33,6 @@ export function useSettings() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasKey,
|
|
||||||
checkApiKey,
|
|
||||||
saveApiKey,
|
|
||||||
removeApiKey,
|
|
||||||
appSettings,
|
appSettings,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
saveSettings,
|
saveSettings,
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
|
|||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<Project>("rebuild_project_container", { projectId });
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const setApiKey = (key: string) =>
|
|
||||||
invoke<void>("set_api_key", { key });
|
|
||||||
export const hasApiKey = () => invoke<boolean>("has_api_key");
|
|
||||||
export const deleteApiKey = () => invoke<void>("delete_api_key");
|
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
export const updateSettings = (settings: AppSettings) =>
|
export const updateSettings = (settings: AppSettings) =>
|
||||||
invoke<AppSettings>("update_settings", { settings });
|
invoke<AppSettings>("update_settings", { settings });
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AuthMode = "login" | "api_key" | "bedrock";
|
export type AuthMode = "anthropic" | "bedrock";
|
||||||
|
|
||||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
@@ -88,6 +88,7 @@ export interface AppSettings {
|
|||||||
custom_image_name: string | null;
|
custom_image_name: string | null;
|
||||||
global_aws: GlobalAwsSettings;
|
global_aws: GlobalAwsSettings;
|
||||||
global_claude_instructions: string | null;
|
global_claude_instructions: string | null;
|
||||||
|
global_custom_env_vars: EnvVar[];
|
||||||
auto_check_updates: boolean;
|
auto_check_updates: boolean;
|
||||||
dismissed_update_version: string | null;
|
dismissed_update_version: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,9 +24,6 @@ interface AppState {
|
|||||||
setDockerAvailable: (available: boolean | null) => void;
|
setDockerAvailable: (available: boolean | null) => void;
|
||||||
imageExists: boolean | null;
|
imageExists: boolean | null;
|
||||||
setImageExists: (exists: boolean | null) => void;
|
setImageExists: (exists: boolean | null) => void;
|
||||||
hasKey: boolean | null;
|
|
||||||
setHasKey: (has: boolean | null) => void;
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: AppSettings | null;
|
appSettings: AppSettings | null;
|
||||||
setAppSettings: (settings: AppSettings) => void;
|
setAppSettings: (settings: AppSettings) => void;
|
||||||
@@ -85,9 +82,6 @@ export const useAppState = create<AppState>((set) => ({
|
|||||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||||
imageExists: null,
|
imageExists: null,
|
||||||
setImageExists: (exists) => set({ imageExists: exists }),
|
setImageExists: (exists) => set({ imageExists: exists }),
|
||||||
hasKey: null,
|
|
||||||
setHasKey: (has) => set({ hasKey: has }),
|
|
||||||
|
|
||||||
// App settings
|
// App settings
|
||||||
appSettings: null,
|
appSettings: null,
|
||||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||||
|
|||||||
Reference in New Issue
Block a user