use std::path::Path;
use std::process::Command;
use std::sync::Arc;
use crate::config::AppConfig;
use clap::Subcommand;
use serde::{Deserialize, Serialize};
use stakpak_api::{AgentClient, AgentClientConfig, AgentProvider, StakpakConfig};
pub mod acp;
pub mod agent;
pub mod auth;
pub mod auto_update;
pub mod autopilot;
pub mod board;
pub mod mcp;
pub mod warden;
pub mod watch;
use autopilot::{StartArgs, StopArgs};
pub use auth::AuthCommands;
pub use autopilot::AutopilotCommands;
pub use mcp::McpCommands;
#[derive(Deserialize, Serialize)]
struct RulebookFrontmatter {
uri: String,
description: String,
#[serde(default)]
tags: Vec<String>,
}
fn parse_rulebook_metadata(content: &str) -> Result<(String, String, Vec<String>, String), String> {
let content = content.trim_start();
if !content.starts_with("---") {
return Err("Rulebook file must start with YAML frontmatter (---) containing uri, description, and tags".into());
}
let rest = &content[3..]; let end_pos = rest
.find("\n---")
.ok_or("Frontmatter must end with '---'")?;
let frontmatter_yaml = &rest[..end_pos];
let frontmatter: RulebookFrontmatter = serde_yaml::from_str(frontmatter_yaml)
.map_err(|e| format!("Failed to parse YAML frontmatter: {}", e))?;
let content_body = rest[end_pos + 4..].trim_start().to_string();
Ok((
frontmatter.uri,
frontmatter.description,
frontmatter.tags,
content_body,
))
}
#[derive(Subcommand, PartialEq)]
pub enum ConfigCommands {
#[command(name = "list", alias = "ls")]
List,
Show,
Sample,
New,
}
#[derive(Subcommand, PartialEq)]
pub enum RulebookCommands {
Get {
uri: Option<String>,
},
Apply {
file_path: String,
},
Delete {
uri: String,
},
}
#[derive(Subcommand, PartialEq)]
pub enum Commands {
Version,
#[command(hide = true)]
Login {
#[arg(long, env("STAKPAK_API_KEY"))]
api_key: String,
},
#[command(hide = true)]
Logout,
Acp {
#[arg(long = "system-prompt-file")]
system_prompt_file: Option<String>,
},
Set {
#[arg(long = "machine-name")]
machine_name: Option<String>,
#[arg(long = "auto-append-gitignore")]
auto_append_gitignore: Option<bool>,
},
#[command(subcommand)]
Config(ConfigCommands),
#[command(subcommand, alias = "rb")]
Rulebooks(RulebookCommands),
Account,
Init,
#[command(subcommand)]
Mcp(McpCommands),
#[command(subcommand)]
Auth(AuthCommands),
Warden {
#[arg(short, long, action = clap::ArgAction::Append)]
env: Vec<String>,
#[arg(short, long, action = clap::ArgAction::Append)]
volume: Vec<String>,
#[command(subcommand)]
command: Option<warden::WardenCommands>,
},
Board {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Update,
#[command(subcommand)]
Autopilot(AutopilotCommands),
Up {
#[command(flatten)]
args: StartArgs,
},
Down {
#[command(flatten)]
args: StopArgs,
},
}
async fn build_agent_client(config: &AppConfig) -> Result<AgentClient, String> {
let providers = config.get_llm_provider_config_async().await;
let stakpak = config.get_stakpak_api_key().map(|api_key| StakpakConfig {
api_key,
api_endpoint: config.api_endpoint.clone(),
});
AgentClient::new(AgentClientConfig {
stakpak,
providers,
eco_model: config.eco_model.clone(),
recovery_model: config.recovery_model.clone(),
smart_model: config.smart_model.clone(),
store_path: None,
hook_registry: None,
})
.await
.map_err(|e| format!("Failed to create agent client: {}", e))
}
async fn get_client(config: &AppConfig) -> Result<Arc<dyn AgentProvider>, String> {
Ok(Arc::new(build_agent_client(config).await?))
}
fn get_config_path_option(config: &AppConfig) -> Option<&Path> {
if config.config_path.is_empty() {
None
} else {
Some(Path::new(&config.config_path))
}
}
impl Commands {
pub fn requires_auth(&self) -> bool {
!matches!(
self,
Commands::Login { .. }
| Commands::Logout
| Commands::Set { .. }
| Commands::Config(_)
| Commands::Version
| Commands::Update
| Commands::Acp { .. }
| Commands::Auth(_)
| Commands::Autopilot(_)
| Commands::Up { .. }
| Commands::Down { .. }
)
}
pub async fn run(self, config: AppConfig) -> Result<(), String> {
match self {
Commands::Mcp(command) => {
command.run(config).await?;
}
Commands::Login { api_key } => {
eprintln!("\x1b[33mWarning: 'stakpak login' is deprecated.\x1b[0m");
eprintln!("Please use: \x1b[1;34mstakpak auth login --provider stakpak\x1b[0m");
eprintln!();
let mut updated_config = config.clone();
updated_config.api_key = Some(api_key);
updated_config
.save()
.map_err(|e| format!("Failed to save config: {}", e))?;
}
Commands::Logout => {
eprintln!("\x1b[33mWarning: 'stakpak logout' is deprecated.\x1b[0m");
eprintln!("Please use: \x1b[1;34mstakpak auth logout --provider stakpak\x1b[0m");
eprintln!();
let mut updated_config = config.clone();
updated_config.api_key = None;
updated_config
.save()
.map_err(|e| format!("Failed to save config: {}", e))?;
}
Commands::Set {
machine_name,
auto_append_gitignore,
} => {
let mut updated_config = config.clone();
let mut config_updated = false;
if let Some(name) = machine_name {
updated_config.machine_name = Some(name.clone());
config_updated = true;
println!("Machine name set to: {}", name);
}
if let Some(append) = auto_append_gitignore {
updated_config.auto_append_gitignore = Some(append);
config_updated = true;
println!("Auto-appending .stakpak to .gitignore: {}", append);
}
if config_updated {
updated_config
.save()
.map_err(|e| format!("Failed to save config: {}", e))?;
} else {
println!("No configuration option provided. Available options:");
println!(
" --machine-name <name> Set machine name for device identification"
);
println!(
" --auto-append-gitignore <bool> Enable/disable auto-appending .stakpak to .gitignore"
);
}
}
Commands::Config(config_command) => {
match config_command {
ConfigCommands::List => {
use crate::onboarding::menu::select_profile_interactive;
let config_path = get_config_path_option(&config);
if let Some(selected_profile) =
select_profile_interactive(config_path).await
{
if selected_profile == "CREATE_NEW_PROFILE" {
use crate::onboarding::{OnboardingMode, run_onboarding};
let mut mutable_config = config.clone();
run_onboarding(&mut mutable_config, OnboardingMode::New).await;
use crate::onboarding::menu::prompt_yes_no;
use crate::onboarding::navigation::NavResult;
if let NavResult::Forward(Some(true)) =
prompt_yes_no("Continue to stakpak?", true)
{
let new_profile = mutable_config.profile_name.clone();
re_execute_stakpak_with_profile(
&new_profile,
get_config_path_option(&config),
);
}
} else {
re_execute_stakpak_with_profile(
&selected_profile,
get_config_path_option(&config),
);
}
}
}
ConfigCommands::Show => {
println!("Current configuration:");
println!(" Profile: {}", config.profile_name);
println!(
" Machine name: {}",
config.machine_name.as_deref().unwrap_or("(not set)")
);
println!(
" Auto-append .stakpak to .gitignore: {}",
config.auto_append_gitignore.unwrap_or(true)
);
println!(" API endpoint: {}", config.api_endpoint);
let api_key_display = match &config.api_key {
Some(key) if !key.is_empty() => "***".to_string(),
_ => "(not set)".to_string(),
};
println!(" API key: {}", api_key_display);
}
ConfigCommands::Sample => {
print_sample_config();
}
ConfigCommands::New => {
use crate::onboarding::{OnboardingMode, run_onboarding};
let mut mutable_config = config.clone();
run_onboarding(&mut mutable_config, OnboardingMode::New).await;
use crate::onboarding::menu::prompt_yes_no;
use crate::onboarding::navigation::NavResult;
if let NavResult::Forward(Some(true)) =
prompt_yes_no("Continue to stakpak?", true)
{
let new_profile = mutable_config.profile_name.clone();
re_execute_stakpak_with_profile(
&new_profile,
get_config_path_option(&config),
);
}
}
}
}
Commands::Rulebooks(rulebook_command) => {
let client = get_client(&config).await?;
match rulebook_command {
RulebookCommands::Get { uri } => {
if let Some(uri) = uri {
let rulebook = client.get_rulebook_by_uri(&uri).await?;
let frontmatter = RulebookFrontmatter {
uri: rulebook.uri,
description: rulebook.description,
tags: rulebook.tags,
};
let yaml = serde_yaml::to_string(&frontmatter)
.map_err(|e| format!("Failed to serialize frontmatter: {}", e))?;
println!("---");
print!("{}", yaml.trim());
println!("\n---");
println!("{}", rulebook.content);
} else {
let rulebooks = client.list_rulebooks().await?;
if rulebooks.is_empty() {
println!("No rulebooks found.");
} else {
println!("Rulebooks:\n");
for rb in rulebooks {
println!(" - URI: {}", rb.uri);
println!(" Description: {}", rb.description);
println!(" Tags: {}", rb.tags.join(", "));
println!(" Visibility: {:?}", rb.visibility);
}
}
}
}
RulebookCommands::Apply { file_path } => {
let content = std::fs::read_to_string(file_path)
.map_err(|e| format!("Failed to read file: {}", e))?;
let (uri, description, tags, content_body) =
parse_rulebook_metadata(&content)?;
client
.create_rulebook(&uri, &description, &content_body, tags, None)
.await?;
println!("✓ Rulebook created/updated successfully");
println!(" URI: {}", uri);
}
RulebookCommands::Delete { uri } => {
client.delete_rulebook(&uri).await?;
println!("✓ Rulebook deleted: {}", uri);
}
}
}
Commands::Account => {
let client = get_client(&config).await?;
let data = client.get_my_account().await?;
println!("{}", data.to_text());
}
Commands::Init => {
unreachable!("stakpak init is handled before Commands::run()")
}
Commands::Version => {
println!(
"stakpak v{} (https://github.com/stakpak/agent)",
env!("CARGO_PKG_VERSION")
);
}
Commands::Warden {
env,
volume,
command,
} => {
match command {
Some(warden_command) => {
warden::WardenCommands::run(warden_command, config).await?;
}
None => {
warden::run_default_warden(config, volume, env).await?;
}
}
}
Commands::Board { args } => {
board::run_board(args).await?;
}
Commands::Update => {
auto_update::run_auto_update(false).await?;
}
Commands::Autopilot(autopilot_command) => {
autopilot_command.run(config).await?;
}
Commands::Up { args } => {
AutopilotCommands::Up {
args,
from_service: false,
}
.run(config)
.await?;
}
Commands::Down { args } => {
AutopilotCommands::Down { args }.run(config).await?;
}
Commands::Auth(auth_command) => {
auth_command.run(config).await?;
}
Commands::Acp { system_prompt_file } => {
use crate::utils::check_update::force_auto_update;
if let Err(e) = force_auto_update().await {
eprintln!("Update check failed: {}", e);
}
let system_prompt = if let Some(system_prompt_file_path) = &system_prompt_file {
match std::fs::read_to_string(system_prompt_file_path) {
Ok(content) => {
println!(
"📖 Reading system prompt from file: {}",
system_prompt_file_path
);
Some(content.trim().to_string())
}
Err(e) => {
eprintln!(
"Failed to read system prompt file '{}': {}",
system_prompt_file_path, e
);
None
}
}
} else {
None
};
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let agent =
match crate::commands::acp::StakpakAcpAgent::new(config, tx, system_prompt)
.await
{
Ok(agent) => agent,
Err(e) => {
eprintln!("Failed to create ACP agent: {}", e);
std::process::exit(1);
}
};
if let Err(e) = agent.run_stdio().await {
eprintln!("ACP agent failed: {}", e);
std::process::exit(1);
}
}
}
Ok(())
}
}
fn print_sample_config() {
println!(
r#"# Stakpak Configuration File
# Profile-based configuration allows different settings for different environments
[profiles]
# Special 'all' profile - settings that apply to ALL profiles as defaults
# Individual profiles can override these settings
[profiles.all]
api_endpoint = "https://apiv2.stakpak.dev"
# Common tools that should be available across all profiles
allowed_tools = ["view", "search_docs", "load_skill", "local_code_search"]
# Conservative auto-approve list that works for all environments
auto_approve = ["view", "search_docs", "load_skill"]
[profiles.all.rulebooks]
# Common rulebook patterns for all profiles
include = ["stakpak://yourdomain.com/common/**"]
exclude = ["stakpak://yourdomain.com/archive/**"]
include_tags = ["common", "shared"]
exclude_tags = ["archived", "obsolete"]
# Default profile - used when no specific profile is selected
# Inherits from 'all' profile and can override specific settings
[profiles.default]
api_key = "your_api_key_here"
# Extends the 'all' profile's allowed_tools with additional development tools
allowed_tools = ["view", "search_docs", "load_skill", "local_code_search", "create", "str_replace", "run_command"]
# Inherits auto_approve from 'all' profile (view, search_docs, load_skill)
# No need to redefine unless you want to override
# Rulebook filtering configuration
[profiles.default.rulebooks]
# URI patterns to include (supports glob patterns like * and **)
include = ["stakpak://yourdomain.com/*", "stakpak://**/*.md"]
# URI patterns to exclude (supports glob patterns)
exclude = ["stakpak://restricted.domain.com/**"]
# Tags to include - only rulebooks with these tags will be loaded
include_tags = ["terraform", "kubernetes", "security"]
# Tags to exclude - rulebooks with these tags will be filtered out
exclude_tags = ["deprecated", "experimental"]
# Warden (runtime security) configuration
# When enabled, the main 'stakpak' command will automatically run with Warden security enforcer
# This provides isolation and security policies for the agent execution
[profiles.default.warden]
enabled = true
volumes = [
# working directory
"./:/agent:ro",
# cloud credentials (read-only)
"~/.aws:/home/agent/.aws:ro",
"~/.config/gcloud:/home/agent/.config/gcloud:ro",
"~/.digitalocean:/home/agent/.digitalocean:ro",
"~/.azure:/home/agent/.azure:ro",
"~/.kube:/home/agent/.kube:ro",
]
# Production profile - stricter settings for production environments
# Inherits from 'all' profile but restricts tools for safety
[profiles.production]
api_key = "prod_api_key_here"
# Restricts allowed_tools to only read-only operations (overrides 'all' profile)
allowed_tools = ["view", "search_docs", "load_skill"]
# Uses the same conservative auto_approve list from 'all' profile
# No need to redefine since 'all' profile already has safe defaults
[profiles.production.rulebooks]
# Only include production-ready rulebooks
include = ["stakpak://yourdomain.com/prod/**"]
exclude = ["stakpak://yourdomain.com/dev/**", "stakpak://yourdomain.com/test/**"]
include_tags = ["production", "stable"]
exclude_tags = ["dev", "test", "experimental"]
# Development profile - more permissive settings for development
# Inherits from 'all' profile and extends with development-specific tools
[profiles.development]
api_key = "dev_api_key_here"
# Extends 'all' profile's allowed_tools with write operations for development
allowed_tools = ["view", "search_docs", "load_skill", "local_code_search", "create", "str_replace", "run_command"]
# Extends 'all' profile's auto_approve with additional development tools
auto_approve = ["view", "search_docs", "load_skill", "create"]
[profiles.development.rulebooks]
# Include development and test rulebooks
include = ["stakpak://yourdomain.com/dev/**", "stakpak://yourdomain.com/test/**"]
exclude = []
include_tags = ["dev", "test", "experimental"]
exclude_tags = []
# Global settings that apply to all profiles
[settings]
# Machine name for device identification
machine_name = "my-development-machine"
# Automatically append .stakpak to .gitignore files
auto_append_gitignore = true
# Preferred external editor for /editor command (vim, nvim, nano, code, etc.)
editor = "nano"
"#
);
}
fn re_execute_stakpak_with_profile(profile: &str, config_path: Option<&std::path::Path>) {
let mut cmd = Command::new("stakpak");
if !profile.is_empty() {
cmd.arg("--profile").arg(profile);
}
if let Some(config_path) = config_path {
cmd.arg("--config").arg(config_path);
}
let args: Vec<String> = std::env::args().skip(1).collect();
let mut skip_next = false;
for arg in args {
if skip_next {
skip_next = false;
continue;
}
if arg == "config" {
skip_next = true;
continue;
}
if arg == "--profile" || arg == "--config" {
skip_next = true;
continue;
}
if arg.starts_with("--profile=") || arg.starts_with("--config=") {
continue;
}
cmd.arg(arg);
}
let status = cmd.status();
match status {
Ok(s) if s.success() => {
std::process::exit(s.code().unwrap_or(0));
}
Ok(s) => {
std::process::exit(s.code().unwrap_or(1));
}
Err(_) => {
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn up_defaults_to_background_mode() {
let args = StartArgs {
bind: "127.0.0.1:4096".to_string(),
show_token: false,
no_auth: false,
model: None,
auto_approve_all: false,
foreground: false,
non_interactive: false,
force: false,
};
assert!(!args.foreground);
}
}