Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,449 @@
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
use clap::{Args, Subcommand, ValueEnum};
|
||||
|
||||
use crate::{
|
||||
config_file::ConfigFileArgs, environment::EnvironmentCreateArgs, mcp::MCPSpec,
|
||||
model::ModelArgs, scope::ObjectScope, share::ShareArgs, skill::SkillSpec,
|
||||
};
|
||||
|
||||
/// Output format for agent results.
|
||||
#[derive(Debug, Copy, Clone, ValueEnum, Eq, PartialEq, Default)]
|
||||
pub enum OutputFormat {
|
||||
/// Output as JSON.
|
||||
#[value(name = "json")]
|
||||
Json,
|
||||
/// Output as newline-delimited JSON.
|
||||
#[value(name = "ndjson")]
|
||||
Ndjson,
|
||||
/// Output as human-readable text.
|
||||
#[default]
|
||||
#[value(name = "pretty")]
|
||||
Pretty,
|
||||
/// Output as plain text.
|
||||
#[value(name = "text")]
|
||||
Text,
|
||||
}
|
||||
|
||||
impl fmt::Display for OutputFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = self.to_possible_value().expect("no values are skipped");
|
||||
f.write_str(value.get_name())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Prompt {
|
||||
PlainText(String),
|
||||
SavedPrompt(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for Prompt {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Prompt::PlainText(text) => write!(f, "Prompt: {text}"),
|
||||
Prompt::SavedPrompt(id) => write!(f, "Saved Prompt ID: {id}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt arguments - mutually exclusive prompt or saved-prompt.
|
||||
/// The required constraint is enforced at the command level via ArgGroup.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[group(multiple = false)]
|
||||
pub struct PromptArg {
|
||||
/// Prompt for the agent to carry out.
|
||||
#[arg(long = "prompt", short = 'p')]
|
||||
pub prompt: Option<String>,
|
||||
/// The saved AI prompt to run, identified by id.
|
||||
#[arg(long = "saved-prompt")]
|
||||
pub saved_prompt: Option<String>,
|
||||
}
|
||||
|
||||
impl PromptArg {
|
||||
pub fn to_prompt(&self) -> Option<Prompt> {
|
||||
match (self.prompt.as_ref(), self.saved_prompt.as_ref()) {
|
||||
(Some(prompt), None) => Some(Prompt::PlainText(prompt.clone())),
|
||||
(None, Some(saved_prompt)) => Some(Prompt::SavedPrompt(saved_prompt.clone())),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared CLI args for controlling computer use capabilities.
|
||||
#[derive(Debug, Clone, Args, Default)]
|
||||
pub struct ComputerUseArgs {
|
||||
/// Enable computer use capabilities for this agent run.
|
||||
#[arg(long = "computer-use", conflicts_with = "no_computer_use")]
|
||||
pub computer_use: bool,
|
||||
|
||||
/// Disable computer use capabilities for this agent run.
|
||||
#[arg(long = "no-computer-use", conflicts_with = "computer_use")]
|
||||
pub no_computer_use: bool,
|
||||
}
|
||||
|
||||
impl ComputerUseArgs {
|
||||
/// Returns the computer use override based on CLI flags.
|
||||
/// - `Some(true)` if `--computer-use` was specified
|
||||
/// - `Some(false)` if `--no-computer-use` was specified
|
||||
/// - `None` if neither was specified (use default behavior)
|
||||
pub fn computer_use_override(&self) -> Option<bool> {
|
||||
match (self.computer_use, self.no_computer_use) {
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hidden variant of [`ComputerUseArgs`] for commands where computer use flags
|
||||
/// should be accepted but not shown in help output.
|
||||
#[derive(Debug, Clone, Args, Default)]
|
||||
pub struct HiddenComputerUseArgs {
|
||||
/// Enable computer use capabilities for this agent run.
|
||||
#[arg(long = "computer-use", conflicts_with = "no_computer_use", hide = true)]
|
||||
pub computer_use: bool,
|
||||
|
||||
/// Disable computer use capabilities for this agent run.
|
||||
#[arg(long = "no-computer-use", conflicts_with = "computer_use", hide = true)]
|
||||
pub no_computer_use: bool,
|
||||
}
|
||||
|
||||
impl HiddenComputerUseArgs {
|
||||
pub fn computer_use_override(&self) -> Option<bool> {
|
||||
match (self.computer_use, self.no_computer_use) {
|
||||
(true, false) => Some(true),
|
||||
(false, true) => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The execution harness for an agent run.
|
||||
#[derive(Debug, Copy, Clone, ValueEnum, Eq, PartialEq, Default)]
|
||||
pub enum Harness {
|
||||
/// Use Warp's built-in MAA infrastructure (default).
|
||||
#[default]
|
||||
#[value(name = "oz")]
|
||||
Oz,
|
||||
/// Delegate to the `claude` CLI.
|
||||
#[value(name = "claude", alias = "claude-code")]
|
||||
Claude,
|
||||
/// Delegate to the `opencode` CLI.
|
||||
#[value(name = "opencode", alias = "open-code")]
|
||||
OpenCode,
|
||||
/// Delegate to the `gemini` CLI.
|
||||
#[value(name = "gemini")]
|
||||
Gemini,
|
||||
/// A harness produced by a newer client/server that this client doesn't
|
||||
/// recognize. Surfaced via deserialization fallbacks (e.g. unknown GraphQL
|
||||
/// enum values, unknown `harness_type` strings); never selectable from the
|
||||
/// CLI or harness dropdown.
|
||||
#[value(skip)]
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl Harness {
|
||||
pub fn parse_orchestration_harness(value: &str) -> Option<Self> {
|
||||
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
|
||||
<Self as ValueEnum>::from_str(&normalized, true).ok()
|
||||
}
|
||||
|
||||
pub fn parse_local_child_harness(value: &str) -> Option<Self> {
|
||||
match Self::parse_orchestration_harness(value) {
|
||||
Some(harness @ (Self::Claude | Self::OpenCode)) => Some(harness),
|
||||
Some(Self::Oz) | Some(Self::Gemini) | Some(Self::Unknown) | None => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn display_name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Oz => "Oz",
|
||||
Self::Claude => "Claude Code",
|
||||
Self::OpenCode => "OpenCode",
|
||||
Self::Gemini => "Gemini CLI",
|
||||
Self::Unknown => "Unknown",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Harness {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let name = match self {
|
||||
Harness::Oz => "oz",
|
||||
Harness::Claude => "claude",
|
||||
Harness::OpenCode => "opencode",
|
||||
Harness::Gemini => "gemini",
|
||||
Harness::Unknown => "unknown",
|
||||
};
|
||||
f.write_str(name)
|
||||
}
|
||||
}
|
||||
|
||||
/// Profile subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum AgentProfileCommand {
|
||||
/// List available agent profiles.
|
||||
List,
|
||||
}
|
||||
|
||||
/// Agent-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum AgentCommand {
|
||||
/// Run a new Oz agent.
|
||||
Run(RunAgentArgs),
|
||||
/// Dispatch an Oz agent that runs remotely.
|
||||
RunCloud(RunCloudArgs),
|
||||
/// Manage agent profiles.
|
||||
#[command(subcommand)]
|
||||
Profile(AgentProfileCommand),
|
||||
/// List all available agents.
|
||||
List(ListAgentConfigsArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(
|
||||
visible_alias = "r",
|
||||
group(
|
||||
clap::ArgGroup::new("prompt_group")
|
||||
.required(true)
|
||||
.multiple(true)
|
||||
.args(["prompt", "saved_prompt", "task_id", "skill"])
|
||||
)
|
||||
)]
|
||||
pub struct RunAgentArgs {
|
||||
#[command(flatten)]
|
||||
pub prompt_arg: PromptArg,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
/// Use a skill as the base prompt for the agent.
|
||||
///
|
||||
/// Format: `skill_name`, `repo:skill_name`, or `org/repo:skill_name`
|
||||
///
|
||||
/// Skills are searched in `.agents/skills/`, `.warp/skills/`, `.claude/skills/`, and `.codex/skills/` directories.
|
||||
/// If a repo is specified, searches only that repo. If org is also specified,
|
||||
/// validates the repo's git remote matches the expected org.
|
||||
///
|
||||
/// When used with --prompt, the skill provides the base context and the prompt is the task.
|
||||
///
|
||||
/// To automate a skill on a schedule, use `oz schedule create --skill <SPEC>`.
|
||||
#[arg(long = "skill", value_name = "SPEC")]
|
||||
pub skill: Option<SkillSpec>,
|
||||
|
||||
/// Name for this agent task.
|
||||
#[arg(long = "name", short = 'n')]
|
||||
pub name: Option<String>,
|
||||
/// Working directory for the agent
|
||||
#[arg(short = 'C', long = "cwd")]
|
||||
pub cwd: Option<PathBuf>,
|
||||
/// Display agent progress in the Warp interface.
|
||||
#[arg(long = "gui", hide = true)]
|
||||
pub gui: bool,
|
||||
#[command(flatten)]
|
||||
pub share: ShareArgs,
|
||||
/// MCP servers to start before executing the agent.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
/// LEGACY: MCP servers to start before executing the agent, identified by UUID.
|
||||
#[arg(long = "mcp-server", value_name = "UUID", hide = true)]
|
||||
pub mcp_servers: Vec<uuid::Uuid>,
|
||||
/// Cloud environment to use, identified by ID.
|
||||
#[arg(long = "environment", short = 'e', value_name = "ID")]
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Keep the agent's session open after the conversation completes.
|
||||
///
|
||||
/// This is useful when you want to keep the session alive for follow-up interactions.
|
||||
///
|
||||
/// You can optionally provide a duration (e.g. `--idle-on-complete 10m`).
|
||||
#[arg(
|
||||
long = "idle-on-complete",
|
||||
value_name = "DURATION",
|
||||
num_args = 0..=1,
|
||||
default_missing_value = "45m",
|
||||
hide = true
|
||||
)]
|
||||
pub idle_on_complete: Option<humantime::Duration>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub snapshot: SnapshotArgs,
|
||||
/// Identifier for the task that spawned this agent, used to report progress.
|
||||
#[arg(long = "task-id", hide = true, conflicts_with_all = ["prompt", "saved_prompt", "file"])]
|
||||
pub task_id: Option<String>,
|
||||
|
||||
/// Whether we are running the agent in a sandboxed environment.
|
||||
#[arg(long = "sandboxed", hide = true)]
|
||||
pub sandboxed: bool,
|
||||
/// IAM role ARN to use for federated AWS Bedrock credentials for this run.
|
||||
#[arg(long = "bedrock-inference-role", value_name = "ROLE_ARN", hide = true)]
|
||||
pub bedrock_inference_role: Option<String>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub computer_use: HiddenComputerUseArgs,
|
||||
|
||||
/// Continue an existing cloud conversation by ID.
|
||||
#[arg(long = "conversation", value_name = "ID")]
|
||||
pub conversation: Option<String>,
|
||||
|
||||
/// Agent profile to configure the terminal session.
|
||||
#[arg(long = "profile", value_name = "ID")]
|
||||
pub profile: Option<String>,
|
||||
|
||||
/// Execution harness for the agent run.
|
||||
///
|
||||
/// "oz" (default) uses Warp's built-in agent infrastructure.
|
||||
/// "claude" delegates to the `claude` CLI.
|
||||
#[arg(long = "harness", value_name = "HARNESS", default_value_t = Harness::Oz, hide = true)]
|
||||
pub harness: Harness,
|
||||
}
|
||||
|
||||
impl RunAgentArgs {
|
||||
/// Combine `mcp_specs` with legacy `mcp_servers` (UUIDs) into a single list.
|
||||
pub fn all_mcp_specs(&self) -> Vec<MCPSpec> {
|
||||
let mut specs = self.mcp_specs.clone();
|
||||
specs.extend(self.mcp_servers.iter().cloned().map(MCPSpec::Uuid));
|
||||
specs
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct SnapshotArgs {
|
||||
/// Disable the end-of-run workspace snapshot upload.
|
||||
#[arg(long = "no-snapshot")]
|
||||
pub no_snapshot: bool,
|
||||
|
||||
/// Maximum time to wait for the end-of-run snapshot upload.
|
||||
#[arg(long = "snapshot-upload-timeout", value_name = "DURATION")]
|
||||
pub snapshot_upload_timeout: Option<humantime::Duration>,
|
||||
|
||||
/// Maximum time to wait for the declarations script before uploading the snapshot.
|
||||
#[arg(long = "snapshot-script-timeout", value_name = "DURATION")]
|
||||
pub snapshot_script_timeout: Option<humantime::Duration>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(
|
||||
name = "run-cloud",
|
||||
visible_alias = "ra",
|
||||
alias = "run-ambient",
|
||||
group(
|
||||
clap::ArgGroup::new("prompt_group")
|
||||
.required(true)
|
||||
.multiple(true)
|
||||
.args(["prompt", "saved_prompt", "skill"])
|
||||
)
|
||||
)]
|
||||
pub struct RunCloudArgs {
|
||||
#[command(flatten)]
|
||||
pub prompt_arg: PromptArg,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
/// Use a skill as the base prompt for the agent.
|
||||
///
|
||||
/// Format: `skill_name`, `repo:skill_name`, or `org/repo:skill_name`
|
||||
///
|
||||
/// Skills are searched in `.agents/skills/`, `.warp/skills/`, `.claude/skills/`, and `.codex/skills/` directories.
|
||||
/// If a repo is specified, searches only that repo. If org is also specified,
|
||||
/// validates the repo's git remote matches the expected org.
|
||||
///
|
||||
/// When used with --prompt, the skill provides the base context and the prompt is the task.
|
||||
///
|
||||
/// To automate a skill on a schedule, use `oz schedule create --skill <SPEC>`.
|
||||
#[arg(long = "skill", value_name = "SPEC")]
|
||||
pub skill: Option<SkillSpec>,
|
||||
|
||||
/// Name for this agent task.
|
||||
#[arg(long = "name", short = 'n')]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// MCP servers to start before executing the agent.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
|
||||
/// The environment to run this ambient agent in.
|
||||
#[command(flatten)]
|
||||
pub environment: EnvironmentCreateArgs,
|
||||
/// Open the agent's session in Warp once it's available.
|
||||
#[arg(long = "open")]
|
||||
pub open: bool,
|
||||
|
||||
/// Continue an existing cloud conversation by ID.
|
||||
#[arg(long = "conversation", value_name = "ID")]
|
||||
pub conversation: Option<String>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
|
||||
/// Where this job should be hosted. Setting "warp" runs it on Warp's infrastructure. Any other
|
||||
/// value is treated is a self-hosted job and the value will be matched with the self-hosted
|
||||
/// worker's name.
|
||||
#[arg(long = "host", value_name = "WORKER_ID")]
|
||||
pub worker_host: Option<String>,
|
||||
|
||||
/// Path to a file to attach to the agent query.
|
||||
///
|
||||
/// Can be specified multiple times to attach multiple files (maximum 5).
|
||||
///
|
||||
/// Example: --attach file1.png --attach file2.txt
|
||||
#[arg(
|
||||
long = "attach",
|
||||
value_name = "PATH",
|
||||
num_args = 1,
|
||||
action = clap::ArgAction::Append,
|
||||
value_parser = clap::value_parser!(PathBuf),
|
||||
)]
|
||||
pub attachment_paths: Vec<PathBuf>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub computer_use: ComputerUseArgs,
|
||||
#[command(flatten)]
|
||||
pub snapshot: SnapshotArgs,
|
||||
|
||||
/// Execution harness for the agent run.
|
||||
///
|
||||
/// "oz" (default) uses Warp's built-in agent infrastructure.
|
||||
/// "claude" delegates to the `claude` CLI.
|
||||
#[arg(long = "harness", value_name = "HARNESS", default_value_t = Harness::Oz, hide = true)]
|
||||
pub harness: Harness,
|
||||
|
||||
/// Name of a managed secret for Claude Code harness authentication.
|
||||
///
|
||||
/// Resolved server-side and injected into the agent container.
|
||||
/// Only valid when --harness is set to "claude".
|
||||
#[arg(long = "claude-auth-secret", value_name = "NAME", hide = true)]
|
||||
pub claude_auth_secret: Option<String>,
|
||||
}
|
||||
|
||||
/// Arguments for listing available agents.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ListAgentConfigsArgs {
|
||||
/// List skills from a specific GitHub repository.
|
||||
///
|
||||
/// Format: `owner/repo` or `https://github.com/owner/repo`
|
||||
///
|
||||
/// When provided, lists skills from this repo instead of from your environments.
|
||||
/// Any environments that include this repo will still be shown in the results.
|
||||
#[arg(long = "repo", short = 'r', value_name = "REPO")]
|
||||
pub repo: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{ArgGroup, Args, Subcommand};
|
||||
|
||||
/// Artifact-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ArtifactCommand {
|
||||
/// Upload an artifact file.
|
||||
#[command(hide = true)]
|
||||
Upload(UploadArtifactArgs),
|
||||
/// Get artifact metadata.
|
||||
Get(GetArtifactArgs),
|
||||
/// Download an artifact file.
|
||||
Download(DownloadArtifactArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(
|
||||
group(
|
||||
ArgGroup::new("artifact_association")
|
||||
.multiple(false)
|
||||
.args(["run_id", "conversation_id"])
|
||||
)
|
||||
)]
|
||||
pub struct UploadArtifactArgs {
|
||||
/// Path to the artifact file to upload.
|
||||
pub path: PathBuf,
|
||||
|
||||
/// Associate the uploaded artifact with a run.
|
||||
#[arg(long = "run-id")]
|
||||
pub run_id: Option<String>,
|
||||
|
||||
/// Associate the uploaded artifact with a conversation.
|
||||
#[arg(long = "conversation-id")]
|
||||
pub conversation_id: Option<String>,
|
||||
|
||||
/// Description for the uploaded artifact.
|
||||
#[arg(long = "description")]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct DownloadArtifactArgs {
|
||||
/// UID of the artifact to download.
|
||||
pub artifact_uid: String,
|
||||
|
||||
/// Write the downloaded artifact to a specific file path.
|
||||
#[arg(long = "out", short = 'o')]
|
||||
pub out: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct GetArtifactArgs {
|
||||
/// UID of the artifact to get.
|
||||
pub artifact_uid: String,
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
use std::io;
|
||||
|
||||
use clap_complete::aot::{Shell, generate};
|
||||
|
||||
use crate::{Args, binary_name};
|
||||
use warp_core::channel::ChannelState;
|
||||
|
||||
/// Generate shell completions for the Warp CLI and write them to stdout.
|
||||
pub fn generate_to_stdout(shell: Option<Shell>) -> anyhow::Result<()> {
|
||||
let shell = match shell.or_else(Shell::from_env) {
|
||||
Some(s) => s,
|
||||
None => anyhow::bail!(
|
||||
"Could not determine shell from environment. Please provide a shell argument."
|
||||
),
|
||||
};
|
||||
|
||||
let mut cmd = Args::clap_command();
|
||||
let bin_name =
|
||||
binary_name().unwrap_or_else(|| ChannelState::channel().cli_command_name().to_string());
|
||||
|
||||
generate(shell, &mut cmd, bin_name, &mut io::stdout());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Shared CLI args for loading command configuration from a file.
|
||||
#[derive(Debug, Default, Clone, clap::Args)]
|
||||
pub struct ConfigFileArgs {
|
||||
/// Path to a YAML or JSON configuration file.
|
||||
#[arg(
|
||||
short = 'f',
|
||||
long = "file",
|
||||
value_name = "PATH",
|
||||
env = "WARP_AGENT_CONFIG_FILE"
|
||||
)]
|
||||
pub file: Option<PathBuf>,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
use clap::{ArgAction, ArgGroup, Args, Subcommand};
|
||||
|
||||
use crate::scope::ObjectScope;
|
||||
|
||||
/// Maximum length for environment descriptions.
|
||||
const MAX_DESCRIPTION_LENGTH: usize = 240;
|
||||
|
||||
/// Validates that a description is within the allowed length.
|
||||
fn validate_description(s: &str) -> Result<String, String> {
|
||||
let len = s.chars().count();
|
||||
if len > MAX_DESCRIPTION_LENGTH {
|
||||
Err(format!(
|
||||
"Description must be at most {} characters (got {})",
|
||||
MAX_DESCRIPTION_LENGTH, len
|
||||
))
|
||||
} else {
|
||||
Ok(s.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
#[command(group(ArgGroup::new("scope").required(false)))]
|
||||
#[command(visible_alias = "e")]
|
||||
pub enum EnvironmentCommand {
|
||||
/// List cloud environments.
|
||||
List,
|
||||
/// Manage base images for cloud environments.
|
||||
#[command(subcommand)]
|
||||
Image(ImageCommand),
|
||||
/// Create a new cloud environment.
|
||||
Create {
|
||||
/// Name of the environment
|
||||
#[arg(long = "name", short = 'n')]
|
||||
name: String,
|
||||
/// Description of the environment (max 240 characters)
|
||||
#[arg(long = "description", value_parser = validate_description)]
|
||||
description: Option<String>,
|
||||
/// Docker image to use. Run `warp environment image list` to list suggested dev images.
|
||||
/// If not specified, you'll be prompted to select from available images.
|
||||
#[arg(long = "docker-image", short = 'd')]
|
||||
docker_image: Option<String>,
|
||||
/// Git repo in format "owner/repo" (can be specified multiple times)
|
||||
#[arg(long = "repo", short = 'r', action = ArgAction::Append)]
|
||||
repo: Vec<String>,
|
||||
/// Accept multiple setup command args to be run after cloning
|
||||
#[arg(long = "setup-command", short = 'c', action = ArgAction::Append)]
|
||||
setup_command: Vec<String>,
|
||||
|
||||
#[command(flatten)]
|
||||
scope: ObjectScope,
|
||||
},
|
||||
/// Delete a cloud environment.
|
||||
Delete {
|
||||
/// ID of the environment to delete
|
||||
id: String,
|
||||
/// Force delete without checking for integration usage
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
/// Get details of a cloud environment.
|
||||
Get {
|
||||
/// ID of the environment to get
|
||||
id: String,
|
||||
},
|
||||
/// Update an existing cloud environment.
|
||||
Update {
|
||||
/// ID of the environment to update
|
||||
id: String,
|
||||
/// Name of the environment (optional, updates if present)
|
||||
#[arg(long = "name", short = 'n')]
|
||||
name: Option<String>,
|
||||
/// Description of the environment (max 240 characters)
|
||||
#[arg(
|
||||
long = "description",
|
||||
value_parser = validate_description,
|
||||
conflicts_with = "remove_description",
|
||||
)]
|
||||
description: Option<String>,
|
||||
/// Remove the description from the environment
|
||||
#[arg(long = "remove-description", conflicts_with = "description")]
|
||||
remove_description: bool,
|
||||
/// Docker image to use (optional, updates if present)
|
||||
#[arg(long = "docker-image", short = 'd')]
|
||||
docker_image: Option<String>,
|
||||
/// Git repo in format "owner/repo" to add (can be specified multiple times)
|
||||
#[arg(long = "repo", short = 'r', action = ArgAction::Append)]
|
||||
repo: Vec<String>,
|
||||
/// Setup command to add to the end of the list (can be specified multiple times)
|
||||
#[arg(long = "setup-command", short = 'c', action = ArgAction::Append)]
|
||||
setup_command: Vec<String>,
|
||||
/// Git repo in format "owner/repo" to remove (can be specified multiple times)
|
||||
#[arg(long, action = ArgAction::Append)]
|
||||
remove_repo: Vec<String>,
|
||||
/// Setup command to remove from the list (can be specified multiple times)
|
||||
#[arg(long, action = ArgAction::Append)]
|
||||
remove_setup_command: Vec<String>,
|
||||
/// Force update without checking for integration usage
|
||||
#[arg(long, default_value_t = false)]
|
||||
force: bool,
|
||||
},
|
||||
}
|
||||
|
||||
/// Common arguments for selecting an environment when creating an integration.
|
||||
#[derive(Args, Clone, Debug)]
|
||||
#[group(required = false, multiple = false)]
|
||||
pub struct EnvironmentCreateArgs {
|
||||
/// Cloud environment to run the agent in.
|
||||
#[arg(long = "environment", value_name = "ENVIRONMENT_ID", short = 'e')]
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Do not run the agent in an environment (not recommended).
|
||||
#[arg(long = "no-environment")]
|
||||
pub no_environment: bool,
|
||||
}
|
||||
|
||||
/// Common arguments for selecting an environment when updating an integration.
|
||||
#[derive(Args, Clone, Debug)]
|
||||
#[group(required = false, multiple = false)]
|
||||
pub struct EnvironmentUpdateArgs {
|
||||
/// Cloud environment to run the agent in.
|
||||
#[arg(long = "environment", value_name = "ENVIRONMENT_ID", short = 'e')]
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Do not run the agent in an environment (not recommended).
|
||||
#[arg(long = "remove-environment")]
|
||||
pub remove_environment: bool,
|
||||
}
|
||||
|
||||
/// Image-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ImageCommand {
|
||||
/// List available Warp dev base images from Docker Hub.
|
||||
List,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
/// Federated authentication between Oz and cloud providers.
|
||||
///
|
||||
/// Oz supports OIDC federation to allow agents to securely authenticate to other systems
|
||||
/// using short-lived credentials.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum FederateCommand {
|
||||
/// Issue an identity token for the current Oz agent. This can only be called within a running Oz agent session.
|
||||
IssueToken(IssueTokenArgs),
|
||||
/// Issue an identity token for the current Oz agent, in the format expected by Google Cloud's
|
||||
/// [executable-sourced credentials](https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-other-providers#executable-sourced-credentials)
|
||||
/// mechanism.
|
||||
#[command(hide = true)]
|
||||
IssueGcpToken(IssueGcpTokenArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(name = "issue-token")]
|
||||
pub struct IssueTokenArgs {
|
||||
/// The run ID to issue the token for.
|
||||
#[arg(long = "run-id")]
|
||||
pub run_id: String,
|
||||
|
||||
/// The audience claim for the identity token.
|
||||
#[arg(long = "audience")]
|
||||
pub audience: String,
|
||||
|
||||
/// Requested token lifetime (e.g. "1h", "30m").
|
||||
#[arg(long = "duration", default_value = "1h")]
|
||||
pub duration: humantime::Duration,
|
||||
|
||||
/// Controls how the OIDC token subject is formatted.
|
||||
///
|
||||
/// The template consists of a list of claims, which are joined together to
|
||||
/// form the subject. The default subject template is the principal, such as
|
||||
/// `user:user-id`.
|
||||
///
|
||||
/// Supported components are:
|
||||
/// - principal (`user:my-user-id`)
|
||||
/// - scoped_principal (`principal:my-team-id/user:my-user-id`)
|
||||
/// - email (`email:user@warp.dev`)
|
||||
/// - teams (`teams:my-team-id`)
|
||||
/// - environment (`environment:my-environment-id`)
|
||||
/// - agent_name (`agent_name:my-agent`)
|
||||
/// - skill_spec (`skill_spec:warpdotdev/repo_path_to_skill`)
|
||||
/// - run_id (`run_id:abc123`)
|
||||
/// - host (`host:my-worker-id`)
|
||||
#[arg(long = "subject-template", num_args = 1..)]
|
||||
pub subject_template: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(name = "issue-gcp-token")]
|
||||
pub struct IssueGcpTokenArgs {
|
||||
/// The run ID to issue the token for.
|
||||
#[arg(long = "run-id")]
|
||||
pub run_id: String,
|
||||
|
||||
/// Requested token lifetime (e.g. "1h", "30m").
|
||||
#[arg(long = "duration", default_value = "1h")]
|
||||
pub duration: humantime::Duration,
|
||||
|
||||
/// The audience for the token request.
|
||||
#[arg(long, env = "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE")]
|
||||
pub audience: String,
|
||||
|
||||
/// The requested token type (e.g. "urn:ietf:params:oauth:token-type:id_token").
|
||||
#[arg(long, env = "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE")]
|
||||
pub token_type: String,
|
||||
|
||||
/// Optional path to write the token output for caching.
|
||||
#[arg(long, env = "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE")]
|
||||
pub output_file: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use clap::{Args, Subcommand, ValueEnum};
|
||||
|
||||
/// Commands to support third-party agent harnesses running within Oz.
|
||||
///
|
||||
/// These commands are invoked by external agent harnesses (e.g. Claude Code)
|
||||
/// during a cloud agent run to interact with Oz platform APIs.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct HarnessSupportArgs {
|
||||
/// The run ID to associate with harness-support API calls.
|
||||
#[arg(long = "run-id", env = "OZ_RUN_ID")]
|
||||
pub run_id: String,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: HarnessSupportCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum HarnessSupportCommand {
|
||||
/// Verify connectivity by fetching and displaying the current run.
|
||||
#[command(hide = true)]
|
||||
Ping,
|
||||
|
||||
/// Report an artifact back to the Oz platform.
|
||||
ReportArtifact(ReportArtifactArgs),
|
||||
|
||||
/// Send a progress notification to the task's originating platform (Slack, Linear, etc.).
|
||||
NotifyUser(NotifyUserArgs),
|
||||
|
||||
/// Report task completion or failure, as well as a summary of the task.
|
||||
FinishTask(FinishTaskArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ReportArtifactArgs {
|
||||
#[command(subcommand)]
|
||||
pub command: ReportArtifactCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ReportArtifactCommand {
|
||||
/// Report a pull request artifact.
|
||||
PullRequest(PullRequestArtifactArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct PullRequestArtifactArgs {
|
||||
/// URL of the pull request.
|
||||
#[arg(long)]
|
||||
pub url: String,
|
||||
|
||||
/// Branch name associated with the pull request.
|
||||
#[arg(long)]
|
||||
pub branch: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct NotifyUserArgs {
|
||||
/// The message to send as a progress update.
|
||||
#[arg(long)]
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum TaskStatus {
|
||||
Success,
|
||||
Failure,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct FinishTaskArgs {
|
||||
/// Whether the task succeeded or failed.
|
||||
#[arg(long)]
|
||||
pub status: TaskStatus,
|
||||
|
||||
/// A summary of the task outcome.
|
||||
#[arg(long)]
|
||||
pub summary: String,
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::{
|
||||
config_file::ConfigFileArgs,
|
||||
environment::{EnvironmentCreateArgs, EnvironmentUpdateArgs},
|
||||
mcp::MCPSpec,
|
||||
model::ModelArgs,
|
||||
provider::ProviderType,
|
||||
};
|
||||
|
||||
/// Integration-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
#[command(visible_alias = "i")]
|
||||
pub enum IntegrationCommand {
|
||||
/// Create a new integration.
|
||||
Create(CreateIntegrationArgs),
|
||||
/// Update an integration.
|
||||
Update(UpdateIntegrationArgs),
|
||||
/// List simple integrations and their connection status.
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct CreateIntegrationArgs {
|
||||
/// Provider to create the integration for.
|
||||
#[arg(value_enum)]
|
||||
pub provider: ProviderType,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub environment: EnvironmentCreateArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
/// MCP servers to configure for this integration.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
|
||||
/// Custom instructions for the integration.
|
||||
#[arg(long = "prompt", short = 'p')]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Worker host ID for self-hosted workers.
|
||||
/// If not specified or set to "warp", tasks will run on Warp-hosted workers.
|
||||
#[arg(long = "host")]
|
||||
pub worker_host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct UpdateIntegrationArgs {
|
||||
/// Provider to update the integration for.
|
||||
#[arg(value_enum)]
|
||||
pub provider: ProviderType,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub environment: EnvironmentUpdateArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
/// MCP servers to configure for this integration.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
|
||||
/// Remove MCP servers from this integration by server name.
|
||||
///
|
||||
/// This removes the server entry whose key matches `SERVER_NAME`.
|
||||
#[arg(long = "remove-mcp", value_name = "SERVER_NAME")]
|
||||
pub remove_mcp: Vec<String>,
|
||||
|
||||
/// Custom instructions for the integration.
|
||||
#[arg(long = "prompt", short = 'p')]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Worker host ID for self-hosted workers.
|
||||
/// If not specified or set to "warp", tasks will run on Warp-hosted workers.
|
||||
#[arg(long = "host")]
|
||||
pub worker_host: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Reusable JSON output formatting component.
|
||||
|
||||
use std::fmt;
|
||||
use std::ops::Deref;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Args;
|
||||
|
||||
use jaq_all::data::{self, DataKind};
|
||||
use jaq_all::load::FileReportsDisp;
|
||||
|
||||
/// A jq filter, compiled and ready to execute against a [`jaq_json::Val`].
|
||||
///
|
||||
/// This wraps the compiled [`jaq_all::data::Filter`] with `Clone` and `Debug`
|
||||
/// implementations.
|
||||
#[derive(Clone)]
|
||||
pub struct JqFilter(Arc<data::Filter>);
|
||||
|
||||
impl fmt::Debug for JqFilter {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
f.debug_tuple("JqFilter").field(&"<compiled>").finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Deref for JqFilter {
|
||||
type Target = data::Filter;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// CLI argument bundle with flags relevant for commands that produce
|
||||
/// JSON output.
|
||||
///
|
||||
/// Embed with `#[command(flatten)]` on any command which can produce
|
||||
/// JSON output, and use the `print_raw_json` utility in the `app` crate
|
||||
/// to format that output.
|
||||
#[derive(Clone, Debug, Default, Args)]
|
||||
pub struct JsonOutput {
|
||||
/// A filter to select values from the response using jq syntax.
|
||||
///
|
||||
/// Example: `--jq '.runs[].creator'
|
||||
///
|
||||
/// When set, the result of the filter expression is printed instead of
|
||||
/// the full JSON output. Top-level scalar outputs are automatically
|
||||
/// unquoted.
|
||||
#[arg(long = "jq", value_parser = parse_jq_filter, value_name = "FILTER")]
|
||||
pub filter: Option<JqFilter>,
|
||||
}
|
||||
|
||||
impl JsonOutput {
|
||||
/// Returns true if this argument bundle requires JSON output regardless
|
||||
/// of the user-selected `--output-format`.
|
||||
///
|
||||
/// For example, `--jq` runs against JSON, so setting it implies the
|
||||
/// command must fetch and process JSON even when the user asked for
|
||||
/// pretty/text output.
|
||||
pub fn force_json_output(&self) -> bool {
|
||||
self.filter.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and compile a jq filter source string.
|
||||
///
|
||||
/// Used as a clap `value_parser` so invalid filters (syntax errors, unknown
|
||||
/// names) fail during argument parsing.
|
||||
pub fn parse_jq_filter(src: &str) -> Result<JqFilter, String> {
|
||||
let compiled = jaq_all::compile_with::<DataKind>(src, jaq_all::defs(), data::base_funs(), &[])
|
||||
.map_err(|reports| {
|
||||
let detail = reports
|
||||
.iter()
|
||||
.map(|report| FileReportsDisp::new(report).to_string())
|
||||
.collect::<String>();
|
||||
format!("invalid jq filter `{src}`:\n{detail}")
|
||||
})?;
|
||||
Ok(JqFilter(Arc::new(compiled)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "json_filter_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Tests for [`super::parse_jq_filter`] and the clap-flattened [`super::JsonOutput`].
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Tiny wrapper so we can exercise `JsonOutput` through clap in isolation.
|
||||
#[derive(Debug, Parser)]
|
||||
struct TestApp {
|
||||
#[clap(flatten)]
|
||||
json_filter: JsonOutput,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jq_filter_accepts_simple_filter() {
|
||||
parse_jq_filter(".foo").expect("valid filter should compile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jq_filter_accepts_stdlib_functions() {
|
||||
// Exercises jaq-std defs/funs: .foo | length
|
||||
parse_jq_filter(".foo | length").expect("stdlib functions should compile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jq_filter_rejects_syntax_error() {
|
||||
let err = parse_jq_filter("@").expect_err("syntax error should be rejected");
|
||||
assert!(
|
||||
err.contains("`@`"),
|
||||
"error should quote the filter source with backticks, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jq_filter_rejects_empty_string() {
|
||||
let err = parse_jq_filter("").expect_err("empty filter should be rejected");
|
||||
assert!(
|
||||
err.contains("``"),
|
||||
"error should quote the (empty) filter source with backticks, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_jq_filter_rejects_unknown_function() {
|
||||
let err = parse_jq_filter(".foo | bogus_function_name")
|
||||
.expect_err("unknown function should be rejected");
|
||||
assert!(
|
||||
err.contains("bogus_function_name") || err.contains("jq filter"),
|
||||
"error should mention the filter or the unknown name, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_populates_filter_when_jq_is_provided() {
|
||||
let app = TestApp::try_parse_from(["test", "--jq", ".foo"]).expect("valid --jq parses");
|
||||
assert!(app.json_filter.filter.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_filter_is_none_by_default() {
|
||||
let app = TestApp::try_parse_from(["test"]).expect("no --jq parses");
|
||||
assert!(app.json_filter.filter.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_rejects_invalid_filter_at_parse_time() {
|
||||
// This is the core fail-fast invariant: an invalid filter fails during
|
||||
// clap parsing, not at runtime.
|
||||
let err = TestApp::try_parse_from(["test", "--jq", "@"])
|
||||
.expect_err("invalid --jq is rejected by clap");
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
#![cfg_attr(target_family = "wasm", allow(dead_code))]
|
||||
|
||||
use std::{env, fmt, path::Path};
|
||||
|
||||
use clap::{CommandFactory, Parser, Subcommand, ValueEnum};
|
||||
use url::Url;
|
||||
|
||||
use warp_core::channel::ChannelState;
|
||||
use warp_core::features::FeatureFlag;
|
||||
|
||||
use crate::agent::OutputFormat;
|
||||
|
||||
#[cfg(windows)]
|
||||
mod process_handle;
|
||||
|
||||
pub mod artifact;
|
||||
pub mod scope;
|
||||
pub mod skill;
|
||||
|
||||
pub mod agent;
|
||||
pub mod completions;
|
||||
pub mod config_file;
|
||||
pub mod environment;
|
||||
pub mod federate;
|
||||
pub mod harness_support;
|
||||
pub mod integration;
|
||||
pub mod json_filter;
|
||||
pub mod mcp;
|
||||
pub mod model;
|
||||
pub mod provider;
|
||||
pub mod schedule;
|
||||
pub mod secret;
|
||||
pub mod share;
|
||||
pub mod task;
|
||||
pub const OZ_RUN_ID_ENV: &str = "OZ_RUN_ID";
|
||||
pub const OZ_PARENT_RUN_ID_ENV: &str = "OZ_PARENT_RUN_ID";
|
||||
pub const OZ_CLI_ENV: &str = "OZ_CLI";
|
||||
pub const OZ_HARNESS_ENV: &str = "OZ_HARNESS";
|
||||
pub const SERVER_ROOT_URL_OVERRIDE_ENV: &str = "WARP_SERVER_ROOT_URL";
|
||||
pub const WS_SERVER_URL_OVERRIDE_ENV: &str = "WARP_WS_SERVER_URL";
|
||||
pub const SESSION_SHARING_SERVER_URL_OVERRIDE_ENV: &str = "WARP_SESSION_SHARING_SERVER_URL";
|
||||
|
||||
/// Options related to the parent process that spawned this Warp instance.
|
||||
#[derive(Debug, Default, Clone, clap::Args)]
|
||||
pub struct ParentOpts {
|
||||
/// The ID of the Warp process that spawned this one.
|
||||
///
|
||||
/// Used by codepaths that attempt to detect when the parent Warp process
|
||||
/// has terminated. Guaranteed to be [`None`] when this is the initial
|
||||
/// Warp process, but may also be [`None`] for Warp child processes if the
|
||||
/// child process doesn't need to keep track of its parent.
|
||||
#[arg(long = "parent-pid", hide = true)]
|
||||
pub pid: Option<u32>,
|
||||
|
||||
/// A handle to our parent process.
|
||||
///
|
||||
/// Used on Windows for crash recovery instead of parent_pid, as process
|
||||
/// IDs can be reused, so a process handle is more robust.
|
||||
#[cfg(windows)]
|
||||
#[arg(long = "parent-handle", hide = true)]
|
||||
pub handle: Option<process_handle::ProcessHandle>,
|
||||
}
|
||||
|
||||
/// Global options that apply to all CLI commands.
|
||||
#[derive(Debug, Default, Clone, clap::Args)]
|
||||
pub struct GlobalOptions {
|
||||
/// API key for server authentication.
|
||||
#[arg(long = "api-key", global = true, env = "WARP_API_KEY")]
|
||||
pub api_key: Option<String>,
|
||||
|
||||
/// Set the output format.
|
||||
#[arg(
|
||||
long = "output-format",
|
||||
global = true,
|
||||
value_enum,
|
||||
default_value_t = OutputFormat::Pretty,
|
||||
env = "WARP_OUTPUT_FORMAT"
|
||||
)]
|
||||
pub output_format: OutputFormat,
|
||||
}
|
||||
|
||||
/// Command-line argument parser for the main Warp binary. This is used across all channels.
|
||||
#[derive(Debug, Default, Parser, Clone)]
|
||||
#[command(
|
||||
name = "oz",
|
||||
display_name = "Oz",
|
||||
about = r#"The orchestration platform for cloud agents
|
||||
|
||||
The Oz CLI is a tool for running, managing, and orchestrating coding agents at scale.
|
||||
Use the CLI to:
|
||||
* Launch and inspect cloud agents
|
||||
* Schedule cloud agents to run in the future
|
||||
* Manage the environments that cloud agents run in
|
||||
* Upload secrets to Oz's secure storage"#
|
||||
)]
|
||||
#[clap(args_conflicts_with_subcommands = true)]
|
||||
pub struct Args {
|
||||
#[clap(flatten)]
|
||||
global_options: GlobalOptions,
|
||||
|
||||
/// Enable debug mode.
|
||||
#[arg(long = "debug", global = true, help = "Enable debug logging")]
|
||||
debug: bool,
|
||||
|
||||
/// Override the server root URL.
|
||||
#[arg(
|
||||
long = "server-root-url",
|
||||
global = true,
|
||||
hide = true,
|
||||
env = "WARP_SERVER_ROOT_URL"
|
||||
)]
|
||||
server_root_url: Option<String>,
|
||||
|
||||
/// Override the websocket server URL.
|
||||
#[arg(
|
||||
long = "ws-server-url",
|
||||
global = true,
|
||||
hide = true,
|
||||
env = "WARP_WS_SERVER_URL"
|
||||
)]
|
||||
ws_server_url: Option<String>,
|
||||
|
||||
/// Override the session sharing server URL.
|
||||
#[arg(
|
||||
long = "session-sharing-server-url",
|
||||
global = true,
|
||||
hide = true,
|
||||
env = "WARP_SESSION_SHARING_SERVER_URL"
|
||||
)]
|
||||
session_sharing_server_url: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Command>,
|
||||
|
||||
#[clap(flatten)]
|
||||
args: AppArgs,
|
||||
}
|
||||
|
||||
/// Flags for the Warp application. Additional binaries, like test runners, may use this type
|
||||
/// along with their own flags, or convert their flags into an `AppArgs` value.
|
||||
#[derive(Debug, Default, clap::Args, Clone)]
|
||||
pub struct AppArgs {
|
||||
/// True if this instance of Warp was launched at the end of the auto-update process.
|
||||
#[arg(long = "finish-update", hide = true)]
|
||||
pub finish_update: bool,
|
||||
|
||||
/// Crash recovery mechanism to use if we detect the parent process terminated.
|
||||
#[cfg(enable_crash_recovery)]
|
||||
#[arg(long = "crash-recovery-mechanism", value_enum, requires = "ParentOpts")]
|
||||
pub crash_recovery_mechanism: Option<RecoveryMechanism>,
|
||||
|
||||
/// Options related to the parent process that spawned this Warp instance.
|
||||
#[clap(flatten)]
|
||||
pub parent: ParentOpts,
|
||||
|
||||
/// URLs to open in Warp.
|
||||
#[arg(hide = true)]
|
||||
pub urls: Vec<Url>,
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// Parses command-line arguments from the operating environment. May exit early if arguments
|
||||
/// are incorrectly specified.
|
||||
pub fn from_env() -> Self {
|
||||
cfg_if::cfg_if! {
|
||||
// wasm doesn't have any concept of an environment, so skip parsing and return defaults
|
||||
if #[cfg(target_family = "wasm")] {
|
||||
Args::default()
|
||||
} else {
|
||||
use clap::FromArgMatches as _;
|
||||
|
||||
// Check for disabled commands before parsing to prevent help from showing (e.g.
|
||||
// `warp environment` should not return help text)
|
||||
if !FeatureFlag::CloudEnvironments.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "environment" {
|
||||
eprintln!("error: unrecognized subcommand 'environment'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::ProviderCommand.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "provider" {
|
||||
eprintln!("error: unrecognized subcommand 'provider'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::IntegrationCommand.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "integration" {
|
||||
eprintln!("error: unrecognized subcommand 'integration'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::ScheduledAmbientAgents.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "schedule" {
|
||||
eprintln!("error: unrecognized subcommand 'schedule'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "secret" {
|
||||
eprintln!("error: unrecognized subcommand 'secret'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::OzIdentityFederation.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "federate" {
|
||||
eprintln!("error: unrecognized subcommand 'federate'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
if !FeatureFlag::ArtifactCommand.is_enabled() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
if args.len() > 1 && args[1] == "artifact" {
|
||||
eprintln!("error: unrecognized subcommand 'artifact'\n");
|
||||
eprintln!("For more information, try '--help'");
|
||||
std::process::exit(2);
|
||||
}
|
||||
}
|
||||
|
||||
let command = Self::clap_command();
|
||||
|
||||
command.try_get_matches()
|
||||
.and_then(|matches| Self::from_arg_matches(&matches))
|
||||
.unwrap_or_else(|err| {
|
||||
// We attach a console to ensure help and error messages are printed
|
||||
// when using the CLI.
|
||||
#[cfg(windows)]
|
||||
warp_util::windows::attach_to_parent_console();
|
||||
err.exit()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Construct the [`clap::Command`] that backs `Args`.
|
||||
///
|
||||
/// IMPORTANT: use this instead of [`CommandFactory::command`], since we customize the command at runtime.
|
||||
pub fn clap_command() -> clap::Command {
|
||||
let mut command = <Args as CommandFactory>::command();
|
||||
|
||||
// Hide the environment subcommands and --environment flags from help text
|
||||
if !FeatureFlag::CloudEnvironments.is_enabled() {
|
||||
command = command.mut_subcommand("environment", |c| c.hide(true));
|
||||
command = command.mut_subcommand("agent", |agent_cmd| {
|
||||
agent_cmd
|
||||
.mut_subcommand("run", |run_cmd| {
|
||||
run_cmd.mut_arg("environment", |arg| arg.hide(true))
|
||||
})
|
||||
.mut_subcommand("run-cloud", |cloud_cmd| {
|
||||
cloud_cmd.mut_arg("environment", |arg| arg.hide(true))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the --conversation flag from help text
|
||||
if !FeatureFlag::CloudConversations.is_enabled() {
|
||||
command = command.mut_subcommand("agent", |agent_cmd| {
|
||||
agent_cmd
|
||||
.mut_subcommand("run", |run_cmd| {
|
||||
run_cmd.mut_arg("conversation", |arg| arg.hide(true))
|
||||
})
|
||||
.mut_subcommand("run-cloud", |cloud_cmd| {
|
||||
cloud_cmd.mut_arg("conversation", |arg| arg.hide(true))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if !FeatureFlag::AmbientAgentsCommandLine.is_enabled() {
|
||||
command = command.mut_subcommand("agent", |agent_cmd| {
|
||||
agent_cmd.mut_subcommand("run-cloud", |c| c.hide(true))
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the provider subcommand from help text
|
||||
if !FeatureFlag::ProviderCommand.is_enabled() {
|
||||
command = command.mut_subcommand("provider", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the integration subcommand from help text
|
||||
if !FeatureFlag::IntegrationCommand.is_enabled() {
|
||||
command = command.mut_subcommand("integration", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the schedule subcommand from help text.
|
||||
if !FeatureFlag::ScheduledAmbientAgents.is_enabled() {
|
||||
command = command.mut_subcommand("schedule", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the secret subcommand from help text.
|
||||
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
|
||||
command = command.mut_subcommand("secret", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the federate subcommand from help text.
|
||||
if !FeatureFlag::OzIdentityFederation.is_enabled() {
|
||||
command = command.mut_subcommand("federate", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the harness-support subcommand from help text.
|
||||
if !FeatureFlag::AgentHarness.is_enabled() {
|
||||
command = command.mut_subcommand("harness-support", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Hide the conversation subcommand and --conversation flag from help text.
|
||||
if !FeatureFlag::ConversationApi.is_enabled() {
|
||||
command = command.mut_subcommand("run", |run_cmd| {
|
||||
run_cmd
|
||||
.mut_subcommand("conversation", |c| c.hide(true))
|
||||
.mut_subcommand("get", |get_cmd| {
|
||||
get_cmd.mut_arg("conversation", |arg| arg.hide(true))
|
||||
})
|
||||
});
|
||||
}
|
||||
// Hide the message subcommand from help text.
|
||||
if !FeatureFlag::OrchestrationV2.is_enabled() {
|
||||
command = command.mut_subcommand("run", |run_cmd| {
|
||||
run_cmd.mut_subcommand("message", |c| c.hide(true))
|
||||
});
|
||||
}
|
||||
|
||||
// Hide the artifact subcommand from help text.
|
||||
if !FeatureFlag::ArtifactCommand.is_enabled() {
|
||||
command = command.mut_subcommand("artifact", |c| c.hide(true));
|
||||
}
|
||||
|
||||
// Substitute the actual binary name into help output. Ideally clap would do this for us.
|
||||
let bin_name =
|
||||
binary_name().unwrap_or_else(|| ChannelState::channel().cli_command_name().to_string());
|
||||
command = command.after_help(color_print::cformat!(
|
||||
r#"<bold><underline>Examples:</underline></bold>
|
||||
|
||||
<dim>$</dim> <bold>{bin_name} agent run --prompt "Build anything"</bold>
|
||||
|
||||
<dim>$</dim> <bold>{bin_name} mcp list</bold>
|
||||
|
||||
<bold><underline>Learn more:</underline></bold>
|
||||
* Use <bold>{bin_name} help</bold> to learn more about each command
|
||||
* Read the documentation at https://docs.warp.dev/reference/cli
|
||||
"#
|
||||
));
|
||||
|
||||
command
|
||||
}
|
||||
|
||||
/// The requested subcommand, if any.
|
||||
pub fn command(&self) -> Option<&Command> {
|
||||
self.command.as_ref()
|
||||
}
|
||||
|
||||
/// Args for the main Warp application, if not running a subcommand.
|
||||
pub fn app_args(&self) -> &AppArgs {
|
||||
&self.args
|
||||
}
|
||||
|
||||
/// Extract the main Warp application args.
|
||||
pub fn into_app_args(self) -> AppArgs {
|
||||
self.args
|
||||
}
|
||||
|
||||
/// Returns the global options.
|
||||
pub fn global_options(&self) -> &GlobalOptions {
|
||||
&self.global_options
|
||||
}
|
||||
|
||||
/// Returns the API key if provided.
|
||||
pub fn api_key(&self) -> Option<&String> {
|
||||
self.global_options.api_key.as_ref()
|
||||
}
|
||||
|
||||
/// Returns the output format.
|
||||
pub fn output_format(&self) -> OutputFormat {
|
||||
self.global_options.output_format
|
||||
}
|
||||
|
||||
/// Returns true if debug logging is enabled.
|
||||
pub fn debug(&self) -> bool {
|
||||
self.debug
|
||||
}
|
||||
|
||||
pub fn server_root_url(&self) -> Option<&str> {
|
||||
self.server_root_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn ws_server_url(&self) -> Option<&str> {
|
||||
self.ws_server_url.as_deref()
|
||||
}
|
||||
|
||||
pub fn session_sharing_server_url(&self) -> Option<&str> {
|
||||
self.session_sharing_server_url.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
/// Warp may spawn several worker processes - mostly servers that support the main application.
|
||||
///
|
||||
/// These subcommands run those worker processes, which are bundled into the Warp binary.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum WorkerCommand {
|
||||
/// Run the terminal server.
|
||||
#[clap(hide = true)]
|
||||
#[cfg(unix)]
|
||||
TerminalServer(TerminalServerArgs),
|
||||
|
||||
/// Run this process as the plugin host rather than the main app.
|
||||
#[cfg(feature = "plugin_host")]
|
||||
#[clap(long_flag = "plugin-host")]
|
||||
PluginHost {
|
||||
#[clap(flatten)]
|
||||
parent: ParentOpts,
|
||||
},
|
||||
|
||||
/// Run the minidump server.
|
||||
#[clap(hide = true)]
|
||||
MinidumpServer {
|
||||
/// Socket name for the minidump server.
|
||||
socket_name: std::path::PathBuf,
|
||||
},
|
||||
|
||||
/// Run the remote development server proxy over SSH stdio.
|
||||
/// Ensures the daemon is running, then bridges its stdin/stdout
|
||||
/// to the daemon via a Unix domain socket.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[clap(hide = true)]
|
||||
RemoteServerProxy,
|
||||
|
||||
/// Run the long-lived remote development server daemon.
|
||||
/// Listens on a Unix domain socket and accepts multiple concurrent
|
||||
/// connections from proxy processes.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[clap(hide = true)]
|
||||
RemoteServerDaemon,
|
||||
|
||||
/// Run a headless ripgrep search worker.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
#[clap(hide = true)]
|
||||
RipgrepSearch {
|
||||
#[clap(flatten)]
|
||||
parent: ParentOpts,
|
||||
#[clap(long = "ignore-case")]
|
||||
ignore_case: bool,
|
||||
#[clap(long = "multiline")]
|
||||
multiline: bool,
|
||||
/// Search pattern.
|
||||
pattern: String,
|
||||
/// Paths to search.
|
||||
paths: Vec<std::path::PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
/// CLI-related subcommands. The command-line interface to Warp isn't a full SDK (e.g. with language bindings),
|
||||
/// but it allows scripting some Warp functionality.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum CliCommand {
|
||||
/// Interact with Oz.
|
||||
#[command(subcommand)]
|
||||
Agent(crate::agent::AgentCommand),
|
||||
|
||||
/// Manage cloud environments.
|
||||
#[command(subcommand)]
|
||||
Environment(crate::environment::EnvironmentCommand),
|
||||
|
||||
/// Manage MCP servers.
|
||||
#[command(subcommand)]
|
||||
MCP(crate::mcp::MCPCommand),
|
||||
|
||||
/// Manage runs.
|
||||
#[command(subcommand, alias = "task")]
|
||||
Run(crate::task::TaskCommand),
|
||||
|
||||
/// Manage available models.
|
||||
#[command(subcommand)]
|
||||
Model(crate::model::ModelCommand),
|
||||
|
||||
/// Log in to Warp.
|
||||
Login,
|
||||
/// Log out of Warp.
|
||||
Logout,
|
||||
/// Print information about the logged-in user.
|
||||
Whoami,
|
||||
|
||||
/// Manage providers.
|
||||
#[command(subcommand)]
|
||||
Provider(crate::provider::ProviderCommand),
|
||||
|
||||
/// Manage integrations.
|
||||
#[command(subcommand)]
|
||||
Integration(crate::integration::IntegrationCommand),
|
||||
|
||||
/// Create and manage scheduled Oz agents. Scheduled agents run a user-defined task periodically, according to a cron schedule.
|
||||
///
|
||||
/// As a shorthand, the `schedule` command behaves identically to `schedule create`.
|
||||
Schedule(crate::schedule::ScheduleCommand),
|
||||
|
||||
/// Manage secrets.
|
||||
#[command(subcommand)]
|
||||
Secret(crate::secret::SecretCommand),
|
||||
|
||||
/// Issue and manage federated identity tokens.
|
||||
#[command(subcommand)]
|
||||
Federate(crate::federate::FederateCommand),
|
||||
|
||||
/// Support commands for agent harnesses to integrate with Oz.
|
||||
#[command(hide = true)]
|
||||
HarnessSupport(crate::harness_support::HarnessSupportArgs),
|
||||
|
||||
/// Manage artifacts.
|
||||
#[command(subcommand)]
|
||||
Artifact(crate::artifact::ArtifactCommand),
|
||||
}
|
||||
|
||||
/// A subcommand of the main Warp application. This includes all [`WorkerCommand`]s as well as app-specific debugging tools.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum Command {
|
||||
#[clap(flatten)]
|
||||
Worker(WorkerCommand),
|
||||
|
||||
/// Commands that make up the Warp CLI.
|
||||
#[clap(flatten)]
|
||||
CommandLine(Box<CliCommand>),
|
||||
|
||||
/// Generate shell completions for your shell to stdout.
|
||||
///
|
||||
///
|
||||
/// For bash, add the following to ~/.bashrc:
|
||||
/// source <(path/to/warp completions bash)
|
||||
///
|
||||
/// For zsh, add the following to ~/.zshrc:
|
||||
/// source <(path/to/warp completions zsh)
|
||||
///
|
||||
/// For fish, add the following to ~/.config/fish/config.fish:
|
||||
/// path/to/warp completions fish | source
|
||||
///
|
||||
/// For Powershell, add the following to $PROFILE:
|
||||
/// path\to\warp | Out-String | Invoke-Expression
|
||||
///
|
||||
/// If no shell is provided, this defaults to the shell that Warp was run from.
|
||||
#[command(verbatim_doc_comment)]
|
||||
Completions {
|
||||
/// Shell to generate completions for.
|
||||
#[arg(value_enum)]
|
||||
shell: Option<clap_complete::aot::Shell>,
|
||||
},
|
||||
|
||||
/// Print debugging information and exit.
|
||||
#[clap(long_flag = "dump-debug-info")]
|
||||
DumpDebugInfo,
|
||||
|
||||
/// Print telemetry events in production and exit.
|
||||
#[clap(long_flag = "print-telemetry-events", hide = true)]
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
PrintTelemetryEvents,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
/// Whether or not the Command should print to stdout.
|
||||
pub fn prints_to_stdout(&self) -> bool {
|
||||
match self {
|
||||
Command::Worker(_) => false,
|
||||
Command::CommandLine(_) | Command::DumpDebugInfo => true,
|
||||
Command::Completions { .. } => true,
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
Command::PrintTelemetryEvents => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arguments for the terminal server.
|
||||
#[cfg(not(windows))]
|
||||
#[derive(Debug, Clone, Default, clap::Args)]
|
||||
pub struct TerminalServerArgs {
|
||||
#[clap(flatten)]
|
||||
pub parent: ParentOpts,
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone, clap::ValueEnum)]
|
||||
pub enum RecoveryMechanism {
|
||||
#[cfg(target_os = "linux")]
|
||||
#[value(name = "force-x11")]
|
||||
X11,
|
||||
#[value(name = "force-dedicated-gpu")]
|
||||
DedicatedGpu,
|
||||
#[value(name = "disable-opengl")]
|
||||
DisableOpenGL,
|
||||
#[value(name = "force-vulkan")]
|
||||
ForceVulkan,
|
||||
}
|
||||
|
||||
impl fmt::Display for RecoveryMechanism {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
let value = self.to_possible_value().expect("no values are skipped");
|
||||
f.write_str(value.get_name())
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the subcommand name to use for starting the terminal server.
|
||||
pub fn terminal_server_subcommand() -> String {
|
||||
<Args as CommandFactory>::command()
|
||||
.find_subcommand("terminal-server")
|
||||
.expect("terminal-server subcommand not found")
|
||||
.get_name()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns the subcommand name to use for starting the installation detection server.
|
||||
pub fn installation_detection_server_subcommand() -> String {
|
||||
<Args as CommandFactory>::command()
|
||||
.find_subcommand("installation-detection-server")
|
||||
.expect("installation-detection-server subcommand not found")
|
||||
.get_name()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns the subcommand name to use for starting the ripgrep search worker.
|
||||
#[cfg(not(target_family = "wasm"))]
|
||||
pub fn ripgrep_search_subcommand() -> String {
|
||||
<Args as CommandFactory>::command()
|
||||
.find_subcommand("ripgrep-search")
|
||||
.expect("ripgrep-search subcommand not found")
|
||||
.get_name()
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Returns the flag to use when finishing the auto-update process.
|
||||
pub fn finish_update_flag() -> String {
|
||||
let command = <Args as CommandFactory>::command();
|
||||
let flag = command
|
||||
.get_arguments()
|
||||
.find(|arg| arg.get_long() == Some("finish-update"))
|
||||
.expect("finish-update flag not found")
|
||||
.get_long()
|
||||
.unwrap();
|
||||
format!("--{flag}")
|
||||
}
|
||||
|
||||
/// Returns the flag to use for the dump-debug-info subcommand.
|
||||
pub fn dump_debug_info_flag() -> String {
|
||||
let command = <Args as CommandFactory>::command();
|
||||
let flag = command
|
||||
.find_subcommand("dump-debug-info")
|
||||
.expect("dump-debug-info subcommand not found")
|
||||
.get_long_flag()
|
||||
.expect("dump-debug-info flag not found");
|
||||
format!("--{flag}")
|
||||
}
|
||||
|
||||
/// Returns a flag that sets the current process as the parent of a Warp subcommand to spawn.
|
||||
pub fn parent_flag() -> String {
|
||||
let command = <Args as CommandFactory>::command();
|
||||
let flag = command
|
||||
.get_arguments()
|
||||
.find(|arg| arg.get_long() == Some("parent-pid"))
|
||||
.expect("parent-pid flag not found")
|
||||
.get_long()
|
||||
.unwrap();
|
||||
format!("--{flag}={}", std::process::id())
|
||||
}
|
||||
|
||||
/// The name that this binary was invoked as.
|
||||
pub fn binary_name() -> Option<String> {
|
||||
// Adapted from https://github.com/clap-rs/clap/blob/2c04acd3607e5c4676477ca14948419bb31c73a1/clap_builder/src/builder/command.rs#L888-L902
|
||||
// Unfortunately, we can't use Command::get_bin_name because it's not populated until args are parsed.
|
||||
let arg0 = env::args().next()?;
|
||||
Path::new(&arg0).file_name()?.to_str().map(|s| s.to_owned())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "lib_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,86 @@
|
||||
use std::ffi::OsStr;
|
||||
|
||||
use clap::builder::PossibleValue;
|
||||
use clap::error::ErrorKind;
|
||||
use clap::{Arg, Command, Subcommand};
|
||||
|
||||
/// MCP-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum MCPCommand {
|
||||
/// List MCP servers.
|
||||
List,
|
||||
}
|
||||
|
||||
/// Represents an MCP server specification from CLI input.
|
||||
///
|
||||
/// This is a lightweight representation - full parsing happens in the app layer
|
||||
/// using `ParsedTemplatableMCPServerResult::from_user_json`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MCPSpec {
|
||||
/// Existing server by UUID.
|
||||
Uuid(uuid::Uuid),
|
||||
/// JSON string (full config, server map, or single server).
|
||||
/// Parsing deferred to app layer.
|
||||
Json(String),
|
||||
}
|
||||
|
||||
impl clap::builder::ValueParserFactory for MCPSpec {
|
||||
type Parser = MCPSpecParser;
|
||||
|
||||
fn value_parser() -> Self::Parser {
|
||||
MCPSpecParser
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct MCPSpecParser;
|
||||
|
||||
impl clap::builder::TypedValueParser for MCPSpecParser {
|
||||
type Value = MCPSpec;
|
||||
|
||||
fn parse_ref(
|
||||
&self,
|
||||
_cmd: &Command,
|
||||
_arg: Option<&Arg>,
|
||||
value: &OsStr,
|
||||
) -> Result<Self::Value, clap::Error> {
|
||||
let s = value
|
||||
.to_str()
|
||||
.ok_or_else(|| clap::Error::raw(ErrorKind::InvalidUtf8, "Invalid UTF-8 in MCP spec"))?;
|
||||
|
||||
// Try UUID first
|
||||
if let Ok(uuid) = uuid::Uuid::parse_str(s) {
|
||||
return Ok(MCPSpec::Uuid(uuid));
|
||||
}
|
||||
|
||||
// Check if it's a file path
|
||||
let path = std::path::Path::new(s);
|
||||
let json_content = if path.exists() && path.is_file() {
|
||||
std::fs::read_to_string(path).map_err(|e| {
|
||||
clap::Error::raw(
|
||||
ErrorKind::Io,
|
||||
format!("Failed to read MCP config file '{}': {e}", path.display()),
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
// Treat as inline JSON
|
||||
s.to_string()
|
||||
};
|
||||
|
||||
Ok(MCPSpec::Json(json_content))
|
||||
}
|
||||
|
||||
fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
|
||||
Some(Box::new(
|
||||
[
|
||||
PossibleValue::new("<path>").help("Path to a JSON file containing MCP config"),
|
||||
PossibleValue::new("<json>").help("Inline JSON MCP server configuration"),
|
||||
]
|
||||
.into_iter(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "mcp_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,75 @@
|
||||
use super::*;
|
||||
use clap::builder::TypedValueParser;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
fn parse_mcp_spec(value: &str) -> Result<MCPSpec, clap::Error> {
|
||||
let cmd = clap::Command::new("test");
|
||||
let parser = MCPSpecParser;
|
||||
parser.parse_ref(&cmd, None, OsStr::new(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_uuid() {
|
||||
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let result = parse_mcp_spec(uuid_str).unwrap();
|
||||
match result {
|
||||
MCPSpec::Uuid(uuid) => assert_eq!(uuid.to_string(), uuid_str),
|
||||
MCPSpec::Json(_) => panic!("Expected Uuid variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_inline_json_cli_server() {
|
||||
let json = r#"{"server-name": {"command": "npx", "args": ["-y", "mcp-server"]}}"#;
|
||||
let result = parse_mcp_spec(json).unwrap();
|
||||
match result {
|
||||
MCPSpec::Json(s) => assert_eq!(s, json),
|
||||
MCPSpec::Uuid(_) => panic!("Expected Json variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_inline_json_single_server() {
|
||||
let json = r#"{"command": "npx", "args": ["-y", "mcp-server"]}"#;
|
||||
let result = parse_mcp_spec(json).unwrap();
|
||||
match result {
|
||||
MCPSpec::Json(s) => assert_eq!(s, json),
|
||||
MCPSpec::Uuid(_) => panic!("Expected Json variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_inline_json_sse_server() {
|
||||
let json = r#"{"url": "http://localhost:3000/mcp", "headers": {"API_KEY": "value"}}"#;
|
||||
let result = parse_mcp_spec(json).unwrap();
|
||||
match result {
|
||||
MCPSpec::Json(s) => assert_eq!(s, json),
|
||||
MCPSpec::Uuid(_) => panic!("Expected Json variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_inline_json_mcp_servers_wrapper() {
|
||||
let json = r#"{"mcpServers": {"server-name": {"command": "npx", "args": []}}}"#;
|
||||
let result = parse_mcp_spec(json).unwrap();
|
||||
match result {
|
||||
MCPSpec::Json(s) => assert_eq!(s, json),
|
||||
MCPSpec::Uuid(_) => panic!("Expected Json variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_uuid_takes_precedence_over_json() {
|
||||
// A valid UUID should be parsed as UUID, not as JSON
|
||||
let uuid_str = "550e8400-e29b-41d4-a716-446655440000";
|
||||
let result = parse_mcp_spec(uuid_str).unwrap();
|
||||
assert!(matches!(result, MCPSpec::Uuid(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_uuid_treated_as_json() {
|
||||
// An invalid UUID that looks like it could be one should be treated as JSON
|
||||
let invalid_uuid = "not-a-valid-uuid";
|
||||
let result = parse_mcp_spec(invalid_uuid).unwrap();
|
||||
assert!(matches!(result, MCPSpec::Json(_)));
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
/// Model-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ModelCommand {
|
||||
/// List available models.
|
||||
List,
|
||||
}
|
||||
|
||||
/// Shared CLI args for selecting a base model.
|
||||
#[derive(Debug, Clone, Args, Default)]
|
||||
pub struct ModelArgs {
|
||||
/// Override the base model used by this command. Use `warp model list` to see available models.
|
||||
#[arg(long = "model", value_name = "MODEL_ID")]
|
||||
pub model: Option<String>,
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
use std::{ffi::c_void, str::FromStr};
|
||||
|
||||
use windows::Win32::Foundation::HANDLE;
|
||||
|
||||
/// A Windows process handle. This wraps the [`HANDLE`] type to support parsing with `clap`.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct ProcessHandle(isize);
|
||||
|
||||
impl ProcessHandle {
|
||||
pub fn into_inner(self) -> HANDLE {
|
||||
HANDLE(self.0 as *mut c_void)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for ProcessHandle {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(raw: &str) -> Result<Self, Self::Err> {
|
||||
let pid = raw
|
||||
.parse::<isize>()
|
||||
.map_err(|e| format!("invalid parent handle: {e}"))?;
|
||||
Ok(Self(pid))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use clap::{ArgGroup, Args, Subcommand, ValueEnum};
|
||||
|
||||
/// Provider-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ProviderCommand {
|
||||
Setup(SetupArgs),
|
||||
List,
|
||||
}
|
||||
|
||||
// If we want these at the top level, we can also set provider as a top level subcommand:
|
||||
#[derive(Debug, Clone, ValueEnum)]
|
||||
#[value(rename_all = "snake_case")]
|
||||
pub enum ProviderType {
|
||||
Linear,
|
||||
Slack,
|
||||
}
|
||||
|
||||
impl ProviderType {
|
||||
pub fn name(&self) -> String {
|
||||
match self {
|
||||
ProviderType::Linear => String::from("Linear"),
|
||||
ProviderType::Slack => String::from("Slack"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn slug(&self) -> String {
|
||||
// add a mapping of provider types to slugs if needed
|
||||
self.name().to_lowercase()
|
||||
}
|
||||
|
||||
pub fn allowed_in_team_context(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::Linear => true,
|
||||
ProviderType::Slack => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allowed_in_personal_context(&self) -> bool {
|
||||
match self {
|
||||
ProviderType::Linear => false,
|
||||
ProviderType::Slack => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(group(ArgGroup::new("scope").required(false)))]
|
||||
pub struct SetupArgs {
|
||||
/// The type of provider to setup.
|
||||
pub provider_type: ProviderType,
|
||||
|
||||
/// Setup provider for a team
|
||||
#[arg(long, group = "scope")]
|
||||
pub team: bool,
|
||||
/// Setup provider for a personal account
|
||||
#[arg(long, conflicts_with = "team", group = "scope")]
|
||||
pub personal: bool,
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
use crate::{
|
||||
config_file::ConfigFileArgs,
|
||||
environment::{EnvironmentCreateArgs, EnvironmentUpdateArgs},
|
||||
mcp::MCPSpec,
|
||||
model::ModelArgs,
|
||||
scope::ObjectScope,
|
||||
skill::SkillSpec,
|
||||
};
|
||||
|
||||
/// `ScheduleCommand` has a slightly unusual definition because we allow `oz schedule` as
|
||||
// a shorthand for `oz schedule create`.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[clap(args_conflicts_with_subcommands = true)]
|
||||
pub struct ScheduleCommand {
|
||||
#[clap(subcommand)]
|
||||
subcommand: Option<ScheduleSubcommand>,
|
||||
|
||||
#[clap(flatten)]
|
||||
create: Option<CreateScheduleArgs>,
|
||||
}
|
||||
|
||||
impl ScheduleCommand {
|
||||
/// Get the specific scheduling subcommand. Returns `None` if using the `oz schedule` creation shorthand.
|
||||
pub fn subcommand(&self) -> Option<&ScheduleSubcommand> {
|
||||
self.subcommand.as_ref()
|
||||
}
|
||||
|
||||
/// Convert into the specific scheduling subcommand to run.
|
||||
pub fn into_subcommand(self) -> ScheduleSubcommand {
|
||||
if let Some(create) = self.create {
|
||||
ScheduleSubcommand::Create(create)
|
||||
} else if let Some(cmd) = self.subcommand {
|
||||
cmd
|
||||
} else {
|
||||
panic!("Either subcommand or create args are required");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Schedule-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ScheduleSubcommand {
|
||||
/// Create a scheduled Oz agent.
|
||||
Create(CreateScheduleArgs),
|
||||
/// List scheduled Oz agents.
|
||||
List,
|
||||
/// Get a scheduled Oz agent's configuration.
|
||||
Get(GetScheduleArgs),
|
||||
/// Update a scheduled Oz agent.
|
||||
Update(UpdateScheduleArgs),
|
||||
/// Pause a scheduled Oz agent.
|
||||
///
|
||||
/// A paused agent still exists, but will not run according to its schedule.
|
||||
Pause(PauseScheduleArgs),
|
||||
/// Unpause a scheduled Oz agent.
|
||||
///
|
||||
/// The agent will resume executing on its previously-configured schedule.
|
||||
#[command(alias = "resume")]
|
||||
Unpause(UnpauseScheduleArgs),
|
||||
/// Delete a scheduled Oz agent.
|
||||
Delete(DeleteScheduleArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(
|
||||
group(
|
||||
clap::ArgGroup::new("prompt_group")
|
||||
.required(true)
|
||||
.multiple(true)
|
||||
.args(["prompt", "skill"])
|
||||
)
|
||||
)]
|
||||
pub struct CreateScheduleArgs {
|
||||
/// Name of the scheduled agent.
|
||||
#[arg(long = "name")]
|
||||
pub name: String,
|
||||
|
||||
/// Cron schedule expression (e.g., "0 9 * * 1" for 9 AM every Monday).
|
||||
#[arg(long = "cron")]
|
||||
pub cron: String,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub environment: EnvironmentCreateArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
|
||||
/// MCP servers to configure for this schedule.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
|
||||
/// Prompt for what the scheduled agent should do.
|
||||
#[arg(long = "prompt", short = 'p')]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Automate a skill to run on a schedule.
|
||||
///
|
||||
/// Format: `repo:skill_name` or `org/repo:skill_name`
|
||||
///
|
||||
/// Skills are searched in `.agents/skills/`, `.warp/skills/`, `.claude/skills/`, and `.codex/skills/` directories.
|
||||
/// The skill is resolved at runtime in the agent's cloud environment.
|
||||
///
|
||||
/// When used with --prompt, the skill provides the base context and the prompt is the user task.
|
||||
/// This is useful for running recurring workflows like code reviews, dependency updates, or reports.
|
||||
#[arg(long = "skill", value_name = "SPEC")]
|
||||
pub skill: Option<SkillSpec>,
|
||||
|
||||
/// Where this job should be hosted.
|
||||
///
|
||||
/// Setting "warp" (or omitting this flag) runs it on Warp's infrastructure.
|
||||
/// Any other value is treated as a self-hosted job and the value will be matched
|
||||
/// with the self-hosted worker's name.
|
||||
#[arg(long = "host", value_name = "WORKER_ID")]
|
||||
pub worker_host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct PauseScheduleArgs {
|
||||
/// ID of the schedule to pause.
|
||||
pub schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct UnpauseScheduleArgs {
|
||||
/// ID of the schedule to unpause.
|
||||
pub schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct UpdateScheduleArgs {
|
||||
/// ID of the schedule to update.
|
||||
pub schedule_id: String,
|
||||
|
||||
/// Update the scheduled agent name.
|
||||
#[arg(long = "name")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Update the cron schedule on which the agent is executed.
|
||||
#[arg(long = "cron")]
|
||||
pub cron: Option<String>,
|
||||
|
||||
#[command(flatten)]
|
||||
pub model: ModelArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub environment: EnvironmentUpdateArgs,
|
||||
|
||||
#[command(flatten)]
|
||||
pub config_file: ConfigFileArgs,
|
||||
|
||||
/// MCP servers to configure for this schedule.
|
||||
///
|
||||
/// Can be specified as:
|
||||
/// - A path to a JSON file containing MCP configuration
|
||||
/// - Inline JSON with MCP server configuration
|
||||
///
|
||||
/// Can be specified multiple times to include multiple servers.
|
||||
#[arg(long = "mcp", value_name = "SPEC")]
|
||||
pub mcp_specs: Vec<MCPSpec>,
|
||||
|
||||
/// Remove MCP servers from this schedule by server name.
|
||||
///
|
||||
/// This removes the server entry whose key matches `SERVER_NAME`.
|
||||
#[arg(long = "remove-mcp", value_name = "SERVER_NAME")]
|
||||
pub remove_mcp: Vec<String>,
|
||||
|
||||
/// Update the scheduled agent's prompt.
|
||||
#[arg(long = "prompt", short = 'p')]
|
||||
pub prompt: Option<String>,
|
||||
|
||||
/// Update the skill used as the base prompt for the scheduled agent.
|
||||
///
|
||||
/// Format: `skill_name`, `repo:skill_name`, or `org/repo:skill_name`
|
||||
///
|
||||
/// Skills are searched in `.agents/skills/`, `.warp/skills/`, `.claude/skills/`, and `.codex/skills/` directories.
|
||||
/// The skill is resolved at runtime in the agent's cloud environment.
|
||||
#[arg(long = "skill", value_name = "SPEC", conflicts_with = "remove_skill")]
|
||||
pub skill: Option<SkillSpec>,
|
||||
|
||||
/// Remove the skill from this scheduled agent.
|
||||
#[arg(long = "remove-skill", conflicts_with = "skill")]
|
||||
pub remove_skill: bool,
|
||||
|
||||
/// Where this job should be hosted.
|
||||
///
|
||||
/// Setting "warp" runs it on Warp's infrastructure.
|
||||
/// Any other value is treated as a self-hosted job and the value will be matched
|
||||
/// with the self-hosted worker's name.
|
||||
#[arg(long = "host", value_name = "WORKER_ID")]
|
||||
pub worker_host: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct DeleteScheduleArgs {
|
||||
/// ID of the schedule to delete.
|
||||
pub schedule_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct GetScheduleArgs {
|
||||
/// ID of the schedule to get.
|
||||
pub schedule_id: String,
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
use clap::Args;
|
||||
|
||||
/// Common args for scoping objects to team or personal drives.
|
||||
#[derive(Args, Debug, Clone)]
|
||||
#[group(required = false, multiple = false)]
|
||||
pub struct ObjectScope {
|
||||
/// Create at the team level.
|
||||
#[arg(long, group = "scope")]
|
||||
pub team: bool,
|
||||
/// Create as private to your account.
|
||||
#[arg(long, conflicts_with = "team", group = "scope")]
|
||||
pub personal: bool,
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
use std::{fmt, path::PathBuf};
|
||||
|
||||
use clap::{Args, Subcommand, ValueEnum};
|
||||
|
||||
use crate::scope::ObjectScope;
|
||||
|
||||
/// Secret-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum SecretCommand {
|
||||
/// Create a new secret.
|
||||
///
|
||||
/// Use `oz secret create anthropic api-key <NAME>` to create a Claude/Anthropic auth secret.
|
||||
Create(CreateSecretArgs),
|
||||
/// Delete a secret.
|
||||
Delete(DeleteSecretArgs),
|
||||
/// Update a secret.
|
||||
///
|
||||
/// This command supports changing the value (via the `--value` or `--value-file` flags) or the description.
|
||||
/// Moving or renaming secrets is not currently supported.
|
||||
Update(UpdateSecretArgs),
|
||||
/// List secrets.
|
||||
List(ListSecretsArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
#[command(args_conflicts_with_subcommands = true)]
|
||||
pub struct CreateSecretArgs {
|
||||
/// Provider-specific creation subcommand.
|
||||
#[command(subcommand)]
|
||||
pub provider: Option<CreateProvider>,
|
||||
|
||||
// --- Fields below are only used when no subcommand is given (generic create). ---
|
||||
/// Name of the secret to create.
|
||||
pub name: Option<String>,
|
||||
|
||||
#[arg(long = "type", short = 't', default_value_t = Default::default())]
|
||||
pub secret_type: SecretType,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub value: ValueArgs,
|
||||
|
||||
/// Description of the secret.
|
||||
#[arg(long = "description", short = 'd')]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
}
|
||||
|
||||
/// Provider-specific secret creation subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum CreateProvider {
|
||||
/// Create a Claude/Anthropic auth secret.
|
||||
Anthropic(AnthropicCreateArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct AnthropicCreateArgs {
|
||||
#[command(subcommand)]
|
||||
pub method: AnthropicMethod,
|
||||
}
|
||||
|
||||
/// Anthropic credential type.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum AnthropicMethod {
|
||||
/// Direct Anthropic API key.
|
||||
#[command(name = "api-key")]
|
||||
ApiKey(AnthropicApiKeyArgs),
|
||||
/// Anthropic API key via Amazon Bedrock.
|
||||
#[command(name = "bedrock-api-key")]
|
||||
BedrockApiKey(BedrockApiKeyArgs),
|
||||
/// Anthropic Bedrock authentication via AWS access keys.
|
||||
#[command(name = "bedrock-access-key")]
|
||||
BedrockAccessKey(BedrockAccessKeyArgs),
|
||||
}
|
||||
|
||||
/// Fields shared by all provider-specific secret creation subcommands.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct CommonSecretCreateArgs {
|
||||
/// Name of the secret.
|
||||
pub name: String,
|
||||
|
||||
/// Description of the secret.
|
||||
#[arg(long = "description", short = 'd')]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
}
|
||||
|
||||
/// Arguments for creating an Anthropic API key secret.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct AnthropicApiKeyArgs {
|
||||
#[clap(flatten)]
|
||||
pub common: CommonSecretCreateArgs,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub value: ValueArgs,
|
||||
}
|
||||
|
||||
/// Arguments for creating an Anthropic Bedrock API key secret.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct BedrockApiKeyArgs {
|
||||
#[clap(flatten)]
|
||||
pub common: CommonSecretCreateArgs,
|
||||
|
||||
/// Bedrock API key. If not provided, prompts interactively.
|
||||
#[arg(long = "bedrock-api-key")]
|
||||
pub bedrock_api_key: Option<String>,
|
||||
|
||||
/// AWS region for the Bedrock endpoint. If not provided, prompts interactively.
|
||||
#[arg(long = "region")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
/// Arguments for creating an Anthropic Bedrock access key secret.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct BedrockAccessKeyArgs {
|
||||
#[clap(flatten)]
|
||||
pub common: CommonSecretCreateArgs,
|
||||
|
||||
/// AWS access key ID. If not provided, prompts interactively.
|
||||
#[arg(long = "access-key-id")]
|
||||
pub access_key_id: Option<String>,
|
||||
|
||||
/// AWS secret access key. If not provided, prompts interactively.
|
||||
#[arg(long = "secret-access-key")]
|
||||
pub secret_access_key: Option<String>,
|
||||
|
||||
/// AWS session token. If not provided, prompts interactively.
|
||||
#[arg(long = "session-token")]
|
||||
pub session_token: Option<String>,
|
||||
|
||||
/// AWS region for the Bedrock endpoint. If not provided, prompts interactively.
|
||||
#[arg(long = "region")]
|
||||
pub region: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct DeleteSecretArgs {
|
||||
/// Name of the secret to delete.
|
||||
pub name: String,
|
||||
|
||||
/// Delete without asking for confirmation.
|
||||
#[arg(long, default_value_t = false)]
|
||||
pub force: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct UpdateSecretArgs {
|
||||
/// Name of the secret to update.
|
||||
pub name: String,
|
||||
|
||||
/// Prompt for a new value for the secret.
|
||||
#[arg(long = "value", conflicts_with = "value_file")]
|
||||
pub value: bool,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub value_args: ValueArgs,
|
||||
/// New description for the secret. If omitted, the description is not changed.
|
||||
#[arg(long = "description", short = 'd')]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub scope: ObjectScope,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ListSecretsArgs {
|
||||
// TODO: consider flags to filter secrets.
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ValueArgs {
|
||||
/// File to read the secret value from. If not provided, the secret value will be read from
|
||||
/// standard input.
|
||||
#[arg(long = "value-file", short = 'f')]
|
||||
pub value_file: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum, Default)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum SecretType {
|
||||
#[default]
|
||||
RawValue,
|
||||
AnthropicApiKey,
|
||||
// Not exposed via the CLI `--type` flag; constructed internally for provider subcommands.
|
||||
#[value(skip)]
|
||||
AnthropicBedrockApiKey,
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SecretType::RawValue => write!(f, "raw-value"),
|
||||
SecretType::AnthropicApiKey => write!(f, "anthropic-api-key"),
|
||||
SecretType::AnthropicBedrockApiKey => write!(f, "anthropic-bedrock-api-key"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
use std::ffi::OsStr;
|
||||
use std::fmt;
|
||||
use std::str::FromStr;
|
||||
|
||||
use clap::builder::{EnumValueParser, PossibleValue};
|
||||
use clap::error::ErrorKind;
|
||||
use clap::{Arg, Args, Command, ValueEnum};
|
||||
|
||||
/// Arguments for sharing a session or other object.
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ShareArgs {
|
||||
/// Share the agent's session
|
||||
///
|
||||
/// Learn more at https://docs.warp.dev/knowledge-and-collaboration/session-sharing
|
||||
#[arg(long = "share", value_name = "RECIPIENTS", num_args=0..=1)]
|
||||
pub share: Option<Vec<ShareRequest>>,
|
||||
}
|
||||
|
||||
impl ShareArgs {
|
||||
/// Returns `true` if the session should be shared.
|
||||
pub fn is_shared(&self) -> bool {
|
||||
self.share.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
/// An individual sharing request, identifying:
|
||||
/// * Who to share with
|
||||
/// * Their permission level
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShareRequest {
|
||||
pub subject: ShareSubject,
|
||||
pub access_level: ShareAccessLevel,
|
||||
}
|
||||
|
||||
impl fmt::Display for ShareRequest {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match &self.subject {
|
||||
ShareSubject::Team => write!(f, "team:{}", self.access_level)?,
|
||||
ShareSubject::Public => write!(f, "public:{}", self.access_level)?,
|
||||
ShareSubject::User { email } => write!(f, "{email}:{}", self.access_level)?,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl clap::builder::ValueParserFactory for ShareRequest {
|
||||
type Parser = ShareRequestParser;
|
||||
|
||||
fn value_parser() -> Self::Parser {
|
||||
ShareRequestParser
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone)]
|
||||
pub struct ShareRequestParser;
|
||||
|
||||
impl clap::builder::TypedValueParser for ShareRequestParser {
|
||||
type Value = ShareRequest;
|
||||
|
||||
fn parse_ref(
|
||||
&self,
|
||||
cmd: &Command,
|
||||
arg: Option<&Arg>,
|
||||
value: &OsStr,
|
||||
) -> Result<Self::Value, clap::Error> {
|
||||
let value_str = value
|
||||
.to_str()
|
||||
.ok_or_else(|| clap::Error::raw(ErrorKind::InvalidUtf8, "Invalid share recipient"))?;
|
||||
|
||||
// If there's a `:`, treat the first part as the subject and the second as the access level. Otherwise, default to `view` access.
|
||||
let (subject_str, level_str) = match value_str.split_once(':') {
|
||||
Some((subject, level)) => (subject, Some(level)),
|
||||
None => (value_str, None),
|
||||
};
|
||||
|
||||
let subject = ShareSubject::from_str(subject_str)?;
|
||||
let access_level = match level_str {
|
||||
Some(level) => EnumValueParser::new().parse_ref(cmd, arg, OsStr::new(level))?,
|
||||
None => ShareAccessLevel::View,
|
||||
};
|
||||
|
||||
Ok(ShareRequest {
|
||||
subject,
|
||||
access_level,
|
||||
})
|
||||
}
|
||||
|
||||
fn possible_values(&self) -> Option<Box<dyn Iterator<Item = PossibleValue> + '_>> {
|
||||
Some(Box::new(
|
||||
[
|
||||
PossibleValue::new("team:view")
|
||||
.help("Share with your team, view-only")
|
||||
.alias("team"),
|
||||
PossibleValue::new("team:edit").help("Share with your team, with edit access"),
|
||||
PossibleValue::new("public:view")
|
||||
.help("Share with anyone who has the link, view-only")
|
||||
.alias("public"),
|
||||
PossibleValue::new("public:edit")
|
||||
.help("Share with anyone who has the link, with edit access"),
|
||||
PossibleValue::new("<user@email.com>:view")
|
||||
.help("Share with <user@email.com>, view-only")
|
||||
.alias("<user@email.com>"),
|
||||
PossibleValue::new("<user@email.com>:edit")
|
||||
.help("Share with <user@email.com>, with edit access"),
|
||||
]
|
||||
.into_iter(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, ValueEnum)]
|
||||
#[value(rename_all = "kebab-case")]
|
||||
pub enum ShareAccessLevel {
|
||||
View,
|
||||
Edit,
|
||||
}
|
||||
|
||||
impl fmt::Display for ShareAccessLevel {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ShareAccessLevel::View => write!(f, "view"),
|
||||
ShareAccessLevel::Edit => write!(f, "edit"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ShareSubject {
|
||||
/// Share with everyone on the caller's current team.
|
||||
Team,
|
||||
/// Share with anyone who has the link (anyone-with-link ACL).
|
||||
/// Subject to the workspace-level anyone-with-link sharing setting.
|
||||
Public,
|
||||
/// Share with an individual user by email.
|
||||
User { email: String },
|
||||
}
|
||||
|
||||
impl FromStr for ShareSubject {
|
||||
type Err = clap::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s {
|
||||
"team" => Ok(ShareSubject::Team),
|
||||
"public" => Ok(ShareSubject::Public),
|
||||
email if email.contains('@') => Ok(ShareSubject::User {
|
||||
email: email.to_string(),
|
||||
}),
|
||||
other => Err(clap::Error::raw(
|
||||
ErrorKind::InvalidValue,
|
||||
format!(
|
||||
"Cannot share with '{other}'. Expected 'team', 'public', or an email address"
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "share_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,114 @@
|
||||
use super::*;
|
||||
use clap::builder::TypedValueParser;
|
||||
use std::ffi::OsStr;
|
||||
|
||||
fn parse_share_request(value: &str) -> Result<ShareRequest, clap::Error> {
|
||||
let cmd = clap::Command::new("test");
|
||||
let parser = ShareRequestParser;
|
||||
parser.parse_ref(&cmd, None, OsStr::new(value))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_team_default() {
|
||||
let result = parse_share_request("team").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Team));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_team_view() {
|
||||
let result = parse_share_request("team:view").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Team));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_team_edit() {
|
||||
let result = parse_share_request("team:edit").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Team));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::Edit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_default() {
|
||||
let result = parse_share_request("ben@warp.dev").unwrap();
|
||||
match result.subject {
|
||||
ShareSubject::User { email } => assert_eq!(email, "ben@warp.dev"),
|
||||
_ => panic!("Expected User subject"),
|
||||
}
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_view() {
|
||||
let result = parse_share_request("ben@warp.dev:view").unwrap();
|
||||
match result.subject {
|
||||
ShareSubject::User { email } => assert_eq!(email, "ben@warp.dev"),
|
||||
_ => panic!("Expected User subject"),
|
||||
}
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_user_edit() {
|
||||
let result = parse_share_request("ben@warp.dev:edit").unwrap();
|
||||
match result.subject {
|
||||
ShareSubject::User { email } => assert_eq!(email, "ben@warp.dev"),
|
||||
_ => panic!("Expected User subject"),
|
||||
}
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::Edit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_format() {
|
||||
let result = parse_share_request("invalid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_invalid_access_level() {
|
||||
let result = parse_share_request("team:invalid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_public_default() {
|
||||
let result = parse_share_request("public").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Public));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_public_view() {
|
||||
let result = parse_share_request("public:view").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Public));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::View));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_public_edit() {
|
||||
let result = parse_share_request("public:edit").unwrap();
|
||||
assert!(matches!(result.subject, ShareSubject::Public));
|
||||
assert!(matches!(result.access_level, ShareAccessLevel::Edit));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_public_invalid_access_level() {
|
||||
let result = parse_share_request("public:invalid");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_public_request_display() {
|
||||
let request = ShareRequest {
|
||||
subject: ShareSubject::Public,
|
||||
access_level: ShareAccessLevel::View,
|
||||
};
|
||||
assert_eq!(format!("{request}"), "public:view");
|
||||
|
||||
let request = ShareRequest {
|
||||
subject: ShareSubject::Public,
|
||||
access_level: ShareAccessLevel::Edit,
|
||||
};
|
||||
assert_eq!(format!("{request}"), "public:edit");
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
use std::path::Path;
|
||||
use std::{fmt, str::FromStr};
|
||||
|
||||
/// A skill specifier that can reference a skill in a specific repo or search the current directory.
|
||||
///
|
||||
/// The skill identifier (after the optional `repo:` or `org/repo:` prefix) can be either:
|
||||
/// - A **simple skill name** - searched across skill directories with precedence (`.agents/skills/`, `.warp/skills/`, `.claude/skills/`, `.codex/skills/`)
|
||||
/// - A **full path to SKILL.md** - resolved directly without precedence
|
||||
///
|
||||
/// # Formats
|
||||
/// - `skill_name` - Simple name, search current directory
|
||||
/// - `skill_path` - Full path (e.g., `.claude/skills/foo/SKILL.md`)
|
||||
/// - `repo:skill_name` - Simple name in specific repo
|
||||
/// - `repo:skill_path` - Full path in specific repo
|
||||
/// - `org/repo:skill_name` - Simple name with org and repo
|
||||
/// - `org/repo:skill_path` - Full path with org and repo
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// Simple skill names (searched with directory precedence):
|
||||
/// ```ignore
|
||||
/// code-review // searches .agents/skills/, .warp/skills/, .claude/skills/, .codex/skills/
|
||||
/// warp-internal:code-review // searches in "warp-internal" repo
|
||||
/// warpdotdev/warp-internal:code-review // searches in specific org/repo
|
||||
/// ```
|
||||
///
|
||||
/// Full paths (resolved directly, no precedence):
|
||||
/// ```ignore
|
||||
/// .agents/skills/my-skill/SKILL.md // directly resolves this path
|
||||
/// warp-server:.claude/skills/deploy/SKILL.md // exact path in "warp-server" repo
|
||||
/// warpdotdev/warp-internal:.claude/skills/code-review/SKILL.md // exact path in org/repo
|
||||
/// ```
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillSpec {
|
||||
/// Optional GitHub organization (e.g., "warpdotdev" in "warpdotdev/warp-internal:code-review")
|
||||
pub org: Option<String>,
|
||||
/// Optional repository name (e.g., "warp-internal")
|
||||
pub repo: Option<String>,
|
||||
/// The skill identifier - either a simple name or a full path to SKILL.md.
|
||||
///
|
||||
/// - **Simple name** (e.g., `"code-review"`): Searched across `.agents/skills/`, `.warp/skills/`, `.claude/skills/`, `.codex/skills/`
|
||||
/// in precedence order. The name is used to construct paths like `.claude/skills/code-review/SKILL.md`.
|
||||
///
|
||||
/// - **Full path** (e.g., `".claude/skills/code-review/SKILL.md"`): Resolved directly without precedence.
|
||||
/// Detected by presence of path separators (e.g., `/` or `\`).
|
||||
///
|
||||
/// Use [`is_full_path()`](Self::is_full_path) to distinguish between the two formats.
|
||||
pub skill_identifier: String,
|
||||
}
|
||||
|
||||
impl SkillSpec {
|
||||
/// Create a new skill spec with org and repo qualifiers.
|
||||
pub fn with_org_and_repo(org: String, repo: String, skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: Some(org),
|
||||
repo: Some(repo),
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new skill spec with a repo qualifier.
|
||||
pub fn with_repo(repo: String, skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: None,
|
||||
repo: Some(repo),
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new skill spec without any qualifier.
|
||||
pub fn without_repo(skill_identifier: String) -> Self {
|
||||
Self {
|
||||
org: None,
|
||||
repo: None,
|
||||
skill_identifier,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if `skill_identifier` is a full path, false if it's a simple skill name.
|
||||
///
|
||||
/// A full path contains path separators (`/` or `\`), such as:
|
||||
/// - `.claude/skills/deploy/SKILL.md`
|
||||
/// - `.agents/skills/my-skill/SKILL.md`
|
||||
///
|
||||
/// A simple skill name has no path separators, such as:
|
||||
/// - `code-review`
|
||||
/// - `deploy`
|
||||
///
|
||||
/// Full paths are resolved directly, while simple names are searched across
|
||||
/// skill directories in precedence order (`.agents/skills/`, `.warp/skills/`, `.claude/skills/`, `.codex/skills/`).
|
||||
///
|
||||
/// Uses cross-platform path semantics via [`std::path::Path`].
|
||||
pub fn is_full_path(&self) -> bool {
|
||||
let path = Path::new(&self.skill_identifier);
|
||||
// A path with multiple components (e.g., "foo/bar" or "foo\\bar") is a full path.
|
||||
// A single component (e.g., "code-review") is just a name.
|
||||
path.components().count() > 1
|
||||
}
|
||||
|
||||
/// Extracts the displayable skill name from this spec.
|
||||
///
|
||||
/// # Returns
|
||||
/// - For path-style identifiers (e.g., `.agents/skills/slack-triage/SKILL.md`): returns the parent directory name
|
||||
/// - For simple names: returns the name as-is
|
||||
/// - For invalid paths: falls back to file stem or the identifier itself
|
||||
pub fn skill_name(&self) -> String {
|
||||
let skill_identifier = self.skill_identifier.trim();
|
||||
let path = Path::new(skill_identifier);
|
||||
|
||||
if path.components().count() > 1 {
|
||||
if let Some(skill_name) = path
|
||||
.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
{
|
||||
return skill_name.to_string();
|
||||
}
|
||||
|
||||
if let Some(file_stem) = path.file_stem().and_then(|stem| stem.to_str()) {
|
||||
return file_stem.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
skill_identifier.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for SkillSpec {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
let s = s.trim();
|
||||
if s.is_empty() {
|
||||
return Err("Skill specifier cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for [qualifier:]skill_identifier format
|
||||
if let Some((qualifier, skill_identifier)) = s.split_once(':') {
|
||||
let qualifier = qualifier.trim();
|
||||
let skill_identifier = skill_identifier.trim();
|
||||
|
||||
if qualifier.is_empty() {
|
||||
return Err(
|
||||
"Qualifier cannot be empty in 'repo:skill_identifier' format".to_string(),
|
||||
);
|
||||
}
|
||||
if skill_identifier.is_empty() {
|
||||
return Err("Skill identifier cannot be empty".to_string());
|
||||
}
|
||||
|
||||
// Check for org/repo format in qualifier
|
||||
if let Some((org, repo)) = qualifier.split_once('/') {
|
||||
let org = org.trim();
|
||||
let repo = repo.trim();
|
||||
|
||||
if org.is_empty() {
|
||||
return Err("Organization cannot be empty".to_string());
|
||||
}
|
||||
if repo.is_empty() {
|
||||
return Err("Repository name cannot be empty".to_string());
|
||||
}
|
||||
|
||||
Ok(Self::with_org_and_repo(
|
||||
org.to_string(),
|
||||
repo.to_string(),
|
||||
skill_identifier.to_string(),
|
||||
))
|
||||
} else {
|
||||
Ok(Self::with_repo(
|
||||
qualifier.to_string(),
|
||||
skill_identifier.to_string(),
|
||||
))
|
||||
}
|
||||
} else {
|
||||
Ok(Self::without_repo(s.to_string()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SkillSpec {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match (&self.org, &self.repo) {
|
||||
(Some(org), Some(repo)) => write!(f, "{}/{}:{}", org, repo, self.skill_identifier),
|
||||
(None, Some(repo)) => write!(f, "{}:{}", repo, self.skill_identifier),
|
||||
_ => write!(f, "{}", self.skill_identifier),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "skill_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,142 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_simple_skill_name() {
|
||||
let spec: SkillSpec = "code-review".parse().unwrap();
|
||||
assert_eq!(spec.org, None);
|
||||
assert_eq!(spec.repo, None);
|
||||
assert_eq!(spec.skill_identifier, "code-review");
|
||||
assert!(!spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_repo_qualified() {
|
||||
let spec: SkillSpec = "warp-internal:code-review".parse().unwrap();
|
||||
assert_eq!(spec.org, None);
|
||||
assert_eq!(spec.repo, Some("warp-internal".to_string()));
|
||||
assert_eq!(spec.skill_identifier, "code-review");
|
||||
assert!(!spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_org_repo_qualified() {
|
||||
let spec: SkillSpec = "warpdotdev/warp-internal:code-review".parse().unwrap();
|
||||
assert_eq!(spec.org, Some("warpdotdev".to_string()));
|
||||
assert_eq!(spec.repo, Some("warp-internal".to_string()));
|
||||
assert_eq!(spec.skill_identifier, "code-review");
|
||||
assert!(!spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_path_with_org_repo() {
|
||||
let spec: SkillSpec = "warpdotdev/warp-internal:.claude/skills/deploy/SKILL.md"
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(spec.org, Some("warpdotdev".to_string()));
|
||||
assert_eq!(spec.repo, Some("warp-internal".to_string()));
|
||||
assert_eq!(spec.skill_identifier, ".claude/skills/deploy/SKILL.md");
|
||||
assert!(spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_full_path_with_repo() {
|
||||
let spec: SkillSpec = "warp-server:.agents/skills/test/SKILL.md".parse().unwrap();
|
||||
assert_eq!(spec.org, None);
|
||||
assert_eq!(spec.repo, Some("warp-server".to_string()));
|
||||
assert_eq!(spec.skill_identifier, ".agents/skills/test/SKILL.md");
|
||||
assert!(spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_simple_name() {
|
||||
let spec = SkillSpec::without_repo("code-review".to_string());
|
||||
assert_eq!(spec.to_string(), "code-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_repo_qualified() {
|
||||
let spec = SkillSpec::with_repo("warp-internal".to_string(), "code-review".to_string());
|
||||
assert_eq!(spec.to_string(), "warp-internal:code-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_org_repo_qualified() {
|
||||
let spec = SkillSpec::with_org_and_repo(
|
||||
"warpdotdev".to_string(),
|
||||
"warp-internal".to_string(),
|
||||
"code-review".to_string(),
|
||||
);
|
||||
assert_eq!(spec.to_string(), "warpdotdev/warp-internal:code-review");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_display_full_path() {
|
||||
let spec = SkillSpec::with_org_and_repo(
|
||||
"warpdotdev".to_string(),
|
||||
"warp-internal".to_string(),
|
||||
".claude/skills/deploy/SKILL.md".to_string(),
|
||||
);
|
||||
assert_eq!(
|
||||
spec.to_string(),
|
||||
"warpdotdev/warp-internal:.claude/skills/deploy/SKILL.md"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_full_path_with_slash() {
|
||||
let spec = SkillSpec::without_repo(".claude/skills/deploy/SKILL.md".to_string());
|
||||
assert!(spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_full_path_single_component_md() {
|
||||
// A single component (even with .md extension) is treated as a skill name, not a full path
|
||||
let spec = SkillSpec::without_repo("something.md".to_string());
|
||||
assert!(!spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_not_full_path() {
|
||||
let spec = SkillSpec::without_repo("code-review".to_string());
|
||||
assert!(!spec.is_full_path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_fails() {
|
||||
let result: Result<SkillSpec, _> = "".parse();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_qualifier_fails() {
|
||||
let result: Result<SkillSpec, _> = ":code-review".parse();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_empty_path_fails() {
|
||||
let result: Result<SkillSpec, _> = "warp-internal:".parse();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_name_simple_name() {
|
||||
let spec: SkillSpec = "feedback-triage-bot".parse().unwrap();
|
||||
assert_eq!(spec.skill_name(), "feedback-triage-bot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_name_repo_qualified_name() {
|
||||
let spec: SkillSpec = "warpdotdev/feedback-triage-bot:feedback-triage-bot"
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(spec.skill_name(), "feedback-triage-bot");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_name_repo_qualified_path() {
|
||||
let spec: SkillSpec = "warpdotdev/feedback-triage-bot:.agents/skills/slack-triage/SKILL.md"
|
||||
.parse()
|
||||
.unwrap();
|
||||
assert_eq!(spec.skill_name(), "slack-triage");
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use clap::{Args, Subcommand, ValueEnum};
|
||||
|
||||
use crate::json_filter::JsonOutput;
|
||||
|
||||
/// Task-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum TaskCommand {
|
||||
/// List ambient agent tasks.
|
||||
List(ListTasksArgs),
|
||||
/// Get status of a specific ambient agent task.
|
||||
Get(TaskGetArgs),
|
||||
/// Retrieve the conversation for a specific run or conversation.
|
||||
#[command(subcommand)]
|
||||
Conversation(ConversationCommand),
|
||||
/// Messages sent to and from runs.
|
||||
#[command(subcommand)]
|
||||
Message(MessageCommand),
|
||||
}
|
||||
|
||||
/// Conversation-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum ConversationCommand {
|
||||
/// Get a conversation by conversation ID.
|
||||
Get(ConversationGetArgs),
|
||||
}
|
||||
|
||||
/// Message-related subcommands.
|
||||
#[derive(Debug, Clone, Subcommand)]
|
||||
pub enum MessageCommand {
|
||||
/// Watch for new messages delivered to a run.
|
||||
Watch(MessageWatchArgs),
|
||||
/// Send a message from one run to one or more recipient runs.
|
||||
Send(MessageSendArgs),
|
||||
/// List inbox message headers for a run.
|
||||
List(MessageListArgs),
|
||||
/// Read a full message body.
|
||||
Read(MessageReadArgs),
|
||||
/// Mark a message as delivered.
|
||||
#[command(alias = "delivered")]
|
||||
MarkDelivered(MessageDeliveredArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ConversationGetArgs {
|
||||
/// The conversation ID to retrieve.
|
||||
pub conversation_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct MessageSendArgs {
|
||||
/// Recipient run ID. Repeat the flag to send to multiple recipients.
|
||||
#[arg(long = "to", required = true, num_args = 1.., value_delimiter = ',')]
|
||||
pub to: Vec<String>,
|
||||
|
||||
/// Message subject.
|
||||
#[arg(long = "subject")]
|
||||
pub subject: String,
|
||||
|
||||
/// Message body.
|
||||
#[arg(long = "body")]
|
||||
pub body: String,
|
||||
|
||||
/// Sender run ID.
|
||||
#[arg(long = "sender-run-id")]
|
||||
pub sender_run_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct MessageListArgs {
|
||||
/// The run ID whose inbox should be listed.
|
||||
pub run_id: String,
|
||||
|
||||
/// Only return unread messages.
|
||||
#[arg(long = "unread")]
|
||||
pub unread: bool,
|
||||
|
||||
/// Only return messages sent at or after this RFC3339 timestamp.
|
||||
#[arg(long = "since")]
|
||||
pub since: Option<String>,
|
||||
|
||||
/// Maximum number of messages to return (default: 50).
|
||||
#[arg(
|
||||
short = 'L',
|
||||
long = "limit",
|
||||
default_value = "50",
|
||||
value_parser = clap::value_parser!(i32).range(1..)
|
||||
)]
|
||||
pub limit: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct MessageWatchArgs {
|
||||
/// The run ID whose inbox should be watched.
|
||||
pub run_id: String,
|
||||
|
||||
/// Resume after this event sequence (inclusive cursor for reconnects).
|
||||
#[arg(
|
||||
long = "since-sequence",
|
||||
default_value = "0",
|
||||
value_parser = clap::value_parser!(i64).range(0..)
|
||||
)]
|
||||
pub since_sequence: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct MessageReadArgs {
|
||||
/// The message ID to read.
|
||||
pub message_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct MessageDeliveredArgs {
|
||||
/// The message ID to mark as delivered.
|
||||
pub message_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct ListTasksArgs {
|
||||
/// Maximum number of tasks to return (default: 10).
|
||||
#[arg(short = 'L', long = "limit", default_value = "10")]
|
||||
pub limit: i32,
|
||||
|
||||
/// Filter by run state. Repeat the flag to match any of multiple states.
|
||||
#[arg(long = "state", value_enum, value_name = "STATE")]
|
||||
pub state: Vec<RunStateArg>,
|
||||
|
||||
/// Filter by run source.
|
||||
#[arg(long = "source", value_enum, value_name = "SOURCE")]
|
||||
pub source: Option<RunSourceArg>,
|
||||
|
||||
/// Filter by where the run executed.
|
||||
#[arg(long = "execution-location", value_enum, value_name = "LOC")]
|
||||
pub execution_location: Option<ExecutionLocationArg>,
|
||||
|
||||
/// Filter by creator ID.
|
||||
#[arg(long = "creator", value_name = "UID")]
|
||||
pub creator: Option<String>,
|
||||
|
||||
/// Filter by environment ID.
|
||||
#[arg(long = "environment", value_name = "ENV_ID")]
|
||||
pub environment: Option<String>,
|
||||
|
||||
/// Filter by skill specification (e.g. `owner/repo:path/to/SKILL.md`).
|
||||
#[arg(long = "skill", value_name = "SPEC")]
|
||||
pub skill: Option<String>,
|
||||
|
||||
/// Filter to runs created by a specific scheduled agent.
|
||||
#[arg(long = "schedule", value_name = "SCHEDULE_ID")]
|
||||
pub schedule: Option<String>,
|
||||
|
||||
/// Filter to descendants of a specific run.
|
||||
#[arg(long = "ancestor-run", value_name = "RUN_ID")]
|
||||
pub ancestor_run: Option<String>,
|
||||
|
||||
/// Filter by agent config name.
|
||||
#[arg(long = "name", value_name = "NAME")]
|
||||
pub name: Option<String>,
|
||||
|
||||
/// Filter by model ID.
|
||||
#[arg(long = "model", value_name = "MODEL_ID")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Filter by produced artifact type.
|
||||
#[arg(long = "artifact-type", value_enum, value_name = "TYPE")]
|
||||
pub artifact_type: Option<ArtifactTypeArg>,
|
||||
|
||||
/// Only include runs created after the given timestamp.
|
||||
#[arg(long = "created-after", value_name = "RFC3339", value_parser = parse_rfc3339)]
|
||||
pub created_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Only include runs created before the given timestamp.
|
||||
#[arg(long = "created-before", value_name = "RFC3339", value_parser = parse_rfc3339)]
|
||||
pub created_before: Option<DateTime<Utc>>,
|
||||
|
||||
/// Only include runs updated after the given timestamp.
|
||||
#[arg(long = "updated-after", value_name = "RFC3339", value_parser = parse_rfc3339)]
|
||||
pub updated_after: Option<DateTime<Utc>>,
|
||||
|
||||
/// Fuzzy search across run title, prompt, and skill spec.
|
||||
#[arg(short = 'q', long = "query", value_name = "TEXT")]
|
||||
pub query: Option<String>,
|
||||
|
||||
/// Sort field.
|
||||
#[arg(long = "sort-by", value_enum, value_name = "FIELD")]
|
||||
pub sort_by: Option<RunSortByArg>,
|
||||
|
||||
/// Sort direction.
|
||||
#[arg(long = "sort-order", value_enum, value_name = "DIR")]
|
||||
pub sort_order: Option<RunSortOrderArg>,
|
||||
|
||||
/// Opaque pagination cursor from a previous list response.
|
||||
///
|
||||
/// When using `--cursor`, `--sort-by` and `--sort-order` must match the
|
||||
/// values used to obtain the cursor.
|
||||
#[arg(long = "cursor", value_name = "CURSOR")]
|
||||
pub cursor: Option<String>,
|
||||
|
||||
/// JSON formatting configuration.
|
||||
#[command(flatten)]
|
||||
pub json_output: JsonOutput,
|
||||
}
|
||||
|
||||
/// Parse an RFC 3339 timestamp into a UTC `DateTime`.
|
||||
fn parse_rfc3339(s: &str) -> Result<DateTime<Utc>, String> {
|
||||
DateTime::parse_from_rfc3339(s)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.map_err(|e| format!("invalid RFC 3339 timestamp '{s}': {e}"))
|
||||
}
|
||||
|
||||
/// Run state values accepted by `--state`. Repeatable; multiple values match any of them.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum RunStateArg {
|
||||
#[value(name = "queued")]
|
||||
Queued,
|
||||
#[value(name = "pending")]
|
||||
Pending,
|
||||
#[value(name = "claimed")]
|
||||
Claimed,
|
||||
#[value(name = "in-progress")]
|
||||
InProgress,
|
||||
#[value(name = "succeeded")]
|
||||
Succeeded,
|
||||
#[value(name = "failed")]
|
||||
Failed,
|
||||
#[value(name = "error")]
|
||||
Error,
|
||||
#[value(name = "blocked")]
|
||||
Blocked,
|
||||
#[value(name = "cancelled")]
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
/// Run source values accepted by `--source`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum RunSourceArg {
|
||||
#[value(name = "api")]
|
||||
Api,
|
||||
#[value(name = "cli")]
|
||||
Cli,
|
||||
#[value(name = "slack")]
|
||||
Slack,
|
||||
#[value(name = "linear")]
|
||||
Linear,
|
||||
#[value(name = "scheduled-agent")]
|
||||
ScheduledAgent,
|
||||
#[value(name = "web-app")]
|
||||
WebApp,
|
||||
#[value(name = "cloud-mode")]
|
||||
CloudMode,
|
||||
#[value(name = "github-action")]
|
||||
GitHubAction,
|
||||
#[value(name = "interactive")]
|
||||
Interactive,
|
||||
}
|
||||
|
||||
/// Execution-location values accepted by `--execution-location`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ExecutionLocationArg {
|
||||
#[value(name = "local")]
|
||||
Local,
|
||||
#[value(name = "remote")]
|
||||
Remote,
|
||||
}
|
||||
|
||||
/// Artifact-type values accepted by `--artifact-type`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum ArtifactTypeArg {
|
||||
#[value(name = "plan")]
|
||||
Plan,
|
||||
#[value(name = "pull-request")]
|
||||
PullRequest,
|
||||
#[value(name = "screenshot")]
|
||||
Screenshot,
|
||||
#[value(name = "file")]
|
||||
File,
|
||||
}
|
||||
|
||||
/// Sort-by values accepted by `--sort-by`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum RunSortByArg {
|
||||
#[value(name = "updated-at")]
|
||||
UpdatedAt,
|
||||
#[value(name = "created-at")]
|
||||
CreatedAt,
|
||||
#[value(name = "title")]
|
||||
Title,
|
||||
#[value(name = "agent")]
|
||||
Agent,
|
||||
}
|
||||
|
||||
/// Sort-order values accepted by `--sort-order`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
|
||||
pub enum RunSortOrderArg {
|
||||
#[value(name = "asc")]
|
||||
Asc,
|
||||
#[value(name = "desc")]
|
||||
Desc,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Args)]
|
||||
pub struct TaskGetArgs {
|
||||
/// The task ID to get status for.
|
||||
pub task_id: String,
|
||||
|
||||
/// Retrieve the conversation for this run instead of the run status.
|
||||
#[arg(long = "conversation")]
|
||||
pub conversation: bool,
|
||||
|
||||
/// JSON formatting configuration.
|
||||
#[command(flatten)]
|
||||
pub json_output: JsonOutput,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "task_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Tests for `ListTasksArgs` clap parsing.
|
||||
use chrono::TimeZone;
|
||||
use clap::Parser;
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Tiny wrapper so we can parse `ListTasksArgs` in isolation, without wiring up the whole Args
|
||||
/// type from `lib.rs`.
|
||||
#[derive(Debug, Parser)]
|
||||
struct TestApp {
|
||||
#[clap(flatten)]
|
||||
args: ListTasksArgs,
|
||||
}
|
||||
|
||||
fn parse(argv: &[&str]) -> TestApp {
|
||||
let mut full = vec!["test"];
|
||||
full.extend_from_slice(argv);
|
||||
TestApp::try_parse_from(full).expect("parse succeeds")
|
||||
}
|
||||
|
||||
fn parse_err(argv: &[&str]) -> clap::Error {
|
||||
let mut full = vec!["test"];
|
||||
full.extend_from_slice(argv);
|
||||
TestApp::try_parse_from(full).expect_err("parse fails")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_match_pre_change_behavior() {
|
||||
let TestApp { args } = parse(&[]);
|
||||
assert_eq!(args.limit, 10);
|
||||
assert!(args.state.is_empty());
|
||||
assert!(args.source.is_none());
|
||||
assert!(args.execution_location.is_none());
|
||||
assert!(args.creator.is_none());
|
||||
assert!(args.environment.is_none());
|
||||
assert!(args.skill.is_none());
|
||||
assert!(args.schedule.is_none());
|
||||
assert!(args.ancestor_run.is_none());
|
||||
assert!(args.name.is_none());
|
||||
assert!(args.model.is_none());
|
||||
assert!(args.artifact_type.is_none());
|
||||
assert!(args.created_after.is_none());
|
||||
assert!(args.created_before.is_none());
|
||||
assert!(args.updated_after.is_none());
|
||||
assert!(args.query.is_none());
|
||||
assert!(args.sort_by.is_none());
|
||||
assert!(args.sort_order.is_none());
|
||||
assert!(args.cursor.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn state_flag_is_repeatable() {
|
||||
let TestApp { args } = parse(&["--state", "failed", "--state", "error"]);
|
||||
assert_eq!(args.state, vec![RunStateArg::Failed, RunStateArg::Error]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_filter_flags_parse() {
|
||||
let TestApp { args } = parse(&[
|
||||
"--limit",
|
||||
"42",
|
||||
"--state",
|
||||
"in-progress",
|
||||
"--source",
|
||||
"api",
|
||||
"--execution-location",
|
||||
"remote",
|
||||
"--creator",
|
||||
"user-uid",
|
||||
"--environment",
|
||||
"env-123",
|
||||
"--skill",
|
||||
"owner/repo:SKILL.md",
|
||||
"--schedule",
|
||||
"sched-1",
|
||||
"--ancestor-run",
|
||||
"run-parent",
|
||||
"--name",
|
||||
"nightly",
|
||||
"--model",
|
||||
"claude-4-5",
|
||||
"--artifact-type",
|
||||
"pull-request",
|
||||
"--created-after",
|
||||
"2026-04-01T00:00:00Z",
|
||||
"--created-before",
|
||||
"2026-04-02T00:00:00Z",
|
||||
"--updated-after",
|
||||
"2026-04-03T12:30:00Z",
|
||||
"-q",
|
||||
"oz run",
|
||||
"--sort-by",
|
||||
"created-at",
|
||||
"--sort-order",
|
||||
"asc",
|
||||
"--cursor",
|
||||
"abcd==",
|
||||
]);
|
||||
|
||||
assert_eq!(args.limit, 42);
|
||||
assert_eq!(args.state, vec![RunStateArg::InProgress]);
|
||||
assert_eq!(args.source, Some(RunSourceArg::Api));
|
||||
assert_eq!(args.execution_location, Some(ExecutionLocationArg::Remote));
|
||||
assert_eq!(args.creator.as_deref(), Some("user-uid"));
|
||||
assert_eq!(args.environment.as_deref(), Some("env-123"));
|
||||
assert_eq!(args.skill.as_deref(), Some("owner/repo:SKILL.md"));
|
||||
assert_eq!(args.schedule.as_deref(), Some("sched-1"));
|
||||
assert_eq!(args.ancestor_run.as_deref(), Some("run-parent"));
|
||||
assert_eq!(args.name.as_deref(), Some("nightly"));
|
||||
assert_eq!(args.model.as_deref(), Some("claude-4-5"));
|
||||
assert_eq!(args.artifact_type, Some(ArtifactTypeArg::PullRequest));
|
||||
assert_eq!(
|
||||
args.created_after,
|
||||
Some(Utc.with_ymd_and_hms(2026, 4, 1, 0, 0, 0).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
args.created_before,
|
||||
Some(Utc.with_ymd_and_hms(2026, 4, 2, 0, 0, 0).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
args.updated_after,
|
||||
Some(Utc.with_ymd_and_hms(2026, 4, 3, 12, 30, 0).unwrap())
|
||||
);
|
||||
assert_eq!(args.query.as_deref(), Some("oz run"));
|
||||
assert_eq!(args.sort_by, Some(RunSortByArg::CreatedAt));
|
||||
assert_eq!(args.sort_order, Some(RunSortOrderArg::Asc));
|
||||
assert_eq!(args.cursor.as_deref(), Some("abcd=="));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_state_is_rejected() {
|
||||
let err = parse_err(&["--state", "bogus"]);
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_sort_by_is_rejected() {
|
||||
let err = parse_err(&["--sort-by", "random"]);
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_execution_location_is_rejected() {
|
||||
let err = parse_err(&["--execution-location", "moon"]);
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_artifact_type_is_rejected() {
|
||||
let err = parse_err(&["--artifact-type", "poem"]);
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::InvalidValue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_created_after_timestamp_is_rejected() {
|
||||
let err = parse_err(&["--created-after", "not-a-timestamp"]);
|
||||
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_are_converted_to_utc() {
|
||||
// Non-UTC offsets should be normalized to UTC in the parsed value.
|
||||
let TestApp { args } = parse(&["--updated-after", "2026-04-03T12:30:00+02:00"]);
|
||||
assert_eq!(
|
||||
args.updated_after,
|
||||
Some(Utc.with_ymd_and_hms(2026, 4, 3, 10, 30, 0).unwrap())
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user