From 37bfc52645eb3da11e3a691b828002ba511b375e Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Sat, 7 Mar 2026 09:10:15 -0600 Subject: [PATCH 1/3] feat: Add command field to terminal agent templates Add a `command` field across agent defs, templates, protocol, engine, CLI, and macOS app so terminal-type agents can specify a shell command to run. Includes code review fixes: whitespace trimming on command input, extracted shared variable substitution helper in Rust, and deduplicated agent type lists into AgentTypes enum in Swift. Co-Authored-By: Claude Opus 4.6 --- .../Models/AgentsHubModels.swift | 19 +- .../Models/ManifestModel.swift | 3 +- .../Models/WorkspaceModel.swift | 7 +- .../Services/DaemonClient.swift | 26 +- .../State/AgentsHubState.swift | 14 +- .../purepoint-macos/State/ProjectState.swift | 10 +- .../Views/Agents/AgentCreationSheet.swift | 48 ++-- .../Views/Agents/AgentsHubView.swift | 37 ++- .../Views/Agents/PromptCreationSheet.swift | 13 +- crates/pu-cli/src/commands/agent_def.rs | 2 + crates/pu-cli/src/commands/prompt.rs | 1 + crates/pu-cli/src/commands/spawn.rs | 35 ++- crates/pu-cli/src/main.rs | 12 +- crates/pu-cli/src/output.rs | 8 + crates/pu-core/src/agent_def.rs | 38 +++ crates/pu-core/src/protocol.rs | 24 ++ crates/pu-core/src/template.rs | 105 +++++++- crates/pu-core/src/types.rs | 10 + crates/pu-engine/src/engine.rs | 227 +++++++++++++----- crates/pu-engine/src/test_helpers.rs | 1 + 20 files changed, 521 insertions(+), 119 deletions(-) diff --git a/apps/purepoint-macos/purepoint-macos/Models/AgentsHubModels.swift b/apps/purepoint-macos/purepoint-macos/Models/AgentsHubModels.swift index 91a09af..d26be5a 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/AgentsHubModels.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/AgentsHubModels.swift @@ -8,6 +8,7 @@ struct SavedPrompt: Identifiable { var body: String var source: String var variables: [String] + var command: String? init(from info: TemplateInfo) { self.id = "\(info.source):\(info.name)" @@ -17,9 +18,13 @@ struct SavedPrompt: Identifiable { self.body = "" // TemplateInfo doesn't include body (list response) self.source = info.source self.variables = info.variables + self.command = info.command } - init(name: String, description: String, agent: String, body: String, source: String, variables: [String]) { + init( + name: String, description: String, agent: String, body: String, source: String, + variables: [String], command: String? = nil + ) { self.id = "\(source):\(name)" self.name = name self.description = description @@ -27,6 +32,7 @@ struct SavedPrompt: Identifiable { self.body = body self.source = source self.variables = variables + self.command = command } } @@ -40,6 +46,7 @@ struct AgentDefinition: Identifiable { var scope: String var availableInCommandDialog: Bool var icon: String? + var command: String? init(from info: AgentDefInfo) { self.id = "\(info.scope):\(info.name)" @@ -51,11 +58,13 @@ struct AgentDefinition: Identifiable { self.scope = info.scope self.availableInCommandDialog = info.availableInCommandDialog self.icon = info.icon + self.command = info.command } init( name: String, agentType: String = "claude", template: String? = nil, inlinePrompt: String? = nil, - tags: [String] = [], scope: String = "local", availableInCommandDialog: Bool = true, icon: String? = nil + tags: [String] = [], scope: String = "local", availableInCommandDialog: Bool = true, icon: String? = nil, + command: String? = nil ) { self.id = "\(scope):\(name)" self.name = name @@ -66,6 +75,7 @@ struct AgentDefinition: Identifiable { self.scope = scope self.availableInCommandDialog = availableInCommandDialog self.icon = icon + self.command = command } } @@ -126,6 +136,11 @@ enum PromptScopeChoice: String, CaseIterable, Identifiable { } } +enum AgentTypes { + static let all = ["claude", "codex", "opencode", "terminal"] + static let withAny = [""] + all +} + enum AgentPromptSourceMode: String, CaseIterable, Identifiable { case library case inline diff --git a/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift b/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift index 4576a4a..ff11f66 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/ManifestModel.swift @@ -64,11 +64,12 @@ nonisolated struct AgentEntry: Codable, Sendable { let pid: Int? let sessionId: String? let suspended: Bool? + let command: String? // Explicit CodingKeys document the camelCase wire format // matching Rust's #[serde(rename_all = "camelCase")] on types::AgentEntry. private enum CodingKeys: String, CodingKey { - case id, name, status, prompt, error, pid, suspended + case id, name, status, prompt, error, pid, suspended, command case agentType, startedAt, completedAt, exitCode, sessionId } } diff --git a/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift b/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift index 2340a96..0d93ae8 100644 --- a/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift +++ b/apps/purepoint-macos/purepoint-macos/Models/WorkspaceModel.swift @@ -22,6 +22,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { let startedAt: String let sessionId: String? let suspended: Bool + let command: String? var displayName: String { name.isEmpty ? id : name @@ -29,7 +30,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { init( id: String, name: String, agentType: String, status: AgentStatus, prompt: String, startedAt: String, - sessionId: String? = nil, suspended: Bool = false + sessionId: String? = nil, suspended: Bool = false, command: String? = nil ) { self.id = id self.name = name @@ -39,6 +40,7 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { self.startedAt = startedAt self.sessionId = sessionId self.suspended = suspended + self.command = command } init(from entry: AgentEntry) { @@ -50,7 +52,8 @@ nonisolated struct AgentModel: Identifiable, Equatable, Sendable { prompt: entry.prompt ?? "", startedAt: entry.startedAt, sessionId: entry.sessionId, - suspended: entry.suspended ?? false + suspended: entry.suspended ?? false, + command: entry.command ) } diff --git a/apps/purepoint-macos/purepoint-macos/Services/DaemonClient.swift b/apps/purepoint-macos/purepoint-macos/Services/DaemonClient.swift index 6397dc7..917e0a5 100644 --- a/apps/purepoint-macos/purepoint-macos/Services/DaemonClient.swift +++ b/apps/purepoint-macos/purepoint-macos/Services/DaemonClient.swift @@ -192,7 +192,7 @@ nonisolated enum DaemonRequest: Encodable { case spawn( projectRoot: String, prompt: String, agent: String = "claude", name: String? = nil, base: String? = nil, root: Bool = false, - worktree: String? = nil) + worktree: String? = nil, command: String? = nil) case kill(projectRoot: String, target: KillTarget) case rename(projectRoot: String, agentId: String, name: String) case suspend(projectRoot: String, target: SuspendTarget) @@ -205,12 +205,13 @@ nonisolated enum DaemonRequest: Encodable { case listTemplates(projectRoot: String) case getTemplate(projectRoot: String, name: String) case saveTemplate( - projectRoot: String, name: String, description: String, agent: String, body: String, scope: String) + projectRoot: String, name: String, description: String, agent: String, body: String, scope: String, + command: String? = nil) case deleteTemplate(projectRoot: String, name: String, scope: String) case listAgentDefs(projectRoot: String) case saveAgentDef( projectRoot: String, name: String, agentType: String, template: String?, inlinePrompt: String?, tags: [String], - scope: String, availableInCommandDialog: Bool, icon: String?) + scope: String, availableInCommandDialog: Bool, icon: String?, command: String? = nil) case deleteAgentDef(projectRoot: String, name: String, scope: String) case listSwarmDefs(projectRoot: String) case saveSwarmDef( @@ -251,7 +252,7 @@ nonisolated enum DaemonRequest: Encodable { try container.encode(agentId, forKey: .key("agent_id")) try container.encode(cols, forKey: .key("cols")) try container.encode(rows, forKey: .key("rows")) - case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree): + case .spawn(let projectRoot, let prompt, let agent, let name, let base, let root, let worktree, let command): try container.encode("spawn", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) try container.encode(prompt, forKey: .key("prompt")) @@ -260,6 +261,7 @@ nonisolated enum DaemonRequest: Encodable { if let base { try container.encode(base, forKey: .key("base")) } if root { try container.encode(root, forKey: .key("root")) } if let worktree { try container.encode(worktree, forKey: .key("worktree")) } + if let command { try container.encode(command, forKey: .key("command")) } case .kill(let projectRoot, let target): try container.encode("kill", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) @@ -303,7 +305,7 @@ nonisolated enum DaemonRequest: Encodable { try container.encode("get_template", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) try container.encode(name, forKey: .key("name")) - case .saveTemplate(let projectRoot, let name, let description, let agent, let body, let scope): + case .saveTemplate(let projectRoot, let name, let description, let agent, let body, let scope, let command): try container.encode("save_template", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) try container.encode(name, forKey: .key("name")) @@ -311,6 +313,7 @@ nonisolated enum DaemonRequest: Encodable { try container.encode(agent, forKey: .key("agent")) try container.encode(body, forKey: .key("body")) try container.encode(scope, forKey: .key("scope")) + if let command { try container.encode(command, forKey: .key("command")) } case .deleteTemplate(let projectRoot, let name, let scope): try container.encode("delete_template", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) @@ -321,7 +324,7 @@ nonisolated enum DaemonRequest: Encodable { try container.encode(projectRoot, forKey: .key("project_root")) case .saveAgentDef( let projectRoot, let name, let agentType, let template, let inlinePrompt, let tags, let scope, - let availableInCommandDialog, let icon): + let availableInCommandDialog, let icon, let command): try container.encode("save_agent_def", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) try container.encode(name, forKey: .key("name")) @@ -332,6 +335,7 @@ nonisolated enum DaemonRequest: Encodable { try container.encode(scope, forKey: .key("scope")) try container.encode(availableInCommandDialog, forKey: .key("available_in_command_dialog")) if let icon { try container.encode(icon, forKey: .key("icon")) } + if let command { try container.encode(command, forKey: .key("command")) } case .deleteAgentDef(let projectRoot, let name, let scope): try container.encode("delete_agent_def", forKey: .key("type")) try container.encode(projectRoot, forKey: .key("project_root")) @@ -414,7 +418,8 @@ nonisolated enum DaemonResponse: Decodable { case deleteWorktreeResult(worktreeId: String, killedAgents: [String]) case templateList(templates: [TemplateInfo]) case templateDetail( - name: String, description: String, agent: String, body: String, source: String, variables: [String]) + name: String, description: String, agent: String, body: String, source: String, variables: [String], + command: String?) case agentDefList(agentDefs: [AgentDefInfo]) case swarmDefList(swarmDefs: [SwarmDefInfo]) case runSwarmResult(spawnedAgents: [String]) @@ -492,7 +497,7 @@ nonisolated enum DaemonResponse: Decodable { let p = try TemplateDetailPayload(from: decoder) self = .templateDetail( name: p.name, description: p.description, agent: p.agent, body: p.body, source: p.source, - variables: p.variables) + variables: p.variables, command: p.command) case "agent_def_list": let p = try AgentDefListPayload(from: decoder) self = .agentDefList(agentDefs: p.agentDefs) @@ -568,6 +573,7 @@ nonisolated struct TemplateInfo: Decodable { let agent: String let source: String let variables: [String] + let command: String? } nonisolated struct AgentDefInfo: Decodable { @@ -579,9 +585,10 @@ nonisolated struct AgentDefInfo: Decodable { let scope: String let availableInCommandDialog: Bool let icon: String? + let command: String? enum CodingKeys: String, CodingKey { - case name, template, tags, scope, icon + case name, template, tags, scope, icon, command case agentType = "agent_type" case inlinePrompt = "inline_prompt" case availableInCommandDialog = "available_in_command_dialog" @@ -617,6 +624,7 @@ private nonisolated struct TemplateDetailPayload: Decodable { let body: String let source: String let variables: [String] + let command: String? } private nonisolated struct AgentDefListPayload: Decodable { diff --git a/apps/purepoint-macos/purepoint-macos/State/AgentsHubState.swift b/apps/purepoint-macos/purepoint-macos/State/AgentsHubState.swift index 07c1a64..53e06ab 100644 --- a/apps/purepoint-macos/purepoint-macos/State/AgentsHubState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/AgentsHubState.swift @@ -90,14 +90,16 @@ final class AgentsHubState { func loadPromptDetail(projectRoot: String, name: String) async { do { let response = try await client.send(.getTemplate(projectRoot: projectRoot, name: name)) - if case .templateDetail(let detailName, let description, let agent, let body, let source, let variables) = - response + if case .templateDetail( + let detailName, let description, let agent, let body, + let source, let variables, let command) = response { if let index = prompts.firstIndex(where: { $0.name == detailName && $0.source == source }) { prompts[index].body = body prompts[index].description = description prompts[index].agent = agent prompts[index].variables = variables + prompts[index].command = command } } } catch { @@ -106,13 +108,14 @@ final class AgentsHubState { } func saveTemplate( - projectRoot: String, name: String, description: String, agent: String, body: String, scope: String + projectRoot: String, name: String, description: String, agent: String, body: String, scope: String, + command: String? = nil ) async { do { _ = try await client.send( .saveTemplate( projectRoot: projectRoot, name: name, description: description, agent: agent, body: body, - scope: scope)) + scope: scope, command: command)) await loadTemplates(projectRoot: projectRoot) await loadPromptDetail(projectRoot: projectRoot, name: name) } catch { @@ -141,7 +144,8 @@ final class AgentsHubState { tags: def.tags, scope: def.scope, availableInCommandDialog: def.availableInCommandDialog, - icon: def.icon + icon: def.icon, + command: def.command )) await loadAgentDefs(projectRoot: projectRoot) } catch { diff --git a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift index cc76657..8b0f507 100644 --- a/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift +++ b/apps/purepoint-macos/purepoint-macos/State/ProjectState.swift @@ -138,7 +138,8 @@ final class ProjectState: Identifiable { // MARK: - Agent Operations func createAgent( - agent: String, prompt: String, name: String? = nil, isWorktree: Bool = false, selection: SidebarSelection? + agent: String, prompt: String, name: String? = nil, isWorktree: Bool = false, selection: SidebarSelection?, + command: String? = nil ) { let root = projectRoot @@ -173,7 +174,8 @@ final class ProjectState: Identifiable { let response = try await client.send( .spawn( projectRoot: root, prompt: prompt, agent: agent, - name: name, root: spawnRoot, worktree: spawnWorktree + name: name, root: spawnRoot, worktree: spawnWorktree, + command: command )) switch response { case .spawnResult(_, let agentId, _): @@ -245,7 +247,9 @@ final class ProjectState: Identifiable { agent: variant.id, prompt: prompt ?? "", name: name, isWorktree: variant.kind == .worktree, selection: selection) case .spawnAgentDef(let def, let prompt): - createAgent(agent: def.agentType, prompt: prompt ?? def.inlinePrompt ?? "", selection: selection) + createAgent( + agent: def.agentType, prompt: prompt ?? def.inlinePrompt ?? "", + selection: selection, command: def.command) case .runSwarm(let def): let root = projectRoot Task { await hub.runSwarm(projectRoot: root, name: def.name) } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentCreationSheet.swift b/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentCreationSheet.swift index b090a53..6eab6eb 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentCreationSheet.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentCreationSheet.swift @@ -13,8 +13,9 @@ struct AgentCreationSheet: View { @State private var tags = "" @State private var scope: PromptScopeChoice = .project @State private var availableInCommandDialog = true + @State private var command = "" - private let agentTypes = ["claude", "codex", "opencode"] + private let agentTypes = AgentTypes.all var body: some View { VStack(spacing: 0) { @@ -52,23 +53,30 @@ struct AgentCreationSheet: View { } } - Picker("Prompt source", selection: $promptMode) { - ForEach(AgentPromptSourceMode.allCases) { mode in - Text(mode.title).tag(mode) - } + if agentType == "terminal" { + TextField("Command (e.g. npm run dev)", text: $command) + .textFieldStyle(.roundedBorder) } - .pickerStyle(.segmented) - if promptMode == .library { - TextField("Template name", text: $templateName) - .textFieldStyle(.roundedBorder) - } else { - TextEditor(text: $inlinePrompt) - .font(.system(size: 13, design: .monospaced)) - .frame(minHeight: 100) - .padding(4) - .background(Color.primary.opacity(0.035)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + if agentType != "terminal" { + Picker("Prompt source", selection: $promptMode) { + ForEach(AgentPromptSourceMode.allCases) { mode in + Text(mode.title).tag(mode) + } + } + .pickerStyle(.segmented) + + if promptMode == .library { + TextField("Template name", text: $templateName) + .textFieldStyle(.roundedBorder) + } else { + TextEditor(text: $inlinePrompt) + .font(.system(size: 13, design: .monospaced)) + .frame(minHeight: 100) + .padding(4) + .background(Color.primary.opacity(0.035)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } } TextField("Tags (comma-separated)", text: $tags) @@ -102,14 +110,16 @@ struct AgentCreationSheet: View { .map { $0.trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } + let trimmedCommand = command.trimmingCharacters(in: .whitespaces) let def = AgentDefinition( name: name.trimmingCharacters(in: .whitespaces), agentType: agentType, - template: promptMode == .library ? templateName : nil, - inlinePrompt: promptMode == .inline ? inlinePrompt : nil, + template: agentType != "terminal" && promptMode == .library ? templateName : nil, + inlinePrompt: agentType != "terminal" && promptMode == .inline ? inlinePrompt : nil, tags: parsedTags, scope: scope.wireValue, - availableInCommandDialog: availableInCommandDialog + availableInCommandDialog: availableInCommandDialog, + command: agentType == "terminal" && !trimmedCommand.isEmpty ? trimmedCommand : nil ) Task { await hubState.saveAgentDef(projectRoot: projectRoot, def: def) diff --git a/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentsHubView.swift b/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentsHubView.swift index 1480266..9c2b030 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentsHubView.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Agents/AgentsHubView.swift @@ -11,6 +11,7 @@ struct AgentsHubView: View { @State private var promptDraft = "" @State private var promptScope: PromptScopeChoice = .project @State private var promptAgent = "" + @State private var promptCommand = "" private var projectRoot: String { appState.activeProjectRoot ?? appState.projects.first?.projectRoot ?? "" @@ -148,22 +149,37 @@ struct AgentsHubView: View { Spacer() Picker("Agent", selection: $promptAgent) { - ForEach(["", "claude", "codex", "opencode"], id: \.self) { t in + ForEach(AgentTypes.withAny, id: \.self) { t in Text(t.isEmpty ? "Any" : t).tag(t) } } .frame(maxWidth: 140) + if promptAgent == "terminal" { + TextField("Command (e.g. npm run dev)", text: $promptCommand) + .textFieldStyle(.roundedBorder) + .frame(maxWidth: 200) + } + HStack(spacing: 8) { Button("Save") { Task { + let trimmed = + promptCommand + .trimmingCharacters( + in: .whitespaces) + let cmd = + promptAgent == "terminal" + && !trimmed.isEmpty + ? trimmed : nil await hubState.saveTemplate( projectRoot: projectRoot, name: prompt.name, description: prompt.description, agent: promptAgent, body: promptDraft, - scope: promptScope.wireValue + scope: promptScope.wireValue, + command: cmd ) } } @@ -309,6 +325,21 @@ struct AgentsHubView: View { } } + if let cmd = agent.command, !cmd.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("Command") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + + Text(cmd) + .font(.system(size: 12, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Color.primary.opacity(0.035)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + } + HStack { Spacer() Button("Delete") { @@ -579,6 +610,7 @@ struct AgentsHubView: View { guard let prompt = hubState.selectedPrompt else { return } promptScope = prompt.source == "global" ? .global : .project promptAgent = prompt.agent + promptCommand = prompt.command ?? "" if !prompt.body.isEmpty { promptDraft = prompt.body } @@ -589,6 +621,7 @@ struct AgentsHubView: View { if let updated = hubState.selectedPrompt { promptDraft = updated.body promptAgent = updated.agent + promptCommand = updated.command ?? "" } } } diff --git a/apps/purepoint-macos/purepoint-macos/Views/Agents/PromptCreationSheet.swift b/apps/purepoint-macos/purepoint-macos/Views/Agents/PromptCreationSheet.swift index b16957b..606dd59 100644 --- a/apps/purepoint-macos/purepoint-macos/Views/Agents/PromptCreationSheet.swift +++ b/apps/purepoint-macos/purepoint-macos/Views/Agents/PromptCreationSheet.swift @@ -10,8 +10,9 @@ struct PromptCreationSheet: View { @State private var scope: PromptScopeChoice = .project @State private var agentType = "" @State private var promptBody = "" + @State private var command = "" - private let agentTypes = ["", "claude", "codex", "opencode"] + private let agentTypes = AgentTypes.withAny var body: some View { VStack(spacing: 0) { @@ -59,6 +60,11 @@ struct PromptCreationSheet: View { } } + if agentType == "terminal" { + TextField("Command (e.g. npm run dev)", text: $command) + .textFieldStyle(.roundedBorder) + } + TextEditor(text: $promptBody) .font(.system(size: 13, design: .monospaced)) .frame(minHeight: 140) @@ -77,6 +83,8 @@ struct PromptCreationSheet: View { .keyboardShortcut(.cancelAction) Spacer() Button("Create") { + let trimmedCommand = command.trimmingCharacters(in: .whitespaces) + let cmd = agentType == "terminal" && !trimmedCommand.isEmpty ? trimmedCommand : nil Task { await hubState.saveTemplate( projectRoot: projectRoot, @@ -84,7 +92,8 @@ struct PromptCreationSheet: View { description: description, agent: agentType, body: promptBody, - scope: scope.wireValue + scope: scope.wireValue, + command: cmd ) dismiss() } diff --git a/crates/pu-cli/src/commands/agent_def.rs b/crates/pu-cli/src/commands/agent_def.rs index 13ad9c5..d615311 100644 --- a/crates/pu-cli/src/commands/agent_def.rs +++ b/crates/pu-cli/src/commands/agent_def.rs @@ -35,6 +35,7 @@ pub async fn run_create( agent_type: &str, template: Option, inline_prompt: Option, + command: Option, tags: &str, scope: &str, json: bool, @@ -54,6 +55,7 @@ pub async fn run_create( scope: scope.to_string(), available_in_command_dialog: true, icon: None, + command, }, ) .await?; diff --git a/crates/pu-cli/src/commands/prompt.rs b/crates/pu-cli/src/commands/prompt.rs index 1d1f8c1..6a5b557 100644 --- a/crates/pu-cli/src/commands/prompt.rs +++ b/crates/pu-cli/src/commands/prompt.rs @@ -97,6 +97,7 @@ pub async fn run_create( agent: agent.to_string(), body: body.to_string(), scope: scope.to_string(), + command: None, }, ) .await?; diff --git a/crates/pu-cli/src/commands/spawn.rs b/crates/pu-cli/src/commands/spawn.rs index 6574988..d949123 100644 --- a/crates/pu-cli/src/commands/spawn.rs +++ b/crates/pu-cli/src/commands/spawn.rs @@ -19,6 +19,7 @@ pub async fn run( worktree: Option, template_name: Option, file: Option, + command: Option, vars: Vec, json: bool, ) -> Result<(), CliError> { @@ -30,22 +31,33 @@ pub async fn run( // Parse --var KEY=VALUE pairs let var_map = commands::parse_vars(&vars)?; - // Resolve prompt + agent override from template/file/inline - let (resolved_prompt, agent_override) = + // Resolve prompt + agent override + command override from template/file/inline + let (resolved_prompt, agent_override, command_override) = resolve_prompt(prompt, template_name, file, &var_map, &cwd)?; let agent = agent.or(agent_override).unwrap_or_else(|| "claude".into()); + // --command flag takes precedence, then template command + let resolved_command = command.or(command_override); + + // Terminal agents with a command don't require a prompt + let final_prompt = if resolved_prompt.is_none() && resolved_command.is_some() { + String::new() + } else { + resolved_prompt.unwrap_or_default() + }; + let resp = client::send_request( socket, &Request::Spawn { project_root, - prompt: resolved_prompt, + prompt: final_prompt, agent, name, base, root, worktree, + command: resolved_command, }, ) .await?; @@ -55,22 +67,23 @@ pub async fn run( } /// Resolve the prompt from one of: --template, --file, or inline positional arg. -/// Returns (prompt_text, optional_agent_override). +/// Returns (optional_prompt, optional_agent_override, optional_command_override). fn resolve_prompt( inline: Option, template_name: Option, file: Option, vars: &HashMap, project_root: &Path, -) -> Result<(String, Option), CliError> { +) -> Result<(Option, Option, Option), CliError> { match (inline, template_name, file) { - (Some(prompt), None, None) => Ok((prompt, None)), + (Some(prompt), None, None) => Ok((Some(prompt), None, None)), (None, Some(name), None) => { let tpl = template::find_template(project_root, &name) .ok_or_else(|| CliError::Other(format!("template not found: {name}")))?; let agent_override = Some(tpl.agent.clone()); let rendered = template::render(&tpl, vars); - Ok((rendered, agent_override)) + let command_override = template::render_command(&tpl, vars); + Ok((Some(rendered), agent_override, command_override)) } (None, None, Some(path)) => { let content = std::fs::read_to_string(&path) @@ -87,11 +100,11 @@ fn resolve_prompt( None }; let rendered = template::render(&tpl, vars); - Ok((rendered, agent_override)) + let command_override = template::render_command(&tpl, vars); + Ok((Some(rendered), agent_override, command_override)) } - (None, None, None) => Err(CliError::Other( - "prompt required — provide inline, --template, or --file".into(), - )), + // No prompt source — allowed when --command is set (terminal agent) + (None, None, None) => Ok((None, None, None)), _ => Err(CliError::Other( "provide only one of: inline prompt, --template, or --file".into(), )), diff --git a/crates/pu-cli/src/main.rs b/crates/pu-cli/src/main.rs index aab3112..b4a5272 100644 --- a/crates/pu-cli/src/main.rs +++ b/crates/pu-cli/src/main.rs @@ -47,6 +47,9 @@ enum Commands { /// Read prompt from a file path #[arg(long, conflicts_with = "template")] file: Option, + /// Command to run in the terminal (for terminal agents) + #[arg(long)] + command: Option, /// Variable substitution (KEY=VALUE), repeatable #[arg(long = "var", value_name = "KEY=VALUE")] vars: Vec, @@ -228,6 +231,9 @@ enum AgentAction { /// Inline prompt text #[arg(long)] inline_prompt: Option, + /// Command to run (for terminal agents) + #[arg(long)] + command: Option, /// Comma-separated tags #[arg(long, default_value = "")] tags: String, @@ -466,11 +472,13 @@ async fn main() { worktree, template, file, + command, vars, json, } => { commands::spawn::run( - &socket, prompt, agent, name, base, root, worktree, template, file, vars, json, + &socket, prompt, agent, name, base, root, worktree, template, file, command, vars, + json, ) .await } @@ -524,6 +532,7 @@ async fn main() { agent_type, template, inline_prompt, + command, tags, scope, json, @@ -534,6 +543,7 @@ async fn main() { &agent_type, template, inline_prompt, + command, &tags, &scope, json, diff --git a/crates/pu-cli/src/output.rs b/crates/pu-cli/src/output.rs index 8cbe6e5..2cd351e 100644 --- a/crates/pu-cli/src/output.rs +++ b/crates/pu-cli/src/output.rs @@ -262,12 +262,16 @@ pub fn print_response(response: &Response, json_mode: bool) { body, source, variables, + command, } => { println!("{} ({})", name.bold(), source.dimmed()); if !description.is_empty() { println!(" {description}"); } println!(" Agent: {agent}"); + if let Some(cmd) = &command { + println!(" Command: {cmd}"); + } if !variables.is_empty() { println!(" Variables: {}", variables.join(", ")); } @@ -283,9 +287,13 @@ pub fn print_response(response: &Response, json_mode: bool) { scope, available_in_command_dialog, icon, + command, } => { println!("{} ({})", name.bold(), scope.dimmed()); println!(" Type: {agent_type}"); + if let Some(cmd) = &command { + println!(" Command: {cmd}"); + } if let Some(tpl) = template { println!(" Template: {tpl}"); } diff --git a/crates/pu-core/src/agent_def.rs b/crates/pu-core/src/agent_def.rs index a6ae555..4a48014 100644 --- a/crates/pu-core/src/agent_def.rs +++ b/crates/pu-core/src/agent_def.rs @@ -23,6 +23,8 @@ pub struct AgentDef { pub available_in_command_dialog: bool, #[serde(default)] pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, } /// Scan both local and global agent definition directories. Local defs take priority. @@ -232,6 +234,7 @@ mod tests { scope: "local".to_string(), available_in_command_dialog: true, icon: None, + command: None, }; save_agent_def(&dir, &def).unwrap(); @@ -287,6 +290,41 @@ mod tests { assert_eq!(def.scope, "local"); } + #[test] + fn given_agent_def_with_command_should_deserialize() { + let yaml = "name: dev-server\nagent_type: terminal\ncommand: \"npm run dev\"\n"; + let def: AgentDef = serde_yml::from_str(yaml).unwrap(); + assert_eq!(def.name, "dev-server"); + assert_eq!(def.agent_type, "terminal"); + assert_eq!(def.command, Some("npm run dev".to_string())); + } + + #[test] + fn given_agent_def_without_command_should_default_to_none() { + let yaml = "name: basic\n"; + let def: AgentDef = serde_yml::from_str(yaml).unwrap(); + assert!(def.command.is_none()); + } + + #[test] + fn given_agent_def_with_command_should_round_trip_yaml() { + let def = AgentDef { + name: "test".to_string(), + agent_type: "terminal".to_string(), + template: None, + inline_prompt: None, + tags: vec![], + scope: "local".to_string(), + available_in_command_dialog: true, + icon: None, + command: Some("cargo test".to_string()), + }; + let yaml = serde_yml::to_string(&def).unwrap(); + assert!(yaml.contains("command: cargo test")); + let loaded: AgentDef = serde_yml::from_str(&yaml).unwrap(); + assert_eq!(loaded.command, Some("cargo test".to_string())); + } + #[test] fn given_path_traversal_name_should_return_none() { let tmp = TempDir::new().unwrap(); diff --git a/crates/pu-core/src/protocol.rs b/crates/pu-core/src/protocol.rs index 3f300a5..a23c916 100644 --- a/crates/pu-core/src/protocol.rs +++ b/crates/pu-core/src/protocol.rs @@ -54,6 +54,8 @@ pub enum Request { root: bool, #[serde(default)] worktree: Option, + #[serde(default)] + command: Option, }, Status { project_root: String, @@ -133,6 +135,8 @@ pub enum Request { agent: String, body: String, scope: String, + #[serde(default)] + command: Option, }, DeleteTemplate { project_root: String, @@ -162,6 +166,8 @@ pub enum Request { available_in_command_dialog: bool, #[serde(default)] icon: Option, + #[serde(default)] + command: Option, }, DeleteAgentDef { project_root: String, @@ -290,6 +296,8 @@ pub struct TemplateInfo { pub agent: String, pub source: String, pub variables: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -302,6 +310,8 @@ pub struct AgentDefInfo { pub scope: String, pub available_in_command_dialog: bool, pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -446,6 +456,8 @@ pub enum Response { body: String, source: String, variables: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, }, AgentDefList { agent_defs: Vec, @@ -459,6 +471,8 @@ pub enum Response { scope: String, available_in_command_dialog: bool, icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + command: Option, }, SwarmDefList { swarm_defs: Vec, @@ -575,6 +589,7 @@ mod tests { base: Some("develop".into()), root: false, worktree: None, + command: None, }; let json = serde_json::to_string(&req).unwrap(); let parsed: Request = serde_json::from_str(&json).unwrap(); @@ -1185,6 +1200,7 @@ mod tests { agent: "claude".into(), source: "local".into(), variables: vec!["BRANCH".into()], + command: None, }; // when @@ -1213,6 +1229,7 @@ mod tests { scope: "local".into(), available_in_command_dialog: true, icon: Some("magnifyingglass".into()), + command: None, }; // when @@ -1313,6 +1330,7 @@ mod tests { agent: "claude".into(), body: "Review {{BRANCH}}.".into(), scope: "local".into(), + command: None, }; // when @@ -1328,6 +1346,7 @@ mod tests { agent, body, scope, + .. } => { assert_eq!(project_root, "/test"); assert_eq!(name, "review"); @@ -1399,6 +1418,7 @@ mod tests { scope: "local".into(), available_in_command_dialog: true, icon: Some("magnifyingglass".into()), + command: None, }; // when @@ -1650,6 +1670,7 @@ mod tests { agent: "claude".into(), source: "local".into(), variables: vec!["BRANCH".into()], + command: None, }], }; @@ -1678,6 +1699,7 @@ mod tests { body: "Review {{BRANCH}}.".into(), source: "local".into(), variables: vec!["BRANCH".into()], + command: None, }; // when @@ -1693,6 +1715,7 @@ mod tests { body, source, variables, + .. } => { assert_eq!(name, "review"); assert_eq!(description, "Code review"); @@ -1718,6 +1741,7 @@ mod tests { scope: "local".into(), available_in_command_dialog: true, icon: None, + command: None, }], }; diff --git a/crates/pu-core/src/template.rs b/crates/pu-core/src/template.rs index 135bb89..582de67 100644 --- a/crates/pu-core/src/template.rs +++ b/crates/pu-core/src/template.rs @@ -16,6 +16,8 @@ pub struct Template { /// Where this template was loaded from ("local" or "global") #[serde(skip_deserializing)] pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, } fn default_agent() -> String { @@ -44,6 +46,8 @@ pub fn parse_template(content: &str, file_name: &str) -> Template { description: Option, #[serde(default)] agent: Option, + #[serde(default)] + command: Option, } if let Ok(fm) = serde_yml::from_str::(yaml) { @@ -53,6 +57,7 @@ pub fn parse_template(content: &str, file_name: &str) -> Template { agent: fm.agent.unwrap_or_else(default_agent), body, source: String::new(), + command: fm.command, }; } } @@ -65,6 +70,7 @@ pub fn parse_template(content: &str, file_name: &str) -> Template { agent: default_agent(), body: content.to_string(), source: String::new(), + command: None, } } @@ -114,12 +120,18 @@ pub fn find_template(project_root: &Path, name: &str) -> Option