Skip to main content

task_graph_mcp/cli/
agent.rs

1//! CLI subcommands for background agents.
2//!
3//! Provides CLI access to task-graph operations for background agents
4//! that don't have MCP access. Calls the same underlying tool functions
5//! used by the MCP server.
6
7use crate::config::{
8    AppConfig, Config, ConfigLoader, PhasesConfig, Prompts, ServerPaths, StatesConfig,
9    workflows::WorkflowsConfig,
10};
11use crate::db::Database;
12use crate::format::{OutputFormat, ToolResult};
13use crate::prompts as prompt_system;
14use crate::tools::{ToolHandler, advisories, agents, attachments, claiming, tasks, tracking};
15use anyhow::Result;
16use clap::{Args, Subcommand, ValueEnum};
17use serde_json::{Value, json};
18use std::path::PathBuf;
19use std::process::ExitCode;
20use std::sync::Arc;
21
22/// CLI exit codes for programmatic error handling.
23pub mod exit_codes {
24    pub const SUCCESS: u8 = 0;
25    pub const GENERAL_ERROR: u8 = 1;
26    pub const INVALID_ARGUMENTS: u8 = 2;
27    pub const TASK_NOT_FOUND: u8 = 3;
28    pub const WORKER_NOT_FOUND: u8 = 4;
29    pub const CLAIM_FAILED: u8 = 5;
30    pub const PERMISSION_DENIED: u8 = 6;
31}
32
33/// Output format for CLI commands.
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, ValueEnum)]
35pub enum CliOutputFormat {
36    /// Markdown output (default, matches MCP behavior)
37    #[default]
38    Markdown,
39    /// JSON output
40    Json,
41}
42
43impl From<CliOutputFormat> for OutputFormat {
44    fn from(f: CliOutputFormat) -> Self {
45        match f {
46            CliOutputFormat::Markdown => OutputFormat::Markdown,
47            CliOutputFormat::Json => OutputFormat::Json,
48        }
49    }
50}
51
52/// Agent subcommand arguments.
53#[derive(Args, Debug)]
54pub struct AgentArgs {
55    /// Worker ID (overrides TASK_GRAPH_WORKER_ID env var and config)
56    #[arg(long, global = true)]
57    pub worker_id: Option<String>,
58
59    /// Output format: markdown (default) or json
60    #[arg(long, global = true, value_enum, default_value = "markdown")]
61    pub format: CliOutputFormat,
62
63    #[command(subcommand)]
64    pub command: AgentCommand,
65}
66
67/// Available agent subcommands.
68#[derive(Subcommand, Debug)]
69pub enum AgentCommand {
70    /// Register as a worker
71    Connect(ConnectArgs),
72
73    /// Unregister and release all claims
74    Disconnect(DisconnectArgs),
75
76    /// Query tasks with flexible filters
77    #[command(alias = "ls")]
78    ListTasks(ListTasksArgs),
79
80    /// Get detailed task information
81    Get(GetArgs),
82
83    /// Claim a task for work
84    Claim(ClaimArgs),
85
86    /// Update task properties
87    Update(UpdateArgs),
88
89    /// Broadcast real-time status updates
90    Thinking(ThinkingArgs),
91
92    /// Add an attachment to a task
93    Attach(AttachArgs),
94
95    /// List connected workers
96    ListAgents(ListAgentsArgs),
97
98    /// Query workflow prompts and advisories
99    Prompts(PromptsArgs),
100
101    /// Run commands interactively or from stdin/file
102    #[command(alias = "repl")]
103    Interactive(InteractiveArgs),
104
105    /// Execute commands from a file (@file syntax alternative)
106    Batch(BatchArgs),
107}
108
109/// Arguments for interactive/batch mode.
110#[derive(Args, Debug)]
111pub struct InteractiveArgs {
112    /// Read commands from stdin instead of prompting
113    #[arg(long)]
114    pub stdin: bool,
115}
116
117/// Arguments for batch file execution.
118#[derive(Args, Debug)]
119pub struct BatchArgs {
120    /// Path to file containing commands (one per line)
121    pub file: PathBuf,
122
123    /// Continue on errors instead of stopping
124    #[arg(long, short = 'k')]
125    pub keep_going: bool,
126}
127
128/// Arguments for the connect command.
129#[derive(Args, Debug)]
130pub struct ConnectArgs {
131    /// Worker ID (optional, auto-generated if not provided)
132    pub worker_id: Option<String>,
133
134    /// Capability/role tags (comma-separated)
135    #[arg(long, value_delimiter = ',')]
136    pub tags: Vec<String>,
137
138    /// Named workflow to use (e.g., 'swarm')
139    #[arg(long)]
140    pub workflow: Option<String>,
141
142    /// Overlays to apply on top of workflow (comma-separated)
143    #[arg(long, value_delimiter = ',')]
144    pub overlays: Vec<String>,
145
146    /// Force reconnection if worker ID already exists
147    #[arg(long)]
148    pub force: bool,
149}
150
151/// Arguments for the disconnect command.
152#[derive(Args, Debug)]
153pub struct DisconnectArgs {
154    /// Worker ID to disconnect
155    pub worker_id: String,
156
157    /// Status to set released tasks to
158    #[arg(long)]
159    pub final_status: Option<String>,
160}
161
162/// Arguments for the list-tasks command.
163#[derive(Args, Debug)]
164pub struct ListTasksArgs {
165    /// Filter for claimable tasks
166    #[arg(long)]
167    pub ready: bool,
168
169    /// Filter for blocked tasks
170    #[arg(long)]
171    pub blocked: bool,
172
173    /// Filter by status (comma-separated)
174    #[arg(long, value_delimiter = ',')]
175    pub status: Vec<String>,
176
177    /// Filter by parent task ID ('null' for root tasks)
178    #[arg(long)]
179    pub parent: Option<String>,
180
181    /// Maximum number of tasks to return
182    #[arg(long)]
183    pub limit: Option<i32>,
184
185    /// Number of tasks to skip for pagination
186    #[arg(long)]
187    pub offset: Option<i32>,
188}
189
190/// Arguments for the get command.
191#[derive(Args, Debug)]
192pub struct GetArgs {
193    /// Task ID to retrieve
194    pub task_id: String,
195}
196
197/// Arguments for the claim command.
198#[derive(Args, Debug)]
199pub struct ClaimArgs {
200    /// Worker ID claiming the task
201    pub worker_id: String,
202
203    /// Task ID to claim
204    pub task_id: String,
205
206    /// Force claim even if owned by another agent
207    #[arg(long)]
208    pub force: bool,
209}
210
211/// Arguments for the update command.
212#[derive(Args, Debug)]
213pub struct UpdateArgs {
214    /// Worker ID performing the update
215    pub worker_id: String,
216
217    /// Task ID to update
218    pub task_id: String,
219
220    /// New status
221    #[arg(long)]
222    pub status: Option<String>,
223
224    /// New title
225    #[arg(long)]
226    pub title: Option<String>,
227
228    /// New description
229    #[arg(long)]
230    pub description: Option<String>,
231
232    /// Reason for the update (stored in audit trail)
233    #[arg(long)]
234    pub reason: Option<String>,
235
236    /// Force update even if owned by another worker
237    #[arg(long)]
238    pub force: bool,
239}
240
241/// Arguments for the thinking command.
242#[derive(Args, Debug)]
243pub struct ThinkingArgs {
244    /// Worker ID broadcasting the thought
245    pub worker_id: String,
246
247    /// Current thought/status message
248    pub message: String,
249
250    /// Specific task IDs to update (comma-separated)
251    #[arg(long, value_delimiter = ',')]
252    pub tasks: Vec<String>,
253}
254
255/// Arguments for the attach command.
256#[derive(Args, Debug)]
257pub struct AttachArgs {
258    /// Worker ID adding the attachment
259    pub worker_id: String,
260
261    /// Task ID to attach to
262    pub task_id: String,
263
264    /// Attachment type/category (e.g., 'commit', 'note')
265    #[arg(long, short = 't')]
266    pub r#type: String,
267
268    /// Attachment content (text)
269    #[arg(long, short = 'c', conflicts_with = "file")]
270    pub content: Option<String>,
271
272    /// Read content from file
273    #[arg(long, short = 'f', conflicts_with = "content")]
274    pub file: Option<PathBuf>,
275
276    /// Optional label/name for the attachment
277    #[arg(long)]
278    pub name: Option<String>,
279}
280
281/// Arguments for the list-agents command.
282#[derive(Args, Debug)]
283pub struct ListAgentsArgs {
284    /// Filter workers that have ALL of these tags (comma-separated)
285    #[arg(long, value_delimiter = ',')]
286    pub tags: Vec<String>,
287
288    /// Filter workers that have claimed this file
289    #[arg(long)]
290    pub file: Option<String>,
291
292    /// Filter workers related to this task ID
293    #[arg(long)]
294    pub task: Option<String>,
295}
296
297/// Arguments for the prompts command.
298#[derive(Args, Debug)]
299pub struct PromptsArgs {
300    /// Show prompts for entering this status
301    #[arg(long)]
302    pub status: Option<String>,
303
304    /// Show prompts for entering this phase
305    #[arg(long)]
306    pub phase: Option<String>,
307
308    /// List advisories or show a specific one (use without value to list, with value to show)
309    #[arg(long, num_args = 0..=1, default_missing_value = "")]
310    pub advisory: Option<String>,
311
312    /// Task ID for context-sensitive filtering
313    #[arg(long)]
314    pub task: Option<String>,
315}
316
317/// Build a ToolHandler with the given configuration.
318fn build_tool_handler(
319    db: Arc<Database>,
320    config: &Config,
321    prompts: Arc<Prompts>,
322    workflows: Arc<WorkflowsConfig>,
323    server_paths: Arc<ServerPaths>,
324) -> ToolHandler {
325    // Derive states and phases from workflows
326    let states_config: StatesConfig = workflows.as_ref().into();
327    let phases_config: PhasesConfig = workflows.as_ref().into();
328
329    // Wrap configs in Arc
330    let states_config = Arc::new(states_config);
331    let phases_config = Arc::new(phases_config);
332    let deps_config = Arc::new(config.dependencies.clone());
333    let auto_advance = Arc::new(config.auto_advance.clone());
334    let attachments_config = Arc::new(config.attachments.clone());
335    let mut tags_config = config.tags.clone();
336    tags_config.register_workflow_tags(&workflows.all_role_tags());
337    let tags_config = Arc::new(tags_config);
338    let ids_config = Arc::new(config.ids.clone());
339    let feedback_config = Arc::new(config.feedback.clone());
340
341    let app_config = AppConfig::new(
342        states_config,
343        phases_config,
344        deps_config,
345        auto_advance,
346        attachments_config,
347        tags_config,
348        ids_config,
349        workflows,
350        feedback_config,
351    );
352
353    // Create path mapper
354    let path_mapper = Arc::new(
355        crate::paths::PathMapper::from_config(&config.paths, Some(config))
356            .unwrap_or_else(|_| crate::paths::PathMapper::default()),
357    );
358
359    ToolHandler::new(
360        db,
361        config.server.media_dir.clone(),
362        config.server.skills_dir.clone(),
363        server_paths,
364        prompts,
365        app_config,
366        config.server.default_format,
367        config.server.default_page_size,
368        path_mapper,
369    )
370}
371
372/// Format output according to the specified format.
373fn format_output(result: ToolResult, format: CliOutputFormat) -> String {
374    match result {
375        ToolResult::Json(v) => {
376            if format == CliOutputFormat::Json {
377                serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
378            } else {
379                // For markdown format, we need to convert JSON to a readable format
380                serde_json::to_string_pretty(&v).unwrap_or_else(|_| v.to_string())
381            }
382        }
383        ToolResult::Raw(s) => {
384            if format == CliOutputFormat::Json {
385                // Wrap raw text in JSON
386                json!({ "output": s }).to_string()
387            } else {
388                s
389            }
390        }
391    }
392}
393
394/// Format a JSON value as pretty-printed JSON (fallback for commands without dedicated formatters).
395fn format_json_output(value: Value, format: CliOutputFormat) -> String {
396    if format == CliOutputFormat::Json {
397        serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
398    } else {
399        serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())
400    }
401}
402
403/// Format connect response with structured markdown.
404fn format_connect_output(value: Value, format: CliOutputFormat) -> String {
405    if format == CliOutputFormat::Json {
406        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
407    }
408
409    let mut out = String::new();
410
411    // Worker ID and tags
412    if let Some(wid) = value.get("worker_id").and_then(|v| v.as_str()) {
413        out.push_str(&format!("**Worker ID:** `{}`\n", wid));
414    }
415    if let Some(tags) = value.get("tags").and_then(|v| v.as_array()) {
416        let tag_list: Vec<&str> = tags.iter().filter_map(|t| t.as_str()).collect();
417        if !tag_list.is_empty() {
418            out.push_str(&format!("**Tags:** {}\n", tag_list.join(", ")));
419        }
420    }
421    if let Some(workflow) = value.get("workflow").and_then(|v| v.as_str()) {
422        out.push_str(&format!("**Workflow:** {}\n", workflow));
423    }
424    if let Some(overlays) = value.get("overlays").and_then(|v| v.as_array()) {
425        let names: Vec<&str> = overlays.iter().filter_map(|o| o.as_str()).collect();
426        if !names.is_empty() {
427            out.push_str(&format!("**Overlays:** {}\n", names.join(", ")));
428        }
429    }
430
431    // Role info
432    if let Some(role) = value.get("role") {
433        out.push('\n');
434        if let Some(name) = role.get("role").and_then(|v| v.as_str()) {
435            out.push_str(&format!("**Role:** `{}`", name));
436        }
437        if let Some(desc) = role.get("description").and_then(|v| v.as_str()) {
438            out.push_str(&format!(" - {}", desc));
439        }
440        out.push('\n');
441    }
442
443    // State machine summary
444    if let Some(config) = value.get("config") {
445        out.push_str("\n### State Machine\n");
446        if let Some(initial) = config.get("initial_state").and_then(|v| v.as_str()) {
447            out.push_str(&format!("- **Initial:** `{}`\n", initial));
448        }
449        if let Some(states) = config.get("states").and_then(|v| v.as_array()) {
450            let names: Vec<&str> = states.iter().filter_map(|s| s.as_str()).collect();
451            out.push_str(&format!("- **States:** {}\n", names.join(", ")));
452        }
453        if let Some(timed) = config.get("timed_states").and_then(|v| v.as_array()) {
454            let names: Vec<&str> = timed.iter().filter_map(|s| s.as_str()).collect();
455            if !names.is_empty() {
456                out.push_str(&format!("- **Timed:** {}\n", names.join(", ")));
457            }
458        }
459        if let Some(terminal) = config.get("terminal_states").and_then(|v| v.as_array()) {
460            let names: Vec<&str> = terminal.iter().filter_map(|s| s.as_str()).collect();
461            if !names.is_empty() {
462                out.push_str(&format!("- **Terminal:** {}\n", names.join(", ")));
463            }
464        }
465        if let Some(phases) = config.get("phases").and_then(|v| v.as_array()) {
466            let names: Vec<&str> = phases.iter().filter_map(|s| s.as_str()).collect();
467            if !names.is_empty() {
468                out.push_str(&format!("- **Phases:** {}\n", names.join(", ")));
469            }
470        }
471    }
472
473    // Role prompts
474    if let Some(prompts) = value.get("role_prompts").and_then(|v| v.as_array()) {
475        out.push_str("\n### Role Prompts\n");
476        for prompt in prompts {
477            if let Some(text) = prompt.as_str() {
478                // Render each prompt as a blockquote
479                for line in text.lines() {
480                    out.push_str(&format!("> {}\n", line));
481                }
482                out.push_str("---\n");
483            }
484        }
485    }
486
487    // Workflow description
488    if let Some(desc) = value.get("workflow_description").and_then(|v| v.as_str()) {
489        out.push_str("\n### Workflow\n");
490        out.push_str(desc);
491        out.push('\n');
492    }
493
494    // Paths
495    if let Some(paths) = value.get("paths") {
496        out.push_str("\n### Paths\n");
497        if let Some(db) = paths.get("db_path").and_then(|v| v.as_str()) {
498            out.push_str(&format!("- db: `{}`\n", db));
499        }
500        if let Some(media) = paths.get("media_dir").and_then(|v| v.as_str()) {
501            out.push_str(&format!("- media: `{}`\n", media));
502        }
503        if let Some(log) = paths.get("log_dir").and_then(|v| v.as_str()) {
504            out.push_str(&format!("- log: `{}`\n", log));
505        }
506    }
507
508    // Warnings
509    if let Some(warnings) = value.get("path_warnings").and_then(|v| v.as_array()) {
510        out.push_str("\n### Warnings\n");
511        for w in warnings {
512            if let Some(text) = w.as_str() {
513                out.push_str(&format!("- {}\n", text));
514            }
515        }
516    }
517    if let Some(warnings) = value.get("tag_warnings").and_then(|v| v.as_array()) {
518        for w in warnings {
519            if let Some(text) = w.as_str() {
520                out.push_str(&format!("- {}\n", text));
521            }
522        }
523    }
524
525    out
526}
527
528/// Format update response with structured markdown, surfacing prompts prominently.
529fn format_update_output(value: Value, format: CliOutputFormat) -> String {
530    if format == CliOutputFormat::Json {
531        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
532    }
533
534    let mut out = String::new();
535
536    // Task summary
537    if let Some(task_id) = value.get("task").and_then(|v| v.as_str()) {
538        out.push_str(&format!("**Task:** `{}`", task_id));
539    }
540    if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
541        out.push_str(&format!(" - {}", title));
542    }
543    out.push('\n');
544    if let Some(status) = value.get("status").and_then(|v| v.as_str()) {
545        out.push_str(&format!("**Status:** `{}`\n", status));
546    }
547    if let Some(phase) = value.get("phase").and_then(|v| v.as_str()) {
548        out.push_str(&format!("**Phase:** `{}`\n", phase));
549    }
550
551    // Transition prompts - render prominently
552    format_prompts_section(&value, &mut out);
553
554    // Advisory hints
555    if let Some(hints) = value.get("advisory_hints").and_then(|v| v.as_array())
556        && !hints.is_empty()
557    {
558        let names: Vec<&str> = hints.iter().filter_map(|h| h.as_str()).collect();
559        out.push_str(&format!(
560            "\n**Advisories:** `get_advisory` topics: {}\n",
561            names.join(", ")
562        ));
563    }
564
565    // Warnings
566    if let Some(warnings) = value.get("warnings").and_then(|v| v.as_array()) {
567        for w in warnings {
568            if let Some(text) = w.as_str() {
569                out.push_str(&format!("- \u{26a0} {}\n", text));
570            }
571        }
572    }
573
574    out
575}
576
577/// Format claim response with structured markdown, surfacing prompts prominently.
578fn format_claim_output(value: Value, format: CliOutputFormat) -> String {
579    if format == CliOutputFormat::Json {
580        return serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
581    }
582
583    let mut out = String::new();
584
585    // Task summary
586    if let Some(task_id) = value.get("task").and_then(|v| v.as_str()) {
587        out.push_str(&format!("**Task:** `{}`", task_id));
588    }
589    if let Some(title) = value.get("title").and_then(|v| v.as_str()) {
590        out.push_str(&format!(" - {}", title));
591    }
592    out.push('\n');
593    if let Some(status) = value.get("status").and_then(|v| v.as_str()) {
594        out.push_str(&format!("**Status:** `{}`\n", status));
595    }
596    if let Some(owner) = value.get("owner").and_then(|v| v.as_str()) {
597        out.push_str(&format!("**Owner:** `{}`\n", owner));
598    }
599
600    // Transition prompts - render prominently
601    format_prompts_section(&value, &mut out);
602
603    // Advisory hints
604    if let Some(hints) = value.get("advisory_hints").and_then(|v| v.as_array())
605        && !hints.is_empty()
606    {
607        let names: Vec<&str> = hints.iter().filter_map(|h| h.as_str()).collect();
608        out.push_str(&format!(
609            "\n**Advisories:** `get_advisory` topics: {}\n",
610            names.join(", ")
611        ));
612    }
613
614    out
615}
616
617/// Render transition prompts as blockquotes, shared by update and claim formatters.
618fn format_prompts_section(value: &Value, out: &mut String) {
619    if let Some(prompts) = value.get("prompts").and_then(|v| v.as_array())
620        && !prompts.is_empty()
621    {
622        out.push_str("\n### Guidance\n");
623        for (i, prompt) in prompts.iter().enumerate() {
624            // Support both attributed prompt objects and plain strings (backward compat)
625            let text = prompt
626                .get("text")
627                .and_then(|v| v.as_str())
628                .or_else(|| prompt.as_str());
629            if let Some(text) = text {
630                // Show source attribution if available
631                if let Some(source) = prompt.get("source").and_then(|v| v.as_str()) {
632                    out.push_str(&format!("*[{}]*\n", source));
633                }
634                for line in text.lines() {
635                    out.push_str(&format!("> {}\n", line));
636                }
637                if i + 1 < prompts.len() {
638                    out.push_str("\n---\n\n");
639                }
640            }
641        }
642    }
643}
644
645/// Map error codes to CLI exit codes.
646fn error_to_exit_code(err: &anyhow::Error) -> u8 {
647    // Try to downcast to ToolError for precise error code mapping
648    if let Some(tool_err) = err.downcast_ref::<crate::error::ToolError>() {
649        use crate::error::ErrorCode;
650        return match tool_err.code {
651            ErrorCode::TaskNotFound | ErrorCode::FileNotFound | ErrorCode::AttachmentNotFound => {
652                exit_codes::TASK_NOT_FOUND
653            }
654            ErrorCode::AgentNotFound => exit_codes::WORKER_NOT_FOUND,
655            ErrorCode::AlreadyClaimed
656            | ErrorCode::LockConflict
657            | ErrorCode::DependencyCycle
658            | ErrorCode::DependencyNotSatisfied
659            | ErrorCode::GatesNotSatisfied
660            | ErrorCode::TagMismatch => exit_codes::CLAIM_FAILED,
661            ErrorCode::NotOwner => exit_codes::PERMISSION_DENIED,
662            ErrorCode::MissingRequiredField
663            | ErrorCode::InvalidFieldValue
664            | ErrorCode::InvalidState
665            | ErrorCode::InvalidPath
666            | ErrorCode::InvalidPrefix => exit_codes::INVALID_ARGUMENTS,
667            ErrorCode::AlreadyExists => exit_codes::CLAIM_FAILED,
668            ErrorCode::DatabaseError | ErrorCode::InternalError | ErrorCode::UnknownTool => {
669                exit_codes::GENERAL_ERROR
670            }
671        };
672    }
673
674    // Fallback: string matching for non-ToolError errors
675    let msg = err.to_string().to_lowercase();
676    if msg.contains("not found") {
677        if msg.contains("task") {
678            exit_codes::TASK_NOT_FOUND
679        } else if msg.contains("worker") || msg.contains("agent") {
680            exit_codes::WORKER_NOT_FOUND
681        } else {
682            exit_codes::GENERAL_ERROR
683        }
684    } else if msg.contains("already claimed")
685        || msg.contains("dependency")
686        || msg.contains("blocked")
687    {
688        exit_codes::CLAIM_FAILED
689    } else if msg.contains("not own") || msg.contains("permission") {
690        exit_codes::PERMISSION_DENIED
691    } else if msg.contains("required") || msg.contains("invalid") {
692        exit_codes::INVALID_ARGUMENTS
693    } else {
694        exit_codes::GENERAL_ERROR
695    }
696}
697
698/// Run agent subcommands.
699pub fn run_agent_command(args: AgentArgs) -> ExitCode {
700    // Load configuration
701    let loader = match ConfigLoader::load() {
702        Ok(l) => l,
703        Err(e) => {
704            eprintln!("Error loading config: {}", e);
705            return ExitCode::from(exit_codes::GENERAL_ERROR);
706        }
707    };
708
709    // Get config from loader
710    let config = loader.config();
711
712    // Load prompts and workflows
713    let prompts = Arc::new(loader.load_prompts());
714    let workflows = Arc::new(load_workflows(&loader, config));
715
716    // Open database
717    let db = match Database::open(&config.server.db_path) {
718        Ok(db) => Arc::new(db),
719        Err(e) => {
720            eprintln!("Error opening database: {}", e);
721            return ExitCode::from(exit_codes::GENERAL_ERROR);
722        }
723    };
724
725    // Create server paths
726    let server_paths = Arc::new(ServerPaths {
727        db_path: config.server.db_path.clone(),
728        media_dir: config.server.media_dir.clone(),
729        log_dir: config.server.log_dir.clone(),
730        config_path: loader.config_path().map(PathBuf::from),
731    });
732
733    // Build tool handler
734    let handler = build_tool_handler(
735        Arc::clone(&db),
736        config,
737        Arc::clone(&prompts),
738        Arc::clone(&workflows),
739        Arc::clone(&server_paths),
740    );
741
742    // Execute the command
743    match &args.command {
744        AgentCommand::Interactive(cmd_args) => {
745            return run_interactive(&handler, &args, cmd_args);
746        }
747        AgentCommand::Batch(cmd_args) => {
748            return run_batch(&handler, &args, cmd_args);
749        }
750        _ => {}
751    }
752
753    let result = match &args.command {
754        AgentCommand::Connect(cmd_args) => run_connect(&handler, &args, cmd_args),
755        AgentCommand::Disconnect(cmd_args) => run_disconnect(&handler, &args, cmd_args),
756        AgentCommand::ListTasks(cmd_args) => run_list_tasks(&handler, &args, cmd_args),
757        AgentCommand::Get(cmd_args) => run_get(&handler, &args, cmd_args),
758        AgentCommand::Claim(cmd_args) => run_claim(&handler, &args, cmd_args),
759        AgentCommand::Update(cmd_args) => run_update(&handler, &args, cmd_args),
760        AgentCommand::Thinking(cmd_args) => run_thinking(&handler, &args, cmd_args),
761        AgentCommand::Attach(cmd_args) => run_attach(&handler, &args, cmd_args),
762        AgentCommand::ListAgents(cmd_args) => run_list_agents(&handler, &args, cmd_args),
763        AgentCommand::Prompts(cmd_args) => run_prompts(&handler, &args, cmd_args),
764        // Interactive and Batch handled above
765        AgentCommand::Interactive(_) | AgentCommand::Batch(_) => unreachable!(),
766    };
767
768    match result {
769        Ok(output) => {
770            println!("{}", output);
771            ExitCode::from(exit_codes::SUCCESS)
772        }
773        Err(e) => {
774            eprintln!("Error: {}", e);
775            ExitCode::from(error_to_exit_code(&e))
776        }
777    }
778}
779
780/// Run agent subcommands and exit the process.
781/// This is a convenience wrapper that calls std::process::exit with the
782/// appropriate exit code, since the main function returns Result<()>.
783pub fn run_agent_command_and_exit(args: AgentArgs) -> ! {
784    let exit_code = run_agent_command(args);
785    // Map ExitCode to u8 via matching on expected codes
786    let code = match exit_code {
787        code if code == ExitCode::from(exit_codes::SUCCESS) => exit_codes::SUCCESS,
788        code if code == ExitCode::from(exit_codes::GENERAL_ERROR) => exit_codes::GENERAL_ERROR,
789        code if code == ExitCode::from(exit_codes::INVALID_ARGUMENTS) => {
790            exit_codes::INVALID_ARGUMENTS
791        }
792        code if code == ExitCode::from(exit_codes::TASK_NOT_FOUND) => exit_codes::TASK_NOT_FOUND,
793        code if code == ExitCode::from(exit_codes::WORKER_NOT_FOUND) => {
794            exit_codes::WORKER_NOT_FOUND
795        }
796        code if code == ExitCode::from(exit_codes::CLAIM_FAILED) => exit_codes::CLAIM_FAILED,
797        code if code == ExitCode::from(exit_codes::PERMISSION_DENIED) => {
798            exit_codes::PERMISSION_DENIED
799        }
800        _ => exit_codes::GENERAL_ERROR,
801    };
802    std::process::exit(code as i32);
803}
804
805/// Load workflows with cache (similar to main.rs pattern).
806fn load_workflows(loader: &ConfigLoader, config: &Config) -> WorkflowsConfig {
807    let default_workflow_name = config.server.default_workflow.clone();
808
809    // If a default workflow is configured, load it as the base
810    let mut workflows = if let Some(ref name) = default_workflow_name {
811        match loader.load_workflow_by_name(name) {
812            Ok(workflow_config) => workflow_config,
813            Err(_) => loader.load_workflows(),
814        }
815    } else {
816        loader.load_workflows()
817    };
818
819    // Load named workflows into cache
820    for name in loader.list_workflows() {
821        if let Ok(workflow_config) = loader.load_workflow_by_name(&name) {
822            workflows
823                .named_workflows
824                .insert(name, Arc::new(workflow_config));
825        }
826    }
827
828    // Load overlays into cache
829    for name in loader.list_overlays() {
830        if let Ok(overlay_config) = loader.load_overlay_by_name(&name) {
831            workflows
832                .named_overlays
833                .insert(name, Arc::new(overlay_config));
834        }
835    }
836
837    workflows
838}
839
840// Command handlers
841
842fn run_connect(handler: &ToolHandler, args: &AgentArgs, cmd_args: &ConnectArgs) -> Result<String> {
843    // For connect, worker_id is optional (can be auto-generated)
844    // Use cmd_args.worker_id or fall back to global --worker-id
845    let worker_id = cmd_args
846        .worker_id
847        .clone()
848        .or_else(|| args.worker_id.clone());
849
850    // Resolve base workflow
851    let base_workflow = cmd_args
852        .workflow
853        .as_ref()
854        .and_then(|name| handler.config.workflows.get_named_workflow(name))
855        .map(Arc::clone)
856        .or_else(|| {
857            handler
858                .config
859                .workflows
860                .get_default_workflow()
861                .map(Arc::clone)
862        })
863        .unwrap_or_else(|| Arc::clone(&handler.config.workflows));
864
865    // Apply overlays if specified
866    let workflow = if cmd_args.overlays.is_empty() {
867        base_workflow
868    } else {
869        let mut merged = (*base_workflow).clone();
870        for name in &cmd_args.overlays {
871            if let Some(overlay) = handler.config.workflows.named_overlays.get(name) {
872                merged.apply_overlay(overlay);
873            }
874        }
875        merged.active_overlays = cmd_args.overlays.clone();
876        Arc::new(merged)
877    };
878
879    let tool_args = json!({
880        "worker_id": worker_id,
881        "tags": cmd_args.tags,
882        "force": cmd_args.force,
883        "workflow": cmd_args.workflow,
884        "overlays": cmd_args.overlays
885    });
886
887    let result = agents::connect(
888        agents::ConnectOptions {
889            db: &handler.db,
890            server_paths: &handler.server_paths,
891            config: &handler.config,
892            workflows: &workflow,
893        },
894        tool_args,
895    )?;
896
897    Ok(format_connect_output(result, args.format))
898}
899
900fn run_disconnect(
901    handler: &ToolHandler,
902    args: &AgentArgs,
903    cmd_args: &DisconnectArgs,
904) -> Result<String> {
905    // Derive states from workflows
906    let states_config: StatesConfig = handler.config.workflows.as_ref().into();
907
908    let tool_args = json!({
909        "worker_id": &cmd_args.worker_id,
910        "final_status": cmd_args.final_status
911    });
912
913    let result = agents::disconnect(&handler.db, &states_config, tool_args)?;
914    Ok(format_json_output(result, args.format))
915}
916
917fn run_list_tasks(
918    handler: &ToolHandler,
919    args: &AgentArgs,
920    cmd_args: &ListTasksArgs,
921) -> Result<String> {
922    let states_config: StatesConfig = handler.config.workflows.as_ref().into();
923
924    let mut tool_args = json!({
925        "ready": cmd_args.ready,
926        "blocked": cmd_args.blocked,
927        "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
928    });
929
930    if !cmd_args.status.is_empty() {
931        tool_args["status"] = json!(cmd_args.status);
932    }
933    if let Some(ref parent) = cmd_args.parent {
934        tool_args["parent"] = json!(parent);
935    }
936    if let Some(limit) = cmd_args.limit {
937        tool_args["limit"] = json!(limit);
938    }
939    if let Some(offset) = cmd_args.offset {
940        tool_args["offset"] = json!(offset);
941    }
942
943    let result = tasks::list_tasks(
944        &handler.db,
945        &states_config,
946        &handler.config.deps,
947        args.format.into(),
948        tool_args,
949    )?;
950
951    Ok(format_output(result, args.format))
952}
953
954fn run_get(handler: &ToolHandler, args: &AgentArgs, cmd_args: &GetArgs) -> Result<String> {
955    let tool_args = json!({
956        "task": cmd_args.task_id,
957        "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
958    });
959
960    let result = tasks::get(&handler.db, args.format.into(), tool_args)?;
961    Ok(format_output(result, args.format))
962}
963
964fn run_claim(handler: &ToolHandler, args: &AgentArgs, cmd_args: &ClaimArgs) -> Result<String> {
965    // Get worker's workflow
966    let workflow = handler.get_workflow_for_worker(&cmd_args.worker_id);
967
968    let tool_args = json!({
969        "worker_id": &cmd_args.worker_id,
970        "task": cmd_args.task_id,
971        "force": cmd_args.force
972    });
973
974    let result = claiming::claim(&handler.db, &handler.config, &workflow, tool_args)?;
975    Ok(format_claim_output(result, args.format))
976}
977
978fn run_update(handler: &ToolHandler, args: &AgentArgs, cmd_args: &UpdateArgs) -> Result<String> {
979    // Get worker's workflow
980    let workflow = handler.get_workflow_for_worker(&cmd_args.worker_id);
981
982    let mut tool_args = json!({
983        "worker_id": &cmd_args.worker_id,
984        "task": cmd_args.task_id,
985        "force": cmd_args.force
986    });
987
988    if let Some(ref status) = cmd_args.status {
989        tool_args["status"] = json!(status);
990    }
991    if let Some(ref title) = cmd_args.title {
992        tool_args["title"] = json!(title);
993    }
994    if let Some(ref description) = cmd_args.description {
995        tool_args["description"] = json!(description);
996    }
997    if let Some(ref reason) = cmd_args.reason {
998        tool_args["reason"] = json!(reason);
999    }
1000
1001    let result = tasks::update(
1002        tasks::UpdateOptions {
1003            db: &handler.db,
1004            config: &handler.config,
1005            workflows: &workflow,
1006        },
1007        tool_args,
1008    )?;
1009    Ok(format_update_output(result, args.format))
1010}
1011
1012fn run_thinking(
1013    handler: &ToolHandler,
1014    args: &AgentArgs,
1015    cmd_args: &ThinkingArgs,
1016) -> Result<String> {
1017    let mut tool_args = json!({
1018        "agent": &cmd_args.worker_id,
1019        "thought": cmd_args.message
1020    });
1021
1022    if !cmd_args.tasks.is_empty() {
1023        tool_args["tasks"] = json!(cmd_args.tasks);
1024    }
1025
1026    let states_config: StatesConfig = handler.config.workflows.as_ref().into();
1027    let result = tracking::thinking(&handler.db, &states_config, tool_args)?;
1028    Ok(format_json_output(result, args.format))
1029}
1030
1031fn run_attach(handler: &ToolHandler, args: &AgentArgs, cmd_args: &AttachArgs) -> Result<String> {
1032    // Resolve content from --content or --file
1033    let content = if let Some(ref content) = cmd_args.content {
1034        content.clone()
1035    } else if let Some(ref file_path) = cmd_args.file {
1036        std::fs::read_to_string(file_path)
1037            .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path.display(), e))?
1038    } else {
1039        return Err(anyhow::anyhow!(
1040            "Either --content or --file must be provided"
1041        ));
1042    };
1043
1044    let mut tool_args = json!({
1045        "agent": &cmd_args.worker_id,
1046        "task": cmd_args.task_id,
1047        "type": cmd_args.r#type,
1048        "content": content
1049    });
1050
1051    if let Some(ref name) = cmd_args.name {
1052        tool_args["name"] = json!(name);
1053    }
1054
1055    let result = attachments::attach(
1056        &handler.db,
1057        &handler.media_dir,
1058        &handler.config.attachments,
1059        tool_args,
1060    )?;
1061    Ok(format_json_output(result, args.format))
1062}
1063
1064fn run_list_agents(
1065    handler: &ToolHandler,
1066    args: &AgentArgs,
1067    cmd_args: &ListAgentsArgs,
1068) -> Result<String> {
1069    let states_config: StatesConfig = handler.config.workflows.as_ref().into();
1070
1071    let mut tool_args = json!({
1072        "format": if args.format == CliOutputFormat::Json { "json" } else { "markdown" }
1073    });
1074
1075    if !cmd_args.tags.is_empty() {
1076        tool_args["tags"] = json!(cmd_args.tags);
1077    }
1078    if let Some(ref file) = cmd_args.file {
1079        tool_args["file"] = json!(file);
1080    }
1081    if let Some(ref task) = cmd_args.task {
1082        tool_args["task"] = json!(task);
1083    }
1084
1085    let result = agents::list_agents(&handler.db, &states_config, args.format.into(), tool_args)?;
1086    Ok(format_output(result, args.format))
1087}
1088
1089fn run_prompts(handler: &ToolHandler, args: &AgentArgs, cmd_args: &PromptsArgs) -> Result<String> {
1090    let workflows = &handler.config.workflows;
1091
1092    // Advisory mode
1093    if let Some(ref advisory_topic) = cmd_args.advisory {
1094        let mut tool_args = json!({});
1095        if !advisory_topic.is_empty() {
1096            tool_args["topic"] = json!(advisory_topic);
1097        }
1098        if let Some(ref task_id) = cmd_args.task {
1099            tool_args["task"] = json!(task_id);
1100        }
1101        if let Some(ref wid) = args.worker_id {
1102            tool_args["worker_id"] = json!(wid);
1103        }
1104
1105        let result = advisories::get_advisory(&handler.db, workflows, tool_args)?;
1106
1107        if args.format == CliOutputFormat::Json {
1108            return Ok(serde_json::to_string_pretty(&result).unwrap_or_else(|_| result.to_string()));
1109        }
1110
1111        // Markdown rendering for advisories
1112        if advisory_topic.is_empty() {
1113            // List mode
1114            let mut out = String::from("### Advisories\n\n");
1115            if let Some(topics) = result.get("advisories").and_then(|v| v.as_array()) {
1116                for entry in topics {
1117                    let name = entry.get("topic").and_then(|v| v.as_str()).unwrap_or("?");
1118                    let relevant = entry
1119                        .get("relevant")
1120                        .and_then(|v| v.as_bool())
1121                        .unwrap_or(false);
1122                    let marker = if relevant { " *" } else { "" };
1123                    out.push_str(&format!("- `{}`{}\n", name, marker));
1124                }
1125                if let Some(count) = result.get("count").and_then(|v| v.as_i64()) {
1126                    out.push_str(&format!(
1127                        "\n{} advisories (* = relevant to current context)\n",
1128                        count
1129                    ));
1130                }
1131            }
1132            return Ok(out);
1133        } else {
1134            // Detail mode
1135            let mut out = String::new();
1136            if let Some(topic) = result.get("topic").and_then(|v| v.as_str()) {
1137                out.push_str(&format!("### Advisory: {}\n\n", topic));
1138            }
1139            if let Some(content) = result.get("content").and_then(|v| v.as_str()) {
1140                out.push_str(content);
1141                out.push('\n');
1142            }
1143            return Ok(out);
1144        }
1145    }
1146
1147    // State/phase mode
1148    if cmd_args.status.is_some() || cmd_args.phase.is_some() {
1149        let states_config: StatesConfig = workflows.as_ref().into();
1150        let phases_config: PhasesConfig = workflows.as_ref().into();
1151
1152        let target_status = cmd_args.status.as_deref().unwrap_or(&states_config.initial);
1153        let target_phase = cmd_args.phase.as_deref();
1154
1155        // Build a prompt context for expansion
1156        let ctx = prompt_system::PromptContext::new(
1157            target_status,
1158            target_phase,
1159            &states_config,
1160            &phases_config,
1161        );
1162
1163        // Get enter prompts with attribution: simulate transition from empty to the target
1164        let attributed_list = prompt_system::get_transition_prompts_attributed(
1165            "",
1166            None,
1167            target_status,
1168            target_phase,
1169            workflows,
1170            &ctx,
1171        );
1172
1173        if args.format == CliOutputFormat::Json {
1174            let prompt_objects: Vec<serde_json::Value> = attributed_list
1175                .iter()
1176                .map(|p| {
1177                    serde_json::json!({
1178                        "text": p.text,
1179                        "source": p.source,
1180                    })
1181                })
1182                .collect();
1183            return Ok(serde_json::to_string_pretty(&serde_json::json!({
1184                "status": target_status,
1185                "phase": target_phase,
1186                "prompts": prompt_objects,
1187            }))?);
1188        }
1189
1190        let mut out = format!("### Prompts for entering `{}`", target_status);
1191        if let Some(phase) = target_phase {
1192            out.push_str(&format!(" (phase: `{}`)", phase));
1193        }
1194        out.push_str("\n\n");
1195
1196        if attributed_list.is_empty() {
1197            out.push_str("_(no prompts configured for this transition)_\n");
1198        } else {
1199            for (i, prompt) in attributed_list.iter().enumerate() {
1200                for line in prompt.text.lines() {
1201                    out.push_str(&format!("> {}\n", line));
1202                }
1203                if i + 1 < attributed_list.len() {
1204                    out.push_str("\n---\n\n");
1205                }
1206            }
1207        }
1208
1209        return Ok(out);
1210    }
1211
1212    // List mode (no flags) - list all available prompt triggers
1213    let triggers = prompt_system::list_available_prompts(workflows);
1214
1215    if args.format == CliOutputFormat::Json {
1216        return Ok(serde_json::to_string_pretty(&json!({
1217            "triggers": triggers,
1218            "count": triggers.len(),
1219        }))?);
1220    }
1221
1222    let mut out = String::from("### Prompt Triggers\n\n");
1223
1224    // Group by type
1225    let mut enter_state: Vec<&str> = Vec::new();
1226    let mut exit_state: Vec<&str> = Vec::new();
1227    let mut enter_phase: Vec<&str> = Vec::new();
1228    let mut exit_phase: Vec<&str> = Vec::new();
1229    let mut combos: Vec<&str> = Vec::new();
1230
1231    for t in &triggers {
1232        if t.contains('~') && t.contains('%') {
1233            combos.push(t);
1234        } else if t.starts_with("enter~") {
1235            enter_state.push(t);
1236        } else if t.starts_with("exit~") {
1237            exit_state.push(t);
1238        } else if t.starts_with("enter%") {
1239            enter_phase.push(t);
1240        } else if t.starts_with("exit%") {
1241            exit_phase.push(t);
1242        }
1243    }
1244
1245    if !enter_state.is_empty() {
1246        out.push_str("**Enter state:**\n");
1247        for t in &enter_state {
1248            out.push_str(&format!("  - `{}`\n", t));
1249        }
1250    }
1251    if !exit_state.is_empty() {
1252        out.push_str("**Exit state:**\n");
1253        for t in &exit_state {
1254            out.push_str(&format!("  - `{}`\n", t));
1255        }
1256    }
1257    if !enter_phase.is_empty() {
1258        out.push_str("**Enter phase:**\n");
1259        for t in &enter_phase {
1260            out.push_str(&format!("  - `{}`\n", t));
1261        }
1262    }
1263    if !exit_phase.is_empty() {
1264        out.push_str("**Exit phase:**\n");
1265        for t in &exit_phase {
1266            out.push_str(&format!("  - `{}`\n", t));
1267        }
1268    }
1269    if !combos.is_empty() {
1270        out.push_str("**State+phase combos:**\n");
1271        for t in &combos {
1272            out.push_str(&format!("  - `{}`\n", t));
1273        }
1274    }
1275
1276    out.push_str(&format!("\n{} triggers total\n", triggers.len()));
1277
1278    Ok(out)
1279}
1280
1281/// Run interactive mode - a REPL for running multiple commands.
1282fn run_interactive(
1283    handler: &ToolHandler,
1284    args: &AgentArgs,
1285    cmd_args: &InteractiveArgs,
1286) -> ExitCode {
1287    use std::io::{BufRead, Write};
1288
1289    let stdin = std::io::stdin();
1290    let mut stdout = std::io::stdout();
1291
1292    if cmd_args.stdin {
1293        // Non-interactive: read commands from stdin
1294        let reader = stdin.lock();
1295        for line in reader.lines() {
1296            match line {
1297                Ok(cmd) => {
1298                    let cmd = cmd.trim();
1299                    if cmd.is_empty() || cmd.starts_with('#') {
1300                        continue;
1301                    }
1302                    if let Err(code) = execute_line_command(handler, args, cmd) {
1303                        return code;
1304                    }
1305                }
1306                Err(e) => {
1307                    eprintln!("Error reading input: {}", e);
1308                    return ExitCode::from(exit_codes::GENERAL_ERROR);
1309                }
1310            }
1311        }
1312    } else {
1313        // Interactive REPL
1314        println!("task-graph agent interactive mode. Type 'help' for commands, 'exit' to quit.");
1315        if let Some(ref worker_id) = args.worker_id {
1316            println!("Worker ID: {}", worker_id);
1317        }
1318        println!();
1319
1320        loop {
1321            print!("> ");
1322            let _ = stdout.flush();
1323
1324            let mut input = String::new();
1325            match stdin.read_line(&mut input) {
1326                Ok(0) => break, // EOF
1327                Ok(_) => {
1328                    let cmd = input.trim();
1329                    if cmd.is_empty() {
1330                        continue;
1331                    }
1332                    if cmd == "exit" || cmd == "quit" || cmd == "q" {
1333                        break;
1334                    }
1335                    if cmd == "help" || cmd == "?" {
1336                        print_interactive_help();
1337                        continue;
1338                    }
1339                    if execute_line_command(handler, args, cmd).is_err() {
1340                        // Continue in interactive mode even on error
1341                    }
1342                }
1343                Err(e) => {
1344                    eprintln!("Error reading input: {}", e);
1345                    break;
1346                }
1347            }
1348        }
1349    }
1350
1351    ExitCode::from(exit_codes::SUCCESS)
1352}
1353
1354/// Run batch mode - execute commands from a file.
1355fn run_batch(handler: &ToolHandler, args: &AgentArgs, cmd_args: &BatchArgs) -> ExitCode {
1356    use std::io::BufRead;
1357
1358    let file = match std::fs::File::open(&cmd_args.file) {
1359        Ok(f) => f,
1360        Err(e) => {
1361            eprintln!("Error opening file '{}': {}", cmd_args.file.display(), e);
1362            return ExitCode::from(exit_codes::GENERAL_ERROR);
1363        }
1364    };
1365
1366    let reader = std::io::BufReader::new(file);
1367    let mut line_num = 0;
1368    let mut had_errors = false;
1369
1370    for line in reader.lines() {
1371        line_num += 1;
1372        match line {
1373            Ok(cmd) => {
1374                let cmd = cmd.trim();
1375                if cmd.is_empty() || cmd.starts_with('#') {
1376                    continue;
1377                }
1378                eprintln!("[{}] > {}", line_num, cmd);
1379                if execute_line_command(handler, args, cmd).is_err() {
1380                    had_errors = true;
1381                    if !cmd_args.keep_going {
1382                        return ExitCode::from(exit_codes::GENERAL_ERROR);
1383                    }
1384                }
1385            }
1386            Err(e) => {
1387                eprintln!("Error reading line {}: {}", line_num, e);
1388                return ExitCode::from(exit_codes::GENERAL_ERROR);
1389            }
1390        }
1391    }
1392
1393    if had_errors {
1394        ExitCode::from(exit_codes::GENERAL_ERROR)
1395    } else {
1396        ExitCode::from(exit_codes::SUCCESS)
1397    }
1398}
1399
1400/// Execute a single line command in interactive/batch mode.
1401/// In interactive mode, worker_id comes from the global --worker-id flag.
1402fn execute_line_command(
1403    handler: &ToolHandler,
1404    args: &AgentArgs,
1405    cmd: &str,
1406) -> Result<(), ExitCode> {
1407    // Parse the command line
1408    let parts: Vec<&str> = cmd.split_whitespace().collect();
1409    if parts.is_empty() {
1410        return Ok(());
1411    }
1412
1413    let subcommand = parts[0];
1414    let subargs = &parts[1..];
1415
1416    // Helper to get worker_id from global args (required for most commands in interactive mode)
1417    let require_worker_id = || -> Result<String, ExitCode> {
1418        args.worker_id.clone().ok_or_else(|| {
1419            eprintln!(
1420                "Error: Worker ID required. Use --worker-id flag with 'interactive' command."
1421            );
1422            ExitCode::from(exit_codes::INVALID_ARGUMENTS)
1423        })
1424    };
1425    // This is a simplified approach - for full support we'd need to
1426    // properly handle argument parsing for each command
1427
1428    let result: Result<String> = match subcommand {
1429        "ls" | "list-tasks" | "list_tasks" => {
1430            // Parse list-tasks args
1431            let mut list_args = ListTasksArgs {
1432                ready: false,
1433                blocked: false,
1434                status: vec![],
1435                parent: None,
1436                limit: None,
1437                offset: None,
1438            };
1439            let mut i = 0;
1440            while i < subargs.len() {
1441                match subargs[i] {
1442                    "--ready" => list_args.ready = true,
1443                    "--blocked" => list_args.blocked = true,
1444                    "--status" if i + 1 < subargs.len() => {
1445                        i += 1;
1446                        list_args.status = subargs[i].split(',').map(String::from).collect();
1447                    }
1448                    "--parent" if i + 1 < subargs.len() => {
1449                        i += 1;
1450                        list_args.parent = Some(subargs[i].to_string());
1451                    }
1452                    "--limit" if i + 1 < subargs.len() => {
1453                        i += 1;
1454                        list_args.limit = subargs[i].parse().ok();
1455                    }
1456                    "--offset" if i + 1 < subargs.len() => {
1457                        i += 1;
1458                        list_args.offset = subargs[i].parse().ok();
1459                    }
1460                    _ => {}
1461                }
1462                i += 1;
1463            }
1464            run_list_tasks(handler, args, &list_args)
1465        }
1466
1467        "get" => {
1468            if subargs.is_empty() {
1469                Err(anyhow::anyhow!("Usage: get <task-id>"))
1470            } else {
1471                let get_args = GetArgs {
1472                    task_id: subargs[0].to_string(),
1473                };
1474                run_get(handler, args, &get_args)
1475            }
1476        }
1477
1478        "claim" => {
1479            if subargs.is_empty() {
1480                Err(anyhow::anyhow!("Usage: claim <task-id> [--force]"))
1481            } else {
1482                let claim_args = ClaimArgs {
1483                    worker_id: require_worker_id()?,
1484                    task_id: subargs[0].to_string(),
1485                    force: subargs.contains(&"--force"),
1486                };
1487                run_claim(handler, args, &claim_args)
1488            }
1489        }
1490
1491        "update" => {
1492            if subargs.is_empty() {
1493                Err(anyhow::anyhow!(
1494                    "Usage: update <task-id> [--status STATUS] [--title TITLE] [--description DESC] [--reason REASON]"
1495                ))
1496            } else {
1497                let mut update_args = UpdateArgs {
1498                    worker_id: require_worker_id()?,
1499                    task_id: subargs[0].to_string(),
1500                    status: None,
1501                    title: None,
1502                    description: None,
1503                    reason: None,
1504                    force: false,
1505                };
1506                let mut i = 1;
1507                while i < subargs.len() {
1508                    match subargs[i] {
1509                        "--status" if i + 1 < subargs.len() => {
1510                            i += 1;
1511                            update_args.status = Some(subargs[i].to_string());
1512                        }
1513                        "--title" if i + 1 < subargs.len() => {
1514                            i += 1;
1515                            update_args.title = Some(subargs[i].to_string());
1516                        }
1517                        "--description" if i + 1 < subargs.len() => {
1518                            i += 1;
1519                            update_args.description = Some(subargs[i].to_string());
1520                        }
1521                        "--reason" if i + 1 < subargs.len() => {
1522                            i += 1;
1523                            update_args.reason = Some(subargs[i].to_string());
1524                        }
1525                        "--force" => update_args.force = true,
1526                        _ => {}
1527                    }
1528                    i += 1;
1529                }
1530                run_update(handler, args, &update_args)
1531            }
1532        }
1533
1534        "thinking" => {
1535            if subargs.is_empty() {
1536                Err(anyhow::anyhow!(
1537                    "Usage: thinking <message> [--tasks TASK1,TASK2]"
1538                ))
1539            } else {
1540                let mut tasks = vec![];
1541                let mut message_parts = vec![];
1542                let mut i = 0;
1543                while i < subargs.len() {
1544                    if subargs[i] == "--tasks" && i + 1 < subargs.len() {
1545                        i += 1;
1546                        tasks = subargs[i].split(',').map(String::from).collect();
1547                    } else {
1548                        message_parts.push(subargs[i]);
1549                    }
1550                    i += 1;
1551                }
1552                let thinking_args = ThinkingArgs {
1553                    worker_id: require_worker_id()?,
1554                    message: message_parts.join(" "),
1555                    tasks,
1556                };
1557                run_thinking(handler, args, &thinking_args)
1558            }
1559        }
1560
1561        "list-agents" | "list_agents" | "agents" => {
1562            let mut list_args = ListAgentsArgs {
1563                tags: vec![],
1564                file: None,
1565                task: None,
1566            };
1567            let mut i = 0;
1568            while i < subargs.len() {
1569                match subargs[i] {
1570                    "--tags" if i + 1 < subargs.len() => {
1571                        i += 1;
1572                        list_args.tags = subargs[i].split(',').map(String::from).collect();
1573                    }
1574                    "--file" if i + 1 < subargs.len() => {
1575                        i += 1;
1576                        list_args.file = Some(subargs[i].to_string());
1577                    }
1578                    "--task" if i + 1 < subargs.len() => {
1579                        i += 1;
1580                        list_args.task = Some(subargs[i].to_string());
1581                    }
1582                    _ => {}
1583                }
1584                i += 1;
1585            }
1586            run_list_agents(handler, args, &list_args)
1587        }
1588
1589        "prompts" => {
1590            let mut prompts_args = PromptsArgs {
1591                status: None,
1592                phase: None,
1593                advisory: None,
1594                task: None,
1595            };
1596            let mut i = 0;
1597            while i < subargs.len() {
1598                match subargs[i] {
1599                    "--status" if i + 1 < subargs.len() => {
1600                        i += 1;
1601                        prompts_args.status = Some(subargs[i].to_string());
1602                    }
1603                    "--phase" if i + 1 < subargs.len() => {
1604                        i += 1;
1605                        prompts_args.phase = Some(subargs[i].to_string());
1606                    }
1607                    "--advisory" => {
1608                        if i + 1 < subargs.len() && !subargs[i + 1].starts_with("--") {
1609                            i += 1;
1610                            prompts_args.advisory = Some(subargs[i].to_string());
1611                        } else {
1612                            prompts_args.advisory = Some(String::new());
1613                        }
1614                    }
1615                    "--task" if i + 1 < subargs.len() => {
1616                        i += 1;
1617                        prompts_args.task = Some(subargs[i].to_string());
1618                    }
1619                    _ => {}
1620                }
1621                i += 1;
1622            }
1623            run_prompts(handler, args, &prompts_args)
1624        }
1625
1626        "connect" => {
1627            // In interactive mode, connect uses the global --worker-id if provided
1628            let mut connect_args = ConnectArgs {
1629                worker_id: args.worker_id.clone(),
1630                tags: vec![],
1631                workflow: None,
1632                overlays: vec![],
1633                force: false,
1634            };
1635            let mut i = 0;
1636            while i < subargs.len() {
1637                match subargs[i] {
1638                    "--tags" if i + 1 < subargs.len() => {
1639                        i += 1;
1640                        connect_args.tags = subargs[i].split(',').map(String::from).collect();
1641                    }
1642                    "--workflow" if i + 1 < subargs.len() => {
1643                        i += 1;
1644                        connect_args.workflow = Some(subargs[i].to_string());
1645                    }
1646                    "--overlays" if i + 1 < subargs.len() => {
1647                        i += 1;
1648                        connect_args.overlays = subargs[i].split(',').map(String::from).collect();
1649                    }
1650                    "--force" => connect_args.force = true,
1651                    _ => {}
1652                }
1653                i += 1;
1654            }
1655            run_connect(handler, args, &connect_args)
1656        }
1657
1658        "disconnect" => {
1659            let disconnect_args = DisconnectArgs {
1660                worker_id: require_worker_id()?,
1661                final_status: None,
1662            };
1663            // Parse --final-status if provided
1664            let mut disconnect_args = disconnect_args;
1665            let mut i = 0;
1666            while i < subargs.len() {
1667                if subargs[i] == "--final-status" && i + 1 < subargs.len() {
1668                    i += 1;
1669                    disconnect_args.final_status = Some(subargs[i].to_string());
1670                }
1671                i += 1;
1672            }
1673            run_disconnect(handler, args, &disconnect_args)
1674        }
1675
1676        _ => Err(anyhow::anyhow!(
1677            "Unknown command: {}. Type 'help' for available commands.",
1678            subcommand
1679        )),
1680    };
1681
1682    match result {
1683        Ok(output) => {
1684            println!("{}", output);
1685            Ok(())
1686        }
1687        Err(e) => {
1688            eprintln!("Error: {}", e);
1689            Err(ExitCode::from(error_to_exit_code(&e)))
1690        }
1691    }
1692}
1693
1694/// Print help for interactive mode.
1695fn print_interactive_help() {
1696    println!(
1697        r#"Available commands (worker_id from --worker-id flag):
1698  ls, list-tasks   Query tasks (--ready, --blocked, --status S, --parent P, --limit N, --offset N)
1699  get <task-id>    Get task details
1700  claim <task-id>  Claim a task (--force) [requires --worker-id]
1701  update <task-id> Update task (--status S, --title T, --reason R, --force) [requires --worker-id]
1702  thinking <msg>   Broadcast status (--tasks T1,T2) [requires --worker-id]
1703  prompts          Query prompts (--status S, --phase P, --advisory [TOPIC], --task T)
1704  agents           List connected workers (--tags T, --file F, --task T)
1705  connect          Register as worker (--tags T, --workflow W, --overlays O, --force)
1706  disconnect       Unregister (--final-status S) [requires --worker-id]
1707  help, ?          Show this help
1708  exit, quit, q    Exit interactive mode
1709"#
1710    );
1711}
1712
1713#[cfg(test)]
1714mod tests {
1715    use super::*;
1716    use clap::Parser;
1717
1718    /// Test CLI struct for parsing (wraps AgentArgs in a subcommand)
1719    #[derive(Parser)]
1720    struct TestCli {
1721        #[command(subcommand)]
1722        command: TestCommand,
1723    }
1724
1725    #[derive(Subcommand)]
1726    enum TestCommand {
1727        Agent(AgentArgs),
1728    }
1729
1730    /// Helper to parse agent args from a command line.
1731    fn parse_agent(args: &[&str]) -> AgentArgs {
1732        let mut full_args = vec!["test", "agent"];
1733        full_args.extend_from_slice(args);
1734        let cli = TestCli::try_parse_from(full_args).unwrap();
1735        let TestCommand::Agent(agent_args) = cli.command;
1736        agent_args
1737    }
1738
1739    // ── Arg parsing: connect ──────────────────────────────────────────
1740
1741    #[test]
1742    fn test_parse_connect_no_worker_id() {
1743        let a = parse_agent(&["connect"]);
1744        let AgentCommand::Connect(c) = a.command else {
1745            panic!()
1746        };
1747        assert_eq!(c.worker_id, None);
1748        assert!(c.tags.is_empty());
1749        assert!(!c.force);
1750    }
1751
1752    #[test]
1753    fn test_parse_connect_with_worker_id() {
1754        let a = parse_agent(&["connect", "my-worker"]);
1755        let AgentCommand::Connect(c) = a.command else {
1756            panic!()
1757        };
1758        assert_eq!(c.worker_id, Some("my-worker".to_string()));
1759    }
1760
1761    #[test]
1762    fn test_parse_connect_with_tags_workflow_overlays() {
1763        let a = parse_agent(&[
1764            "connect",
1765            "--tags",
1766            "build,test",
1767            "--workflow",
1768            "swarm",
1769            "--overlays",
1770            "reasoning,patch",
1771            "--force",
1772        ]);
1773        let AgentCommand::Connect(c) = a.command else {
1774            panic!()
1775        };
1776        assert_eq!(c.tags, vec!["build", "test"]);
1777        assert_eq!(c.workflow, Some("swarm".to_string()));
1778        assert_eq!(c.overlays, vec!["reasoning", "patch"]);
1779        assert!(c.force);
1780    }
1781
1782    // ── Arg parsing: disconnect ───────────────────────────────────────
1783
1784    #[test]
1785    fn test_parse_disconnect() {
1786        let a = parse_agent(&["disconnect", "worker-1"]);
1787        let AgentCommand::Disconnect(d) = a.command else {
1788            panic!()
1789        };
1790        assert_eq!(d.worker_id, "worker-1");
1791        assert_eq!(d.final_status, None);
1792    }
1793
1794    #[test]
1795    fn test_parse_disconnect_with_final_status() {
1796        let a = parse_agent(&["disconnect", "w1", "--final-status", "pending"]);
1797        let AgentCommand::Disconnect(d) = a.command else {
1798            panic!()
1799        };
1800        assert_eq!(d.worker_id, "w1");
1801        assert_eq!(d.final_status, Some("pending".to_string()));
1802    }
1803
1804    #[test]
1805    fn test_parse_disconnect_missing_worker_id() {
1806        let full = vec!["test", "agent", "disconnect"];
1807        let result = TestCli::try_parse_from(full);
1808        assert!(result.is_err(), "disconnect without worker_id should fail");
1809    }
1810
1811    // ── Arg parsing: list-tasks ───────────────────────────────────────
1812
1813    #[test]
1814    fn test_parse_list_tasks_alias_ls() {
1815        let a = parse_agent(&["ls", "--ready"]);
1816        let AgentCommand::ListTasks(l) = a.command else {
1817            panic!()
1818        };
1819        assert!(l.ready);
1820        assert!(!l.blocked);
1821    }
1822
1823    #[test]
1824    fn test_parse_list_tasks_full_filters() {
1825        let a = parse_agent(&[
1826            "list-tasks",
1827            "--ready",
1828            "--blocked",
1829            "--status",
1830            "open,in_progress",
1831            "--parent",
1832            "root-1",
1833            "--limit",
1834            "10",
1835            "--offset",
1836            "5",
1837        ]);
1838        let AgentCommand::ListTasks(l) = a.command else {
1839            panic!()
1840        };
1841        assert!(l.ready);
1842        assert!(l.blocked);
1843        assert_eq!(l.status, vec!["open", "in_progress"]);
1844        assert_eq!(l.parent, Some("root-1".to_string()));
1845        assert_eq!(l.limit, Some(10));
1846        assert_eq!(l.offset, Some(5));
1847    }
1848
1849    #[test]
1850    fn test_parse_list_tasks_defaults() {
1851        let a = parse_agent(&["list-tasks"]);
1852        let AgentCommand::ListTasks(l) = a.command else {
1853            panic!()
1854        };
1855        assert!(!l.ready);
1856        assert!(!l.blocked);
1857        assert!(l.status.is_empty());
1858        assert_eq!(l.parent, None);
1859        assert_eq!(l.limit, None);
1860        assert_eq!(l.offset, None);
1861    }
1862
1863    // ── Arg parsing: get ──────────────────────────────────────────────
1864
1865    #[test]
1866    fn test_parse_get() {
1867        let a = parse_agent(&["get", "task-abc"]);
1868        let AgentCommand::Get(g) = a.command else {
1869            panic!()
1870        };
1871        assert_eq!(g.task_id, "task-abc");
1872    }
1873
1874    #[test]
1875    fn test_parse_get_missing_task_id() {
1876        let result = TestCli::try_parse_from(["test", "agent", "get"]);
1877        assert!(result.is_err(), "get without task_id should fail");
1878    }
1879
1880    // ── Arg parsing: claim ────────────────────────────────────────────
1881
1882    #[test]
1883    fn test_parse_claim_basic() {
1884        let a = parse_agent(&["claim", "worker-1", "task-123"]);
1885        let AgentCommand::Claim(c) = a.command else {
1886            panic!()
1887        };
1888        assert_eq!(c.worker_id, "worker-1");
1889        assert_eq!(c.task_id, "task-123");
1890        assert!(!c.force);
1891    }
1892
1893    #[test]
1894    fn test_parse_claim_force() {
1895        let a = parse_agent(&["claim", "w1", "t1", "--force"]);
1896        let AgentCommand::Claim(c) = a.command else {
1897            panic!()
1898        };
1899        assert!(c.force);
1900    }
1901
1902    #[test]
1903    fn test_parse_claim_missing_args() {
1904        // Missing both worker_id and task_id
1905        let result = TestCli::try_parse_from(["test", "agent", "claim"]);
1906        assert!(result.is_err());
1907
1908        // Missing task_id
1909        let result = TestCli::try_parse_from(["test", "agent", "claim", "w1"]);
1910        assert!(result.is_err());
1911    }
1912
1913    // ── Arg parsing: update ───────────────────────────────────────────
1914
1915    #[test]
1916    fn test_parse_update_all_fields() {
1917        let a = parse_agent(&[
1918            "update",
1919            "w1",
1920            "task-1",
1921            "--status",
1922            "completed",
1923            "--title",
1924            "New title",
1925            "--description",
1926            "New desc",
1927            "--reason",
1928            "Done",
1929            "--force",
1930        ]);
1931        let AgentCommand::Update(u) = a.command else {
1932            panic!()
1933        };
1934        assert_eq!(u.worker_id, "w1");
1935        assert_eq!(u.task_id, "task-1");
1936        assert_eq!(u.status, Some("completed".to_string()));
1937        assert_eq!(u.title, Some("New title".to_string()));
1938        assert_eq!(u.description, Some("New desc".to_string()));
1939        assert_eq!(u.reason, Some("Done".to_string()));
1940        assert!(u.force);
1941    }
1942
1943    #[test]
1944    fn test_parse_update_minimal() {
1945        let a = parse_agent(&["update", "w1", "task-1"]);
1946        let AgentCommand::Update(u) = a.command else {
1947            panic!()
1948        };
1949        assert_eq!(u.worker_id, "w1");
1950        assert_eq!(u.task_id, "task-1");
1951        assert_eq!(u.status, None);
1952        assert_eq!(u.title, None);
1953        assert!(!u.force);
1954    }
1955
1956    // ── Arg parsing: thinking ─────────────────────────────────────────
1957
1958    #[test]
1959    fn test_parse_thinking() {
1960        let a = parse_agent(&["thinking", "w1", "Analyzing code"]);
1961        let AgentCommand::Thinking(t) = a.command else {
1962            panic!()
1963        };
1964        assert_eq!(t.worker_id, "w1");
1965        assert_eq!(t.message, "Analyzing code");
1966        assert!(t.tasks.is_empty());
1967    }
1968
1969    #[test]
1970    fn test_parse_thinking_with_tasks() {
1971        let a = parse_agent(&["thinking", "w1", "Working", "--tasks", "t1,t2,t3"]);
1972        let AgentCommand::Thinking(t) = a.command else {
1973            panic!()
1974        };
1975        assert_eq!(t.tasks, vec!["t1", "t2", "t3"]);
1976    }
1977
1978    // ── Arg parsing: attach ───────────────────────────────────────────
1979
1980    #[test]
1981    fn test_parse_attach_with_content() {
1982        let a = parse_agent(&["attach", "w1", "task-1", "-t", "note", "-c", "My note"]);
1983        let AgentCommand::Attach(att) = a.command else {
1984            panic!()
1985        };
1986        assert_eq!(att.worker_id, "w1");
1987        assert_eq!(att.task_id, "task-1");
1988        assert_eq!(att.r#type, "note");
1989        assert_eq!(att.content, Some("My note".to_string()));
1990        assert_eq!(att.file, None);
1991    }
1992
1993    #[test]
1994    fn test_parse_attach_with_file() {
1995        let a = parse_agent(&["attach", "w1", "task-1", "-t", "log", "--file", "out.log"]);
1996        let AgentCommand::Attach(att) = a.command else {
1997            panic!()
1998        };
1999        assert_eq!(att.file, Some(PathBuf::from("out.log")));
2000        assert_eq!(att.content, None);
2001    }
2002
2003    #[test]
2004    fn test_parse_attach_content_and_file_conflict() {
2005        let result = TestCli::try_parse_from([
2006            "test", "agent", "attach", "w1", "t1", "-t", "note", "-c", "text", "--file", "f.txt",
2007        ]);
2008        assert!(result.is_err(), "--content and --file should conflict");
2009    }
2010
2011    // ── Arg parsing: list-agents ──────────────────────────────────────
2012
2013    #[test]
2014    fn test_parse_list_agents_defaults() {
2015        let a = parse_agent(&["list-agents"]);
2016        let AgentCommand::ListAgents(la) = a.command else {
2017            panic!()
2018        };
2019        assert!(la.tags.is_empty());
2020        assert_eq!(la.file, None);
2021        assert_eq!(la.task, None);
2022    }
2023
2024    #[test]
2025    fn test_parse_list_agents_with_filters() {
2026        let a = parse_agent(&["list-agents", "--tags", "build", "--task", "t1"]);
2027        let AgentCommand::ListAgents(la) = a.command else {
2028            panic!()
2029        };
2030        assert_eq!(la.tags, vec!["build"]);
2031        assert_eq!(la.task, Some("t1".to_string()));
2032    }
2033
2034    // ── Arg parsing: interactive / batch ──────────────────────────────
2035
2036    #[test]
2037    fn test_parse_interactive() {
2038        let a = parse_agent(&["interactive"]);
2039        let AgentCommand::Interactive(i) = a.command else {
2040            panic!()
2041        };
2042        assert!(!i.stdin);
2043    }
2044
2045    #[test]
2046    fn test_parse_interactive_stdin() {
2047        let a = parse_agent(&["interactive", "--stdin"]);
2048        let AgentCommand::Interactive(i) = a.command else {
2049            panic!()
2050        };
2051        assert!(i.stdin);
2052    }
2053
2054    #[test]
2055    fn test_parse_repl_alias() {
2056        let a = parse_agent(&["repl"]);
2057        assert!(matches!(a.command, AgentCommand::Interactive(_)));
2058    }
2059
2060    #[test]
2061    fn test_parse_batch() {
2062        let a = parse_agent(&["batch", "commands.txt"]);
2063        let AgentCommand::Batch(b) = a.command else {
2064            panic!()
2065        };
2066        assert_eq!(b.file, PathBuf::from("commands.txt"));
2067        assert!(!b.keep_going);
2068    }
2069
2070    #[test]
2071    fn test_parse_batch_keep_going() {
2072        let a = parse_agent(&["batch", "-k", "cmds.txt"]);
2073        let AgentCommand::Batch(b) = a.command else {
2074            panic!()
2075        };
2076        assert!(b.keep_going);
2077    }
2078
2079    // ── Global flags ──────────────────────────────────────────────────
2080
2081    #[test]
2082    fn test_parse_format_json() {
2083        let a = parse_agent(&["--format", "json", "list-tasks"]);
2084        assert_eq!(a.format, CliOutputFormat::Json);
2085    }
2086
2087    #[test]
2088    fn test_parse_format_default_is_markdown() {
2089        let a = parse_agent(&["list-tasks"]);
2090        assert_eq!(a.format, CliOutputFormat::Markdown);
2091    }
2092
2093    #[test]
2094    fn test_parse_global_worker_id() {
2095        let a = parse_agent(&["--worker-id", "global-w", "list-tasks"]);
2096        assert_eq!(a.worker_id, Some("global-w".to_string()));
2097    }
2098
2099    #[test]
2100    fn test_parse_no_global_worker_id() {
2101        let a = parse_agent(&["list-tasks"]);
2102        assert_eq!(a.worker_id, None);
2103    }
2104
2105    // ── CliOutputFormat conversion ────────────────────────────────────
2106
2107    #[test]
2108    fn test_cli_format_to_output_format() {
2109        assert!(matches!(
2110            OutputFormat::from(CliOutputFormat::Markdown),
2111            OutputFormat::Markdown
2112        ));
2113        assert!(matches!(
2114            OutputFormat::from(CliOutputFormat::Json),
2115            OutputFormat::Json
2116        ));
2117    }
2118
2119    // ── Exit code mapping ─────────────────────────────────────────────
2120    // error_to_exit_code does string matching on the error message
2121
2122    #[test]
2123    fn test_error_to_exit_code_task_not_found() {
2124        let err = anyhow::anyhow!(crate::error::ToolError::task_not_found("abc"));
2125        assert_eq!(error_to_exit_code(&err), exit_codes::TASK_NOT_FOUND);
2126    }
2127
2128    #[test]
2129    fn test_error_to_exit_code_agent_not_found() {
2130        let err = anyhow::anyhow!(crate::error::ToolError::agent_not_found("w1"));
2131        assert_eq!(error_to_exit_code(&err), exit_codes::WORKER_NOT_FOUND);
2132    }
2133
2134    #[test]
2135    fn test_error_to_exit_code_already_claimed() {
2136        let err = anyhow::anyhow!(crate::error::ToolError::already_claimed("t1", "w2"));
2137        assert_eq!(error_to_exit_code(&err), exit_codes::CLAIM_FAILED);
2138    }
2139
2140    #[test]
2141    fn test_error_to_exit_code_not_owner() {
2142        let err = anyhow::anyhow!(crate::error::ToolError::not_owner("t1", "w1"));
2143        assert_eq!(error_to_exit_code(&err), exit_codes::PERMISSION_DENIED);
2144    }
2145
2146    #[test]
2147    fn test_error_to_exit_code_missing_field() {
2148        let err = anyhow::anyhow!(crate::error::ToolError::missing_field("worker_id"));
2149        assert_eq!(error_to_exit_code(&err), exit_codes::INVALID_ARGUMENTS);
2150    }
2151
2152    #[test]
2153    fn test_error_to_exit_code_invalid_value() {
2154        let err = anyhow::anyhow!(crate::error::ToolError::invalid_value("status", "bad"));
2155        assert_eq!(error_to_exit_code(&err), exit_codes::INVALID_ARGUMENTS);
2156    }
2157
2158    #[test]
2159    fn test_error_to_exit_code_generic() {
2160        let err = anyhow::anyhow!("some random error");
2161        assert_eq!(error_to_exit_code(&err), exit_codes::GENERAL_ERROR);
2162    }
2163
2164    #[test]
2165    fn test_error_to_exit_code_dependency_blocked() {
2166        let err = anyhow::anyhow!(crate::error::ToolError::deps_not_satisfied(&[
2167            "dep-1".to_string(),
2168        ]));
2169        assert_eq!(error_to_exit_code(&err), exit_codes::CLAIM_FAILED);
2170    }
2171
2172    // ── Format helpers ────────────────────────────────────────────────
2173
2174    #[test]
2175    fn test_format_json_output_json_mode() {
2176        let v = serde_json::json!({"status": "ok"});
2177        let out = format_json_output(v.clone(), CliOutputFormat::Json);
2178        assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2179    }
2180
2181    #[test]
2182    fn test_format_json_output_markdown_mode() {
2183        let v = serde_json::json!({"status": "ok"});
2184        let out = format_json_output(v.clone(), CliOutputFormat::Markdown);
2185        // In markdown mode, JSON values are still pretty-printed
2186        assert!(out.contains("status"));
2187    }
2188
2189    #[test]
2190    fn test_format_output_raw_markdown() {
2191        let result = ToolResult::Raw("# Tasks\n- task-1".to_string());
2192        let out = format_output(result, CliOutputFormat::Markdown);
2193        assert_eq!(out, "# Tasks\n- task-1");
2194    }
2195
2196    #[test]
2197    fn test_format_output_raw_json() {
2198        let result = ToolResult::Raw("hello".to_string());
2199        let out = format_output(result, CliOutputFormat::Json);
2200        assert!(out.contains("\"output\""));
2201        assert!(out.contains("hello"));
2202    }
2203
2204    // ── Arg parsing: prompts ────────────────────────────────────────────
2205
2206    #[test]
2207    fn test_parse_prompts_no_args() {
2208        let a = parse_agent(&["prompts"]);
2209        let AgentCommand::Prompts(p) = a.command else {
2210            panic!()
2211        };
2212        assert_eq!(p.status, None);
2213        assert_eq!(p.phase, None);
2214        assert_eq!(p.advisory, None);
2215        assert_eq!(p.task, None);
2216    }
2217
2218    #[test]
2219    fn test_parse_prompts_with_status() {
2220        let a = parse_agent(&["prompts", "--status", "working"]);
2221        let AgentCommand::Prompts(p) = a.command else {
2222            panic!()
2223        };
2224        assert_eq!(p.status, Some("working".to_string()));
2225    }
2226
2227    #[test]
2228    fn test_parse_prompts_with_phase() {
2229        let a = parse_agent(&["prompts", "--phase", "implement"]);
2230        let AgentCommand::Prompts(p) = a.command else {
2231            panic!()
2232        };
2233        assert_eq!(p.phase, Some("implement".to_string()));
2234    }
2235
2236    #[test]
2237    fn test_parse_prompts_advisory_list() {
2238        let a = parse_agent(&["prompts", "--advisory"]);
2239        let AgentCommand::Prompts(p) = a.command else {
2240            panic!()
2241        };
2242        assert_eq!(p.advisory, Some(String::new()));
2243    }
2244
2245    #[test]
2246    fn test_parse_prompts_advisory_specific() {
2247        let a = parse_agent(&["prompts", "--advisory", "decompose-epic"]);
2248        let AgentCommand::Prompts(p) = a.command else {
2249            panic!()
2250        };
2251        assert_eq!(p.advisory, Some("decompose-epic".to_string()));
2252    }
2253
2254    #[test]
2255    fn test_parse_prompts_with_task() {
2256        let a = parse_agent(&["prompts", "--status", "working", "--task", "task-123"]);
2257        let AgentCommand::Prompts(p) = a.command else {
2258            panic!()
2259        };
2260        assert_eq!(p.status, Some("working".to_string()));
2261        assert_eq!(p.task, Some("task-123".to_string()));
2262    }
2263
2264    // ── Format: connect output ──────────────────────────────────────────
2265
2266    #[test]
2267    fn test_format_connect_output_json_mode() {
2268        let v = json!({
2269            "worker_id": "w1",
2270            "tags": ["build"],
2271            "config": { "states": ["pending", "working"], "initial_state": "pending" }
2272        });
2273        let out = format_connect_output(v.clone(), CliOutputFormat::Json);
2274        assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2275    }
2276
2277    #[test]
2278    fn test_format_connect_output_markdown_mode() {
2279        let v = json!({
2280            "worker_id": "test-worker",
2281            "tags": ["build", "test"],
2282            "config": {
2283                "states": ["pending", "working", "completed"],
2284                "initial_state": "pending",
2285                "timed_states": ["working"],
2286                "terminal_states": ["completed"],
2287                "phases": ["implement", "test"]
2288            },
2289            "role": { "role": "worker", "description": "A worker role" },
2290            "role_prompts": ["You are actively working."],
2291            "paths": {
2292                "db_path": "tasks.db",
2293                "media_dir": "media",
2294                "log_dir": "logs"
2295            }
2296        });
2297        let out = format_connect_output(v, CliOutputFormat::Markdown);
2298        assert!(out.contains("**Worker ID:** `test-worker`"));
2299        assert!(out.contains("**Tags:** build, test"));
2300        assert!(out.contains("**Role:** `worker`"));
2301        assert!(out.contains("### State Machine"));
2302        assert!(out.contains("**Initial:** `pending`"));
2303        assert!(out.contains("### Role Prompts"));
2304        assert!(out.contains("> You are actively working."));
2305        assert!(out.contains("### Paths"));
2306    }
2307
2308    // ── Format: update output ───────────────────────────────────────────
2309
2310    #[test]
2311    fn test_format_update_output_json_mode() {
2312        let v = json!({ "task": "t1", "status": "working" });
2313        let out = format_update_output(v.clone(), CliOutputFormat::Json);
2314        assert_eq!(out, serde_json::to_string_pretty(&v).unwrap());
2315    }
2316
2317    #[test]
2318    fn test_format_update_output_markdown_with_prompts() {
2319        let v = json!({
2320            "task": "fix-bug",
2321            "title": "Fix auth bug",
2322            "status": "working",
2323            "prompts": [
2324                "You are now actively working on this task.",
2325                "Remember to run tests before completing."
2326            ]
2327        });
2328        let out = format_update_output(v, CliOutputFormat::Markdown);
2329        assert!(out.contains("**Task:** `fix-bug` - Fix auth bug"));
2330        assert!(out.contains("**Status:** `working`"));
2331        assert!(out.contains("### Guidance"));
2332        assert!(out.contains("> You are now actively working on this task."));
2333        assert!(out.contains("> Remember to run tests before completing."));
2334    }
2335
2336    #[test]
2337    fn test_format_update_output_markdown_no_prompts() {
2338        let v = json!({
2339            "task": "t1",
2340            "title": "Some task",
2341            "status": "pending"
2342        });
2343        let out = format_update_output(v, CliOutputFormat::Markdown);
2344        assert!(out.contains("**Task:** `t1`"));
2345        assert!(!out.contains("### Guidance"));
2346    }
2347
2348    // ── Format: claim output ────────────────────────────────────────────
2349
2350    #[test]
2351    fn test_format_claim_output_markdown_with_prompts() {
2352        let v = json!({
2353            "task": "task-1",
2354            "title": "Implement feature",
2355            "status": "working",
2356            "owner": "worker-5",
2357            "prompts": ["Start by reading the existing code."]
2358        });
2359        let out = format_claim_output(v, CliOutputFormat::Markdown);
2360        assert!(out.contains("**Task:** `task-1` - Implement feature"));
2361        assert!(out.contains("**Owner:** `worker-5`"));
2362        assert!(out.contains("### Guidance"));
2363        assert!(out.contains("> Start by reading the existing code."));
2364    }
2365}