ask-coding-harness 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bb36f1a55f6b263fdbffd97383c1a744d9b4864a74d04d3003270134437bc3fd
4
+ data.tar.gz: f2a6fd12ffb36d5fff6baf1d59151c913dd5596db22a5d6abd366494d57be328
5
+ SHA512:
6
+ metadata.gz: 37ba3d010ae2ca1f044e919b2e82f3fc55e82bf96d792c65896e5e4141a225cecbcdc8e82c07d06eca24b12089d4e624b679b2a8fc5bd42d149b7e3047e37333
7
+ data.tar.gz: 3cd3c97b80c739cd6b9a6182241083856d514a2aee9f1ee6c8758f48671847377efe19f67a3a232c4eb412c725b51b3b3acd0b9f2df59381177bdf21e211643b
data/CHANGELOG.md ADDED
@@ -0,0 +1,42 @@
1
+ # Changelog
2
+
3
+ ## [0.2.0] - 2026-08-10
4
+
5
+ ### Added
6
+
7
+ - **Demo adapter and `ach demo`** — a scripted coding agent (todos, tool
8
+ calls, diffs, approval flow) for trying the harness without API keys.
9
+ - **`ach run --adapter NAME`** — pick the coding agent adapter for headless
10
+ runs (ask_agent, demo, acp, ...).
11
+ - **Approval-aware external adapters** — the configured approval mode
12
+ passes through to external coding-agent adapters (the demo adapter
13
+ auto-approves in headless mode).
14
+ - **Web frontend** — mobile-first PWA built on ask-ui-kit: live tool
15
+ cards, approvals, todos, plans, diff/terminal viewers, conversation
16
+ sidebar, settings; installable with service worker + manifest.
17
+ - **ask-ui-kit components** — `ask-diff`, `ask-tool-approval`,
18
+ `ask-todo-list`, `ask-plan`, `ask-terminal-output`.
19
+
20
+ ### Fixed
21
+
22
+ - Server startup via Rackup (CLI now wires the server with its own config;
23
+ flags like `--adapter` previously lost to the module-global config).
24
+ - Frontend turn state: Svelte 5 `$state` does not proxy `Map` mutations —
25
+ tool cards now render by replacing the map immutably.
26
+ - Duplicate assistant message after turn completion (the finished turn is
27
+ folded into the message list on the next send).
28
+
29
+ ## [0.1.0] - 2026-08-10
30
+
31
+ ### Added
32
+
33
+ - **Initial release** — general-purpose coding agent in the browser.
34
+ - **Harness core** — config (approval modes, plan mode, todos), conversation
35
+ store on ask-state-providers, event translator normalizing adapter events
36
+ into one browser-friendly SSE schema, agent runner (ask-agent runtime via
37
+ ask-coding-providers, extensible to ACP/Codex/Claude adapters).
38
+ - **Web server** — Roda REST + SSE: conversations CRUD, streaming turns,
39
+ approvals, plan approve/reject, abort, SPA fallback, CORS.
40
+ - **CLI** — `ach serve`, `ach run` (headless dogfooding path), `ach
41
+ sessions`, `ach version`; `ask-coding-harness` executable.
42
+ - **Renamed from askoda** — full git history preserved.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kaka Ruto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # ask-coding-harness
2
+
3
+ **A general-purpose coding agent in the browser.** Self-hosted, mobile-first,
4
+ and built on the ask-rb ecosystem. Point it at any project directory and let
5
+ the agent fire away — reading, writing, and running commands while you watch
6
+ every tool call, approve what needs approving, and review the diffs.
7
+
8
+ ## Quick start
9
+
10
+ ```bash
11
+ gem install ask-coding-harness
12
+
13
+ cd /path/to/your/project
14
+ ask-coding-harness # or: ach serve
15
+ ```
16
+
17
+ Open http://localhost:8080. Set a model via `ACH_MODEL` (or
18
+ `ASK_AGENT_MODEL`) and an API key for the provider (e.g. `OPENCODE_API_KEY`).
19
+
20
+ **No API key yet?** Try the scripted demo agent:
21
+
22
+ ```bash
23
+ ach demo
24
+ ```
25
+
26
+ ## What you get
27
+
28
+ - **The agent, unleashed** — `ask-agent` runs the loop in-process with the
29
+ shell toolset (`bash`, `read`, `write`, `edit`, `grep`, `glob`, `code`,
30
+ `apply_patch`), all routed through `ask-sandbox-providers`.
31
+ - **Live event stream** — text deltas, thinking, tool calls, todos, and
32
+ plan proposals stream to the browser over SSE as they happen.
33
+ - **Approvals** — mutating tools (`bash`, `write`, `edit`, ...) queue for
34
+ your approval by default; approve, reject, or approve-all from the UI.
35
+ - **Plan mode** — opt in with `ACH_PLAN_MODE=1`: the agent researches
36
+ read-only, proposes a plan, and only executes after you approve it.
37
+ - **Conversations** — saved to SQLite, resumable, renameable, archivable.
38
+ - **PWA** — installable on desktop and mobile, works offline for the shell
39
+ (the agent itself needs the server).
40
+ - **Extensible** — other coding agents (Codex, Claude Code, ACP-based)
41
+ plug in via `ask-coding-providers` by setting `ACH_ADAPTER=acp`.
42
+
43
+ ## The `ach` CLI
44
+
45
+ ```bash
46
+ ach serve # web server (default command)
47
+ ach demo # scripted agent — no API key needed
48
+ ach run "refactor the auth flow" # headless run, prints a transcript
49
+ ach sessions # list saved conversations
50
+ ach version
51
+ ```
52
+
53
+ `ach run` is the dogfooding path: ask-coding-harness builds itself with it.
54
+
55
+ ## Configuration
56
+
57
+ | Env var | Default | Purpose |
58
+ |---|---|---|
59
+ | `ACH_WORKSPACE` | current dir | project the agent operates on |
60
+ | `ACH_HOST` / `ACH_PORT` | `0.0.0.0` / `8080` | server bind |
61
+ | `ACH_MODEL` | `deepseek-v4-flash` | default model |
62
+ | `ACH_ADAPTER` | `ask_agent` | coding agent adapter (`ask_agent`, `acp`, ...) |
63
+ | `ACH_APPROVAL` | `require` | `off`, `require`, `auto` |
64
+ | `ACH_PLAN_MODE` | off | plan mode (research first, then execute) |
65
+ | `ACH_TODOS` | on | todo list tool |
66
+ | `ACH_DB_PATH` | `./data/ask-coding-harness.db` | conversation database |
67
+ | `ACH_SYSTEM_PROMPT` | — | extra system prompt lines |
68
+
69
+ Programmatic use:
70
+
71
+ ```ruby
72
+ require "ask-coding-harness"
73
+
74
+ Ask::CodingHarness.configure do |c|
75
+ c.workspace = "/path/to/project"
76
+ c.model = "claude-sonnet-4"
77
+ c.approval = :require
78
+ end
79
+
80
+ result = Ask::CodingHarness.run("Run the test suite and fix failures")
81
+ puts result.response
82
+ ```
83
+
84
+ ## Architecture
85
+
86
+ ```
87
+ Browser (PWA) ──SSE──> Server (Roda) ──> AgentRunner ──> ask-coding-providers
88
+ ├─ ask_agent (default)
89
+ └─ acp / codex / claude
90
+ └── SQLite (ask-state-providers) <── Store
91
+ ```
92
+
93
+ The server is a thin shell: conversations live in the Store, turns run in
94
+ the AgentRunner, adapter events normalize through the EventTranslator into
95
+ one browser-friendly schema.
96
+
97
+ ## Development
98
+
99
+ ```bash
100
+ bundle install
101
+ bundle exec rake test # Ruby tests
102
+ (cd web && npm install && npm run build) # build the PWA into public/
103
+ (cd web && npm test) # frontend unit tests
104
+ ```
105
+
106
+ The frontend imports `ask-ui-kit` from source (a sibling repo, via a vite
107
+ alias) — keep `ask-ui-kit` next to this repo and rebuild it after changing
108
+ components.
109
+
110
+ ## The ask-rb ecosystem
111
+
112
+ ask-coding-harness is part of the ask-rb ecosystem: `ask-agent` runs the
113
+ loop, `ask-coding-providers` supplies the adapters, `ask-tools-shell` the
114
+ tools (sandboxed via `ask-sandbox-providers`), `ask-state-providers` the
115
+ storage, and `ask-ui-kit` the reusable web components. See
116
+ https://ask-rb.github.io/ask-docs for the full picture.
117
+
118
+ ## License
119
+
120
+ MIT — see [LICENSE](LICENSE).
data/bin/ach ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ask-coding-harness"
5
+ require "ask/coding_harness/cli"
6
+
7
+ Ask::CodingHarness::CLI.run(ARGV)
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "ask-coding-harness"
5
+ require "ask/coding_harness/cli"
6
+
7
+ Ask::CodingHarness::CLI.run(ARGV)
@@ -0,0 +1,260 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thread"
4
+
5
+ module Ask
6
+ module CodingHarness
7
+ # Runs coding-agent turns for conversations: maps each conversation to
8
+ # an adapter session, streams translated events, and persists assistant
9
+ # messages back into the store.
10
+ #
11
+ # The default runtime is ask-agent (via the ask_agent adapter). Other
12
+ # coding agents (Codex, Claude Code, ACP-based agents) plug in through
13
+ # ask-coding-providers by setting ACH_ADAPTER/CODING_PROVIDER — the
14
+ # runner only needs the adapter's duck-typed surface:
15
+ # create_session, send_and_stream, and (when supported) approve_action,
16
+ # reject_action, approve_all, approve_plan, reject_plan, abort.
17
+ class AgentRunner
18
+ # Tool name → ask-tools-shell class, for the ask_agent runtime.
19
+ TOOL_CLASSES = {
20
+ "bash" => Ask::Tools::Bash,
21
+ "read" => Ask::Tools::Read,
22
+ "write" => Ask::Tools::Write,
23
+ "edit" => Ask::Tools::Edit,
24
+ "glob" => Ask::Tools::Glob,
25
+ "grep" => Ask::Tools::Grep,
26
+ "code" => Ask::Tools::Code,
27
+ "apply_patch" => Ask::Tools::ApplyPatch,
28
+ "repl" => Ask::Tools::Repl
29
+ }.freeze
30
+
31
+ # @param config [Config] harness configuration
32
+ # @param store [Store] conversation persistence
33
+ # @param translator [EventTranslator] adapter event → SSE event
34
+ # @param tool_classes [Hash{String => Class}] tool name → class map;
35
+ # overridable so hosts can register custom tools
36
+ def initialize(config:, store:, translator: EventTranslator.new, tool_classes: TOOL_CLASSES)
37
+ @config = config
38
+ @store = store
39
+ @translator = translator
40
+ @tool_classes = tool_classes
41
+ @adapter = nil
42
+ @mutex = Mutex.new
43
+ @adapter_mutex = Mutex.new
44
+ @sessions = {}
45
+ @turns = {}
46
+ end
47
+
48
+ # The adapter (created lazily on first use). Uses its own mutex so
49
+ # callers holding @mutex (session_for, stop) can still build it.
50
+ def adapter
51
+ @adapter_mutex.synchronize { @adapter ||= build_adapter }
52
+ end
53
+
54
+ # Run a turn for a conversation, streaming harness events to the
55
+ # block. Runs in a background thread; returns the thread.
56
+ #
57
+ # @param conversation [Hash] conversation record (from the store)
58
+ # @param prompt [String] the user message
59
+ # @param model [String, nil] model override
60
+ # @yield [Hash] harness events ({ type:, data: })
61
+ # @return [Thread]
62
+ def start_turn(conversation, prompt, model: nil, &on_event)
63
+ raise ArgumentError, "prompt must not be empty" if prompt.to_s.strip.empty?
64
+
65
+ thread = Thread.new do
66
+ begin
67
+ run_turn(conversation, prompt, model: model, &on_event)
68
+ rescue => e
69
+ emit(on_event, { type: "error", data: { error: e.message } })
70
+ end
71
+ end
72
+ @mutex.synchronize { @turns[conversation["id"]] = thread }
73
+ thread
74
+ end
75
+
76
+ def running?(conversation_id)
77
+ thread = @mutex.synchronize { @turns[conversation_id] }
78
+ thread&.alive? ? true : false
79
+ end
80
+
81
+ # ── Approval / plan controls (no-ops when the adapter lacks them) ──
82
+
83
+ def approve(conversation_id, action_id)
84
+ send_adapter(conversation_id, :approve_action, action_id)
85
+ end
86
+
87
+ def reject(conversation_id, action_id)
88
+ send_adapter(conversation_id, :reject_action, action_id)
89
+ end
90
+
91
+ def approve_all(conversation_id)
92
+ send_adapter(conversation_id, :approve_all)
93
+ end
94
+
95
+ def approve_plan(conversation_id)
96
+ send_adapter(conversation_id, :approve_plan)
97
+ end
98
+
99
+ def reject_plan(conversation_id)
100
+ send_adapter(conversation_id, :reject_plan)
101
+ end
102
+
103
+ def abort(conversation_id)
104
+ send_adapter(conversation_id, :abort)
105
+ end
106
+
107
+ def pending_approvals(conversation_id)
108
+ sid = @mutex.synchronize { @sessions[conversation_id] }
109
+ return [] unless sid
110
+ if adapter.respond_to?(:pending_approvals)
111
+ adapter.pending_approvals(sid)
112
+ else
113
+ []
114
+ end
115
+ end
116
+
117
+ def stop
118
+ @mutex.synchronize do
119
+ @turns.each_value { |t| t.kill if t.alive? }
120
+ @turns.clear
121
+ @sessions.clear
122
+ @adapter&.stop if @adapter.respond_to?(:stop)
123
+ @adapter = nil
124
+ end
125
+ end
126
+
127
+ private
128
+
129
+ def run_turn(conversation, prompt, model: nil, &on_event)
130
+ sid = session_for(conversation, model: model)
131
+
132
+ conv = @store.load(conversation["id"]) || conversation
133
+ conv["messages"] << { "role" => "user", "content" => prompt, "created_at" => Time.now.iso8601 }
134
+ @store.save(conv)
135
+
136
+ outcome = :completed
137
+ accumulated = +""
138
+ adapter.send_and_stream(sid, prompt, turn_timeout: @config.turn_timeout) do |event|
139
+ translated = @translator.translate(event)
140
+ next unless translated
141
+
142
+ case translated[:type]
143
+ when "message.delta"
144
+ accumulated << translated[:data][:delta].to_s
145
+ when "turn.failed"
146
+ outcome = :failed
147
+ when "turn.aborted"
148
+ outcome = :aborted
149
+ end
150
+ emit(on_event, translated)
151
+ end
152
+
153
+ persist_outcome(conversation["id"], accumulated, outcome)
154
+ end
155
+
156
+ # Persist the assistant turn outcome (response, error, or abort) so
157
+ # the conversation reads correctly when reopened. Best-effort; the
158
+ # streamed events are the source of truth for the live view.
159
+ def persist_outcome(conversation_id, accumulated, outcome)
160
+ conv = @store.load(conversation_id)
161
+ return unless conv
162
+ # Only persist when the last message is still the user's prompt.
163
+ last = conv["messages"].last
164
+ return if last && last["role"] == "assistant"
165
+
166
+ content =
167
+ case outcome
168
+ when :aborted then "Turn aborted."
169
+ when :failed then accumulated.empty? ? "The turn failed." : accumulated
170
+ else accumulated
171
+ end
172
+ conv["messages"] << { "role" => "assistant", "content" => content, "created_at" => Time.now.iso8601 }
173
+ @store.save(conv)
174
+ rescue StandardError
175
+ nil
176
+ end
177
+
178
+ # One adapter session per conversation, created on first use.
179
+ def session_for(conversation, model: nil)
180
+ @mutex.synchronize do
181
+ @sessions[conversation["id"]] ||= adapter.create_session(
182
+ conversation["directory"] || @config.workspace,
183
+ model: model || @config.model
184
+ )
185
+ end
186
+ end
187
+
188
+ # Dispatch a control to the adapter, returning nil when the adapter
189
+ # doesn't support it (e.g. external ACP agents without approvals).
190
+ def send_adapter(conversation_id, method_name, *args)
191
+ sid = @mutex.synchronize { @sessions[conversation_id] }
192
+ return nil unless sid
193
+ return nil unless adapter.respond_to?(method_name)
194
+ adapter.public_send(method_name, sid, *args)
195
+ end
196
+
197
+ def emit(on_event, event)
198
+ on_event&.call(event)
199
+ end
200
+
201
+ def build_adapter
202
+ case @config.adapter.to_s
203
+ when "ask_agent"
204
+ build_ask_agent_adapter
205
+ else
206
+ build_external_adapter
207
+ end
208
+ end
209
+
210
+ def build_ask_agent_adapter
211
+ require "ask-coding-providers"
212
+ adapter = Ask::CodingProviders::AskAgent::Adapter.new(
213
+ model: @config.model,
214
+ provider: ENV.fetch("ASK_AGENT_LLM_PROVIDER", "opencode_go"),
215
+ tools: resolve_tools,
216
+ max_turns: @config.max_turns,
217
+ approval: @config.approval,
218
+ approval_required: @config.approval_policy_tools,
219
+ plan_mode: @config.plan_mode,
220
+ todos: @config.todos,
221
+ system_prompt: default_system_prompt
222
+ )
223
+ adapter.start
224
+ # Tools operate relative to the process working directory; the
225
+ # workspace IS the working directory for this server.
226
+ Dir.chdir(@config.workspace) if File.directory?(@config.workspace)
227
+ adapter
228
+ end
229
+
230
+ def build_external_adapter
231
+ require "ask/coding_providers"
232
+ adapter = Ask::CodingProviders.build_adapter(
233
+ @config.adapter,
234
+ workspace_path: @config.workspace,
235
+ turn_timeout: @config.turn_timeout,
236
+ approval: @config.approval
237
+ )
238
+ adapter.start if adapter.respond_to?(:start)
239
+ adapter
240
+ end
241
+
242
+ def resolve_tools
243
+ @config.tools.filter_map do |name|
244
+ klass = @tool_classes[name.to_s]
245
+ raise ArgumentError, "Unknown tool: #{name}" unless klass
246
+ klass.new
247
+ end
248
+ end
249
+
250
+ def default_system_prompt
251
+ base = "You are the coding agent for the workspace \"#{File.basename(@config.workspace)}\" " \
252
+ "at #{@config.workspace}. You can read, write, and edit files, search the codebase, " \
253
+ "and run shell commands to inspect and modify the project. Work autonomously: " \
254
+ "investigate, make changes, and verify your work."
255
+ extra = ENV["ACH_SYSTEM_PROMPT"]
256
+ extra ? "#{base}\n\n#{extra}" : base
257
+ end
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "optparse"
4
+
5
+ module Ask
6
+ module CodingHarness
7
+ # Command-line interface for the coding harness.
8
+ #
9
+ # ach serve Start the web server (default)
10
+ # ach run "task" Run a prompt headlessly against the workspace
11
+ # ach sessions List saved conversations
12
+ # ach version Print the version
13
+ # ach help Show help
14
+ #
15
+ # The `ask-coding-harness` executable runs `serve` by default.
16
+ class CLI
17
+ def self.run(argv, stdout: $stdout, stderr: $stderr)
18
+ new(argv, stdout: stdout, stderr: stderr).run
19
+ end
20
+
21
+ def initialize(argv, stdout: $stdout, stderr: $stderr)
22
+ @argv = argv.dup
23
+ @stdout = stdout
24
+ @stderr = stderr
25
+ # Fresh config per invocation: the CLI is a process boundary and
26
+ # must honor the environment at launch, not a memoized global.
27
+ @config = Ask::CodingHarness::Config.new
28
+ end
29
+
30
+ def run
31
+ command = @argv.shift || "serve"
32
+ case command
33
+ when "serve", "server" then serve
34
+ when "demo" then serve(demo: true)
35
+ when "run" then run_headless
36
+ when "sessions" then list_sessions
37
+ when "version", "--version", "-v" then @stdout.puts Ask::CodingHarness::VERSION
38
+ when "help", "--help", "-h" then help
39
+ else
40
+ @stderr.puts "Unknown command: #{command}"
41
+ help
42
+ exit 1
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def serve(demo: false)
49
+ if demo
50
+ @config.adapter = "demo"
51
+ require "ask/coding_harness/demo_adapter"
52
+ @stdout.puts "demo mode: scripted agent, no API key needed"
53
+ end
54
+ options = { host: @config.host, port: @config.port }
55
+ parser = OptionParser.new do |o|
56
+ o.banner = "Usage: ach serve [options]"
57
+ o.on("-H", "--host HOST", "Bind host (default #{@config.host})") { |v| options[:host] = v }
58
+ o.on("-p", "--port PORT", Integer, "Port (default #{@config.port})") { |v| options[:port] = v }
59
+ o.on("-w", "--workspace DIR", "Workspace directory (default #{@config.workspace})") { |v| @config.workspace = v }
60
+ o.on("--model MODEL", "Default model") { |v| @config.model = v }
61
+ o.on("--approval MODE", "off|require|auto") { |v| @config.approval = v.to_sym }
62
+ o.on("--plan-mode", "Enable plan mode") { @config.plan_mode = true }
63
+ o.on("--no-todos", "Disable the todo list") { @config.todos = false }
64
+ o.on("-h", "--help", "Show help") { puts o; return }
65
+ end
66
+ parser.parse!(@argv)
67
+
68
+ require "ask/coding_harness/server"
69
+ require "rackup"
70
+ @stdout.puts "ask-coding-harness #{Ask::CodingHarness::VERSION}"
71
+ @stdout.puts "workspace: #{@config.workspace}"
72
+ @stdout.puts "model: #{@config.model}"
73
+ @stdout.puts "approval: #{@config.approval}"
74
+ @stdout.puts "listening: http://#{options[:host]}:#{options[:port]}"
75
+ store = Store.new(db_path: @config.db_path)
76
+ runner = AgentRunner.new(config: @config, store: store)
77
+ app = Server.build(config: @config, store: store, runner: runner).freeze.app
78
+ Rackup::Server.start(app: app, Host: options[:host], Port: options[:port])
79
+ end
80
+
81
+ def run_headless
82
+ options = { quiet: false, model: nil, workspace: nil, approval: :off, adapter: nil }
83
+ parser = OptionParser.new do |o|
84
+ o.banner = "Usage: ach run [options] \"prompt\""
85
+ o.on("-w", "--workspace DIR", "Workspace directory") { |v| options[:workspace] = v }
86
+ o.on("--model MODEL", "Model to use") { |v| options[:model] = v }
87
+ o.on("--adapter NAME", "Coding agent adapter (ask_agent, demo, acp, ...)") { |v| options[:adapter] = v }
88
+ o.on("--approval MODE", "off|require|auto (default off)") { |v| options[:approval] = v.to_sym }
89
+ o.on("-q", "--quiet", "Suppress the transcript") { options[:quiet] = true }
90
+ o.on("-h", "--help", "Show help") { puts o; return }
91
+ end
92
+ parser.parse!(@argv)
93
+ if options[:adapter] == "demo"
94
+ require "ask/coding_harness/demo_adapter"
95
+ end
96
+ @config.adapter = options[:adapter] if options[:adapter]
97
+
98
+ prompt = @argv.join(" ").strip
99
+ if prompt.empty?
100
+ @stderr.puts "Error: a prompt is required"
101
+ @stderr.puts "Usage: ach run [options] \"prompt\""
102
+ exit 1
103
+ end
104
+
105
+ @config.workspace = options[:workspace] if options[:workspace]
106
+ require "ask/coding_harness/runner"
107
+ result = Runner.new(config: @config, approval: options[:approval]).run(
108
+ prompt, model: options[:model], quiet: options[:quiet]
109
+ )
110
+ exit(result.success? ? 0 : 1)
111
+ end
112
+
113
+ def list_sessions
114
+ store = Store.new(db_path: @config.db_path)
115
+ conversations = store.list
116
+ if conversations.empty?
117
+ @stdout.puts "No conversations yet."
118
+ return
119
+ end
120
+ conversations.each do |c|
121
+ @stdout.puts "#{c["id"][0, 8]} #{c["title"]} (#{c["message_count"]} messages, #{c["updated_at"]})"
122
+ end
123
+ end
124
+
125
+ def help
126
+ @stdout.puts <<~HELP
127
+ ask-coding-harness #{Ask::CodingHarness::VERSION} — general-purpose coding agent in the browser.
128
+
129
+ Usage:
130
+ ach serve Start the web server (default)
131
+ ach demo Serve with the scripted demo agent (no API key)
132
+ ach run "task" Run a prompt headlessly in the workspace
133
+ ach sessions List saved conversations
134
+ ach version Print the version
135
+ ach help Show this help
136
+
137
+ Environment:
138
+ ACH_WORKSPACE workspace directory (default: current dir)
139
+ ACH_HOST / ACH_PORT
140
+ ACH_MODEL default model
141
+ ACH_ADAPTER coding agent adapter: ask_agent (default) or any
142
+ ask-coding-providers adapter (acp, codex, claude)
143
+ ACH_APPROVAL off | require | auto (default: require)
144
+ ACH_PLAN_MODE enable plan mode (1/true)
145
+ ACH_TODOS todo list (default: on)
146
+ ACH_DB_PATH conversation database path
147
+ HELP
148
+ end
149
+ end
150
+ end
151
+ end