Compare commits
10 Commits
v0.1.44-wi
...
v0.1.54-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| db51abb970 | |||
| d947824436 | |||
| c2b21b794c | |||
| 40493ae284 | |||
| 2e81b52205 | |||
| 06be613e36 | |||
| da078af73f | |||
| 01ea581f8a | |||
| 552aaebf16 | |||
| c2736ace90 |
@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
||||
|
||||
Each project can independently use one of:
|
||||
|
||||
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
|
||||
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
|
||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
149
app/src-tauri/Cargo.lock
generated
@@ -41,56 +41,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
@@ -549,12 +499,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
@@ -944,29 +888,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1030,6 +951,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@@ -1948,12 +1879,6 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
@@ -1983,30 +1908,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
@@ -2612,12 +2513,6 @@ version = "1.21.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "open"
|
||||
version = "5.3.3"
|
||||
@@ -2928,21 +2823,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -4793,8 +4673,9 @@ dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"env_logger",
|
||||
"fern",
|
||||
"futures-util",
|
||||
"iana-time-zone",
|
||||
"keyring",
|
||||
"log",
|
||||
"reqwest 0.12.28",
|
||||
@@ -4941,12 +4822,6 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "utf8parse"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.21.0"
|
||||
|
||||
@@ -29,6 +29,7 @@ log = "0.4"
|
||||
fern = { version = "0.7", features = ["date-based"] }
|
||||
tar = "0.4"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
iana-time-zone = "0.1"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 918 B |
|
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 91 KiB |
@@ -124,26 +124,15 @@ pub async fn start_project_container(
|
||||
let settings = state.settings_store.get();
|
||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Get API key only if auth mode requires it
|
||||
let api_key = match project.auth_mode {
|
||||
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)
|
||||
// Validate auth mode requirements
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||
// Region can come from per-project or global
|
||||
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());
|
||||
}
|
||||
AuthMode::Login => {
|
||||
None
|
||||
}
|
||||
AuthMode::Bedrock => {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||
// Region can come from per-project or global
|
||||
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());
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Update status to starting
|
||||
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
|
||||
@@ -171,6 +160,7 @@ pub async fn start_project_container(
|
||||
&project,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
@@ -180,13 +170,13 @@ pub async fn start_project_container(
|
||||
docker::remove_container(&existing_id).await?;
|
||||
let new_id = docker::create_container(
|
||||
&project,
|
||||
api_key.as_deref(),
|
||||
&docker_socket,
|
||||
&image_name,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
).await?;
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
@@ -197,13 +187,13 @@ pub async fn start_project_container(
|
||||
} else {
|
||||
let new_id = docker::create_container(
|
||||
&project,
|
||||
api_key.as_deref(),
|
||||
&docker_socket,
|
||||
&image_name,
|
||||
aws_config_path.as_deref(),
|
||||
&settings.global_aws,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
).await?;
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
|
||||
@@ -2,24 +2,8 @@ use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::AppSettings;
|
||||
use crate::storage::secure;
|
||||
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]
|
||||
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
|
||||
Ok(state.settings_store.get())
|
||||
@@ -45,6 +29,33 @@ pub async fn pull_image(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_host_timezone() -> Result<String, String> {
|
||||
// Try the iana-time-zone crate first (cross-platform)
|
||||
match iana_time_zone::get_timezone() {
|
||||
Ok(tz) => return Ok(tz),
|
||||
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
|
||||
}
|
||||
|
||||
// Fallback: check TZ env var
|
||||
if let Ok(tz) = std::env::var("TZ") {
|
||||
if !tz.is_empty() {
|
||||
return Ok(tz);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: read /etc/timezone (Linux)
|
||||
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
|
||||
let tz = tz.trim().to_string();
|
||||
if !tz.is_empty() {
|
||||
return Ok(tz);
|
||||
}
|
||||
}
|
||||
|
||||
// Default to UTC if detection fails
|
||||
Ok("UTC".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn detect_aws_config() -> Result<Option<String>, String> {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
|
||||
@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn paste_image_to_terminal(
|
||||
session_id: String,
|
||||
image_data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis();
|
||||
let file_name = format!("clipboard_{}.png", timestamp);
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -2,13 +2,43 @@ use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
StartContainerOptions, StopContainerOptions,
|
||||
};
|
||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
||||
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
|
||||
|
||||
### Commands
|
||||
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
|
||||
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
|
||||
- `triple-c-scheduler list` — List all scheduled tasks
|
||||
- `triple-c-scheduler remove --id ID` — Remove a task
|
||||
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
|
||||
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
|
||||
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
|
||||
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
|
||||
|
||||
### Cron format
|
||||
Standard 5-field cron: `minute hour day-of-month month day-of-week`
|
||||
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
|
||||
|
||||
### One-time tasks
|
||||
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
|
||||
|
||||
### Working directory
|
||||
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
|
||||
|
||||
### Checking results
|
||||
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
|
||||
|
||||
### Timezone
|
||||
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||
|
||||
/// Compute a fingerprint string for the custom environment variables.
|
||||
/// Sorted alphabetically so order changes do not cause spurious recreation.
|
||||
@@ -95,6 +125,20 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
||||
format!("{:x}", hasher.finish())
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for port mappings so we can detect changes.
|
||||
/// Sorted so order changes don't cause spurious recreation.
|
||||
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
|
||||
let mut parts: Vec<String> = port_mappings
|
||||
.iter()
|
||||
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
|
||||
.collect();
|
||||
parts.sort();
|
||||
let joined = parts.join(",");
|
||||
let mut hasher = DefaultHasher::new();
|
||||
joined.hash(&mut hasher);
|
||||
format!("{:x}", hasher.finish())
|
||||
}
|
||||
|
||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -127,13 +171,13 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
|
||||
|
||||
pub async fn create_container(
|
||||
project: &Project,
|
||||
api_key: Option<&str>,
|
||||
docker_socket_path: &str,
|
||||
image_name: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
global_aws: &GlobalAwsSettings,
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let container_name = project.container_name();
|
||||
@@ -176,10 +220,6 @@ pub async fn create_container(
|
||||
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 {
|
||||
env_vars.push(format!("GIT_TOKEN={}", token));
|
||||
}
|
||||
@@ -260,11 +300,41 @@ pub async fn create_container(
|
||||
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
|
||||
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
|
||||
|
||||
// Claude instructions (global + per-project)
|
||||
let combined_instructions = merge_claude_instructions(
|
||||
// Container timezone
|
||||
if let Some(tz) = timezone {
|
||||
if !tz.is_empty() {
|
||||
env_vars.push(format!("TZ={}", tz));
|
||||
}
|
||||
}
|
||||
|
||||
// Claude instructions (global + per-project, plus port mapping info)
|
||||
let mut combined_instructions = merge_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
);
|
||||
if !project.port_mappings.is_empty() {
|
||||
let mut port_lines: Vec<String> = Vec::new();
|
||||
port_lines.push("## Available Port Mappings".to_string());
|
||||
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
|
||||
for pm in &project.port_mappings {
|
||||
port_lines.push(format!(
|
||||
"- Host port {} -> Container port {} ({})",
|
||||
pm.host_port, pm.container_port, pm.protocol
|
||||
));
|
||||
}
|
||||
let port_info = port_lines.join("\n");
|
||||
combined_instructions = Some(match combined_instructions {
|
||||
Some(existing) => format!("{}\n\n{}", existing, port_info),
|
||||
None => port_info,
|
||||
});
|
||||
}
|
||||
// Scheduler instructions (always appended so all containers get scheduling docs)
|
||||
let scheduler_docs = SCHEDULER_INSTRUCTIONS;
|
||||
combined_instructions = Some(match combined_instructions {
|
||||
Some(existing) => format!("{}\n\n{}", existing, scheduler_docs),
|
||||
None => scheduler_docs.to_string(),
|
||||
});
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
|
||||
}
|
||||
@@ -351,6 +421,21 @@ pub async fn create_container(
|
||||
});
|
||||
}
|
||||
|
||||
// Port mappings
|
||||
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
|
||||
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
|
||||
for pm in &project.port_mappings {
|
||||
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
|
||||
exposed_ports.insert(container_key.clone(), HashMap::new());
|
||||
port_bindings.insert(
|
||||
container_key,
|
||||
Some(vec![PortBinding {
|
||||
host_ip: Some("0.0.0.0".to_string()),
|
||||
host_port: Some(pm.host_port.to_string()),
|
||||
}]),
|
||||
);
|
||||
}
|
||||
|
||||
let mut labels = HashMap::new();
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
@@ -358,10 +443,14 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||
|
||||
let host_config = HostConfig {
|
||||
mounts: Some(mounts),
|
||||
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
|
||||
init: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -378,6 +467,7 @@ pub async fn create_container(
|
||||
labels: Some(labels),
|
||||
working_dir: Some(working_dir),
|
||||
host_config: Some(host_config),
|
||||
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
|
||||
tty: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -441,6 +531,7 @@ pub async fn container_needs_recreation(
|
||||
project: &Project,
|
||||
global_claude_instructions: Option<&str>,
|
||||
global_custom_env_vars: &[EnvVar],
|
||||
timezone: Option<&str>,
|
||||
) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
let info = docker
|
||||
@@ -493,6 +584,14 @@ pub async fn container_needs_recreation(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Port mappings fingerprint ──────────────────────────────────────────
|
||||
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
|
||||
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
|
||||
if container_ports_fp != expected_ports_fp {
|
||||
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Bedrock config fingerprint ───────────────────────────────────────
|
||||
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
|
||||
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
|
||||
@@ -520,6 +619,14 @@ pub async fn container_needs_recreation(
|
||||
}
|
||||
}
|
||||
|
||||
// ── Timezone ─────────────────────────────────────────────────────────
|
||||
let expected_tz = timezone.unwrap_or("");
|
||||
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
|
||||
if container_tz != expected_tz {
|
||||
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── SSH key path mount ───────────────────────────────────────────────
|
||||
let ssh_mount_source = mounts
|
||||
.and_then(|m| {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use bollard::container::UploadToContainerOptions;
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
@@ -212,4 +213,51 @@ impl ExecSessionManager {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.container_id.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
container_id: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Build a tar archive in memory containing the file
|
||||
let mut tar_buf = Vec::new();
|
||||
{
|
||||
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(data.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
builder
|
||||
.append_data(&mut header, file_name, data)
|
||||
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||
builder
|
||||
.finish()
|
||||
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||
}
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
container_id,
|
||||
Some(UploadToContainerOptions {
|
||||
path: "/tmp".to_string(),
|
||||
..Default::default()
|
||||
}),
|
||||
tar_buf.into(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||
|
||||
Ok(format!("/tmp/{}", file_name))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::models::container_config;
|
||||
|
||||
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
|
||||
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
|
||||
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
|
||||
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
|
||||
|
||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
|
||||
|
||||
let scheduler_bytes = SCHEDULER.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(scheduler_bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
|
||||
|
||||
let task_runner_bytes = TASK_RUNNER.as_bytes();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(task_runner_bytes.len() as u64);
|
||||
header.set_mode(0o755);
|
||||
header.set_cksum();
|
||||
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ pub fn run() {
|
||||
exec_manager: ExecSessionManager::new(),
|
||||
})
|
||||
.setup(|app| {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.ico")) {
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.set_icon(icon);
|
||||
@@ -79,19 +79,18 @@ pub fn run() {
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
// 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::update_settings,
|
||||
commands::settings_commands::pull_image,
|
||||
commands::settings_commands::detect_aws_config,
|
||||
commands::settings_commands::list_aws_profiles,
|
||||
commands::settings_commands::detect_host_timezone,
|
||||
// Terminal
|
||||
commands::terminal_commands::open_terminal_session,
|
||||
commands::terminal_commands::terminal_input,
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
// Updates
|
||||
commands::update_commands::get_app_version,
|
||||
commands::update_commands::check_for_updates,
|
||||
|
||||
@@ -7,7 +7,7 @@ fn default_true() -> bool {
|
||||
}
|
||||
|
||||
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())
|
||||
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.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -68,6 +68,8 @@ pub struct AppSettings {
|
||||
pub auto_check_updates: bool,
|
||||
#[serde(default)]
|
||||
pub dismissed_update_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -84,6 +86,7 @@ impl Default for AppSettings {
|
||||
global_custom_env_vars: Vec::new(),
|
||||
auto_check_updates: true,
|
||||
dismissed_update_version: None,
|
||||
timezone: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,18 @@ pub struct ProjectPath {
|
||||
pub mount_name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PortMapping {
|
||||
pub host_port: u16,
|
||||
pub container_port: u16,
|
||||
#[serde(default = "default_protocol")]
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
fn default_protocol() -> String {
|
||||
"tcp".to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub id: String,
|
||||
@@ -30,6 +42,8 @@ pub struct Project {
|
||||
#[serde(default)]
|
||||
pub custom_env_vars: Vec<EnvVar>,
|
||||
#[serde(default)]
|
||||
pub port_mappings: Vec<PortMapping>,
|
||||
#[serde(default)]
|
||||
pub claude_instructions: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
@@ -46,20 +60,21 @@ pub enum ProjectStatus {
|
||||
}
|
||||
|
||||
/// How the project authenticates with Claude.
|
||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
||||
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||
/// persisted in the config volume)
|
||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
Login,
|
||||
ApiKey,
|
||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||
#[serde(alias = "login", alias = "api_key")]
|
||||
Anthropic,
|
||||
Bedrock,
|
||||
}
|
||||
|
||||
impl Default for AuthMode {
|
||||
fn default() -> Self {
|
||||
Self::Login
|
||||
Self::Anthropic
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +128,7 @@ impl Project {
|
||||
git_user_name: None,
|
||||
git_user_email: None,
|
||||
custom_env_vars: Vec::new(),
|
||||
port_mappings: Vec::new(),
|
||||
claude_instructions: None,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
|
||||
@@ -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.
|
||||
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);
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useAppState } from "./store/appState";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage } = useDocker();
|
||||
const { checkApiKey, loadSettings } = useSettings();
|
||||
const { loadSettings } = useSettings();
|
||||
const { refresh } = useProjects();
|
||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId } = useAppState(
|
||||
@@ -25,7 +25,6 @@ export default function App() {
|
||||
checkDocker().then((available) => {
|
||||
if (available) checkImage();
|
||||
});
|
||||
checkApiKey();
|
||||
refresh();
|
||||
|
||||
// Update detection
|
||||
|
||||
157
app/src/components/projects/PortMappingsModal.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import type { PortMapping } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
portMappings: PortMapping[];
|
||||
disabled: boolean;
|
||||
onSave: (mappings: PortMapping[]) => Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
|
||||
const [mappings, setMappings] = useState<PortMapping[]>(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 updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
|
||||
const updated = [...mappings];
|
||||
const num = parseInt(value, 10);
|
||||
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
const updateProtocol = (index: number, value: string) => {
|
||||
const updated = [...mappings];
|
||||
updated[index] = { ...updated[index], protocol: value };
|
||||
setMappings(updated);
|
||||
};
|
||||
|
||||
const removeMapping = async (index: number) => {
|
||||
const updated = mappings.filter((_, i) => i !== index);
|
||||
setMappings(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to remove port mapping:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const addMapping = async () => {
|
||||
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
|
||||
setMappings(updated);
|
||||
try { await onSave(updated); } catch (err) {
|
||||
console.error("Failed to add port mapping:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
try { await onSave(mappings); } catch (err) {
|
||||
console.error("Failed to update port mappings:", 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-2">Port Mappings</h2>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Map host ports to container ports. Services can be started after the container is running.
|
||||
</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 port mappings.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 mb-4">
|
||||
{mappings.length === 0 && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
|
||||
)}
|
||||
{mappings.length > 0 && (
|
||||
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
|
||||
<span className="w-[30%]">Host Port</span>
|
||||
<span className="w-[30%]">Container Port</span>
|
||||
<span className="w-[25%]">Protocol</span>
|
||||
<span className="w-[15%]" />
|
||||
</div>
|
||||
)}
|
||||
{mappings.map((pm, i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.host_port || ""}
|
||||
onChange={(e) => updatePort(i, "host_port", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="8080"
|
||||
disabled={disabled}
|
||||
className="w-[30%] 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
|
||||
type="number"
|
||||
min="1"
|
||||
max="65535"
|
||||
value={pm.container_port || ""}
|
||||
onChange={(e) => updatePort(i, "container_port", e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
placeholder="8080"
|
||||
disabled={disabled}
|
||||
className="w-[30%] 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"
|
||||
/>
|
||||
<select
|
||||
value={pm.protocol}
|
||||
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
|
||||
disabled={disabled}
|
||||
className="w-[25%] 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"
|
||||
>
|
||||
<option value="tcp">TCP</option>
|
||||
<option value="udp">UDP</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => removeMapping(i)}
|
||||
disabled={disabled}
|
||||
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={addMapping}
|
||||
disabled={disabled}
|
||||
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
|
||||
>
|
||||
+ Add port mapping
|
||||
</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" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
auth_mode: "login",
|
||||
auth_mode: "anthropic",
|
||||
bedrock_config: null,
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: null,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useProjects } from "../../hooks/useProjects";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
|
||||
interface Props {
|
||||
@@ -20,6 +21,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showConfig, setShowConfig] = useState(false);
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const isSelected = selectedProjectId === project.id;
|
||||
const isStopped = project.status === "stopped" || project.status === "error";
|
||||
@@ -32,6 +34,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [gitToken, setGitToken] = useState(project.git_token ?? "");
|
||||
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
|
||||
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
|
||||
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
|
||||
|
||||
// Bedrock local state for text fields
|
||||
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||
@@ -51,6 +54,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
setGitToken(project.git_token ?? "");
|
||||
setClaudeInstructions(project.claude_instructions ?? "");
|
||||
setEnvVars(project.custom_env_vars ?? []);
|
||||
setPortMappings(project.port_mappings ?? []);
|
||||
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
|
||||
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
|
||||
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
|
||||
@@ -267,26 +271,15 @@ export default function ProjectCard({ project }: Props) {
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("login"); }}
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "login"
|
||||
project.auth_mode === "anthropic"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
/login
|
||||
</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
|
||||
Anthropic
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||
@@ -535,6 +528,19 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Port Mappings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowPortMappingsModal(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>
|
||||
|
||||
{/* Claude Instructions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
@@ -693,6 +699,18 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPortMappingsModal && (
|
||||
<PortMappingsModal
|
||||
portMappings={portMappings}
|
||||
disabled={!isStopped}
|
||||
onSave={async (mappings) => {
|
||||
setPortMappings(mappings);
|
||||
await update({ ...project, port_mappings: mappings });
|
||||
}}
|
||||
onClose={() => setShowPortMappingsModal(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showClaudeInstructionsModal && (
|
||||
<ClaudeInstructionsModal
|
||||
instructions={claudeInstructions}
|
||||
|
||||
@@ -1,68 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
|
||||
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 (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ export default function AwsSettings() {
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Global AWS defaults for Bedrock projects. Per-project settings override these.
|
||||
Changes here require a container rebuild to take effect.
|
||||
</p>
|
||||
|
||||
{/* AWS Config Path */}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useSettings } from "../../hooks/useSettings";
|
||||
import { useUpdates } from "../../hooks/useUpdates";
|
||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
@@ -14,6 +15,7 @@ export default function SettingsPanel() {
|
||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
|
||||
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
|
||||
@@ -21,7 +23,18 @@ export default function SettingsPanel() {
|
||||
useEffect(() => {
|
||||
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
|
||||
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
|
||||
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars]);
|
||||
setTimezone(appSettings?.timezone ?? "");
|
||||
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
|
||||
|
||||
// Auto-detect timezone on first load if not yet set
|
||||
useEffect(() => {
|
||||
if (appSettings && !appSettings.timezone) {
|
||||
detectHostTimezone().then((tz) => {
|
||||
setTimezone(tz);
|
||||
saveSettings({ ...appSettings, timezone: tz });
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, [appSettings?.timezone]);
|
||||
|
||||
const handleCheckNow = async () => {
|
||||
setCheckingUpdates(true);
|
||||
@@ -46,6 +59,26 @@ export default function SettingsPanel() {
|
||||
<DockerSettings />
|
||||
<AwsSettings />
|
||||
|
||||
{/* Container Timezone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={timezone}
|
||||
onChange={(e) => setTimezone(e.target.value)}
|
||||
onBlur={async () => {
|
||||
if (appSettings) {
|
||||
await saveSettings({ ...appSettings, timezone: timezone || null });
|
||||
}
|
||||
}}
|
||||
placeholder="UTC"
|
||||
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global Claude Instructions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebglAddon } from "@xterm/addon-webgl";
|
||||
@@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
@@ -14,10 +16,15 @@ interface Props {
|
||||
|
||||
export default function TerminalView({ sessionId, active }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const terminalContainerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const { sendInput, resize, onOutput, onExit } = useTerminal();
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
@@ -79,12 +86,50 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
sendInput(sessionId, data);
|
||||
});
|
||||
|
||||
// Handle image paste: intercept paste events with image data,
|
||||
// upload to the container, and inject the file path into terminal input.
|
||||
const handlePaste = (e: ClipboardEvent) => {
|
||||
const items = e.clipboardData?.items;
|
||||
if (!items) return;
|
||||
|
||||
for (const item of Array.from(items)) {
|
||||
if (item.type.startsWith("image/")) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
const blob = item.getAsFile();
|
||||
if (!blob) return;
|
||||
|
||||
blob.arrayBuffer().then(async (buf) => {
|
||||
try {
|
||||
setImagePasteMsg("Uploading image...");
|
||||
const data = new Uint8Array(buf);
|
||||
const filePath = await pasteImage(sessionId, data);
|
||||
// Inject the file path into terminal stdin
|
||||
sendInput(sessionId, filePath);
|
||||
setImagePasteMsg(`Image saved to ${filePath}`);
|
||||
} catch (err) {
|
||||
console.error("Image paste failed:", err);
|
||||
setImagePasteMsg("Image paste failed");
|
||||
}
|
||||
});
|
||||
return; // Only handle the first image
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
|
||||
|
||||
// Handle backend output -> terminal
|
||||
let aborted = false;
|
||||
|
||||
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||
detectorRef.current = detector;
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data);
|
||||
detector.feed(data);
|
||||
}).then((unlisten) => {
|
||||
if (aborted) unlisten();
|
||||
return unlisten;
|
||||
@@ -116,7 +161,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
return () => {
|
||||
aborted = true;
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
inputDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
outputPromise.then((fn) => fn?.());
|
||||
exitPromise.then((fn) => fn?.());
|
||||
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
|
||||
@@ -160,11 +208,54 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
}
|
||||
}, [active]);
|
||||
|
||||
// Auto-dismiss toast after 30 seconds
|
||||
useEffect(() => {
|
||||
if (!detectedUrl) return;
|
||||
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [detectedUrl]);
|
||||
|
||||
// Auto-dismiss image paste message after 3 seconds
|
||||
useEffect(() => {
|
||||
if (!imagePasteMsg) return;
|
||||
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [imagePasteMsg]);
|
||||
|
||||
const handleOpenUrl = useCallback(() => {
|
||||
if (detectedUrl) {
|
||||
openUrl(detectedUrl).catch((e) =>
|
||||
console.error("Failed to open URL:", e),
|
||||
);
|
||||
setDetectedUrl(null);
|
||||
}
|
||||
}, [detectedUrl]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`w-full h-full ${active ? "" : "hidden"}`}
|
||||
style={{ padding: "8px" }}
|
||||
/>
|
||||
ref={terminalContainerRef}
|
||||
className={`w-full h-full relative ${active ? "" : "hidden"}`}
|
||||
>
|
||||
{detectedUrl && (
|
||||
<UrlToast
|
||||
url={detectedUrl}
|
||||
onOpen={handleOpenUrl}
|
||||
onDismiss={() => setDetectedUrl(null)}
|
||||
/>
|
||||
)}
|
||||
{imagePasteMsg && (
|
||||
<div
|
||||
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
|
||||
onClick={() => setImagePasteMsg(null)}
|
||||
>
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full"
|
||||
style={{ padding: "8px" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
101
app/src/components/terminal/UrlToast.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
interface Props {
|
||||
url: string;
|
||||
onOpen: () => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="animate-slide-down"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 12,
|
||||
left: "50%",
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 40,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
padding: "8px 12px",
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border-color)",
|
||||
borderRadius: 8,
|
||||
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
|
||||
maxWidth: "min(90%, 600px)",
|
||||
}}
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-secondary)",
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
Long URL detected
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
fontFamily: "monospace",
|
||||
color: "var(--text-primary)",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onOpen}
|
||||
style={{
|
||||
padding: "4px 12px",
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
color: "#fff",
|
||||
background: "var(--accent)",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
whiteSpace: "nowrap",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent-hover)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.background = "var(--accent)")
|
||||
}
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
style={{
|
||||
padding: "2px 6px",
|
||||
fontSize: 14,
|
||||
lineHeight: 1,
|
||||
color: "var(--text-secondary)",
|
||||
background: "transparent",
|
||||
border: "none",
|
||||
borderRadius: 4,
|
||||
cursor: "pointer",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
onMouseEnter={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-primary)")
|
||||
}
|
||||
onMouseLeave={(e) =>
|
||||
(e.currentTarget.style.color = "var(--text-secondary)")
|
||||
}
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
|
||||
import type { AppSettings } from "../lib/types";
|
||||
|
||||
export function useSettings() {
|
||||
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
|
||||
const { appSettings, setAppSettings } = useAppState(
|
||||
useShallow(s => ({
|
||||
hasKey: s.hasKey,
|
||||
setHasKey: s.setHasKey,
|
||||
appSettings: s.appSettings,
|
||||
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 () => {
|
||||
try {
|
||||
const settings = await commands.getSettings();
|
||||
@@ -59,10 +33,6 @@ export function useSettings() {
|
||||
);
|
||||
|
||||
return {
|
||||
hasKey,
|
||||
checkApiKey,
|
||||
saveApiKey,
|
||||
removeApiKey,
|
||||
appSettings,
|
||||
loadSettings,
|
||||
saveSettings,
|
||||
|
||||
@@ -49,6 +49,14 @@ export function useTerminal() {
|
||||
[],
|
||||
);
|
||||
|
||||
const pasteImage = useCallback(
|
||||
async (sessionId: string, imageData: Uint8Array) => {
|
||||
const bytes = Array.from(imageData);
|
||||
return commands.pasteImageToTerminal(sessionId, bytes);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const onOutput = useCallback(
|
||||
(sessionId: string, callback: (data: Uint8Array) => void) => {
|
||||
const eventName = `terminal-output-${sessionId}`;
|
||||
@@ -76,6 +84,7 @@ export function useTerminal() {
|
||||
open,
|
||||
close,
|
||||
sendInput,
|
||||
pasteImage,
|
||||
resize,
|
||||
onOutput,
|
||||
onExit,
|
||||
|
||||
@@ -46,3 +46,10 @@ body {
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
/* Toast slide-down animation */
|
||||
@keyframes slide-down {
|
||||
from { opacity: 0; transform: translate(-50%, -8px); }
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||
|
||||
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
|
||||
// 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 updateSettings = (settings: AppSettings) =>
|
||||
invoke<AppSettings>("update_settings", { settings });
|
||||
@@ -39,6 +35,8 @@ export const detectAwsConfig = () =>
|
||||
invoke<string | null>("detect_aws_config");
|
||||
export const listAwsProfiles = () =>
|
||||
invoke<string[]>("list_aws_profiles");
|
||||
export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
@@ -49,6 +47,8 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
invoke<void>("terminal_resize", { sessionId, cols, rows });
|
||||
export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
|
||||
// Updates
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
|
||||
@@ -8,6 +8,12 @@ export interface ProjectPath {
|
||||
mount_name: string;
|
||||
}
|
||||
|
||||
export interface PortMapping {
|
||||
host_port: number;
|
||||
container_port: number;
|
||||
protocol: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -22,6 +28,7 @@ export interface Project {
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
custom_env_vars: EnvVar[];
|
||||
port_mappings: PortMapping[];
|
||||
claude_instructions: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -34,7 +41,7 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type AuthMode = "login" | "api_key" | "bedrock";
|
||||
export type AuthMode = "anthropic" | "bedrock";
|
||||
|
||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||
|
||||
@@ -91,6 +98,7 @@ export interface AppSettings {
|
||||
global_custom_env_vars: EnvVar[];
|
||||
auto_check_updates: boolean;
|
||||
dismissed_update_version: string | null;
|
||||
timezone: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
|
||||
127
app/src/lib/urlDetector.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
|
||||
*
|
||||
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
|
||||
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
|
||||
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
|
||||
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
|
||||
*
|
||||
* When a URL match extends to the end of the flattened buffer, emission is
|
||||
* deferred (more chunks may still be arriving). A confirmation timer emits
|
||||
* the pending URL if no further data arrives within 500 ms.
|
||||
*/
|
||||
|
||||
const ANSI_RE =
|
||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||
|
||||
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
|
||||
const DEBOUNCE_MS = 300;
|
||||
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
|
||||
const MIN_URL_LENGTH = 100;
|
||||
|
||||
export type UrlCallback = (url: string) => void;
|
||||
|
||||
export class UrlDetector {
|
||||
private decoder = new TextDecoder();
|
||||
private buffer = "";
|
||||
private timer: ReturnType<typeof setTimeout> | null = null;
|
||||
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastEmitted = "";
|
||||
private pendingUrl: string | null = null;
|
||||
private callback: UrlCallback;
|
||||
|
||||
constructor(callback: UrlCallback) {
|
||||
this.callback = callback;
|
||||
}
|
||||
|
||||
/** Feed raw PTY output chunks. */
|
||||
feed(data: Uint8Array): void {
|
||||
this.buffer += this.decoder.decode(data, { stream: true });
|
||||
|
||||
// Cap buffer to avoid unbounded growth
|
||||
if (this.buffer.length > MAX_BUFFER) {
|
||||
this.buffer = this.buffer.slice(-MAX_BUFFER);
|
||||
}
|
||||
|
||||
// Cancel pending timers — new data arrived, rescan from scratch
|
||||
if (this.timer !== null) clearTimeout(this.timer);
|
||||
if (this.confirmTimer !== null) {
|
||||
clearTimeout(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
}
|
||||
|
||||
// Debounce — scan after 300 ms of silence
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.scan();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private scan(): void {
|
||||
// 1. Strip ANSI escape sequences
|
||||
const clean = this.buffer.replace(ANSI_RE, "");
|
||||
|
||||
// 2. Flatten the buffer:
|
||||
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
|
||||
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
|
||||
const flat = clean
|
||||
.replace(/(\r?\n){2,}/g, " ")
|
||||
.replace(/[\r\n]/g, "");
|
||||
|
||||
if (!flat) return;
|
||||
|
||||
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
|
||||
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
const url = m[0];
|
||||
|
||||
// 4. Filter by length
|
||||
if (url.length < MIN_URL_LENGTH) continue;
|
||||
|
||||
// 5. If the match extends to the very end of the flattened string,
|
||||
// more chunks may still be arriving — defer emission.
|
||||
if (m.index + url.length >= flat.length) {
|
||||
this.pendingUrl = url;
|
||||
this.confirmTimer = setTimeout(() => {
|
||||
this.confirmTimer = null;
|
||||
this.emitPending();
|
||||
}, CONFIRM_MS);
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. URL is clearly complete (more content follows) — dedup + emit
|
||||
this.pendingUrl = null;
|
||||
if (url !== this.lastEmitted) {
|
||||
this.lastEmitted = url;
|
||||
this.callback(url);
|
||||
}
|
||||
}
|
||||
|
||||
// Scan finished without a URL at the buffer end.
|
||||
// If we had a pending URL from a previous scan, it's now confirmed complete.
|
||||
if (this.pendingUrl) {
|
||||
this.emitPending();
|
||||
}
|
||||
}
|
||||
|
||||
private emitPending(): void {
|
||||
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
|
||||
this.lastEmitted = this.pendingUrl;
|
||||
this.callback(this.pendingUrl);
|
||||
}
|
||||
this.pendingUrl = null;
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
if (this.timer !== null) {
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
if (this.confirmTimer !== null) {
|
||||
clearTimeout(this.confirmTimer);
|
||||
this.confirmTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,9 +24,6 @@ interface AppState {
|
||||
setDockerAvailable: (available: boolean | null) => void;
|
||||
imageExists: boolean | null;
|
||||
setImageExists: (exists: boolean | null) => void;
|
||||
hasKey: boolean | null;
|
||||
setHasKey: (has: boolean | null) => void;
|
||||
|
||||
// App settings
|
||||
appSettings: AppSettings | null;
|
||||
setAppSettings: (settings: AppSettings) => void;
|
||||
@@ -85,9 +82,6 @@ export const useAppState = create<AppState>((set) => ({
|
||||
setDockerAvailable: (available) => set({ dockerAvailable: available }),
|
||||
imageExists: null,
|
||||
setImageExists: (exists) => set({ imageExists: exists }),
|
||||
hasKey: null,
|
||||
setHasKey: (has) => set({ hasKey: has }),
|
||||
|
||||
// App settings
|
||||
appSettings: null,
|
||||
setAppSettings: (settings) => set({ appSettings: settings }),
|
||||
|
||||
@@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
unzip \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
cron \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Remove default ubuntu user to free UID 1000 for host-user remapping
|
||||
@@ -101,5 +102,9 @@ WORKDIR /workspace
|
||||
USER root
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||
RUN chmod +x /usr/local/bin/triple-c-scheduler
|
||||
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
|
||||
RUN chmod +x /usr/local/bin/triple-c-task-runner
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
|
||||
@@ -113,6 +113,59 @@ if [ -S /var/run/docker.sock ]; then
|
||||
usermod -aG "$DOCKER_GROUP" claude
|
||||
fi
|
||||
|
||||
# ── Timezone setup ───────────────────────────────────────────────────────────
|
||||
if [ -n "${TZ:-}" ]; then
|
||||
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
|
||||
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
|
||||
echo "$TZ" > /etc/timezone
|
||||
echo "entrypoint: timezone set to $TZ"
|
||||
else
|
||||
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Scheduler setup ─────────────────────────────────────────────────────────
|
||||
SCHEDULER_DIR="/home/claude/.claude/scheduler"
|
||||
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
|
||||
chown -R claude:claude "$SCHEDULER_DIR"
|
||||
|
||||
# Start cron daemon (runs as root, executes jobs per user crontab)
|
||||
cron
|
||||
|
||||
# Save environment variables for cron jobs (cron runs with a minimal env)
|
||||
ENV_FILE="$SCHEDULER_DIR/.env"
|
||||
: > "$ENV_FILE"
|
||||
env | while IFS='=' read -r key value; do
|
||||
case "$key" in
|
||||
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
|
||||
# Escape single quotes in value and write as KEY='VALUE'
|
||||
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
|
||||
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
chown claude:claude "$ENV_FILE"
|
||||
chmod 600 "$ENV_FILE"
|
||||
|
||||
# Restore crontab from persisted task JSON files (survives container recreation)
|
||||
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
|
||||
CRON_TMP=$(mktemp)
|
||||
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
|
||||
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
|
||||
echo "" >> "$CRON_TMP"
|
||||
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
|
||||
[ -f "$task_file" ] || continue
|
||||
enabled=$(jq -r '.enabled' "$task_file")
|
||||
[ "$enabled" = "true" ] || continue
|
||||
schedule=$(jq -r '.schedule' "$task_file")
|
||||
id=$(jq -r '.id' "$task_file")
|
||||
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
|
||||
done
|
||||
crontab -u claude "$CRON_TMP" 2>/dev/null || true
|
||||
rm -f "$CRON_TMP"
|
||||
echo "entrypoint: restored crontab from persisted tasks"
|
||||
fi
|
||||
|
||||
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
||||
echo "Triple-C container ready."
|
||||
exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||
|
||||
436
container/triple-c-scheduler
Normal file
@@ -0,0 +1,436 @@
|
||||
#!/bin/bash
|
||||
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
|
||||
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
ensure_dirs() {
|
||||
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
|
||||
}
|
||||
|
||||
generate_id() {
|
||||
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
|
||||
}
|
||||
|
||||
rebuild_crontab() {
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
# Header
|
||||
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
|
||||
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
|
||||
echo "" >> "$tmp"
|
||||
|
||||
for task_file in "$TASKS_DIR"/*.json; do
|
||||
[ -f "$task_file" ] || continue
|
||||
local enabled schedule id
|
||||
enabled=$(jq -r '.enabled' "$task_file")
|
||||
[ "$enabled" = "true" ] || continue
|
||||
schedule=$(jq -r '.schedule' "$task_file")
|
||||
id=$(jq -r '.id' "$task_file")
|
||||
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
|
||||
done
|
||||
|
||||
crontab "$tmp" 2>/dev/null || true
|
||||
rm -f "$tmp"
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: triple-c-scheduler <command> [options]
|
||||
|
||||
Commands:
|
||||
add Add a new scheduled task
|
||||
remove Remove a task
|
||||
enable Enable a disabled task
|
||||
disable Disable a task
|
||||
list List all tasks
|
||||
logs Show execution logs
|
||||
run Manually trigger a task now
|
||||
notifications Show or clear completion notifications
|
||||
|
||||
Add options:
|
||||
--name NAME Task name (required)
|
||||
--prompt "TASK" Task prompt for Claude (required)
|
||||
--schedule "CRON" Cron schedule expression (for recurring tasks)
|
||||
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
|
||||
--working-dir DIR Working directory (default: /workspace)
|
||||
|
||||
Remove/Enable/Disable/Run options:
|
||||
--id ID Task ID (required)
|
||||
|
||||
Logs options:
|
||||
--id ID Show logs for a specific task (optional)
|
||||
--tail N Show last N lines (default: 50)
|
||||
|
||||
Notifications options:
|
||||
--clear Clear all notifications
|
||||
|
||||
Examples:
|
||||
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
|
||||
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
|
||||
triple-c-scheduler list
|
||||
triple-c-scheduler logs --id a1b2c3d4 --tail 20
|
||||
triple-c-scheduler run --id a1b2c3d4
|
||||
EOF
|
||||
}
|
||||
|
||||
# ── Commands ─────────────────────────────────────────────────────────────────
|
||||
|
||||
cmd_add() {
|
||||
local name="" prompt="" schedule="" at="" working_dir="/workspace"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--name) name="$2"; shift 2 ;;
|
||||
--prompt) prompt="$2"; shift 2 ;;
|
||||
--schedule) schedule="$2"; shift 2 ;;
|
||||
--at) at="$2"; shift 2 ;;
|
||||
--working-dir) working_dir="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$name" ]; then
|
||||
echo "Error: --name is required" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$prompt" ]; then
|
||||
echo "Error: --prompt is required" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -z "$schedule" ] && [ -z "$at" ]; then
|
||||
echo "Error: either --schedule or --at is required" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$schedule" ] && [ -n "$at" ]; then
|
||||
echo "Error: use either --schedule or --at, not both" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local id task_type cron_expr
|
||||
id=$(generate_id)
|
||||
|
||||
if [ -n "$at" ]; then
|
||||
task_type="once"
|
||||
# Parse "YYYY-MM-DD HH:MM" into cron expression
|
||||
local year month day hour minute
|
||||
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
|
||||
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
|
||||
return 1
|
||||
fi
|
||||
year="${BASH_REMATCH[1]}"
|
||||
month="${BASH_REMATCH[2]}"
|
||||
day="${BASH_REMATCH[3]}"
|
||||
hour="${BASH_REMATCH[4]}"
|
||||
minute="${BASH_REMATCH[5]}"
|
||||
# Remove leading zeros for cron
|
||||
month=$((10#$month))
|
||||
day=$((10#$day))
|
||||
hour=$((10#$hour))
|
||||
minute=$((10#$minute))
|
||||
cron_expr="$minute $hour $day $month *"
|
||||
else
|
||||
task_type="recurring"
|
||||
cron_expr="$schedule"
|
||||
fi
|
||||
|
||||
local created_at
|
||||
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
local task_json
|
||||
task_json=$(jq -n \
|
||||
--arg id "$id" \
|
||||
--arg name "$name" \
|
||||
--arg prompt "$prompt" \
|
||||
--arg schedule "$cron_expr" \
|
||||
--arg type "$task_type" \
|
||||
--arg at "$at" \
|
||||
--arg created_at "$created_at" \
|
||||
--argjson enabled true \
|
||||
--arg working_dir "$working_dir" \
|
||||
'{
|
||||
id: $id,
|
||||
name: $name,
|
||||
prompt: $prompt,
|
||||
schedule: $schedule,
|
||||
type: $type,
|
||||
at: $at,
|
||||
created_at: $created_at,
|
||||
enabled: $enabled,
|
||||
working_dir: $working_dir
|
||||
}')
|
||||
|
||||
echo "$task_json" > "$TASKS_DIR/${id}.json"
|
||||
rebuild_crontab
|
||||
|
||||
echo "Task created:"
|
||||
echo " ID: $id"
|
||||
echo " Name: $name"
|
||||
echo " Type: $task_type"
|
||||
if [ "$task_type" = "once" ]; then
|
||||
echo " At: $at"
|
||||
fi
|
||||
echo " Schedule: $cron_expr"
|
||||
echo " Prompt: $prompt"
|
||||
}
|
||||
|
||||
cmd_remove() {
|
||||
local id=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) id="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "Error: --id is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local task_file="$TASKS_DIR/${id}.json"
|
||||
if [ ! -f "$task_file" ]; then
|
||||
echo "Error: task '$id' not found" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
rm -f "$task_file"
|
||||
rebuild_crontab
|
||||
echo "Removed task '$name' ($id)"
|
||||
}
|
||||
|
||||
cmd_enable() {
|
||||
local id=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) id="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "Error: --id is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local task_file="$TASKS_DIR/${id}.json"
|
||||
if [ ! -f "$task_file" ]; then
|
||||
echo "Error: task '$id' not found" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||
rebuild_crontab
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
echo "Enabled task '$name' ($id)"
|
||||
}
|
||||
|
||||
cmd_disable() {
|
||||
local id=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) id="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "Error: --id is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local task_file="$TASKS_DIR/${id}.json"
|
||||
if [ ! -f "$task_file" ]; then
|
||||
echo "Error: task '$id' not found" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local tmp
|
||||
tmp=$(mktemp)
|
||||
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
|
||||
rebuild_crontab
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
echo "Disabled task '$name' ($id)"
|
||||
}
|
||||
|
||||
cmd_list() {
|
||||
local found=false
|
||||
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
|
||||
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
|
||||
|
||||
for task_file in "$TASKS_DIR"/*.json; do
|
||||
[ -f "$task_file" ] || continue
|
||||
found=true
|
||||
local id name type enabled schedule at prompt
|
||||
id=$(jq -r '.id' "$task_file")
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
type=$(jq -r '.type' "$task_file")
|
||||
enabled=$(jq -r '.enabled' "$task_file")
|
||||
schedule=$(jq -r '.schedule' "$task_file")
|
||||
at=$(jq -r '.at // ""' "$task_file")
|
||||
prompt=$(jq -r '.prompt' "$task_file")
|
||||
|
||||
local display_schedule="$schedule"
|
||||
if [ "$type" = "once" ] && [ -n "$at" ]; then
|
||||
display_schedule="at $at"
|
||||
fi
|
||||
|
||||
# Truncate long fields for display
|
||||
[ ${#name} -gt 20 ] && name="${name:0:17}..."
|
||||
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
|
||||
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
|
||||
|
||||
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
|
||||
done
|
||||
|
||||
if [ "$found" = "false" ]; then
|
||||
echo "No scheduled tasks."
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_logs() {
|
||||
local id="" tail_n=50
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) id="$2"; shift 2 ;;
|
||||
--tail) tail_n="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$id" ]; then
|
||||
local log_dir="$LOGS_DIR/$id"
|
||||
if [ ! -d "$log_dir" ]; then
|
||||
echo "No logs found for task '$id'"
|
||||
return 0
|
||||
fi
|
||||
# Show the most recent log file
|
||||
local latest
|
||||
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
|
||||
if [ -z "$latest" ]; then
|
||||
echo "No logs found for task '$id'"
|
||||
return 0
|
||||
fi
|
||||
echo "=== Latest log for task $id: $(basename "$latest") ==="
|
||||
tail -n "$tail_n" "$latest"
|
||||
else
|
||||
# Show recent logs across all tasks
|
||||
local all_logs
|
||||
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
|
||||
if [ -z "$all_logs" ]; then
|
||||
echo "No logs found."
|
||||
return 0
|
||||
fi
|
||||
for log_file in $all_logs; do
|
||||
local task_id
|
||||
task_id=$(basename "$(dirname "$log_file")")
|
||||
echo "=== Task $task_id: $(basename "$log_file") ==="
|
||||
tail -n 5 "$log_file"
|
||||
echo ""
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
cmd_run() {
|
||||
local id=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--id) id="$2"; shift 2 ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$id" ]; then
|
||||
echo "Error: --id is required" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local task_file="$TASKS_DIR/${id}.json"
|
||||
if [ ! -f "$task_file" ]; then
|
||||
echo "Error: task '$id' not found" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
local name
|
||||
name=$(jq -r '.name' "$task_file")
|
||||
echo "Manually triggering task '$name' ($id)..."
|
||||
/usr/local/bin/triple-c-task-runner "$id"
|
||||
}
|
||||
|
||||
cmd_notifications() {
|
||||
local clear=false
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--clear) clear=true; shift ;;
|
||||
*) echo "Unknown option: $1" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ "$clear" = "true" ]; then
|
||||
rm -f "$NOTIFICATIONS_DIR"/*.notify
|
||||
echo "Notifications cleared."
|
||||
return 0
|
||||
fi
|
||||
|
||||
local found=false
|
||||
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
|
||||
[ -f "$notify_file" ] || continue
|
||||
found=true
|
||||
cat "$notify_file"
|
||||
echo "---"
|
||||
done
|
||||
|
||||
if [ "$found" = "false" ]; then
|
||||
echo "No notifications."
|
||||
fi
|
||||
}
|
||||
|
||||
# ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ensure_dirs
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command="$1"
|
||||
shift
|
||||
|
||||
case "$command" in
|
||||
add) cmd_add "$@" ;;
|
||||
remove) cmd_remove "$@" ;;
|
||||
enable) cmd_enable "$@" ;;
|
||||
disable) cmd_disable "$@" ;;
|
||||
list) cmd_list ;;
|
||||
logs) cmd_logs "$@" ;;
|
||||
run) cmd_run "$@" ;;
|
||||
notifications) cmd_notifications "$@" ;;
|
||||
help|--help|-h) usage ;;
|
||||
*)
|
||||
echo "Unknown command: $command" >&2
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
142
container/triple-c-task-runner
Normal file
@@ -0,0 +1,142 @@
|
||||
#!/bin/bash
|
||||
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
|
||||
# Called by cron with a task ID argument. Handles locking, logging,
|
||||
# notifications, one-time task cleanup, and log pruning.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCHEDULER_DIR="${HOME}/.claude/scheduler"
|
||||
TASKS_DIR="${SCHEDULER_DIR}/tasks"
|
||||
LOGS_DIR="${SCHEDULER_DIR}/logs"
|
||||
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
|
||||
ENV_FILE="${SCHEDULER_DIR}/.env"
|
||||
|
||||
TASK_ID="${1:-}"
|
||||
|
||||
if [ -z "$TASK_ID" ]; then
|
||||
echo "Usage: triple-c-task-runner <task-id>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
|
||||
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
|
||||
|
||||
if [ ! -f "$TASK_FILE" ]; then
|
||||
echo "Task file not found: $TASK_FILE" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
|
||||
exec 200>"$LOCK_FILE"
|
||||
if ! flock -n 200; then
|
||||
echo "Task $TASK_ID is already running, skipping." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Source saved environment ─────────────────────────────────────────────────
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
source "$ENV_FILE"
|
||||
set +a
|
||||
fi
|
||||
|
||||
# ── Read task definition ────────────────────────────────────────────────────
|
||||
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
|
||||
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
|
||||
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
|
||||
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
|
||||
|
||||
# ── Prepare log directory ───────────────────────────────────────────────────
|
||||
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
|
||||
mkdir -p "$TASK_LOG_DIR"
|
||||
|
||||
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
|
||||
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
|
||||
|
||||
# ── Execute Claude agent ────────────────────────────────────────────────────
|
||||
{
|
||||
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
|
||||
echo "=== Started: $(date) ==="
|
||||
echo "=== Working dir: $WORKING_DIR ==="
|
||||
echo "=== Prompt: $PROMPT ==="
|
||||
echo ""
|
||||
} > "$LOG_FILE"
|
||||
|
||||
EXIT_CODE=0
|
||||
if [ -d "$WORKING_DIR" ]; then
|
||||
cd "$WORKING_DIR"
|
||||
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
|
||||
else
|
||||
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
|
||||
EXIT_CODE=1
|
||||
fi
|
||||
|
||||
{
|
||||
echo ""
|
||||
echo "=== Finished: $(date) ==="
|
||||
echo "=== Exit code: $EXIT_CODE ==="
|
||||
} >> "$LOG_FILE"
|
||||
|
||||
# ── Write notification ──────────────────────────────────────────────────────
|
||||
mkdir -p "$NOTIFICATIONS_DIR"
|
||||
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
STATUS="SUCCESS"
|
||||
else
|
||||
STATUS="FAILED (exit code $EXIT_CODE)"
|
||||
fi
|
||||
|
||||
# Extract a summary (last 10 meaningful lines before the footer)
|
||||
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
|
||||
|
||||
cat > "$NOTIFY_FILE" <<NOTIFY
|
||||
Task: $TASK_NAME ($TASK_ID)
|
||||
Status: $STATUS
|
||||
Time: $(date)
|
||||
Type: $TASK_TYPE
|
||||
|
||||
Summary:
|
||||
$SUMMARY
|
||||
NOTIFY
|
||||
|
||||
# ── One-time task cleanup ───────────────────────────────────────────────────
|
||||
if [ "$TASK_TYPE" = "once" ]; then
|
||||
rm -f "$TASK_FILE"
|
||||
# Rebuild crontab to remove the completed one-time task
|
||||
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
|
||||
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
|
||||
TMP_CRON=$(mktemp)
|
||||
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
|
||||
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
|
||||
echo "" >> "$TMP_CRON"
|
||||
for tf in "$TASKS_DIR"/*.json; do
|
||||
[ -f "$tf" ] || continue
|
||||
local_enabled=$(jq -r '.enabled' "$tf")
|
||||
[ "$local_enabled" = "true" ] || continue
|
||||
local_schedule=$(jq -r '.schedule' "$tf")
|
||||
local_id=$(jq -r '.id' "$tf")
|
||||
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
|
||||
done
|
||||
crontab "$TMP_CRON" 2>/dev/null || true
|
||||
rm -f "$TMP_CRON"
|
||||
fi
|
||||
|
||||
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
|
||||
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
|
||||
if [ "$LOG_COUNT" -gt 20 ]; then
|
||||
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
|
||||
fi
|
||||
|
||||
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
|
||||
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
|
||||
if [ "$NOTIFY_COUNT" -gt 50 ]; then
|
||||
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
|
||||
fi
|
||||
|
||||
# Release lock
|
||||
flock -u 200
|
||||
rm -f "$LOCK_FILE"
|
||||
|
||||
exit $EXIT_CODE
|
||||
BIN
triple-c-app-logov2.png
Normal file
|
After Width: | Height: | Size: 191 KiB |