native macOS codings agent orchestrator
6
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add CLI and deeplinks for running arbitrary user-defined scripts (#253)

* Add runScript/stopScript deeplinks for named scripts

Introduces sub-path deeplinks that target a specific user-defined
script by UUID, so CLI tooling and share URLs can run or stop
scripts other than the primary .run-kind one.

- Add .runScript(scriptID:) and .stopScript(scriptID:) cases to
Deeplink.WorktreeAction and a parseWorktreeScript helper that
matches worktree/<id>/script/<uuid>/run|stop.
- Route the new actions in worktreeActionEffect through existing
runNamedScript / stopScript reducer paths. Unknown script UUIDs
surface an alert rather than silently dropping.
- Reuse requiresInputConfirmation for URL-scheme run requests so
script commands display in the confirmation sheet under the
existing automatedActionPolicy gate. Stop requests are not
gated, matching the bare .stop behaviour.
- Extend parser and reducer test suites covering happy paths,
unknown UUIDs, missing verb, and policy bypass.

* Add CLI and socket query for named script run/stop

Wires the earlier runScript/stopScript deeplinks to actual CLI
tooling and adds the socket query needed to discover script UUIDs.

- Add "scripts" socket query resource keyed on worktreeID that
returns each ScriptDefinition as id / kind / name / displayName
plus a running marker derived from runningScriptsByWorktreeID.
- Extend `supacode worktree run` and `stop` with `--script/-s`
so callers can target a specific script by UUID while the
zero-argument form keeps firing the bare run / stop deeplinks
for backward compatibility.
- Introduce `supacode worktree script list` (via a sibling
WorktreeScriptCommand to satisfy SwiftLint nesting) that prints
tab-separated id / kind / displayName rows and underlines any
currently running script.
- Validate `--script` arguments as UUIDs at the CLI layer so a
typo surfaces a helpful error instead of a generic deeplink
parse failure.

* Harden named-script deeplinks and document new CLI surface

Review follow-ups covering cross-worktree correctness, silent
success paths, the `-s` flag collision, and in-app docs.

- Resolve the target script directly from the worktree's
@SharedReader RepositorySettings in runScript/stopScript
deeplink handlers so cross-worktree CLI calls no longer miss
a freshly loaded selection's scripts.
- Validate empty command, already-running, and not-running
cases inline in the deeplink effects and surface them as
alerts. Previously these hit silent `.none` guards in
runNamedScript / stopScript and misled the CLI into ok:true.
- Normalize trailing slashes in the `scripts` socket query so
`supacode worktree script list -w <path>` matches worktree
IDs with or without a trailing slash, matching resolveWorktreeID.
- Rename the CLI short flag from `-s` to `-c` under `worktree
run`/`stop` so it stops colliding with `-s` = `--surface`
across the rest of the CLI. Long form `--script` is unchanged.
- Update CLIReferenceView, DeeplinkReferenceView, and the
supacode-cli skill content with the new subcommand, option,
and deeplink URLs.
- Extend the test suite with confirm-accepted round trip,
socket-source responseFD storage, empty command, already
running, and not-running-stop cases, and rewrite the existing
script deeplink tests to seed scripts via @Shared repository
settings now that the reducer reads them from there.

* Surface an alert when the target worktree vanishes mid-flight

Phase 3 verification caught a narrow but real silent-success
path in the script deeplink effects: if the target worktree is
removed between the initial `handleWorktreeDeeplink` check and
the confirmation-accept re-dispatch, both runScript and
stopScript handlers would return `.none` without setting an
alert, so the outer response flow would report `ok:true` to the
CLI even though nothing ran.

- Set a "Worktree not found" alert in the worktree-missing guard
of both runScriptDeeplinkEffect and stopScriptDeeplinkEffect
so the alert-diff check drives the socket response to ok:false.
- Extract a shared worktreeNotFoundAlert helper.
- Add stopScriptSocketDeeplinkSendsErrorWhenNotRunning as a
regression guard exercising the socket responseFD path for
stop failures, which had no coverage.

* Polish named-script CLI: DRY alert, sanitize list output, skill doc

Final polish pass after Phase 3 self-review.

- Collapse the inline "Worktree not found" AlertState block in
handleWorktreeDeeplink into the worktreeNotFoundAlert helper
introduced for the script deeplink effects.
- Sanitize tab, newline, and carriage return characters in the
`worktree script list` output so a script name containing
those characters cannot corrupt the tab-separated columns
when the output is piped into xargs/cut/awk.
- Expand CLISkillContent's shared/codex sections to document
the new `worktree script list` subcommand, the `-c`
`--script` flag, and the tab-separated list output format
so agents reading the skill pick up the new surface.

authored by

Stefano Bertagno and committed by
GitHub
788dcff4 1f38a0c1

+698 -30
+16 -13
SupacodeSettingsShared/BusinessLogic/CLISkillContent.swift
··· 93 93 ### Worktree 94 94 95 95 ``` 96 - supacode worktree list [-f] # List worktree IDs (-f = focused only). 97 - supacode worktree focus [-w <id>] # Focus worktree. 98 - supacode worktree run [-w <id>] # Run the worktree script. 99 - supacode worktree stop [-w <id>] # Stop the running script. 100 - supacode worktree archive [-w <id>] # Archive worktree. 101 - supacode worktree unarchive [-w <id>] # Unarchive worktree. 102 - supacode worktree delete [-w <id>] # Delete worktree. 103 - supacode worktree pin [-w <id>] # Pin worktree. 104 - supacode worktree unpin [-w <id>] # Unpin worktree. 96 + supacode worktree list [-f] # List worktree IDs (-f = focused only). 97 + supacode worktree focus [-w <id>] # Focus worktree. 98 + supacode worktree run [-w <id>] [-c <uuid>] # Run script (default: primary run-kind; -c for a specific UUID). 99 + supacode worktree stop [-w <id>] [-c <uuid>] # Stop script (default: all run-kind; -c for a specific UUID). 100 + supacode worktree script list [-w <id>] # List configured scripts (id / kind / name). Running rows are underlined. 101 + supacode worktree archive [-w <id>] # Archive worktree. 102 + supacode worktree unarchive [-w <id>] # Unarchive worktree. 103 + supacode worktree delete [-w <id>] # Delete worktree. 104 + supacode worktree pin [-w <id>] # Pin worktree. 105 + supacode worktree unpin [-w <id>] # Unpin worktree. 105 106 ``` 106 107 107 108 ### Tab ··· 150 151 | `--worktree` | `-w` | `$SUPACODE_WORKTREE_ID` | Worktree ID. | 151 152 | `--tab` | `-t` | `$SUPACODE_TAB_ID` | Tab UUID. | 152 153 | `--surface` | `-s` | `$SUPACODE_SURFACE_ID` | Surface UUID. | 154 + | `--script` | `-c` | — | Script UUID (for `worktree run`/`stop`). | 153 155 | `--repo` | `-r` | `$SUPACODE_REPO_ID` | Repository ID. | 154 156 | `--input` | `-i` | — | Command to run in the terminal. | 155 157 | `--direction` | `-d` | `horizontal` | Split direction (`horizontal`/`h` or `vertical`/`v`). | ··· 199 201 200 202 ## Commands 201 203 202 - - `supacode worktree [list [-f]|focus|run|stop|archive|unarchive|delete|pin|unpin] [-w <id>]` 204 + - `supacode worktree [list [-f]|focus|run [-c]|stop [-c]|script list|archive|unarchive|delete|pin|unpin] [-w <id>]` 203 205 - `supacode tab [list [-w] [-f]|focus|new|close] [-w <id>] [-t <id>] [-i <cmd>] [-n <uuid>]` 204 206 - `supacode surface [list [-w] [-t] [-f]|focus|split|close] [-w <id>] [-t <id>] [-s <id>] [-i <cmd>] [-d h|v] [-n <uuid>]` 205 207 - `supacode repo [list | open <path> | worktree-new [-r <id>] [--branch] [--base] [--fetch]]` ··· 207 209 - `supacode socket` 208 210 209 211 `list` outputs one ID per line (percent-encoded for worktrees/repos, UUIDs for tabs/surfaces). 210 - Use these IDs directly as `-w`, `-t`, `-s`, `-r` flag values. 212 + `worktree script list` outputs tab-separated `<uuid>\\t<kind>\\t<displayName>` rows; running scripts are ANSI-underlined. 213 + Use these IDs directly as `-w`, `-t`, `-s`, `-r`, `-c` flag values. 211 214 212 - Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-i` (input), `-d` (direction), `-n` (new ID). 215 + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 213 216 Env var defaults only target your own shell session. Pass explicit IDs for created resources. 214 217 """ 215 218 ··· 245 248 supacode surface split -d v -i "test" # BAD: missing -t/-s, targets your shell 246 249 ``` 247 250 248 - Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-i` (input), `-d` (direction), `-n` (new ID). 251 + Flags: `-w` (worktree), `-t` (tab), `-s` (surface), `-r` (repo), `-c` (script UUID for `worktree run`/`stop`), `-i` (input), `-d` (direction), `-n` (new ID). 249 252 Env var defaults only target your own shell session. Pass explicit IDs for created resources. 250 253 """ 251 254 }
+29 -4
supacode-cli/Commands/WorktreeCommand.swift
··· 10 10 Focus.self, 11 11 Run.self, 12 12 Stop.self, 13 + WorktreeScriptCommand.self, 13 14 Archive.self, 14 15 Unarchive.self, 15 16 Delete.self, ··· 52 53 } 53 54 54 55 struct Run: ParsableCommand { 55 - static let configuration = CommandConfiguration(abstract: "Run the worktree script.") 56 + static let configuration = CommandConfiguration( 57 + abstract: "Run a script. Defaults to the primary run-kind script when --script is omitted." 58 + ) 56 59 57 60 @Option(name: [.short, .long], help: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID.") 58 61 var worktree: String? 59 62 63 + @Option(name: [.customShort("c"), .long], help: "Script UUID (see `worktree script list`).") 64 + var script: String? 65 + 60 66 func run() throws { 61 67 let id = try resolveWorktreeID(worktree) 62 - try Dispatcher.dispatch(deeplinkURL: DeeplinkURLBuilder.worktreeAction("run", worktreeID: id)) 68 + guard let script else { 69 + try Dispatcher.dispatch(deeplinkURL: DeeplinkURLBuilder.worktreeAction("run", worktreeID: id)) 70 + return 71 + } 72 + let scriptID = try validatedScriptID(script) 73 + try Dispatcher.dispatch( 74 + deeplinkURL: DeeplinkURLBuilder.scriptRun(worktreeID: id, scriptID: scriptID) 75 + ) 63 76 } 64 77 } 65 78 66 79 struct Stop: ParsableCommand { 67 - static let configuration = CommandConfiguration(abstract: "Stop the running script.") 80 + static let configuration = CommandConfiguration( 81 + abstract: "Stop a running script. Defaults to all run-kind scripts when --script is omitted." 82 + ) 68 83 69 84 @Option(name: [.short, .long], help: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID.") 70 85 var worktree: String? 71 86 87 + @Option(name: [.customShort("c"), .long], help: "Script UUID (see `worktree script list`).") 88 + var script: String? 89 + 72 90 func run() throws { 73 91 let id = try resolveWorktreeID(worktree) 74 - try Dispatcher.dispatch(deeplinkURL: DeeplinkURLBuilder.worktreeAction("stop", worktreeID: id)) 92 + guard let script else { 93 + try Dispatcher.dispatch(deeplinkURL: DeeplinkURLBuilder.worktreeAction("stop", worktreeID: id)) 94 + return 95 + } 96 + let scriptID = try validatedScriptID(script) 97 + try Dispatcher.dispatch( 98 + deeplinkURL: DeeplinkURLBuilder.scriptStop(worktreeID: id, scriptID: scriptID) 99 + ) 75 100 } 76 101 } 77 102
+33
supacode-cli/Commands/WorktreeScriptCommand.swift
··· 1 + import ArgumentParser 2 + import Foundation 3 + 4 + /// `supacode worktree script` — inspect user-defined scripts for a worktree. 5 + /// Lives at the top level (not nested in `WorktreeCommand`) so SwiftLint's 6 + /// `nesting` rule stays satisfied while still appearing as a `worktree` 7 + /// subcommand at runtime via its `commandName`. 8 + struct WorktreeScriptCommand: ParsableCommand { 9 + static let configuration = CommandConfiguration( 10 + commandName: "script", 11 + abstract: "Inspect user-defined scripts for a worktree.", 12 + subcommands: [List.self], 13 + defaultSubcommand: List.self 14 + ) 15 + } 16 + 17 + extension WorktreeScriptCommand { 18 + struct List: ParsableCommand { 19 + static let configuration = CommandConfiguration(abstract: "List scripts configured for a worktree.") 20 + 21 + @Option(name: [.short, .long], help: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID.") 22 + var worktree: String? 23 + 24 + func run() throws { 25 + let id = try resolveWorktreeID(worktree) 26 + let items = try QueryDispatcher.query(resource: "scripts", params: ["worktreeID": id]) 27 + for item in items { 28 + let running = !(item["running"] ?? "").isEmpty 29 + print(formatScriptListLine(item, running: running)) 30 + } 31 + } 32 + } 33 + }
+10
supacode-cli/Helpers/DeeplinkURLBuilder.swift
··· 18 18 "supacode://worktree/\(worktreeID)/\(action)" 19 19 } 20 20 21 + // MARK: - Script. 22 + 23 + static func scriptRun(worktreeID: String, scriptID: String) -> String { 24 + "supacode://worktree/\(worktreeID)/script/\(scriptID)/run" 25 + } 26 + 27 + static func scriptStop(worktreeID: String, scriptID: String) -> String { 28 + "supacode://worktree/\(worktreeID)/script/\(scriptID)/stop" 29 + } 30 + 21 31 // MARK: - Tab. 22 32 23 33 static func tabFocus(worktreeID: String, tabID: String) -> String {
+13
supacode-cli/Helpers/IDResolvers.swift
··· 1 1 import ArgumentParser 2 + import Foundation 2 3 3 4 /// Resolves a worktree ID from an explicit flag or `$SUPACODE_WORKTREE_ID`. 4 5 nonisolated func resolveWorktreeID(_ explicit: String?) throws -> String { ··· 38 39 ) 39 40 } 40 41 return id 42 + } 43 + 44 + /// Validates that a `--script` argument is a well-formed UUID and returns 45 + /// the canonical `UUID.uuidString` form (uppercased). Fails early so the 46 + /// CLI surfaces a helpful error before dispatching an unparsable deeplink. 47 + nonisolated func validatedScriptID(_ raw: String) throws -> String { 48 + guard let uuid = UUID(uuidString: raw) else { 49 + throw ValidationError( 50 + "Invalid --script value: expected a UUID. Run `supacode worktree script list` to list script IDs." 51 + ) 52 + } 53 + return uuid.uuidString 41 54 } 42 55 43 56 private nonisolated func nonEmpty(_ value: String?) -> String? {
+17
supacode-cli/Helpers/ListFormatting.swift
··· 2 2 nonisolated func formatListLine(_ text: String, focused: Bool) -> String { 3 3 focused ? "\u{1B}[4m\(text)\u{1B}[0m" : text 4 4 } 5 + 6 + /// Formats a script row from the `scripts` query as tab-separated 7 + /// columns: `<uuid>\t<kind>\t<displayName>`. Running scripts are 8 + /// underlined so humans can spot them at a glance. Tabs and newlines 9 + /// embedded in user-editable names are replaced with spaces so they 10 + /// cannot corrupt the column layout when piped to other tools. 11 + nonisolated func formatScriptListLine(_ row: [String: String], running: Bool) -> String { 12 + let id = sanitizeColumnValue(row["id"] ?? "") 13 + let kind = sanitizeColumnValue(row["kind"] ?? "") 14 + let name = sanitizeColumnValue(row["displayName"] ?? row["name"] ?? "") 15 + let line = "\(id)\t\(kind)\t\(name)" 16 + return formatListLine(line, focused: running) 17 + } 18 + 19 + private nonisolated func sanitizeColumnValue(_ value: String) -> String { 20 + value.replacing("\t", with: " ").replacing("\n", with: " ").replacing("\r", with: " ") 21 + }
+13 -2
supacode/App/CLIReferenceView.swift
··· 62 62 private static let worktreeRows: [CLIEntry] = [ 63 63 .init(command: "supacode worktree list [-f]", description: "List worktree IDs. -f for focused only."), 64 64 .init(command: "supacode worktree focus [-w <id>]", description: "Focus a worktree."), 65 - .init(command: "supacode worktree run [-w <id>]", description: "Run the worktree script."), 66 - .init(command: "supacode worktree stop [-w <id>]", description: "Stop the running script."), 65 + .init( 66 + command: "supacode worktree run [-w <id>] [-c <uuid>]", 67 + description: "Run a script. Defaults to the primary run-kind script; -c targets a specific one." 68 + ), 69 + .init( 70 + command: "supacode worktree stop [-w <id>] [-c <uuid>]", 71 + description: "Stop a script. Defaults to all run-kind scripts; -c targets a specific one." 72 + ), 73 + .init( 74 + command: "supacode worktree script list [-w <id>]", 75 + description: "List configured scripts. Underlined rows are currently running." 76 + ), 67 77 .init(command: "supacode worktree archive [-w <id>]", description: "Archive the worktree."), 68 78 .init(command: "supacode worktree unarchive [-w <id>]", description: "Unarchive the worktree."), 69 79 .init(command: "supacode worktree delete [-w <id>]", description: "Delete the worktree."), ··· 123 133 .init(command: "-w, --worktree", description: "Worktree ID. Defaults to $SUPACODE_WORKTREE_ID."), 124 134 .init(command: "-t, --tab", description: "Tab UUID. Defaults to $SUPACODE_TAB_ID."), 125 135 .init(command: "-s, --surface", description: "Surface UUID. Defaults to $SUPACODE_SURFACE_ID."), 136 + .init(command: "-c, --script", description: "Script UUID (for `worktree run`/`stop`)."), 126 137 .init(command: "-r, --repo", description: "Repository ID. Defaults to $SUPACODE_REPO_ID."), 127 138 .init(command: "-i, --input", description: "Command to run in the terminal."), 128 139 .init(command: "-d, --direction", description: "Split direction: horizontal (h) or vertical (v)."),
+10 -2
supacode/App/DeeplinkReferenceView.swift
··· 46 46 47 47 private static let worktreeRows: [DeeplinkEntry] = [ 48 48 .init(url: "supacode://worktree/<worktree_id>", description: "Select worktree."), 49 - .init(url: "supacode://worktree/<worktree_id>/run", description: "Run the worktree script."), 50 - .init(url: "supacode://worktree/<worktree_id>/stop", description: "Stop the running script."), 49 + .init(url: "supacode://worktree/<worktree_id>/run", description: "Run the primary run-kind script."), 50 + .init(url: "supacode://worktree/<worktree_id>/stop", description: "Stop all run-kind scripts."), 51 + .init( 52 + url: "supacode://worktree/<worktree_id>/script/<script_id>/run", 53 + description: "Run a specific configured script by UUID." 54 + ), 55 + .init( 56 + url: "supacode://worktree/<worktree_id>/script/<script_id>/stop", 57 + description: "Stop a specific running script by UUID." 58 + ), 51 59 .init(url: "supacode://worktree/<worktree_id>/archive", description: "Archive the worktree."), 52 60 .init(url: "supacode://worktree/<worktree_id>/unarchive", description: "Unarchive the worktree."), 53 61 .init(url: "supacode://worktree/<worktree_id>/delete", description: "Delete the worktree."),
+30
supacode/App/supacodeApp.swift
··· 290 290 return 291 291 } 292 292 AgentHookSocketServer.sendQueryResponse(clientFD: clientFD, data: surfaces) 293 + case "scripts": 294 + guard let worktreeID = params["worktreeID"] else { 295 + AgentHookSocketServer.sendCommandResponse( 296 + clientFD: clientFD, ok: false, error: "Missing worktreeID for script list.") 297 + return 298 + } 299 + let decoded = worktreeID.removingPercentEncoding ?? worktreeID 300 + // Worktree IDs from standardizedFileURL include a trailing slash, so 301 + // accept both forms — matching the deeplink reducer's resolveWorktreeID. 302 + let allWorktrees = repos.flatMap(\.worktrees) 303 + let worktree = 304 + allWorktrees.first(where: { $0.id == decoded }) 305 + ?? allWorktrees.first(where: { $0.id == decoded + "/" }) 306 + guard let worktree else { 307 + AgentHookSocketServer.sendCommandResponse( 308 + clientFD: clientFD, ok: false, error: "Worktree not found: \(worktreeID)") 309 + return 310 + } 311 + @SharedReader(.repositorySettings(worktree.repositoryRootURL)) var settings 312 + let runningIDs = store.repositories.runningScriptsByWorktreeID[worktree.id] ?? [] 313 + let data = settings.scripts.map { script in 314 + [ 315 + "id": script.id.uuidString, 316 + "kind": script.kind.rawValue, 317 + "name": script.name, 318 + "displayName": script.displayName, 319 + "running": runningIDs.contains(script.id) ? "1" : "", 320 + ] 321 + } 322 + AgentHookSocketServer.sendQueryResponse(clientFD: clientFD, data: data) 293 323 default: 294 324 AgentHookSocketServer.sendCommandResponse( 295 325 clientFD: clientFD, ok: false, error: "Unknown resource: \(resource)")
+27
supacode/Clients/Deeplink/DeeplinkClient.swift
··· 118 118 pathSegments: pathSegments, 119 119 queryItems: queryItems, 120 120 ) 121 + case "script": 122 + return parseWorktreeScript(worktreeID: worktreeID, pathSegments: pathSegments) 121 123 default: 122 124 logger.warning("Unrecognized worktree action: \(action)") 125 + return nil 126 + } 127 + } 128 + 129 + private static func parseWorktreeScript( 130 + worktreeID: Worktree.ID, 131 + pathSegments: [String] 132 + ) -> Deeplink? { 133 + // Expected: "script/<script-uuid>/run" or "script/<script-uuid>/stop". 134 + guard pathSegments.count >= 4 else { 135 + logger.warning("Script deeplink missing script ID or action") 136 + return nil 137 + } 138 + guard let scriptID = UUID(uuidString: pathSegments[2]) else { 139 + logger.warning("Invalid script UUID: \(pathSegments[2])") 140 + return nil 141 + } 142 + let verb = pathSegments[3] 143 + switch verb { 144 + case "run": 145 + return .worktree(id: worktreeID, action: .runScript(scriptID: scriptID)) 146 + case "stop": 147 + return .worktree(id: worktreeID, action: .stopScript(scriptID: scriptID)) 148 + default: 149 + logger.warning("Unrecognized script action: \(verb)") 123 150 return nil 124 151 } 125 152 }
+2
supacode/Domain/Deeplink.swift
··· 19 19 case select 20 20 case run 21 21 case stop 22 + case runScript(scriptID: UUID) 23 + case stopScript(scriptID: UUID) 22 24 case archive 23 25 case unarchive 24 26 case delete
+125 -9
supacode/Features/App/Reducer/AppFeature.swift
··· 977 977 let worktreeID = resolveWorktreeID(rawWorktreeID, state: state) 978 978 guard state.repositories.worktree(for: worktreeID) != nil else { 979 979 deeplinkLogger.warning("Worktree not found: \(rawWorktreeID)") 980 - state.alert = AlertState { 981 - TextState("Worktree not found") 982 - } actions: { 983 - ButtonState(role: .cancel, action: .dismiss) { 984 - TextState("OK") 985 - } 986 - } message: { 987 - TextState("No worktree matching the deeplink could be found. It may have been removed.") 988 - } 980 + state.alert = worktreeNotFoundAlert() 989 981 return .none 990 982 } 991 983 ··· 1017 1009 return .send(.runScript) 1018 1010 case .stop: 1019 1011 return .send(.stopRunScripts) 1012 + case .runScript(let scriptID): 1013 + return runScriptDeeplinkEffect( 1014 + worktreeID: worktreeID, 1015 + scriptID: scriptID, 1016 + state: &state, 1017 + bypassConfirmation: bypassConfirmation, 1018 + responseFD: responseFD 1019 + ) 1020 + case .stopScript(let scriptID): 1021 + return stopScriptDeeplinkEffect(worktreeID: worktreeID, scriptID: scriptID, state: &state) 1020 1022 case .archive: 1021 1023 guard let repositoryID = resolveRepositoryID(for: worktreeID, label: "archive", state: &state) else { 1022 1024 return .none ··· 1135 1137 return sendTerminalCommand(worktreeID: worktreeID, state: state) { worktree in 1136 1138 .destroySurface(worktree, tabID: TerminalTabID(rawValue: tabID), surfaceID: surfaceID) 1137 1139 } 1140 + } 1141 + } 1142 + 1143 + private func runScriptDeeplinkEffect( 1144 + worktreeID: Worktree.ID, 1145 + scriptID: UUID, 1146 + state: inout State, 1147 + bypassConfirmation: Bool, 1148 + responseFD: Int32? 1149 + ) -> Effect<Action> { 1150 + // Read the target worktree's scripts directly so cross-worktree 1151 + // deeplinks do not depend on the currently selected worktree's 1152 + // `state.scripts`, which may still reflect an older selection. 1153 + guard let worktree = state.repositories.worktree(for: worktreeID) else { 1154 + state.alert = worktreeNotFoundAlert() 1155 + return .none 1156 + } 1157 + @SharedReader(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1158 + guard let definition = repositorySettings.scripts.first(where: { $0.id == scriptID }) else { 1159 + state.alert = scriptAlert( 1160 + title: "Script not found", 1161 + message: "No script matching the deeplink could be found. It may have been removed." 1162 + ) 1163 + return .none 1164 + } 1165 + let trimmed = definition.command.trimmingCharacters(in: .whitespacesAndNewlines) 1166 + guard !trimmed.isEmpty else { 1167 + state.alert = scriptAlert( 1168 + title: "Script has no command", 1169 + message: "\"\(definition.displayName)\" has an empty command. Configure it in Settings first." 1170 + ) 1171 + return .none 1172 + } 1173 + let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 1174 + guard !runningIDs.contains(scriptID) else { 1175 + state.alert = scriptAlert( 1176 + title: "Script already running", 1177 + message: "\"\(definition.displayName)\" is already running in this worktree." 1178 + ) 1179 + return .none 1180 + } 1181 + if requiresInputConfirmation(state: state, bypassConfirmation: bypassConfirmation) { 1182 + return presentDeeplinkConfirmation( 1183 + worktreeID: worktreeID, 1184 + responseFD: responseFD, 1185 + message: .command(definition.command), 1186 + action: .runScript(scriptID: scriptID), 1187 + state: &state 1188 + ) 1189 + } 1190 + analyticsClient.capture("script_run", ["kind": definition.kind.rawValue]) 1191 + var updated = runningIDs 1192 + updated.insert(scriptID) 1193 + state.repositories.runningScriptsByWorktreeID[worktreeID] = updated 1194 + let terminalClient = terminalClient 1195 + return .run { _ in 1196 + await terminalClient.send( 1197 + .runBlockingScript(worktree, kind: .script(definition), script: definition.command) 1198 + ) 1199 + } 1200 + } 1201 + 1202 + private func stopScriptDeeplinkEffect( 1203 + worktreeID: Worktree.ID, 1204 + scriptID: UUID, 1205 + state: inout State 1206 + ) -> Effect<Action> { 1207 + guard let worktree = state.repositories.worktree(for: worktreeID) else { 1208 + state.alert = worktreeNotFoundAlert() 1209 + return .none 1210 + } 1211 + @SharedReader(.repositorySettings(worktree.repositoryRootURL)) var repositorySettings 1212 + guard let definition = repositorySettings.scripts.first(where: { $0.id == scriptID }) else { 1213 + state.alert = scriptAlert( 1214 + title: "Script not found", 1215 + message: "No script matching the deeplink could be found. It may have been removed." 1216 + ) 1217 + return .none 1218 + } 1219 + let runningIDs = state.repositories.runningScriptsByWorktreeID[worktreeID] ?? [] 1220 + guard runningIDs.contains(scriptID) else { 1221 + state.alert = scriptAlert( 1222 + title: "Script not running", 1223 + message: "\"\(definition.displayName)\" is not currently running in this worktree." 1224 + ) 1225 + return .none 1226 + } 1227 + let terminalClient = terminalClient 1228 + return .run { _ in 1229 + await terminalClient.send(.stopScript(worktree, definitionID: scriptID)) 1230 + } 1231 + } 1232 + 1233 + private func scriptAlert(title: String, message: String) -> AlertState<Alert> { 1234 + AlertState { 1235 + TextState(title) 1236 + } actions: { 1237 + ButtonState(role: .cancel, action: .dismiss) { 1238 + TextState("OK") 1239 + } 1240 + } message: { 1241 + TextState(message) 1242 + } 1243 + } 1244 + 1245 + private func worktreeNotFoundAlert() -> AlertState<Alert> { 1246 + AlertState { 1247 + TextState("Worktree not found") 1248 + } actions: { 1249 + ButtonState(role: .cancel, action: .dismiss) { 1250 + TextState("OK") 1251 + } 1252 + } message: { 1253 + TextState("No worktree matching the deeplink could be found. It may have been removed.") 1138 1254 } 1139 1255 } 1140 1256
+325
supacodeTests/AppFeatureDeeplinkTests.swift
··· 168 168 await store.receive(\.stopRunScripts) 169 169 } 170 170 171 + // MARK: - Named script deeplinks. 172 + 173 + @Test(.dependencies) func runScriptDeeplinkShowsConfirmation() async { 174 + let worktree = makeWorktree() 175 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 176 + let rootURL = worktree.repositoryRootURL 177 + @Shared(.repositorySettings(rootURL)) var persisted = .default 178 + $persisted.withLock { $0.scripts = [definition] } 179 + defer { $persisted.withLock { $0.scripts = [] } } 180 + let store = TestStore( 181 + initialState: AppFeature.State( 182 + repositories: makeRepositoriesState(worktree: worktree), 183 + settings: SettingsFeature.State() 184 + ) 185 + ) { 186 + AppFeature() 187 + } 188 + store.exhaustivity = .off 189 + 190 + await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) 191 + #expect(store.state.deeplinkInputConfirmation?.message == .command("npm test")) 192 + #expect(store.state.deeplinkInputConfirmation?.action == .runScript(scriptID: definition.id)) 193 + } 194 + 195 + @Test(.dependencies) func runScriptDeeplinkSkipsConfirmationWhenPolicyAllows() async { 196 + let worktree = makeWorktree() 197 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 198 + let rootURL = worktree.repositoryRootURL 199 + @Shared(.repositorySettings(rootURL)) var persisted = .default 200 + $persisted.withLock { $0.scripts = [definition] } 201 + defer { $persisted.withLock { $0.scripts = [] } } 202 + let sent = LockIsolated<[TerminalClient.Command]>([]) 203 + var settings = SettingsFeature.State() 204 + settings.automatedActionPolicy = .always 205 + let store = TestStore( 206 + initialState: AppFeature.State( 207 + repositories: makeRepositoriesState(worktree: worktree), 208 + settings: settings 209 + ) 210 + ) { 211 + AppFeature() 212 + } withDependencies: { 213 + $0.terminalClient.send = { command in 214 + sent.withValue { $0.append(command) } 215 + } 216 + } 217 + store.exhaustivity = .off 218 + 219 + await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) 220 + await store.finish() 221 + 222 + #expect(store.state.deeplinkInputConfirmation == nil) 223 + let hasRun = sent.value.contains(where: { 224 + if case .runBlockingScript(_, .script(let sentDefinition), _) = $0 { 225 + return sentDefinition.id == definition.id 226 + } 227 + return false 228 + }) 229 + #expect(hasRun) 230 + } 231 + 232 + @Test(.dependencies) func runScriptDeeplinkWithUnknownScriptShowsAlert() async { 233 + let worktree = makeWorktree() 234 + let store = makeStore(worktree: worktree) 235 + 236 + await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: UUID())))) 237 + #expect(store.state.alert != nil) 238 + #expect(store.state.deeplinkInputConfirmation == nil) 239 + } 240 + 241 + @Test(.dependencies) func stopScriptDeeplinkSendsStopCommand() async { 242 + let worktree = makeWorktree() 243 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 244 + let rootURL = worktree.repositoryRootURL 245 + @Shared(.repositorySettings(rootURL)) var persisted = .default 246 + $persisted.withLock { $0.scripts = [definition] } 247 + defer { $persisted.withLock { $0.scripts = [] } } 248 + var repositories = makeRepositoriesState(worktree: worktree) 249 + repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 250 + let sent = LockIsolated<[TerminalClient.Command]>([]) 251 + let store = TestStore( 252 + initialState: AppFeature.State(repositories: repositories, settings: SettingsFeature.State()) 253 + ) { 254 + AppFeature() 255 + } withDependencies: { 256 + $0.terminalClient.send = { command in 257 + sent.withValue { $0.append(command) } 258 + } 259 + } 260 + store.exhaustivity = .off 261 + 262 + await store.send(.deeplink(.worktree(id: worktree.id, action: .stopScript(scriptID: definition.id)))) 263 + await store.finish() 264 + 265 + #expect(store.state.deeplinkInputConfirmation == nil) 266 + let hasStop = sent.value.contains(where: { 267 + if case .stopScript(_, let definitionID) = $0 { return definitionID == definition.id } 268 + return false 269 + }) 270 + #expect(hasStop) 271 + } 272 + 273 + @Test(.dependencies) func stopScriptDeeplinkWithUnknownScriptShowsAlert() async { 274 + let worktree = makeWorktree() 275 + let store = makeStore(worktree: worktree) 276 + 277 + await store.send(.deeplink(.worktree(id: worktree.id, action: .stopScript(scriptID: UUID())))) 278 + #expect(store.state.alert != nil) 279 + } 280 + 281 + @Test(.dependencies) func stopScriptDeeplinkWhenNotRunningShowsAlert() async { 282 + // A user running `supacode worktree stop --script <uuid>` for a script 283 + // that isn't currently running should get an explicit alert, not a 284 + // silent success that misleads the CLI into reporting ok:true. 285 + let worktree = makeWorktree() 286 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 287 + let rootURL = worktree.repositoryRootURL 288 + @Shared(.repositorySettings(rootURL)) var persisted = .default 289 + $persisted.withLock { $0.scripts = [definition] } 290 + defer { $persisted.withLock { $0.scripts = [] } } 291 + let sent = LockIsolated<[TerminalClient.Command]>([]) 292 + let store = TestStore( 293 + initialState: AppFeature.State( 294 + repositories: makeRepositoriesState(worktree: worktree), 295 + settings: SettingsFeature.State() 296 + ) 297 + ) { 298 + AppFeature() 299 + } withDependencies: { 300 + $0.terminalClient.send = { command in 301 + sent.withValue { $0.append(command) } 302 + } 303 + } 304 + store.exhaustivity = .off 305 + 306 + await store.send(.deeplink(.worktree(id: worktree.id, action: .stopScript(scriptID: definition.id)))) 307 + #expect(store.state.alert != nil) 308 + let didStop = sent.value.contains(where: { 309 + if case .stopScript = $0 { return true } 310 + return false 311 + }) 312 + #expect(!didStop) 313 + } 314 + 315 + @Test(.dependencies) func runScriptDeeplinkWithEmptyCommandShowsAlert() async { 316 + let worktree = makeWorktree() 317 + let definition = ScriptDefinition(kind: .test, name: "Test", command: " ") 318 + let rootURL = worktree.repositoryRootURL 319 + @Shared(.repositorySettings(rootURL)) var persisted = .default 320 + $persisted.withLock { $0.scripts = [definition] } 321 + defer { $persisted.withLock { $0.scripts = [] } } 322 + var settings = SettingsFeature.State() 323 + settings.automatedActionPolicy = .always 324 + let sent = LockIsolated<[TerminalClient.Command]>([]) 325 + let store = TestStore( 326 + initialState: AppFeature.State( 327 + repositories: makeRepositoriesState(worktree: worktree), 328 + settings: settings 329 + ) 330 + ) { 331 + AppFeature() 332 + } withDependencies: { 333 + $0.terminalClient.send = { command in 334 + sent.withValue { $0.append(command) } 335 + } 336 + } 337 + store.exhaustivity = .off 338 + 339 + await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) 340 + #expect(store.state.alert != nil) 341 + let didRun = sent.value.contains(where: { 342 + if case .runBlockingScript = $0 { return true } 343 + return false 344 + }) 345 + #expect(!didRun) 346 + } 347 + 348 + @Test(.dependencies) func runScriptDeeplinkWhenAlreadyRunningShowsAlert() async { 349 + let worktree = makeWorktree() 350 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 351 + let rootURL = worktree.repositoryRootURL 352 + @Shared(.repositorySettings(rootURL)) var persisted = .default 353 + $persisted.withLock { $0.scripts = [definition] } 354 + defer { $persisted.withLock { $0.scripts = [] } } 355 + var repositories = makeRepositoriesState(worktree: worktree) 356 + repositories.runningScriptsByWorktreeID = [worktree.id: [definition.id]] 357 + var settings = SettingsFeature.State() 358 + settings.automatedActionPolicy = .always 359 + let sent = LockIsolated<[TerminalClient.Command]>([]) 360 + let store = TestStore( 361 + initialState: AppFeature.State(repositories: repositories, settings: settings) 362 + ) { 363 + AppFeature() 364 + } withDependencies: { 365 + $0.terminalClient.send = { command in 366 + sent.withValue { $0.append(command) } 367 + } 368 + } 369 + store.exhaustivity = .off 370 + 371 + await store.send(.deeplink(.worktree(id: worktree.id, action: .runScript(scriptID: definition.id)))) 372 + #expect(store.state.alert != nil) 373 + let didRun = sent.value.contains(where: { 374 + if case .runBlockingScript = $0 { return true } 375 + return false 376 + }) 377 + #expect(!didRun) 378 + } 379 + 380 + @Test(.dependencies) func runScriptConfirmationAcceptedDispatchesCommand() async { 381 + let worktree = makeWorktree() 382 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 383 + let rootURL = worktree.repositoryRootURL 384 + @Shared(.repositorySettings(rootURL)) var persisted = .default 385 + $persisted.withLock { $0.scripts = [definition] } 386 + defer { $persisted.withLock { $0.scripts = [] } } 387 + let sent = LockIsolated<[TerminalClient.Command]>([]) 388 + var initialState = AppFeature.State( 389 + repositories: makeRepositoriesState(worktree: worktree), 390 + settings: SettingsFeature.State() 391 + ) 392 + initialState.deeplinkInputConfirmation = DeeplinkInputConfirmationFeature.State( 393 + worktreeID: worktree.id, 394 + worktreeName: worktree.name, 395 + repositoryName: "repo", 396 + message: .command(definition.command), 397 + action: .runScript(scriptID: definition.id) 398 + ) 399 + let store = TestStore(initialState: initialState) { 400 + AppFeature() 401 + } withDependencies: { 402 + $0.terminalClient.send = { command in 403 + sent.withValue { $0.append(command) } 404 + } 405 + } 406 + store.exhaustivity = .off 407 + 408 + await withKnownIssue("TCA @Presents dismiss tracking") { 409 + await store.send( 410 + .deeplinkInputConfirmation( 411 + .presented( 412 + .delegate( 413 + .confirm(worktreeID: worktree.id, action: .runScript(scriptID: definition.id), alwaysAllow: false))) 414 + ) 415 + ) { 416 + $0.deeplinkInputConfirmation = nil 417 + } 418 + } 419 + await store.finish() 420 + 421 + let hasRun = sent.value.contains(where: { 422 + if case .runBlockingScript(_, .script(let sentDefinition), _) = $0 { 423 + return sentDefinition.id == definition.id 424 + } 425 + return false 426 + }) 427 + #expect(hasRun) 428 + } 429 + 430 + @Test(.dependencies) func stopScriptSocketDeeplinkSendsErrorWhenNotRunning() async { 431 + // Regression guard: stopping a script that exists but isn't running 432 + // must surface an error on the socket responseFD so the CLI exits 433 + // non-zero instead of reporting a false positive. 434 + let worktree = makeWorktree() 435 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 436 + let rootURL = worktree.repositoryRootURL 437 + @Shared(.repositorySettings(rootURL)) var persisted = .default 438 + $persisted.withLock { $0.scripts = [definition] } 439 + defer { $persisted.withLock { $0.scripts = [] } } 440 + let store = TestStore( 441 + initialState: AppFeature.State( 442 + repositories: makeRepositoriesState(worktree: worktree), 443 + settings: SettingsFeature.State() 444 + ) 445 + ) { 446 + AppFeature() 447 + } 448 + store.exhaustivity = .off 449 + let (readFD, writeFD) = makePipe() 450 + defer { close(readFD) } 451 + 452 + await store.send( 453 + .deeplink( 454 + .worktree(id: worktree.id, action: .stopScript(scriptID: definition.id)), 455 + source: .socket, 456 + responseFD: writeFD 457 + ) 458 + ) 459 + await store.finish() 460 + 461 + let response = readPipeJSON(readFD) 462 + #expect(response?["ok"] as? Bool == false) 463 + #expect((response?["error"] as? String)?.isEmpty == false) 464 + } 465 + 466 + @Test(.dependencies) func runScriptSocketDeeplinkStoresResponseFDInConfirmation() async { 467 + let worktree = makeWorktree() 468 + let definition = ScriptDefinition(kind: .test, name: "Test", command: "npm test") 469 + let rootURL = worktree.repositoryRootURL 470 + @Shared(.repositorySettings(rootURL)) var persisted = .default 471 + $persisted.withLock { $0.scripts = [definition] } 472 + defer { $persisted.withLock { $0.scripts = [] } } 473 + var settings = SettingsFeature.State() 474 + settings.automatedActionPolicy = .never 475 + let store = TestStore( 476 + initialState: AppFeature.State( 477 + repositories: makeRepositoriesState(worktree: worktree), 478 + settings: settings 479 + ) 480 + ) { 481 + AppFeature() 482 + } 483 + store.exhaustivity = .off 484 + 485 + await store.send( 486 + .deeplink( 487 + .worktree(id: worktree.id, action: .runScript(scriptID: definition.id)), 488 + source: .socket, 489 + responseFD: 42 490 + ) 491 + ) 492 + #expect(store.state.deeplinkInputConfirmation?.responseFD == 42) 493 + #expect(store.state.deeplinkInputConfirmation?.action == .runScript(scriptID: definition.id)) 494 + } 495 + 171 496 // MARK: - Help deeplink. 172 497 173 498 @Test(.dependencies) func helpDeeplinkSetsReferenceRequested() async {
+48
supacodeTests/DeeplinkClientTests.swift
··· 361 361 #expect(parse(url) == .worktree(id: "/tmp/repo/wt-1", action: .stop)) 362 362 } 363 363 364 + // MARK: - Named script actions. 365 + 366 + @Test func worktreeScriptRun() { 367 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 368 + let scriptID = UUID(uuidString: "AA0E8400-E29B-41D4-A716-446655440000")! 369 + let url = URL(string: "supacode://worktree/\(encoded)/script/\(scriptID.uuidString)/run")! 370 + #expect( 371 + parse(url) 372 + == .worktree(id: "/tmp/repo/wt-1", action: .runScript(scriptID: scriptID)) 373 + ) 374 + } 375 + 376 + @Test func worktreeScriptStop() { 377 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 378 + let scriptID = UUID(uuidString: "AA0E8400-E29B-41D4-A716-446655440000")! 379 + let url = URL(string: "supacode://worktree/\(encoded)/script/\(scriptID.uuidString)/stop")! 380 + #expect( 381 + parse(url) 382 + == .worktree(id: "/tmp/repo/wt-1", action: .stopScript(scriptID: scriptID)) 383 + ) 384 + } 385 + 386 + @Test func worktreeScriptInvalidUUIDReturnsNil() { 387 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 388 + let url = URL(string: "supacode://worktree/\(encoded)/script/not-a-uuid/run")! 389 + #expect(parse(url) == nil) 390 + } 391 + 392 + @Test func worktreeScriptUnknownVerbReturnsNil() { 393 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 394 + let scriptID = UUID(uuidString: "AA0E8400-E29B-41D4-A716-446655440000")! 395 + let url = URL(string: "supacode://worktree/\(encoded)/script/\(scriptID.uuidString)/explode")! 396 + #expect(parse(url) == nil) 397 + } 398 + 399 + @Test func worktreeScriptMissingVerbReturnsNil() { 400 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 401 + let scriptID = UUID(uuidString: "AA0E8400-E29B-41D4-A716-446655440000")! 402 + let url = URL(string: "supacode://worktree/\(encoded)/script/\(scriptID.uuidString)")! 403 + #expect(parse(url) == nil) 404 + } 405 + 406 + @Test func worktreeScriptMissingIDReturnsNil() { 407 + let encoded = "%2Ftmp%2Frepo%2Fwt-1" 408 + let url = URL(string: "supacode://worktree/\(encoded)/script")! 409 + #expect(parse(url) == nil) 410 + } 411 + 364 412 // MARK: - Worktree with no ID. 365 413 366 414 @Test func worktreeWithNoIDReturnsNil() {