feat: implement Claude Code workflows
This commit is contained in:
parent
c7d2c74437
commit
1923d7df13
|
|
@ -9,7 +9,7 @@ Last updated: 2026-04-03
|
|||
- Current `main` HEAD: `ee31e00` (stub implementations replaced with real AskUserQuestion + RemoteTrigger).
|
||||
- Repository stats at this checkpoint: **292 commits on `main` / 293 across all branches**, **9 crates**, **48,599 tracked Rust LOC**, **2,568 test LOC**, **3 authors**, date range **2026-03-31 → 2026-04-03**.
|
||||
- Mock parity harness stats: **12 scripted scenarios**, **21 captured `/v1/messages` requests** in `rust/crates/rusty-claude-cli/tests/mock_parity_harness.rs`.
|
||||
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, and new host-facing contracts (`Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
|
||||
- Claude Code v2.1.201 migration branch note: `TaskCreate`/`TaskUpdate` accept the structured `subject`/`activeForm`/`metadata` contract, `McpAuth` exposes login/logout host-flow payloads, workflows add `/workflows`, `/deep-research`, `ultracode` keyword, and `/effort ultracode` entry points, and the remaining host-facing contracts (`Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, `ShowOnboardingRolePicker`) are registered with stable JSON responses.
|
||||
|
||||
## Mock parity harness — milestone 1
|
||||
|
||||
|
|
@ -153,13 +153,14 @@ Canonical scenario map: `rust/mock_parity_scenarios.json`
|
|||
- Agent view now uses the Claude Code v2.1.201 background-session layout: `CLAUDE_CONFIG_DIR` or `~/.claude`, `jobs/<id>/state.json`, `jobs/<id>/tmp/`, `daemon/roster.json`, and `daemon.log`. `claw agents`, `claw agents --json [--all] [--cwd <path>]`, `claw --bg`, `claw --bg --exec`, `claw attach/logs/stop/kill/respawn/rm <id>`, and `claw daemon status/stop --any` are wired through `runtime::AgentSupervisor`; TTY `claw agents` opens a full-screen crossterm view with grouped rows, peek, dispatch, and lifecycle shortcuts.
|
||||
- The 9-lane push replaced pure fixed-payload stubs for `Task*`, `Team*`, `Cron*`, `LSP`, and MCP tools with registry-backed handlers on `main`; the v2.1.201 migration extends `TaskCreate`/`TaskUpdate` to the structured task-list payload shape.
|
||||
- The v2.1.201 host-facing contracts include `Workflow`, `Monitor`, `ScheduleWakeup`, `PushNotification`, `ReportFindings`, `ReadMcpResourceDir`, `Artifact`, `Projects`, `ClaudeDesign`, and `ShowOnboardingRolePicker`.
|
||||
- `Workflow` now resolves inline scripts, `scriptPath`, saved scripts under `.claude/workflows` and `.claw/workflows`, user-level workflow roots, and the bundled `deep-research` workflow. Static `agent(...)` calls spawn background agents and write run manifests; `/workflows`, `claw workflows`, `/deep-research`, `claw deep-research`, `ultracode` keyword trigger, and session-only `/effort ultracode` are wired through the CLI.
|
||||
- `Brief` is handled as an execution alias in `execute_tool()`, but it is not a separately exposed tool spec in `mvp_tool_specs()`.
|
||||
|
||||
### Still limited or intentionally shallow
|
||||
|
||||
- `AskUserQuestion` supports local stdin/stdout question flow, but it is not wired to an async host UI prompt surface.
|
||||
- `McpAuth` reports structured login/logout contract state and host-flow requirements, but it does not open a browser or complete OAuth by itself.
|
||||
- `Workflow` and `Monitor` expose compatibility payloads, but local workflow JavaScript execution and live monitor scheduling are not implemented in this runtime.
|
||||
- `Workflow` does not yet embed a full isolated JavaScript workflow VM. The current runner is a local parity layer for static `agent(...)` orchestration, saved workflow lookup, run registry, and disable switches; arbitrary `workflow`, `parallel`, `pipeline`, `Date`, and `Math.random` script behavior remains incomplete. `Monitor` still exposes only compatibility payloads and does not schedule live monitor loops.
|
||||
- `RemoteTrigger` performs direct HTTP requests, but it has not been validated against every upstream host-trigger edge case.
|
||||
- Agent view has a local supervisor, official state files, shell lifecycle commands, raw `agents --json` arrays, background worker spawning, a basic full-screen TUI, queued peek replies into resumable prompt sessions without live workers, row pin/rename/reorder/collapse shortcuts, directory/state grouping, and terminal attach once workers record their managed session id. Remaining gaps are live in-process peek reply routing, generated row summaries/PR status, notification hooks for background completion/needs-input, `/bg` or left-arrow handoff from an already-running foreground session, and a real supervisor socket/pre-warmed worker pool.
|
||||
- `TestingPermission` remains test-only.
|
||||
|
|
|
|||
17
USAGE.md
17
USAGE.md
|
|
@ -572,6 +572,23 @@ Agent view follows the Claude Code background-session layout. `CLAUDE_CONFIG_DIR
|
|||
|
||||
When stdout/stdin are attached to a terminal, `claw agents` opens a full-screen view. Type a prompt and press Enter to dispatch a new background session, use Up/Down to select rows, Space to peek at captured output, type while peek is open and press Enter to send a reply to a resumable session without a live worker, Enter or Right on an empty input to attach or fold a group header, `Ctrl+S` to group by directory/state, `Ctrl+T` to pin a row, `Ctrl+R` to rename it, `Shift+Up`/`Shift+Down` to reorder within a group, `Ctrl+X` to stop and press `Ctrl+X` again to remove, `s`/`k` to stop or kill, `r` to respawn, and `d` twice to remove a row. In JSON mode or non-TTY automation it keeps the structured list behavior.
|
||||
|
||||
## Run workflows
|
||||
|
||||
`claw` supports Claude Code-style dynamic workflow entry points for local orchestration. Saved workflow scripts are discovered from `.claude/workflows/`, `.claw/workflows/`, `~/.claude/workflows/`, and `~/.claw/workflows/`. Inline scripts, `scriptPath`, saved workflow names, and the bundled `deep-research` workflow all route through the `Workflow` tool and record run manifests in `.clawd-workflows/` by default.
|
||||
|
||||
```bash
|
||||
./target/debug/claw workflows
|
||||
./target/debug/claw workflows show <workflow-run-id>
|
||||
./target/debug/claw deep-research "what changed in Node.js permission model?"
|
||||
./target/debug/claw /deep-research "compare these APIs"
|
||||
```
|
||||
|
||||
In the REPL, `/workflows` lists recorded runs and `/deep-research <question>` launches the bundled research workflow in the background. `/effort ultracode` is session-scoped: it sets API reasoning to `xhigh` and enables workflow orchestration for substantive prompts until you change effort again or start a new session. The `ultracode` keyword in a normal prompt also triggers workflow orchestration when `workflowKeywordTriggerEnabled` is true.
|
||||
|
||||
Workflow feature switches follow Claude Code-compatible settings: `disableWorkflows`, `enableWorkflows`, `workflowKeywordTriggerEnabled`, and `CLAUDE_CODE_DISABLE_WORKFLOWS=1`. When workflows are disabled, `/workflows`, `/deep-research`, and `ultracode` orchestration return a disabled workflow payload instead of starting agents.
|
||||
|
||||
Current local parity is intentionally bounded: the runner executes scripts with static `agent(...)` calls, stores run state, and spawns background agents. It does not yet embed a full isolated JavaScript workflow VM for arbitrary `workflow`, `parallel`, `pipeline`, `Date`, or `Math.random` behavior.
|
||||
|
||||
## Session management
|
||||
|
||||
REPL turns are persisted under `.claw/sessions/` in the current workspace.
|
||||
|
|
|
|||
|
|
@ -86,7 +86,7 @@ Hashes below come from `git log --oneline`. Merge line counts come from `git sho
|
|||
| **StructuredOutput** | `tools` | passthrough JSON — **good parity** |
|
||||
| **REPL** | `tools` | subprocess code execution — **moderate parity** |
|
||||
| **PowerShell** | `tools` | Windows PowerShell execution — **moderate parity** |
|
||||
| **Workflow / Monitor** | `tools` | v2.1.201 compatibility contracts with stable unsupported payloads; local workflow JS and live monitor scheduling are not implemented |
|
||||
| **Workflow / Monitor** | `tools` | Workflow supports saved/inline/scriptPath lookup, bundled `deep-research`, static `agent(...)` orchestration, run manifests, `/workflows`, `ultracode` keyword, and `/effort ultracode`; full JS VM and live Monitor scheduling are not implemented |
|
||||
| **ScheduleWakeup / PushNotification / ReportFindings / Artifact / Projects / ClaudeDesign / ShowOnboardingRolePicker** | `tools` | host-facing v2.1.201 contract payloads — **contract parity** |
|
||||
|
||||
### Stubs Only (surface parity, no behavior)
|
||||
|
|
|
|||
|
|
@ -244,6 +244,20 @@ const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
|||
argument_hint: Some("[list|show <name>|create <name>|help]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "workflows",
|
||||
aliases: &[],
|
||||
summary: "List or inspect dynamic workflow runs",
|
||||
argument_hint: Some("[show <run-id>]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "deep-research",
|
||||
aliases: &[],
|
||||
summary: "Run the bundled deep research workflow",
|
||||
argument_hint: Some("<question>"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "skills",
|
||||
aliases: &["skill"],
|
||||
|
|
@ -1119,6 +1133,12 @@ pub enum SlashCommand {
|
|||
Agents {
|
||||
args: Option<String>,
|
||||
},
|
||||
Workflows {
|
||||
args: Option<String>,
|
||||
},
|
||||
DeepResearch {
|
||||
question: Option<String>,
|
||||
},
|
||||
Skills {
|
||||
args: Option<String>,
|
||||
},
|
||||
|
|
@ -1266,6 +1286,10 @@ impl SlashCommand {
|
|||
Self::Permissions { .. } => "/permissions",
|
||||
Self::Session { .. } => "/session",
|
||||
Self::Plugins { .. } => "/plugins",
|
||||
Self::Agents { .. } => "/agents",
|
||||
Self::Workflows { .. } => "/workflows",
|
||||
Self::DeepResearch { .. } => "/deep-research",
|
||||
Self::Skills { .. } => "/skills",
|
||||
Self::Login => "/login",
|
||||
Self::Logout => "/logout",
|
||||
Self::Vim => "/vim",
|
||||
|
|
@ -1408,6 +1432,10 @@ pub fn validate_slash_command_input(
|
|||
"agents" => SlashCommand::Agents {
|
||||
args: parse_list_or_help_args(command, remainder)?,
|
||||
},
|
||||
"workflows" => SlashCommand::Workflows { args: remainder },
|
||||
"deep-research" => SlashCommand::DeepResearch {
|
||||
question: Some(require_remainder(command, remainder, "<question>")?),
|
||||
},
|
||||
"skills" | "skill" => SlashCommand::Skills {
|
||||
args: parse_skills_args(remainder.as_deref())?,
|
||||
},
|
||||
|
|
@ -5805,6 +5833,8 @@ pub fn handle_slash_command(
|
|||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Plugins { .. }
|
||||
| SlashCommand::Agents { .. }
|
||||
| SlashCommand::Workflows { .. }
|
||||
| SlashCommand::DeepResearch { .. }
|
||||
| SlashCommand::Skills { .. }
|
||||
| SlashCommand::Doctor
|
||||
| SlashCommand::Login
|
||||
|
|
@ -6015,6 +6045,26 @@ mod tests {
|
|||
task: Some("ship both features".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/workflows show workflow-123"),
|
||||
Ok(Some(SlashCommand::Workflows {
|
||||
args: Some("show workflow-123".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/deep-research Node.js permission model changes"),
|
||||
Ok(Some(SlashCommand::DeepResearch {
|
||||
question: Some("Node.js permission model changes".to_string())
|
||||
}))
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/ultracode implement login"),
|
||||
Ok(Some(SlashCommand::Unknown("ultracode".to_string())))
|
||||
);
|
||||
assert!(
|
||||
super::find_slash_command_spec("ultracode").is_none(),
|
||||
"ultracode is a workflow keyword and /effort mode, not a slash command"
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/teleport conversation.rs"),
|
||||
Ok(Some(SlashCommand::Teleport {
|
||||
|
|
@ -6470,6 +6520,8 @@ mod tests {
|
|||
));
|
||||
assert!(help.contains("aliases: /plugins, /marketplace"));
|
||||
assert!(help.contains("/agents [list|show <name>|create <name>|help]"));
|
||||
assert!(help.contains("/workflows [show <run-id>]"));
|
||||
assert!(help.contains("/deep-research <question>"));
|
||||
assert!(help.contains(
|
||||
"/skills [list|show <name>|install <path>|uninstall <name>|help|<skill> [args]]"
|
||||
));
|
||||
|
|
@ -6477,7 +6529,7 @@ mod tests {
|
|||
assert!(!help.contains("/login"));
|
||||
assert!(!help.contains("/logout"));
|
||||
assert!(help.contains("/setup"));
|
||||
assert_eq!(slash_command_specs().len(), 142);
|
||||
assert_eq!(slash_command_specs().len(), 144);
|
||||
assert!(resume_supported_slash_commands().len() >= 39);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,7 @@ pub struct RuntimeFeatureConfig {
|
|||
plugins: RuntimePluginConfig,
|
||||
mcp: McpConfigCollection,
|
||||
oauth: Option<OAuthConfig>,
|
||||
workflows: RuntimeWorkflowConfig,
|
||||
model: Option<String>,
|
||||
aliases: BTreeMap<String, String>,
|
||||
permission_mode: Option<ResolvedPermissionMode>,
|
||||
|
|
@ -170,6 +171,24 @@ pub struct RuntimeFeatureConfig {
|
|||
provider: RuntimeProviderConfig,
|
||||
}
|
||||
|
||||
/// Dynamic workflow feature switches from Claude Code-compatible settings.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeWorkflowConfig {
|
||||
disable_workflows: bool,
|
||||
enable_workflows: Option<bool>,
|
||||
workflow_keyword_trigger_enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for RuntimeWorkflowConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
disable_workflows: false,
|
||||
enable_workflows: None,
|
||||
workflow_keyword_trigger_enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Controls which external AI coding framework rules are imported into the system prompt.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub enum RulesImportConfig {
|
||||
|
|
@ -796,6 +815,7 @@ fn build_runtime_config(
|
|||
plugins: parse_optional_plugin_config(&merged_value)?,
|
||||
mcp,
|
||||
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||
workflows: parse_optional_workflow_config(&merged_value)?,
|
||||
model: parse_optional_model(&merged_value),
|
||||
aliases: parse_optional_aliases(&merged_value)?,
|
||||
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
||||
|
|
@ -880,6 +900,21 @@ impl RuntimeConfig {
|
|||
self.feature_config.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflows(&self) -> &RuntimeWorkflowConfig {
|
||||
&self.feature_config.workflows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflows_enabled(&self) -> bool {
|
||||
self.feature_config.workflows_enabled()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_keyword_trigger_enabled(&self) -> bool {
|
||||
self.feature_config.workflow_keyword_trigger_enabled()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.feature_config.model.as_deref()
|
||||
|
|
@ -977,6 +1012,21 @@ impl RuntimeFeatureConfig {
|
|||
self.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflows(&self) -> &RuntimeWorkflowConfig {
|
||||
&self.workflows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflows_enabled(&self) -> bool {
|
||||
self.workflows.enabled()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn workflow_keyword_trigger_enabled(&self) -> bool {
|
||||
self.workflows.keyword_trigger_enabled() && self.workflows_enabled()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
|
|
@ -1024,6 +1074,34 @@ impl RuntimeFeatureConfig {
|
|||
}
|
||||
}
|
||||
|
||||
impl RuntimeWorkflowConfig {
|
||||
#[must_use]
|
||||
pub fn disable_workflows(&self) -> bool {
|
||||
self.disable_workflows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn enable_workflows(&self) -> Option<bool> {
|
||||
self.enable_workflows
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn keyword_trigger_enabled(&self) -> bool {
|
||||
self.workflow_keyword_trigger_enabled
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn enabled(&self) -> bool {
|
||||
if env_flag_enabled("CLAUDE_CODE_DISABLE_WORKFLOWS") {
|
||||
return false;
|
||||
}
|
||||
if self.disable_workflows {
|
||||
return false;
|
||||
}
|
||||
self.enable_workflows.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Vec<String> {
|
||||
let mut merged = Vec::with_capacity(config_roots.len() + per_call_roots.len());
|
||||
for root in config_roots.iter().chain(per_call_roots.iter()) {
|
||||
|
|
@ -1034,6 +1112,15 @@ fn merge_trusted_roots(config_roots: &[String], per_call_roots: &[String]) -> Ve
|
|||
merged
|
||||
}
|
||||
|
||||
fn env_flag_enabled(name: &str) -> bool {
|
||||
std::env::var(name).is_ok_and(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
impl ProviderFallbackConfig {
|
||||
#[must_use]
|
||||
pub fn new(primary: Option<String>, fallbacks: Vec<String>) -> Self {
|
||||
|
|
@ -1749,6 +1836,24 @@ fn parse_optional_aliases(root: &JsonValue) -> Result<BTreeMap<String, String>,
|
|||
Ok(optional_string_map(object, "aliases", "merged settings")?.unwrap_or_default())
|
||||
}
|
||||
|
||||
fn parse_optional_workflow_config(root: &JsonValue) -> Result<RuntimeWorkflowConfig, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(RuntimeWorkflowConfig::default());
|
||||
};
|
||||
|
||||
Ok(RuntimeWorkflowConfig {
|
||||
disable_workflows: optional_bool(object, "disableWorkflows", "merged settings")?
|
||||
.unwrap_or(false),
|
||||
enable_workflows: optional_bool(object, "enableWorkflows", "merged settings")?,
|
||||
workflow_keyword_trigger_enabled: optional_bool(
|
||||
object,
|
||||
"workflowKeywordTriggerEnabled",
|
||||
"merged settings",
|
||||
)?
|
||||
.unwrap_or(true),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(RuntimeHookConfig::default());
|
||||
|
|
@ -3944,4 +4049,33 @@ mod tests {
|
|||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_workflow_feature_switches_and_env_disable() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claw");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"enableWorkflows": true, "workflowKeywordTriggerEnabled": false}"#,
|
||||
)
|
||||
.expect("write settings");
|
||||
|
||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("workflow settings should load");
|
||||
assert!(loaded.workflows_enabled());
|
||||
assert!(!loaded.workflow_keyword_trigger_enabled());
|
||||
assert_eq!(loaded.workflows().enable_workflows(), Some(true));
|
||||
|
||||
std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1");
|
||||
assert!(!loaded.workflows_enabled());
|
||||
assert!(!loaded.workflow_keyword_trigger_enabled());
|
||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,11 +74,16 @@ use runtime::{
|
|||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use tools::{
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, GlobalToolRegistry,
|
||||
RuntimeToolDefinition, ToolSearchOutput,
|
||||
canonical_allowed_tool_name, execute_tool, mvp_tool_specs, render_workflows_report,
|
||||
workflows_report_json, GlobalToolRegistry, RuntimeToolDefinition, ToolSearchOutput,
|
||||
};
|
||||
|
||||
const DEFAULT_MODEL: &str = "anthropic/claude-sonnet-5";
|
||||
const API_REASONING_EFFORT_VALUES: &[&str] = &["low", "medium", "high", "xhigh", "max"];
|
||||
const SESSION_EFFORT_VALUES: &[&str] =
|
||||
&["auto", "low", "medium", "high", "xhigh", "max", "ultracode"];
|
||||
const REASONING_EFFORT_FLAG_USAGE: &str = "--reasoning-effort low|medium|high|xhigh|max";
|
||||
const SESSION_EFFORT_USAGE: &str = "/effort auto|low|medium|high|xhigh|max|ultracode";
|
||||
|
||||
/// #148: Model provenance for `claw status` JSON/text output. Records where
|
||||
/// the resolved model string came from so claws don't have to re-read argv
|
||||
|
|
@ -1037,6 +1042,14 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
|
|||
reasoning_effort: reasoning_effort.as_deref(),
|
||||
agent: agent.as_deref(),
|
||||
})?,
|
||||
CliAction::Workflows {
|
||||
args,
|
||||
output_format,
|
||||
} => LiveCli::print_workflows(args.as_deref(), output_format)?,
|
||||
CliAction::DeepResearch {
|
||||
question,
|
||||
output_format,
|
||||
} => LiveCli::run_deep_research_command(&question, output_format)?,
|
||||
CliAction::Background {
|
||||
prompt,
|
||||
exec,
|
||||
|
|
@ -1239,6 +1252,14 @@ enum CliAction {
|
|||
reasoning_effort: Option<String>,
|
||||
agent: Option<String>,
|
||||
},
|
||||
Workflows {
|
||||
args: Option<String>,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
DeepResearch {
|
||||
question: String,
|
||||
output_format: CliOutputFormat,
|
||||
},
|
||||
Background {
|
||||
prompt: Option<String>,
|
||||
exec: Option<String>,
|
||||
|
|
@ -1795,22 +1816,14 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
"--reasoning-effort" => {
|
||||
let value = args
|
||||
.get(index + 1)
|
||||
.ok_or_else(|| "missing_flag_value: missing value for --reasoning-effort.\nUsage: --reasoning-effort low|medium|high".to_string())?;
|
||||
if !matches!(value.as_str(), "low" | "medium" | "high") {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high"
|
||||
));
|
||||
}
|
||||
.ok_or_else(|| format!("missing_flag_value: missing value for --reasoning-effort.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"))?;
|
||||
validate_reasoning_effort_flag(value)?;
|
||||
reasoning_effort = Some(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
flag if flag.starts_with("--reasoning-effort=") => {
|
||||
let value = &flag[19..];
|
||||
if !matches!(value, "low" | "medium" | "high") {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: --reasoning-effort low|medium|high"
|
||||
));
|
||||
}
|
||||
validate_reasoning_effort_flag(value)?;
|
||||
reasoning_effort = Some(value.to_string());
|
||||
index += 1;
|
||||
}
|
||||
|
|
@ -2188,6 +2201,22 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
|
|||
reasoning_effort: reasoning_effort.clone(),
|
||||
agent: background_agent.clone(),
|
||||
}),
|
||||
"workflows" => Ok(CliAction::Workflows {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
}),
|
||||
"deep-research" => {
|
||||
let Some(question) = join_optional_args(&rest[1..]) else {
|
||||
return Err(
|
||||
"missing_argument: deep-research requires a question.\nUsage: claw deep-research <question>"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
Ok(CliAction::DeepResearch {
|
||||
question,
|
||||
output_format,
|
||||
})
|
||||
}
|
||||
"mcp" => Ok(CliAction::Mcp {
|
||||
args: join_optional_args(&rest[1..]),
|
||||
output_format,
|
||||
|
|
@ -2732,6 +2761,8 @@ fn bare_slash_command_guidance(command_name: &str) -> Option<String> {
|
|||
| "remove"
|
||||
| "daemon"
|
||||
| "agents"
|
||||
| "workflows"
|
||||
| "deep-research"
|
||||
| "mcp"
|
||||
| "plugin"
|
||||
| "plugins"
|
||||
|
|
@ -2970,6 +3001,28 @@ fn join_optional_args(args: &[String]) -> Option<String> {
|
|||
(!trimmed.is_empty()).then(|| trimmed.to_string())
|
||||
}
|
||||
|
||||
fn is_api_reasoning_effort(value: &str) -> bool {
|
||||
API_REASONING_EFFORT_VALUES.contains(&value)
|
||||
}
|
||||
|
||||
fn is_session_effort(value: &str) -> bool {
|
||||
SESSION_EFFORT_VALUES.contains(&value)
|
||||
}
|
||||
|
||||
fn validate_reasoning_effort_flag(value: &str) -> Result<(), String> {
|
||||
if is_api_reasoning_effort(value) {
|
||||
return Ok(());
|
||||
}
|
||||
if value == "ultracode" {
|
||||
return Err(format!(
|
||||
"invalid_flag_value: ultracode is session-scoped; use /effort ultracode inside the REPL.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"
|
||||
));
|
||||
}
|
||||
Err(format!(
|
||||
"invalid_flag_value: invalid value for --reasoning-effort: '{value}'.\nUsage: {REASONING_EFFORT_FLAG_USAGE}"
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments, clippy::needless_pass_by_value)]
|
||||
fn parse_direct_slash_cli_action(
|
||||
rest: &[String],
|
||||
|
|
@ -3007,6 +3060,17 @@ fn parse_direct_slash_cli_action(
|
|||
reasoning_effort,
|
||||
agent: None,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Workflows { args })) => Ok(CliAction::Workflows {
|
||||
args,
|
||||
output_format,
|
||||
}),
|
||||
Ok(Some(SlashCommand::DeepResearch { question })) => Ok(CliAction::DeepResearch {
|
||||
question: question.unwrap_or_default(),
|
||||
output_format,
|
||||
}),
|
||||
Ok(Some(SlashCommand::Effort { .. })) => Err(format!(
|
||||
"interactive_only: /effort is session-scoped.\nStart `claw` and run `{SESSION_EFFORT_USAGE}` inside the REPL."
|
||||
)),
|
||||
Ok(Some(SlashCommand::Mcp { action, target })) => Ok(CliAction::Mcp {
|
||||
args: match (action, target) {
|
||||
(None, None) => None,
|
||||
|
|
@ -3172,6 +3236,8 @@ fn suggest_similar_subcommand(input: &str) -> Option<Vec<String>> {
|
|||
"dump-manifests",
|
||||
"bootstrap-plan",
|
||||
"agents",
|
||||
"workflows",
|
||||
"deep-research",
|
||||
"mcp",
|
||||
"skills",
|
||||
"system-prompt",
|
||||
|
|
@ -3217,6 +3283,8 @@ fn is_known_top_level_subcommand(value: &str) -> bool {
|
|||
| "bootstrap-plan"
|
||||
| "agents"
|
||||
| "agent"
|
||||
| "workflows"
|
||||
| "deep-research"
|
||||
| "mcp"
|
||||
| "skills"
|
||||
| "skill"
|
||||
|
|
@ -7115,6 +7183,33 @@ fn run_resume_command(
|
|||
),
|
||||
})
|
||||
}
|
||||
SlashCommand::Workflows { args } => Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(render_workflows_report(args.as_deref())?),
|
||||
json: Some(workflows_report_json(args.as_deref())?),
|
||||
}),
|
||||
SlashCommand::DeepResearch { question } => {
|
||||
let question = question.as_deref().unwrap_or_default();
|
||||
let output = execute_tool(
|
||||
"Workflow",
|
||||
&json!({
|
||||
"name": "deep-research",
|
||||
"args": question,
|
||||
}),
|
||||
)?;
|
||||
let value = serde_json::from_str::<Value>(&output).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"kind": "workflow",
|
||||
"status": "error",
|
||||
"message": output,
|
||||
})
|
||||
});
|
||||
Ok(ResumeCommandOutcome {
|
||||
session: session.clone(),
|
||||
message: Some(format_workflow_tool_result(&value)),
|
||||
json: Some(value),
|
||||
})
|
||||
}
|
||||
SlashCommand::Skills { args } => {
|
||||
if let SkillSlashDispatch::Invoke(_) = classify_skills_slash_command(args.as_deref()) {
|
||||
// #779: use interactive_only: prefix + \n hint so #776 classify/split emits
|
||||
|
|
@ -7518,7 +7613,7 @@ fn run_live_cli_loop(cli: &mut LiveCli) -> Result<(), Box<dyn std::error::Error>
|
|||
}
|
||||
editor.push_history(input);
|
||||
cli.record_prompt_history(&trimmed);
|
||||
cli.run_turn(&trimmed)?;
|
||||
cli.run_user_input(&trimmed)?;
|
||||
}
|
||||
input::ReadOutcome::Cancel => {}
|
||||
input::ReadOutcome::Exit => {
|
||||
|
|
@ -7550,10 +7645,137 @@ struct ManagedSessionSummary {
|
|||
lifecycle: SessionLifecycleSummary,
|
||||
}
|
||||
|
||||
fn workflow_runtime_config_for_current_dir() -> runtime::RuntimeConfig {
|
||||
let Ok(cwd) = env::current_dir() else {
|
||||
return runtime::RuntimeConfig::empty();
|
||||
};
|
||||
ConfigLoader::default_for(&cwd)
|
||||
.load()
|
||||
.unwrap_or_else(|_| runtime::RuntimeConfig::empty())
|
||||
}
|
||||
|
||||
fn workflows_enabled_for_current_dir() -> bool {
|
||||
workflow_runtime_config_for_current_dir().workflows_enabled()
|
||||
}
|
||||
|
||||
fn workflow_keyword_trigger_enabled_for_current_dir() -> bool {
|
||||
workflow_runtime_config_for_current_dir().workflow_keyword_trigger_enabled()
|
||||
}
|
||||
|
||||
fn workflow_disabled_value() -> Value {
|
||||
json!({
|
||||
"kind": "workflow",
|
||||
"status": "disabled",
|
||||
"message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.",
|
||||
})
|
||||
}
|
||||
|
||||
fn contains_ultracode_keyword(input: &str) -> bool {
|
||||
input
|
||||
.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-')
|
||||
.any(|token| token.eq_ignore_ascii_case("ultracode"))
|
||||
}
|
||||
|
||||
fn strip_ultracode_keyword(input: &str) -> String {
|
||||
let stripped = input
|
||||
.split_whitespace()
|
||||
.filter(|token| {
|
||||
!token
|
||||
.trim_matches(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_' && ch != '-')
|
||||
.eq_ignore_ascii_case("ultracode")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if stripped.trim().is_empty() {
|
||||
input.trim().to_string()
|
||||
} else {
|
||||
stripped
|
||||
}
|
||||
}
|
||||
|
||||
fn looks_like_substantive_workflow_task(input: &str) -> bool {
|
||||
let lower = input.to_ascii_lowercase();
|
||||
let substantive_verbs = [
|
||||
"implement",
|
||||
"fix",
|
||||
"debug",
|
||||
"migrate",
|
||||
"upgrade",
|
||||
"refactor",
|
||||
"build",
|
||||
"create",
|
||||
"design",
|
||||
"analyze",
|
||||
"review",
|
||||
"test",
|
||||
"investigate",
|
||||
"research",
|
||||
"port",
|
||||
"integrate",
|
||||
];
|
||||
input.chars().count() >= 80
|
||||
|| substantive_verbs
|
||||
.iter()
|
||||
.any(|verb| lower.split_whitespace().any(|token| token == *verb))
|
||||
}
|
||||
|
||||
fn escape_workflow_template_text(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace("${", "\\${")
|
||||
}
|
||||
|
||||
fn ultracode_workflow_script(task: &str, trigger: &str) -> String {
|
||||
let task = escape_workflow_template_text(task);
|
||||
format!(
|
||||
"export const meta = {{ name: 'ultracode', description: 'Session-scoped ultracode workflow orchestration.' }}\n\nconst result = await agent(`Use ultracode workflow orchestration for this substantive coding task. Inspect the workspace, plan the implementation, make the necessary changes, and report verification status. Trigger: {trigger}.\\n\\nTask:\\n{task}`, {{ label: 'ultracode', agentType: 'Explore', effort: 'xhigh' }})\n\nreturn result\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn format_workflow_tool_result(value: &Value) -> String {
|
||||
let mut lines = vec![String::from("Workflow")];
|
||||
if let Some(status) = value.get("status").and_then(Value::as_str) {
|
||||
lines.push(format!(" Status {status}"));
|
||||
}
|
||||
if let Some(run_id) = value.get("runId").and_then(Value::as_str) {
|
||||
lines.push(format!(" Run {run_id}"));
|
||||
}
|
||||
if let Some(name) = value.get("name").and_then(Value::as_str) {
|
||||
lines.push(format!(" Name {name}"));
|
||||
}
|
||||
if let Some(source) = value.get("source").and_then(Value::as_str) {
|
||||
lines.push(format!(" Source {source}"));
|
||||
}
|
||||
if let Some(script_path) = value.get("scriptPath").and_then(Value::as_str) {
|
||||
lines.push(format!(" Script {script_path}"));
|
||||
}
|
||||
if let Some(agents) = value.get("agents").and_then(Value::as_array) {
|
||||
lines.push(format!(" Agents {}", agents.len()));
|
||||
for agent in agents.iter().take(5) {
|
||||
let label = agent
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("workflow-agent");
|
||||
let status = agent
|
||||
.get("status")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("queued");
|
||||
lines.push(format!(" - {label} ({status})"));
|
||||
}
|
||||
}
|
||||
if let Some(message) = value.get("message").and_then(Value::as_str) {
|
||||
lines.push(format!(" Message {message}"));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
struct LiveCli {
|
||||
model: String,
|
||||
allowed_tools: Option<AllowedToolSet>,
|
||||
permission_mode: PermissionMode,
|
||||
effort_mode: String,
|
||||
reasoning_effort: Option<String>,
|
||||
system_prompt: Vec<String>,
|
||||
runtime: BuiltRuntime,
|
||||
session: SessionHandle,
|
||||
|
|
@ -8960,6 +9182,8 @@ impl LiveCli {
|
|||
model,
|
||||
allowed_tools,
|
||||
permission_mode,
|
||||
effort_mode: "auto".to_string(),
|
||||
reasoning_effort: None,
|
||||
system_prompt,
|
||||
runtime,
|
||||
session,
|
||||
|
|
@ -8970,9 +9194,9 @@ impl LiveCli {
|
|||
}
|
||||
|
||||
fn set_reasoning_effort(&mut self, effort: Option<String>) {
|
||||
if let Some(rt) = self.runtime.runtime.as_mut() {
|
||||
rt.api_client_mut().set_reasoning_effort(effort);
|
||||
}
|
||||
self.effort_mode = effort.clone().unwrap_or_else(|| "auto".to_string());
|
||||
self.reasoning_effort = effort;
|
||||
Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone());
|
||||
}
|
||||
|
||||
fn startup_banner(&self) -> String {
|
||||
|
|
@ -9035,7 +9259,7 @@ impl LiveCli {
|
|||
emit_output: bool,
|
||||
) -> Result<(BuiltRuntime, HookAbortMonitor), Box<dyn std::error::Error>> {
|
||||
let hook_abort_signal = runtime::HookAbortSignal::new();
|
||||
let runtime = build_runtime(
|
||||
let mut runtime = build_runtime(
|
||||
self.runtime.session().clone(),
|
||||
&self.session.id,
|
||||
self.model.clone(),
|
||||
|
|
@ -9047,17 +9271,28 @@ impl LiveCli {
|
|||
None,
|
||||
)?
|
||||
.with_hook_abort_signal(hook_abort_signal.clone());
|
||||
Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone());
|
||||
let hook_abort_monitor = HookAbortMonitor::spawn(hook_abort_signal);
|
||||
|
||||
Ok((runtime, hook_abort_monitor))
|
||||
}
|
||||
|
||||
fn replace_runtime(&mut self, runtime: BuiltRuntime) -> Result<(), Box<dyn std::error::Error>> {
|
||||
fn replace_runtime(
|
||||
&mut self,
|
||||
mut runtime: BuiltRuntime,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
Self::apply_reasoning_effort(&mut runtime, self.reasoning_effort.clone());
|
||||
self.runtime.shutdown_plugins()?;
|
||||
self.runtime = runtime;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_reasoning_effort(runtime: &mut BuiltRuntime, effort: Option<String>) {
|
||||
if let Some(rt) = runtime.runtime.as_mut() {
|
||||
rt.api_client_mut().set_reasoning_effort(effort);
|
||||
}
|
||||
}
|
||||
|
||||
fn run_turn(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(true)?;
|
||||
let mut spinner = Spinner::new();
|
||||
|
|
@ -9290,6 +9525,9 @@ impl LiveCli {
|
|||
output_format: CliOutputFormat,
|
||||
compact: bool,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(trigger) = self.workflow_trigger_for_input(input) {
|
||||
return self.run_ultracode_workflow_with_output(input, trigger, output_format);
|
||||
}
|
||||
match output_format {
|
||||
CliOutputFormat::Json if compact => self.run_prompt_compact_json(input),
|
||||
CliOutputFormat::Text if compact => self.run_prompt_compact(input),
|
||||
|
|
@ -9298,6 +9536,53 @@ impl LiveCli {
|
|||
}
|
||||
}
|
||||
|
||||
fn run_user_input(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(trigger) = self.workflow_trigger_for_input(input) {
|
||||
return self.run_ultracode_workflow_with_output(input, trigger, CliOutputFormat::Text);
|
||||
}
|
||||
self.run_turn(input)
|
||||
}
|
||||
|
||||
fn workflow_trigger_for_input(&self, input: &str) -> Option<&'static str> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with('/') || !workflows_enabled_for_current_dir() {
|
||||
return None;
|
||||
}
|
||||
if contains_ultracode_keyword(trimmed) {
|
||||
return workflow_keyword_trigger_enabled_for_current_dir().then_some("keyword");
|
||||
}
|
||||
if self.effort_mode == "ultracode" && looks_like_substantive_workflow_task(trimmed) {
|
||||
return Some("effort");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn run_ultracode_workflow_with_output(
|
||||
&mut self,
|
||||
input: &str,
|
||||
trigger: &'static str,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let task = if trigger == "keyword" {
|
||||
strip_ultracode_keyword(input)
|
||||
} else {
|
||||
input.trim().to_string()
|
||||
};
|
||||
let script = ultracode_workflow_script(&task, trigger);
|
||||
let value = Self::execute_workflow_tool_json(&json!({
|
||||
"script": script,
|
||||
"args": {
|
||||
"prompt": task,
|
||||
"trigger": trigger,
|
||||
},
|
||||
}))?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
|
||||
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_prompt_compact(&mut self, input: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let (mut runtime, hook_abort_monitor) = self.prepare_turn_runtime(false)?;
|
||||
let mut permission_prompter = CliPermissionPrompter::new(self.permission_mode);
|
||||
|
|
@ -9485,6 +9770,24 @@ impl LiveCli {
|
|||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Workflows { args } => {
|
||||
if let Err(error) = Self::print_workflows(args.as_deref(), CliOutputFormat::Text) {
|
||||
eprintln!("{error}");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::DeepResearch { question } => {
|
||||
if let Some(question) = question {
|
||||
if let Err(error) =
|
||||
Self::run_deep_research_command(&question, CliOutputFormat::Text)
|
||||
{
|
||||
eprintln!("{error}");
|
||||
}
|
||||
} else {
|
||||
eprintln!("Usage: /deep-research <question>");
|
||||
}
|
||||
false
|
||||
}
|
||||
SlashCommand::Skills { args } => {
|
||||
match classify_skills_slash_command(args.as_deref()) {
|
||||
SkillSlashDispatch::Invoke(prompt) => self.run_turn(&prompt)?,
|
||||
|
|
@ -9524,6 +9827,7 @@ impl LiveCli {
|
|||
println!("{}", format_cost_report(usage));
|
||||
false
|
||||
}
|
||||
SlashCommand::Effort { level } => self.handle_effort_command(level.as_deref())?,
|
||||
SlashCommand::Review { scope } => {
|
||||
let scope = scope.unwrap_or_else(|| "medium".to_string());
|
||||
self.run_turn(&format!("$code-review {scope}"))?;
|
||||
|
|
@ -9559,7 +9863,6 @@ impl LiveCli {
|
|||
| SlashCommand::Hooks { .. }
|
||||
| SlashCommand::Context { .. }
|
||||
| SlashCommand::Color { .. }
|
||||
| SlashCommand::Effort { .. }
|
||||
| SlashCommand::Branch { .. }
|
||||
| SlashCommand::Rewind { .. }
|
||||
| SlashCommand::Ide { .. }
|
||||
|
|
@ -9882,6 +10185,59 @@ impl LiveCli {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn print_workflows(
|
||||
args: Option<&str>,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !workflows_enabled_for_current_dir() {
|
||||
let value = workflow_disabled_value();
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
|
||||
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
match output_format {
|
||||
CliOutputFormat::Text => {
|
||||
println!(
|
||||
"{}",
|
||||
render_workflows_report(args).map_err(io::Error::other)?
|
||||
)
|
||||
}
|
||||
CliOutputFormat::Json => {
|
||||
let value = workflows_report_json(args).map_err(io::Error::other)?;
|
||||
println!("{}", serde_json::to_string_pretty(&value)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_deep_research_command(
|
||||
question: &str,
|
||||
output_format: CliOutputFormat,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let value = Self::execute_workflow_tool_json(&json!({
|
||||
"name": "deep-research",
|
||||
"args": question,
|
||||
}))?;
|
||||
match output_format {
|
||||
CliOutputFormat::Text => println!("{}", format_workflow_tool_result(&value)),
|
||||
CliOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&value)?),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn execute_workflow_tool_json(input: &Value) -> Result<Value, Box<dyn std::error::Error>> {
|
||||
let output = execute_tool("Workflow", input).map_err(io::Error::other)?;
|
||||
Ok(serde_json::from_str(&output).unwrap_or_else(|_| {
|
||||
json!({
|
||||
"kind": "workflow",
|
||||
"status": "error",
|
||||
"message": output,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
fn print_mcp(
|
||||
args: Option<&str>,
|
||||
output_format: CliOutputFormat,
|
||||
|
|
@ -9936,6 +10292,57 @@ impl LiveCli {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_effort_command(
|
||||
&mut self,
|
||||
level: Option<&str>,
|
||||
) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let Some(raw_level) = level.map(str::trim).filter(|value| !value.is_empty()) else {
|
||||
println!(
|
||||
"Effort\n Current mode {}\n API reasoning {}\n Usage {}",
|
||||
self.effort_mode,
|
||||
self.reasoning_effort.as_deref().unwrap_or("auto"),
|
||||
SESSION_EFFORT_USAGE
|
||||
);
|
||||
return Ok(false);
|
||||
};
|
||||
let normalized = raw_level.to_ascii_lowercase();
|
||||
if !is_session_effort(&normalized) {
|
||||
println!(
|
||||
"Effort\n Status error\n Message unsupported effort level '{}'\n Usage {}",
|
||||
raw_level, SESSION_EFFORT_USAGE
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
if normalized == "ultracode" && !workflows_enabled_for_current_dir() {
|
||||
println!(
|
||||
"{}",
|
||||
format_workflow_tool_result(&workflow_disabled_value())
|
||||
);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let previous = self.effort_mode.clone();
|
||||
self.effort_mode = normalized.clone();
|
||||
self.reasoning_effort = match normalized.as_str() {
|
||||
"auto" => None,
|
||||
"ultracode" => Some("xhigh".to_string()),
|
||||
other => Some(other.to_string()),
|
||||
};
|
||||
Self::apply_reasoning_effort(&mut self.runtime, self.reasoning_effort.clone());
|
||||
println!(
|
||||
"Effort\n Previous mode {}\n Current mode {}\n API reasoning {}\n Workflow mode {}",
|
||||
previous,
|
||||
self.effort_mode,
|
||||
self.reasoning_effort.as_deref().unwrap_or("auto"),
|
||||
if self.effort_mode == "ultracode" {
|
||||
"enabled"
|
||||
} else {
|
||||
"off"
|
||||
}
|
||||
);
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn print_plugins(
|
||||
action: Option<&str>,
|
||||
target: Option<&str>,
|
||||
|
|
@ -10955,6 +11362,8 @@ fn run_attached_agent_session(
|
|||
model,
|
||||
allowed_tools: None,
|
||||
permission_mode,
|
||||
effort_mode: "auto".to_string(),
|
||||
reasoning_effort: None,
|
||||
system_prompt,
|
||||
runtime,
|
||||
session: SessionHandle {
|
||||
|
|
@ -15217,7 +15626,6 @@ const STUB_COMMANDS: &[&str] = &[
|
|||
"hooks",
|
||||
"context",
|
||||
"color",
|
||||
"effort",
|
||||
"branch",
|
||||
"rewind",
|
||||
"ide",
|
||||
|
|
@ -15325,6 +15733,9 @@ fn slash_command_completion_candidates_with_sessions(
|
|||
"/config hooks",
|
||||
"/config model",
|
||||
"/config plugins",
|
||||
"/deep-research ",
|
||||
"/effort ",
|
||||
"/effort ultracode",
|
||||
"/mcp ",
|
||||
"/mcp list",
|
||||
"/mcp show ",
|
||||
|
|
@ -15352,6 +15763,8 @@ fn slash_command_completion_candidates_with_sessions(
|
|||
"/session fork ",
|
||||
"/teleport ",
|
||||
"/ultraplan ",
|
||||
"/workflows ",
|
||||
"/workflows show ",
|
||||
"/agents help",
|
||||
"/mcp help",
|
||||
"/skills help",
|
||||
|
|
@ -19113,6 +19526,31 @@ mod tests {
|
|||
output_format: CliOutputFormat::Json,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"--output-format=json".to_string(),
|
||||
"/workflows".to_string(),
|
||||
"show".to_string(),
|
||||
"workflow-123".to_string(),
|
||||
])
|
||||
.expect("json /workflows show should parse"),
|
||||
CliAction::Workflows {
|
||||
args: Some("show workflow-123".to_string()),
|
||||
output_format: CliOutputFormat::Json,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_args(&[
|
||||
"--output-format=json".to_string(),
|
||||
"/deep-research".to_string(),
|
||||
"workflow docs".to_string(),
|
||||
])
|
||||
.expect("json /deep-research should parse"),
|
||||
CliAction::DeepResearch {
|
||||
question: "workflow docs".to_string(),
|
||||
output_format: CliOutputFormat::Json,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -19693,6 +20131,9 @@ mod tests {
|
|||
assert!(completions.contains(&"/resume session-old".to_string()));
|
||||
assert!(completions.contains(&"/mcp list".to_string()));
|
||||
assert!(completions.contains(&"/ultraplan ".to_string()));
|
||||
assert!(completions.contains(&"/workflows ".to_string()));
|
||||
assert!(completions.contains(&"/deep-research ".to_string()));
|
||||
assert!(completions.contains(&"/effort ultracode".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -21778,7 +22219,7 @@ UU conflicted.rs",
|
|||
|
||||
#[test]
|
||||
fn accepts_valid_reasoning_effort_values() {
|
||||
for value in ["low", "medium", "high"] {
|
||||
for value in ["low", "medium", "high", "xhigh", "max"] {
|
||||
let result = parse_args(&[
|
||||
"--reasoning-effort".to_string(),
|
||||
value.to_string(),
|
||||
|
|
@ -21798,6 +22239,19 @@ UU conflicted.rs",
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_ultracode_as_persistent_reasoning_effort_flag() {
|
||||
let err = parse_args(&[
|
||||
"--reasoning-effort".to_string(),
|
||||
"ultracode".to_string(),
|
||||
"prompt".to_string(),
|
||||
"hello".to_string(),
|
||||
])
|
||||
.unwrap_err();
|
||||
assert!(err.contains("session-scoped"), "unexpected error: {err}");
|
||||
assert!(err.contains("/effort ultracode"), "unexpected error: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_commands_absent_from_repl_completions() {
|
||||
let candidates =
|
||||
|
|
|
|||
|
|
@ -1254,7 +1254,7 @@ pub fn mvp_tool_specs() -> Vec<ToolSpec> {
|
|||
},
|
||||
ToolSpec {
|
||||
name: "Workflow",
|
||||
description: "Run a deterministic multi-agent workflow script. v2.1.201 contract surface; local execution is reported as unsupported unless a workflow runner is configured.",
|
||||
description: "Run a deterministic multi-agent workflow script by resolving inline script, saved workflow name, or scriptPath and spawning workflow agents in the background.",
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
|
@ -2255,18 +2255,687 @@ fn run_remote_trigger(input: RemoteTriggerInput) -> Result<String, String> {
|
|||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_workflow(input: WorkflowInput) -> Result<String, String> {
|
||||
to_pretty_json(json!({
|
||||
"status": "unsupported",
|
||||
"kind": "workflow",
|
||||
"message": "Workflow tool contract is registered for Claude Code v2.1.201 compatibility, but this local runtime does not execute workflow JavaScript yet.",
|
||||
"name": input.name,
|
||||
"has_script": input.script.as_ref().is_some_and(|script| !script.trim().is_empty()),
|
||||
"scriptPath": input.script_path,
|
||||
"resumeFromRunId": input.resume_from_run_id,
|
||||
"args": input.args,
|
||||
run_workflow_with_spawn(input, spawn_agent_job)
|
||||
}
|
||||
|
||||
fn run_workflow_with_spawn<F>(input: WorkflowInput, spawn_fn: F) -> Result<String, String>
|
||||
where
|
||||
F: Fn(AgentJob) -> Result<(), String> + Copy,
|
||||
{
|
||||
if !workflow_feature_enabled() {
|
||||
return to_pretty_json(json!({
|
||||
"status": "disabled",
|
||||
"kind": "workflow",
|
||||
"message": "Dynamic workflows are disabled by settings or CLAUDE_CODE_DISABLE_WORKFLOWS.",
|
||||
"name": input.name,
|
||||
"scriptPath": input.script_path,
|
||||
"resumeFromRunId": input.resume_from_run_id,
|
||||
"args": input.args,
|
||||
}));
|
||||
}
|
||||
|
||||
if input.resume_from_run_id.is_some() {
|
||||
return to_pretty_json(json!({
|
||||
"status": "resume_requested",
|
||||
"kind": "workflow",
|
||||
"message": "This local workflow runner records run state, but resumeFromRunId replay is not implemented yet. Relaunching requires the original scriptPath.",
|
||||
"resumeFromRunId": input.resume_from_run_id,
|
||||
"scriptPath": input.script_path,
|
||||
"name": input.name,
|
||||
"args": input.args,
|
||||
}));
|
||||
}
|
||||
|
||||
let resolved = resolve_workflow_script(input)?;
|
||||
let run = start_workflow_run(resolved, spawn_fn)?;
|
||||
to_pretty_json(serde_json::to_value(run).map_err(|error| error.to_string())?)
|
||||
}
|
||||
|
||||
fn workflow_feature_enabled() -> bool {
|
||||
if workflow_env_disabled() {
|
||||
return false;
|
||||
}
|
||||
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
|
||||
ConfigLoader::default_for(cwd)
|
||||
.load()
|
||||
.map_or(true, |config| config.workflows_enabled())
|
||||
}
|
||||
|
||||
fn workflow_env_disabled() -> bool {
|
||||
std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").is_ok_and(|value| {
|
||||
matches!(
|
||||
value.trim().to_ascii_lowercase().as_str(),
|
||||
"1" | "true" | "yes" | "on"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_workflow_script(input: WorkflowInput) -> Result<ResolvedWorkflowScript, String> {
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
let args = input.args;
|
||||
let requested_name = input
|
||||
.name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(ToOwned::to_owned);
|
||||
|
||||
if let Some(script) = input
|
||||
.script
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|script| !script.is_empty())
|
||||
{
|
||||
let name = requested_name
|
||||
.or_else(|| parse_workflow_meta_string(script, "name"))
|
||||
.unwrap_or_else(|| String::from("inline-workflow"));
|
||||
return Ok(ResolvedWorkflowScript {
|
||||
name,
|
||||
description: parse_workflow_meta_string(script, "description"),
|
||||
script: script.to_string(),
|
||||
script_path: None,
|
||||
source: String::from("inline"),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(script_path) = input
|
||||
.script_path
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|path| !path.is_empty())
|
||||
{
|
||||
let path = resolve_workflow_script_path(script_path, &cwd)?;
|
||||
let script = read_workflow_script_file(&path)?;
|
||||
let name = requested_name
|
||||
.or_else(|| parse_workflow_meta_string(&script, "name"))
|
||||
.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.unwrap_or_else(|| String::from("workflow"));
|
||||
return Ok(ResolvedWorkflowScript {
|
||||
name,
|
||||
description: parse_workflow_meta_string(&script, "description"),
|
||||
script,
|
||||
script_path: Some(path),
|
||||
source: String::from("scriptPath"),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
let Some(name) = requested_name else {
|
||||
return Err(String::from("Must provide script, name, or scriptPath"));
|
||||
};
|
||||
|
||||
if name == "deep-research" {
|
||||
let script = builtin_deep_research_script(args.as_ref());
|
||||
return Ok(ResolvedWorkflowScript {
|
||||
name,
|
||||
description: Some(String::from(
|
||||
"Deep research harness that fans out source discovery and verification.",
|
||||
)),
|
||||
script,
|
||||
script_path: None,
|
||||
source: String::from("built-in"),
|
||||
args,
|
||||
});
|
||||
}
|
||||
|
||||
let path = find_saved_workflow_script(&name, &cwd)?;
|
||||
let script = read_workflow_script_file(&path)?;
|
||||
Ok(ResolvedWorkflowScript {
|
||||
name: parse_workflow_meta_string(&script, "name").unwrap_or(name),
|
||||
description: parse_workflow_meta_string(&script, "description"),
|
||||
script,
|
||||
script_path: Some(path),
|
||||
source: String::from("saved"),
|
||||
args,
|
||||
})
|
||||
}
|
||||
|
||||
fn start_workflow_run<F>(
|
||||
workflow: ResolvedWorkflowScript,
|
||||
spawn_fn: F,
|
||||
) -> Result<WorkflowRunOutput, String>
|
||||
where
|
||||
F: Fn(AgentJob) -> Result<(), String> + Copy,
|
||||
{
|
||||
let run_id = make_workflow_run_id();
|
||||
let created_at = iso8601_now();
|
||||
let store_dir = workflow_store_dir()?;
|
||||
std::fs::create_dir_all(&store_dir).map_err(|error| error.to_string())?;
|
||||
let script_path = persist_workflow_script_for_run(&store_dir, &run_id, &workflow)?;
|
||||
let manifest_file = store_dir.join(format!("{run_id}.json"));
|
||||
let calls = parse_workflow_agent_calls(&workflow.script);
|
||||
|
||||
let mut agents = Vec::new();
|
||||
for (index, call) in calls.into_iter().enumerate() {
|
||||
let description = call
|
||||
.label
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("{} phase {}", workflow.name, index + 1));
|
||||
let manifest = execute_agent_with_spawn(
|
||||
AgentInput {
|
||||
description: description.clone(),
|
||||
prompt: workflow_agent_prompt(&workflow, &call),
|
||||
subagent_type: call
|
||||
.agent_type
|
||||
.or_else(|| Some(String::from("general-purpose"))),
|
||||
name: Some(format!("{}-{}", workflow.name, index + 1)),
|
||||
model: call.model,
|
||||
effort: call.effort,
|
||||
run_in_background: Some(true),
|
||||
isolation: call.isolation,
|
||||
},
|
||||
spawn_fn,
|
||||
)?;
|
||||
agents.push(WorkflowAgentRecord {
|
||||
agent_id: manifest.agent_id,
|
||||
name: manifest.name,
|
||||
description,
|
||||
status: manifest.status,
|
||||
prompt_preview: preview_text(&call.prompt, 160),
|
||||
manifest_file: manifest.manifest_file,
|
||||
});
|
||||
}
|
||||
|
||||
let (status, message) = if agents.is_empty() {
|
||||
(
|
||||
String::from("script_error"),
|
||||
String::from(
|
||||
"Workflow script did not contain a static agent(...) call this local runner can execute.",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
(
|
||||
String::from("running"),
|
||||
format!(
|
||||
"Running in background: {} agent(s). Use /workflows to monitor and save.",
|
||||
agents.len()
|
||||
),
|
||||
)
|
||||
};
|
||||
|
||||
let run = WorkflowRunOutput {
|
||||
run_id,
|
||||
kind: String::from("workflow"),
|
||||
status,
|
||||
name: workflow.name,
|
||||
description: workflow.description,
|
||||
source: workflow.source,
|
||||
script_path: script_path.display().to_string(),
|
||||
manifest_file: manifest_file.display().to_string(),
|
||||
created_at,
|
||||
agents,
|
||||
args: workflow.args,
|
||||
message,
|
||||
};
|
||||
write_workflow_run_manifest(&run)?;
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
fn workflow_agent_prompt(workflow: &ResolvedWorkflowScript, call: &WorkflowAgentCall) -> String {
|
||||
let args = workflow
|
||||
.args
|
||||
.as_ref()
|
||||
.map(|value| format!("\n\nWorkflow args:\n{value}"))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
"You are a subagent spawned by workflow `{}`. Complete this workflow step and return a concise result.\n\n{}{}",
|
||||
workflow.name, call.prompt, args
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_workflow_script_path(raw: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||
if raw.starts_with("\\\\") {
|
||||
return Err(format!(
|
||||
"UNC paths are not allowed for workflow scriptPath: {raw}"
|
||||
));
|
||||
}
|
||||
let path = PathBuf::from(raw);
|
||||
if path.is_absolute() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Ok(cwd.join(path))
|
||||
}
|
||||
}
|
||||
|
||||
const WORKFLOW_SCRIPT_MAX_BYTES: u64 = 1024 * 1024;
|
||||
|
||||
fn read_workflow_script_file(path: &Path) -> Result<String, String> {
|
||||
let metadata = std::fs::metadata(path)
|
||||
.map_err(|_| format!("Workflow script file not found: {}", path.display()))?;
|
||||
if metadata.len() > WORKFLOW_SCRIPT_MAX_BYTES {
|
||||
return Err(format!(
|
||||
"Workflow script file {} exceeds {} bytes",
|
||||
path.display(),
|
||||
WORKFLOW_SCRIPT_MAX_BYTES
|
||||
));
|
||||
}
|
||||
let bytes = std::fs::read(path).map_err(|error| {
|
||||
format!(
|
||||
"Failed to read workflow script file {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
String::from_utf8(bytes).map_err(|error| {
|
||||
format!(
|
||||
"Workflow script file {} is not utf-8: {error}",
|
||||
path.display()
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn persist_workflow_script_for_run(
|
||||
store_dir: &Path,
|
||||
run_id: &str,
|
||||
workflow: &ResolvedWorkflowScript,
|
||||
) -> Result<PathBuf, String> {
|
||||
if let Some(path) = &workflow.script_path {
|
||||
return Ok(path.clone());
|
||||
}
|
||||
let script_dir = store_dir.join("scripts");
|
||||
std::fs::create_dir_all(&script_dir).map_err(|error| error.to_string())?;
|
||||
let path = script_dir.join(format!("{run_id}.js"));
|
||||
std::fs::write(&path, &workflow.script).map_err(|error| {
|
||||
format!(
|
||||
"Failed to persist workflow script to {}: {error}",
|
||||
path.display()
|
||||
)
|
||||
})?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
fn write_workflow_run_manifest(run: &WorkflowRunOutput) -> Result<(), String> {
|
||||
std::fs::write(
|
||||
&run.manifest_file,
|
||||
serde_json::to_string_pretty(run).map_err(|error| error.to_string())?,
|
||||
)
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn make_workflow_run_id() -> String {
|
||||
let nanos = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
format!("workflow-{nanos}")
|
||||
}
|
||||
|
||||
fn workflow_store_dir() -> Result<PathBuf, String> {
|
||||
if let Ok(path) = std::env::var("CLAWD_WORKFLOW_STORE") {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
let cwd = std::env::current_dir().map_err(|error| error.to_string())?;
|
||||
if let Some(workspace_root) = cwd.ancestors().nth(2) {
|
||||
return Ok(workspace_root.join(".clawd-workflows"));
|
||||
}
|
||||
Ok(cwd.join(".clawd-workflows"))
|
||||
}
|
||||
|
||||
fn parse_workflow_agent_calls(script: &str) -> Vec<WorkflowAgentCall> {
|
||||
let mut calls = Vec::new();
|
||||
let mut offset = 0;
|
||||
while let Some(relative) = script[offset..].find("agent(") {
|
||||
let start = offset + relative + "agent(".len();
|
||||
let Some((prompt, consumed)) = extract_js_string_literal(&script[start..]) else {
|
||||
offset = start;
|
||||
continue;
|
||||
};
|
||||
let options = &script[start + consumed..script.len().min(start + consumed + 1024)];
|
||||
calls.push(WorkflowAgentCall {
|
||||
prompt,
|
||||
label: parse_workflow_option_string(options, "label"),
|
||||
model: parse_workflow_option_string(options, "model"),
|
||||
effort: parse_workflow_option_string(options, "effort"),
|
||||
agent_type: parse_workflow_option_string(options, "agentType")
|
||||
.or_else(|| parse_workflow_option_string(options, "subagent_type")),
|
||||
isolation: parse_workflow_option_string(options, "isolation"),
|
||||
});
|
||||
offset = start + consumed;
|
||||
}
|
||||
calls
|
||||
}
|
||||
|
||||
fn extract_js_string_literal(input: &str) -> Option<(String, usize)> {
|
||||
let trimmed_start = input.len() - input.trim_start().len();
|
||||
let mut chars = input[trimmed_start..].char_indices();
|
||||
let (_, quote) = chars.next()?;
|
||||
if !matches!(quote, '\'' | '"' | '`') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut escaped = false;
|
||||
let mut value = String::new();
|
||||
for (index, ch) in chars {
|
||||
if escaped {
|
||||
value.push(ch);
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if ch == '\\' {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ch == quote {
|
||||
let consumed = trimmed_start + index + ch.len_utf8();
|
||||
return Some((normalize_workflow_template_literal(&value), consumed));
|
||||
}
|
||||
value.push(ch);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_workflow_template_literal(value: &str) -> String {
|
||||
value.replace("${", "{")
|
||||
}
|
||||
|
||||
fn parse_workflow_option_string(input: &str, key: &str) -> Option<String> {
|
||||
let mut offset = 0;
|
||||
while let Some(relative) = input[offset..].find(key) {
|
||||
let key_start = offset + relative;
|
||||
let key_end = key_start + key.len();
|
||||
let before_ok = key_start == 0
|
||||
|| !input[..key_start]
|
||||
.chars()
|
||||
.next_back()
|
||||
.is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_');
|
||||
let after_ok = input[key_end..]
|
||||
.chars()
|
||||
.next()
|
||||
.is_none_or(|ch| !(ch.is_ascii_alphanumeric() || ch == '_'));
|
||||
if before_ok && after_ok {
|
||||
let after_key = &input[key_end..];
|
||||
let colon_index = after_key.find(':')?;
|
||||
let after_colon = &after_key[colon_index + 1..];
|
||||
return extract_js_string_literal(after_colon).map(|(value, _)| value);
|
||||
}
|
||||
offset = key_end;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_workflow_meta_string(script: &str, key: &str) -> Option<String> {
|
||||
parse_workflow_option_string(script, key)
|
||||
}
|
||||
|
||||
fn builtin_deep_research_script(args: Option<&Value>) -> String {
|
||||
let question =
|
||||
workflow_args_to_prompt(args).unwrap_or_else(|| String::from("the requested topic"));
|
||||
let question = escape_workflow_template_text(&question);
|
||||
format!(
|
||||
"export const meta = {{ name: 'deep-research', description: 'Deep research harness - fan out source discovery, verification, and synthesis.' }}\n\nconst report = await agent(`Research this question deeply using available search and fetch tools, cross-check claims, and return a cited report: {question}`, {{ label: 'deep-research', agentType: 'Explore', effort: 'high' }})\n\nreturn report\n"
|
||||
)
|
||||
}
|
||||
|
||||
fn workflow_args_to_prompt(args: Option<&Value>) -> Option<String> {
|
||||
match args {
|
||||
Some(Value::String(value)) if !value.trim().is_empty() => Some(value.trim().to_string()),
|
||||
Some(value) if !value.is_null() => Some(value.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn escape_workflow_template_text(value: &str) -> String {
|
||||
value
|
||||
.replace('\\', "\\\\")
|
||||
.replace('`', "\\`")
|
||||
.replace("${", "\\${")
|
||||
}
|
||||
|
||||
fn find_saved_workflow_script(name: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||||
for root in workflow_lookup_roots(cwd) {
|
||||
if let Some(path) = find_workflow_in_root(&root, name) {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
let available = list_saved_workflow_names(cwd);
|
||||
let available = if available.is_empty() {
|
||||
String::from("(none)")
|
||||
} else {
|
||||
available.join(", ")
|
||||
};
|
||||
Err(format!(
|
||||
"Workflow \"{name}\" not found. Available: {available}"
|
||||
))
|
||||
}
|
||||
|
||||
fn workflow_lookup_roots(cwd: &Path) -> Vec<PathBuf> {
|
||||
let mut roots = Vec::new();
|
||||
for ancestor in cwd.ancestors() {
|
||||
roots.push(ancestor.join(".claude").join("workflows"));
|
||||
roots.push(ancestor.join(".claw").join("workflows"));
|
||||
}
|
||||
if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
|
||||
let home = PathBuf::from(home);
|
||||
roots.push(home.join(".claude").join("workflows"));
|
||||
roots.push(home.join(".claw").join("workflows"));
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn find_workflow_in_root(root: &Path, requested: &str) -> Option<PathBuf> {
|
||||
let candidates = [
|
||||
root.join(format!("{requested}.js")),
|
||||
root.join(format!("{requested}.mjs")),
|
||||
root.join(requested),
|
||||
];
|
||||
for candidate in candidates {
|
||||
if candidate.is_file() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
for entry in std::fs::read_dir(root).ok()?.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let extension_ok = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| matches!(ext, "js" | "mjs"));
|
||||
if !extension_ok {
|
||||
continue;
|
||||
}
|
||||
let matches_stem = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.is_some_and(|stem| stem.eq_ignore_ascii_case(requested));
|
||||
if matches_stem {
|
||||
return Some(path);
|
||||
}
|
||||
if read_workflow_script_file(&path)
|
||||
.ok()
|
||||
.and_then(|script| parse_workflow_meta_string(&script, "name"))
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case(requested))
|
||||
{
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn list_saved_workflow_names(cwd: &Path) -> Vec<String> {
|
||||
let mut names = BTreeSet::from([String::from("deep-research")]);
|
||||
for root in workflow_lookup_roots(cwd) {
|
||||
let Ok(entries) = std::fs::read_dir(root) else {
|
||||
continue;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
let extension_ok = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| matches!(ext, "js" | "mjs"));
|
||||
if !extension_ok {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = read_workflow_script_file(&path)
|
||||
.ok()
|
||||
.and_then(|script| parse_workflow_meta_string(&script, "name"))
|
||||
.or_else(|| {
|
||||
path.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
{
|
||||
names.insert(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
names.into_iter().collect()
|
||||
}
|
||||
|
||||
pub fn render_workflows_report(args: Option<&str>) -> Result<String, String> {
|
||||
let value = workflows_report_json(args)?;
|
||||
if value["status"] == "empty" {
|
||||
return Ok(String::from(
|
||||
"Workflows\n Status no workflow runs found\n Hint run /deep-research <question> or ask with the ultracode keyword",
|
||||
));
|
||||
}
|
||||
if value["action"] == "show" {
|
||||
return Ok(format_workflow_detail_report(&value));
|
||||
}
|
||||
Ok(format_workflow_list_report(&value))
|
||||
}
|
||||
|
||||
pub fn workflows_report_json(args: Option<&str>) -> Result<Value, String> {
|
||||
let requested = args.map(str::trim).filter(|value| !value.is_empty());
|
||||
let runs = load_workflow_run_manifests()?;
|
||||
if let Some(requested) = requested {
|
||||
let requested = requested
|
||||
.strip_prefix("show ")
|
||||
.or_else(|| requested.strip_prefix("open "))
|
||||
.unwrap_or(requested)
|
||||
.trim();
|
||||
let Some(run) = runs.iter().find(|run| run.run_id == requested) else {
|
||||
return Ok(json!({
|
||||
"kind": "workflows",
|
||||
"action": "show",
|
||||
"status": "not_found",
|
||||
"runId": requested,
|
||||
"available": runs.iter().map(|run| run.run_id.clone()).collect::<Vec<_>>(),
|
||||
}));
|
||||
};
|
||||
return Ok(json!({
|
||||
"kind": "workflows",
|
||||
"action": "show",
|
||||
"status": "ok",
|
||||
"run": run,
|
||||
}));
|
||||
}
|
||||
if runs.is_empty() {
|
||||
return Ok(json!({
|
||||
"kind": "workflows",
|
||||
"action": "list",
|
||||
"status": "empty",
|
||||
"runs": [],
|
||||
}));
|
||||
}
|
||||
Ok(json!({
|
||||
"kind": "workflows",
|
||||
"action": "list",
|
||||
"status": "ok",
|
||||
"count": runs.len(),
|
||||
"runs": runs,
|
||||
}))
|
||||
}
|
||||
|
||||
fn load_workflow_run_manifests() -> Result<Vec<WorkflowRunOutput>, String> {
|
||||
let store_dir = workflow_store_dir()?;
|
||||
let Ok(entries) = std::fs::read_dir(store_dir) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut runs = Vec::new();
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
|
||||
continue;
|
||||
}
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
if let Ok(run) = serde_json::from_str::<WorkflowRunOutput>(&contents) {
|
||||
runs.push(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
runs.sort_by(|left, right| right.created_at.cmp(&left.created_at));
|
||||
Ok(runs)
|
||||
}
|
||||
|
||||
fn format_workflow_list_report(value: &Value) -> String {
|
||||
let mut lines = vec![format!(
|
||||
"Workflows\n Count {}",
|
||||
value["count"].as_u64().unwrap_or(0)
|
||||
)];
|
||||
if let Some(runs) = value["runs"].as_array() {
|
||||
for run in runs {
|
||||
lines.push(format!(
|
||||
" {} {} {} {} agent(s)",
|
||||
run["runId"].as_str().unwrap_or("<unknown>"),
|
||||
run["status"].as_str().unwrap_or("unknown"),
|
||||
run["name"].as_str().unwrap_or("workflow"),
|
||||
run["agents"].as_array().map_or(0, Vec::len)
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn format_workflow_detail_report(value: &Value) -> String {
|
||||
if value["status"] == "not_found" {
|
||||
return format!(
|
||||
"Workflows\n Status not found\n Run {}",
|
||||
value["runId"].as_str().unwrap_or("<unknown>")
|
||||
);
|
||||
}
|
||||
let run = &value["run"];
|
||||
let mut lines = vec![
|
||||
String::from("Workflow"),
|
||||
format!(
|
||||
" Run {}",
|
||||
run["runId"].as_str().unwrap_or("<unknown>")
|
||||
),
|
||||
format!(
|
||||
" Name {}",
|
||||
run["name"].as_str().unwrap_or("workflow")
|
||||
),
|
||||
format!(
|
||||
" Status {}",
|
||||
run["status"].as_str().unwrap_or("unknown")
|
||||
),
|
||||
format!(
|
||||
" Source {}",
|
||||
run["source"].as_str().unwrap_or("unknown")
|
||||
),
|
||||
format!(
|
||||
" Script {}",
|
||||
run["scriptPath"].as_str().unwrap_or("<unknown>")
|
||||
),
|
||||
format!(
|
||||
" Message {}",
|
||||
run["message"].as_str().unwrap_or("")
|
||||
),
|
||||
];
|
||||
if let Some(agents) = run["agents"].as_array() {
|
||||
lines.push(format!(" Agents {}", agents.len()));
|
||||
for agent in agents {
|
||||
lines.push(format!(
|
||||
" {} {} {}",
|
||||
agent["agentId"].as_str().unwrap_or("<unknown>"),
|
||||
agent["status"].as_str().unwrap_or("unknown"),
|
||||
agent["description"].as_str().unwrap_or("")
|
||||
));
|
||||
}
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
fn run_monitor(input: MonitorInput) -> Result<String, String> {
|
||||
to_pretty_json(json!({
|
||||
|
|
@ -3487,6 +4156,62 @@ struct WorkflowInput {
|
|||
args: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ResolvedWorkflowScript {
|
||||
name: String,
|
||||
description: Option<String>,
|
||||
script: String,
|
||||
script_path: Option<PathBuf>,
|
||||
source: String,
|
||||
args: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WorkflowAgentCall {
|
||||
prompt: String,
|
||||
label: Option<String>,
|
||||
model: Option<String>,
|
||||
effort: Option<String>,
|
||||
agent_type: Option<String>,
|
||||
isolation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WorkflowRunOutput {
|
||||
#[serde(rename = "runId")]
|
||||
run_id: String,
|
||||
kind: String,
|
||||
status: String,
|
||||
name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
description: Option<String>,
|
||||
source: String,
|
||||
#[serde(rename = "scriptPath")]
|
||||
script_path: String,
|
||||
#[serde(rename = "manifestFile")]
|
||||
manifest_file: String,
|
||||
#[serde(rename = "createdAt")]
|
||||
created_at: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
agents: Vec<WorkflowAgentRecord>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
args: Option<Value>,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct WorkflowAgentRecord {
|
||||
#[serde(rename = "agentId")]
|
||||
agent_id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
status: String,
|
||||
#[serde(rename = "promptPreview")]
|
||||
prompt_preview: String,
|
||||
#[serde(rename = "manifestFile")]
|
||||
manifest_file: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
struct MonitorInput {
|
||||
|
|
@ -7382,9 +8107,9 @@ mod tests {
|
|||
classify_lane_failure, derive_agent_state, execute_agent_with_spawn, execute_tool,
|
||||
extract_recovery_outcome, final_assistant_text, global_cron_registry, global_mcp_registry,
|
||||
maybe_commit_provenance, mvp_tool_specs, permission_mode_from_plugin,
|
||||
persist_agent_terminal_state, push_output_block, run_task_packet, AgentInput, AgentJob,
|
||||
GlobalToolRegistry, LaneEventName, LaneFailureClass, ProviderRuntimeClient,
|
||||
SubagentToolExecutor,
|
||||
persist_agent_terminal_state, push_output_block, run_task_packet, run_workflow_with_spawn,
|
||||
AgentInput, AgentJob, GlobalToolRegistry, LaneEventName, LaneFailureClass,
|
||||
ProviderRuntimeClient, SubagentToolExecutor, WorkflowInput,
|
||||
};
|
||||
use api::OutputContentBlock;
|
||||
use runtime::mcp_tool_bridge::{McpConnectionStatus, McpResourceInfo, McpToolInfo};
|
||||
|
|
@ -7550,6 +8275,13 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn v201_host_contract_tools_return_stable_payloads() {
|
||||
let _guard = env_guard();
|
||||
let workflow_store = temp_path("workflow-contract-store");
|
||||
let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok();
|
||||
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
|
||||
std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store);
|
||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||
|
||||
let workflow = execute_tool(
|
||||
"Workflow",
|
||||
&json!({
|
||||
|
|
@ -7560,9 +8292,13 @@ mod tests {
|
|||
)
|
||||
.expect("Workflow contract should be registered");
|
||||
let workflow: serde_json::Value = serde_json::from_str(&workflow).expect("workflow json");
|
||||
assert_eq!(workflow["status"], "unsupported");
|
||||
assert_eq!(workflow["status"], "script_error");
|
||||
assert_eq!(workflow["kind"], "workflow");
|
||||
assert_eq!(workflow["has_script"], true);
|
||||
assert_eq!(workflow["name"], "release-check");
|
||||
assert_eq!(workflow["source"], "inline");
|
||||
let script_path = Path::new(workflow["scriptPath"].as_str().expect("script path"));
|
||||
assert!(script_path.exists());
|
||||
assert!(script_path.starts_with(&workflow_store));
|
||||
|
||||
let monitor = execute_tool(
|
||||
"Monitor",
|
||||
|
|
@ -7643,6 +8379,105 @@ mod tests {
|
|||
.as_array()
|
||||
.expect("roles")
|
||||
.contains(&json!("coding")));
|
||||
|
||||
match original_workflow_store {
|
||||
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
|
||||
None => std::env::remove_var("CLAWD_WORKFLOW_STORE"),
|
||||
}
|
||||
match original_disable_workflows {
|
||||
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
||||
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
||||
}
|
||||
let _ = fs::remove_dir_all(workflow_store);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_static_agent_runner_records_run_and_agents() {
|
||||
fn ok_spawn(_: AgentJob) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
let _guard = env_guard();
|
||||
let root = temp_path("workflow-static-agent");
|
||||
let workflow_store = root.join("workflows");
|
||||
let agent_store = root.join("agents");
|
||||
let original_workflow_store = std::env::var("CLAWD_WORKFLOW_STORE").ok();
|
||||
let original_agent_store = std::env::var("CLAWD_AGENT_STORE").ok();
|
||||
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
|
||||
std::env::set_var("CLAWD_WORKFLOW_STORE", &workflow_store);
|
||||
std::env::set_var("CLAWD_AGENT_STORE", &agent_store);
|
||||
std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS");
|
||||
|
||||
let output = run_workflow_with_spawn(
|
||||
WorkflowInput {
|
||||
script: Some(
|
||||
"export const meta = { name: 'release-check', description: 'Check release readiness' }\nconst result = await agent(`Check release readiness`, { label: 'research', agentType: 'Explore', effort: 'xhigh' })\nreturn result\n"
|
||||
.to_string(),
|
||||
),
|
||||
args: Some(json!({"target": "tools"})),
|
||||
..WorkflowInput::default()
|
||||
},
|
||||
ok_spawn,
|
||||
)
|
||||
.expect("static agent workflow should run");
|
||||
let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json");
|
||||
assert_eq!(output["status"], "running");
|
||||
assert_eq!(output["kind"], "workflow");
|
||||
assert_eq!(output["name"], "release-check");
|
||||
assert_eq!(output["description"], "Check release readiness");
|
||||
assert_eq!(output["source"], "inline");
|
||||
assert_eq!(output["agents"].as_array().expect("agents").len(), 1);
|
||||
assert_eq!(output["agents"][0]["description"], "research");
|
||||
assert_eq!(output["agents"][0]["status"], "running");
|
||||
assert!(output["message"]
|
||||
.as_str()
|
||||
.expect("message")
|
||||
.contains("/workflows"));
|
||||
assert!(Path::new(output["scriptPath"].as_str().expect("script path")).exists());
|
||||
|
||||
let report = super::workflows_report_json(None).expect("workflow report");
|
||||
assert_eq!(report["status"], "ok");
|
||||
assert_eq!(report["count"], 1);
|
||||
assert_eq!(report["runs"][0]["name"], "release-check");
|
||||
|
||||
match original_workflow_store {
|
||||
Some(value) => std::env::set_var("CLAWD_WORKFLOW_STORE", value),
|
||||
None => std::env::remove_var("CLAWD_WORKFLOW_STORE"),
|
||||
}
|
||||
match original_agent_store {
|
||||
Some(value) => std::env::set_var("CLAWD_AGENT_STORE", value),
|
||||
None => std::env::remove_var("CLAWD_AGENT_STORE"),
|
||||
}
|
||||
match original_disable_workflows {
|
||||
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
||||
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
||||
}
|
||||
let _ = fs::remove_dir_all(root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflow_tool_respects_disable_env() {
|
||||
let _guard = env_guard();
|
||||
let original_disable_workflows = std::env::var("CLAUDE_CODE_DISABLE_WORKFLOWS").ok();
|
||||
std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", "1");
|
||||
|
||||
let output = execute_tool(
|
||||
"Workflow",
|
||||
&json!({
|
||||
"name": "deep-research",
|
||||
"args": "workflow docs"
|
||||
}),
|
||||
)
|
||||
.expect("disabled workflow should return structured payload");
|
||||
let output: serde_json::Value = serde_json::from_str(&output).expect("workflow json");
|
||||
assert_eq!(output["status"], "disabled");
|
||||
assert_eq!(output["kind"], "workflow");
|
||||
assert_eq!(output["name"], "deep-research");
|
||||
|
||||
match original_disable_workflows {
|
||||
Some(value) => std::env::set_var("CLAUDE_CODE_DISABLE_WORKFLOWS", value),
|
||||
None => std::env::remove_var("CLAUDE_CODE_DISABLE_WORKFLOWS"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Reference in New Issue