runspec-claude 0.1.0__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,61 @@
1
+ # runspec-claude Changelog
2
+
3
+ ## [0.1.0] — 2026-06-22
4
+
5
+ Initial release.
6
+
7
+ One runnable, `claude-run`, that drives the **Claude Code CLI** (`claude`)
8
+ headlessly so you can use Claude — and let the runspec-console agent (itself
9
+ Claude) delegate to Claude — as a runspec tool. Four verbs (subcommands) mirror
10
+ the way you talk to Claude in a chat app:
11
+
12
+ - **`claude-run ask "<prompt>"`** — start a fresh headless turn / session.
13
+ - **`claude-run continue "<prompt>"`** — continue the most recent session in this
14
+ directory (`claude --continue`).
15
+ - **`claude-run resume --session <id> "<prompt>"`** — continue a specific prior
16
+ session by id (`claude --resume`).
17
+ - **`claude-run sessions`** — list recent sessions you can resume (read-only).
18
+
19
+ Each verb shells out to `claude -p … --output-format json` and returns the
20
+ structured result: `result` text, `session_id`, `num_turns`, `total_cost_usd`,
21
+ `is_error`, and the full `raw` payload. The returned `session_id` lets a caller
22
+ (or the console agent) resume the same delegated worker on a later turn — the
23
+ "drive yourself across turns" loop with one tool.
24
+
25
+ **MCP surface.** `runspec serve` flattens the runnable into one leaf tool per
26
+ verb — `claude-run_ask`, `claude-run_continue`, `claude-run_resume`,
27
+ `claude-run_sessions` — so the console agent sees four clearly-named tools while
28
+ the package stays a single installable entry point. Each verb tool also carries
29
+ the inherited global args (requires `runspec >= 0.44.0`), so an operator or the
30
+ agent can set `permission-mode` / `model` / etc. per call — still behind the
31
+ `confirm` gate.
32
+
33
+ **Safe-by-default, operator-governed.** Every action verb is
34
+ `autonomy = "confirm"` (a human approves each delegation); `sessions` is
35
+ `autonomous`. The headless run defaults to `--permission-mode default` (it can
36
+ read and plan but blocks on edits/commands) with `--max-turns 12`. The delegated
37
+ run's power — `permission-mode`, `model`, working directory, `allowed-tools`,
38
+ `append-system-prompt`, `bare` — is set through global args (operator-controlled,
39
+ inherited by every verb), not by the autonomous agent: the agent's tools take a
40
+ prompt (and `session`/`limit`); a human or the run form chooses how much the
41
+ nested agent may do. Opt up explicitly, e.g.
42
+ `claude-run --permission-mode acceptEdits ask "…"`.
43
+
44
+ **Session listing** reads Claude Code's transcript store
45
+ (`~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`, `CLAUDE_CONFIG_DIR`
46
+ honoured). The per-line schema is undocumented, so titles are best-effort
47
+ (sniffed from the first user message) and the lister degrades gracefully — an
48
+ absent directory yields an empty list.
49
+
50
+ **Entry point** is deliberately named `claude-run`, not `claude`, so installing
51
+ this package never shadows the real Claude Code CLI binary.
52
+
53
+ **Public Python API** for wrapper packages that want to bake in corporate
54
+ defaults (a fixed model, allowlist, or working directory): `run_claude`,
55
+ `list_sessions`, `encode_project_dir`, `ClaudeError`, `ClaudeNotFoundError`.
56
+ Every function takes plain parameters and raises typed errors — the same
57
+ core/wrapper split used by `runspec-jsm` / `runspec-logops`.
58
+
59
+ **Requirements.** The `claude` CLI must be installed on the host where the
60
+ runnable runs (it is invoked over `subprocess`). Zero extra runtime
61
+ dependencies beyond `runspec`.
@@ -0,0 +1,10 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-claude
3
+ Version: 0.1.0
4
+ Summary: Drive the Claude Code CLI (`claude`) headlessly as a runspec runnable
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: runspec>=0.44.0
7
+ Provides-Extra: dev
8
+ Requires-Dist: mypy; extra == 'dev'
9
+ Requires-Dist: pytest>=8.0; extra == 'dev'
10
+ Requires-Dist: ruff; extra == 'dev'
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-claude"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Drive the Claude Code CLI (`claude`) headlessly as a runspec runnable"
10
+ dependencies = [
11
+ # 0.44.0 surfaces inherited global args (model, permission-mode, …) on each
12
+ # leaf subcommand's MCP tool, so the console agent can set them per call.
13
+ "runspec>=0.44.0",
14
+ ]
15
+
16
+ [project.scripts]
17
+ # Entry point name MUST match the runnable name. Deliberately `claude-run`, not
18
+ # `claude`, so installing this never shadows the real Claude Code CLI binary.
19
+ claude-run = "runspec_claude.cli:main"
20
+
21
+ [project.optional-dependencies]
22
+ dev = [
23
+ "ruff",
24
+ "mypy",
25
+ "pytest>=8.0",
26
+ ]
27
+
28
+ [tool.pytest.ini_options]
29
+ testpaths = ["tests"]
30
+
31
+ [tool.mypy]
32
+ python_version = "3.10"
33
+
34
+ [tool.ruff]
35
+ line-length = 200
36
+ target-version = "py310"
37
+
38
+ [tool.ruff.lint]
39
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,24 @@
1
+ """runspec-claude — drive the Claude Code CLI (`claude`) headlessly from runspec.
2
+
3
+ Public API (importable for a private wrapper package that bakes in defaults):
4
+
5
+ from runspec_claude import run_claude, list_sessions, ClaudeError
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .runner import (
11
+ ClaudeError,
12
+ ClaudeNotFoundError,
13
+ encode_project_dir,
14
+ list_sessions,
15
+ run_claude,
16
+ )
17
+
18
+ __all__ = [
19
+ "run_claude",
20
+ "list_sessions",
21
+ "encode_project_dir",
22
+ "ClaudeError",
23
+ "ClaudeNotFoundError",
24
+ ]
@@ -0,0 +1,82 @@
1
+ """cli.py — the `claude-run` entry point.
2
+
3
+ One binary, four verbs. ``rs.parse("claude-run")`` resolves the subcommand and
4
+ the (globals + per-command) args; we dispatch on ``spec.runspec_command`` and
5
+ delegate the actual work to :mod:`runspec_claude.runner`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import logging
12
+ import sys
13
+ from datetime import datetime, timezone
14
+ from typing import Any
15
+
16
+ import runspec as rs
17
+
18
+ from .runner import ClaudeError, ClaudeNotFoundError, list_sessions, run_claude
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def _opt(arg: Any) -> Any:
24
+ """Underlying value of a runspec ``Arg`` (or the value as-is); '' -> None."""
25
+ value = getattr(arg, "value", arg)
26
+ if isinstance(value, str) and not value:
27
+ return None
28
+ return value
29
+
30
+
31
+ def _emit(payload: dict[str, Any]) -> None:
32
+ print(json.dumps(payload))
33
+
34
+
35
+ def _run_turn(spec: rs.RunSpec, *, mode: str, session: str | None) -> None:
36
+ """Shared body for ask / continue / resume."""
37
+ result = run_claude(
38
+ str(spec.prompt.value),
39
+ mode=mode,
40
+ session=session,
41
+ model=_opt(spec.model),
42
+ cwd=_opt(spec.dir),
43
+ add_dir=_opt(spec.add_dir),
44
+ permission_mode=_opt(spec.permission_mode),
45
+ allowed_tools=_opt(spec.allowed_tools),
46
+ max_turns=_opt(spec.max_turns),
47
+ append_system_prompt=_opt(spec.append_system_prompt),
48
+ bare=bool(spec.bare.value),
49
+ )
50
+ if result.get("session_id"):
51
+ logger.info("session %s (%s turns)", result["session_id"], result.get("num_turns", "?"))
52
+ _emit(result)
53
+ if result.get("is_error"):
54
+ sys.exit(1)
55
+
56
+
57
+ def main() -> None:
58
+ spec = rs.parse("claude-run")
59
+ command = spec.runspec_command
60
+
61
+ try:
62
+ if command == "ask":
63
+ _run_turn(spec, mode="ask", session=None)
64
+ elif command == "continue":
65
+ _run_turn(spec, mode="continue", session=None)
66
+ elif command == "resume":
67
+ _run_turn(spec, mode="resume", session=str(spec.session.value))
68
+ elif command == "sessions":
69
+ sessions = list_sessions(cwd=_opt(spec.dir), limit=int(spec.limit.value))
70
+ for s in sessions:
71
+ # Render the mtime as ISO for human/agent consumption.
72
+ s["modified"] = datetime.fromtimestamp(s["modified"], tz=timezone.utc).isoformat()
73
+ _emit({"sessions": sessions, "count": len(sessions)})
74
+ else: # pragma: no cover - require-command makes this unreachable
75
+ _emit({"error": "no command — try: ask, continue, resume, sessions"})
76
+ sys.exit(2)
77
+ except ClaudeNotFoundError as exc:
78
+ _emit({"error": str(exc), "kind": "claude-not-found"})
79
+ sys.exit(127)
80
+ except ClaudeError as exc:
81
+ _emit({"error": str(exc)})
82
+ sys.exit(1)
@@ -0,0 +1,280 @@
1
+ """runner.py — pure logic for driving the Claude Code CLI (`claude`) headlessly.
2
+
3
+ No ``runspec`` dependency lives here: every function takes plain parameters and
4
+ either returns plain data or raises a typed error. The thin CLI wrappers in
5
+ :mod:`runspec_claude.cli` read args with ``runspec`` and call into here, so the
6
+ same logic is importable by a private wrapper package that wants to bake in
7
+ corporate defaults (a fixed model, allowlist, working directory, …).
8
+
9
+ The headless contract is ``claude -p "<prompt>" --output-format json``; we parse
10
+ the final JSON result object and normalise the fields we surface.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ __all__ = [
23
+ "ClaudeError",
24
+ "ClaudeNotFoundError",
25
+ "run_claude",
26
+ "list_sessions",
27
+ "encode_project_dir",
28
+ ]
29
+
30
+ # Keys we lift out of the `--output-format json` result object. The CLI emits
31
+ # more than this; we surface the stable, useful subset and keep the rest under
32
+ # "raw" so nothing is silently lost.
33
+ _RESULT_KEYS = ("result", "session_id", "is_error", "num_turns", "total_cost_usd", "duration_ms", "subtype")
34
+
35
+
36
+ class ClaudeError(Exception):
37
+ """The ``claude`` CLI ran but failed, or its output could not be parsed."""
38
+
39
+
40
+ class ClaudeNotFoundError(ClaudeError):
41
+ """The ``claude`` binary is not on PATH."""
42
+
43
+
44
+ def _build_command(
45
+ *,
46
+ prompt: str | None,
47
+ mode: str,
48
+ session: str | None,
49
+ model: str | None,
50
+ add_dir: str | None,
51
+ permission_mode: str | None,
52
+ allowed_tools: str | None,
53
+ max_turns: int | None,
54
+ append_system_prompt: str | None,
55
+ bare: bool,
56
+ claude_bin: str,
57
+ ) -> list[str]:
58
+ """Assemble the argv for a headless ``claude`` invocation.
59
+
60
+ ``mode`` is one of ``"ask"`` (fresh run), ``"continue"`` (most recent
61
+ session in cwd) or ``"resume"`` (``session`` required).
62
+ """
63
+ cmd: list[str] = [claude_bin, "-p"]
64
+ if prompt is not None:
65
+ cmd.append(prompt)
66
+ cmd += ["--output-format", "json"]
67
+
68
+ if mode == "continue":
69
+ cmd.append("--continue")
70
+ elif mode == "resume":
71
+ if not session:
72
+ raise ClaudeError("resume requires a session id")
73
+ cmd += ["--resume", session]
74
+
75
+ if model:
76
+ cmd += ["--model", model]
77
+ if add_dir:
78
+ cmd += ["--add-dir", add_dir]
79
+ if permission_mode:
80
+ cmd += ["--permission-mode", permission_mode]
81
+ if allowed_tools:
82
+ cmd += ["--allowedTools", allowed_tools]
83
+ # max_turns of 0 (or None) means "leave it uncapped".
84
+ if max_turns:
85
+ cmd += ["--max-turns", str(max_turns)]
86
+ if append_system_prompt:
87
+ cmd += ["--append-system-prompt", append_system_prompt]
88
+ if bare:
89
+ cmd.append("--bare")
90
+ return cmd
91
+
92
+
93
+ def _normalise_result(stdout: str) -> dict[str, Any]:
94
+ """Parse the `--output-format json` result object into our surfaced shape."""
95
+ text = stdout.strip()
96
+ if not text:
97
+ raise ClaudeError("claude produced no output")
98
+ try:
99
+ payload = json.loads(text)
100
+ except json.JSONDecodeError as exc:
101
+ # stream-json or an unexpected format: take the last non-empty JSON line.
102
+ payload = None
103
+ for line in reversed(text.splitlines()):
104
+ line = line.strip()
105
+ if not line:
106
+ continue
107
+ try:
108
+ payload = json.loads(line)
109
+ break
110
+ except json.JSONDecodeError:
111
+ continue
112
+ if payload is None:
113
+ raise ClaudeError(f"could not parse claude output as JSON: {exc}") from exc
114
+
115
+ if not isinstance(payload, dict):
116
+ raise ClaudeError("unexpected claude output (not a JSON object)")
117
+
118
+ out: dict[str, Any] = {k: payload[k] for k in _RESULT_KEYS if k in payload}
119
+ out["raw"] = payload
120
+ return out
121
+
122
+
123
+ def run_claude(
124
+ prompt: str | None,
125
+ *,
126
+ mode: str = "ask",
127
+ session: str | None = None,
128
+ model: str | None = None,
129
+ cwd: str | None = None,
130
+ add_dir: str | None = None,
131
+ permission_mode: str | None = "default",
132
+ allowed_tools: str | None = None,
133
+ max_turns: int | None = None,
134
+ append_system_prompt: str | None = None,
135
+ bare: bool = False,
136
+ claude_bin: str = "claude",
137
+ timeout: float | None = None,
138
+ ) -> dict[str, Any]:
139
+ """Run a headless ``claude`` turn and return the parsed result.
140
+
141
+ Returns a dict with (when present) ``result``, ``session_id``, ``is_error``,
142
+ ``num_turns``, ``total_cost_usd``, ``duration_ms`` and the full ``raw``
143
+ payload. ``is_error`` reflects the CLI's own flag — callers decide how to map
144
+ it to an exit code.
145
+
146
+ Raises :class:`ClaudeNotFoundError` if the binary is missing and
147
+ :class:`ClaudeError` on a non-zero exit with unparseable output, a timeout,
148
+ or output that is not JSON.
149
+ """
150
+ cmd = _build_command(
151
+ prompt=prompt,
152
+ mode=mode,
153
+ session=session,
154
+ model=model,
155
+ add_dir=add_dir,
156
+ permission_mode=permission_mode,
157
+ allowed_tools=allowed_tools,
158
+ max_turns=max_turns,
159
+ append_system_prompt=append_system_prompt,
160
+ bare=bare,
161
+ claude_bin=claude_bin,
162
+ )
163
+ try:
164
+ proc = subprocess.run(
165
+ cmd,
166
+ capture_output=True,
167
+ text=True,
168
+ cwd=cwd,
169
+ timeout=timeout,
170
+ )
171
+ except FileNotFoundError as exc:
172
+ raise ClaudeNotFoundError(f"the '{claude_bin}' CLI was not found on PATH — install Claude Code on this host") from exc
173
+ except subprocess.TimeoutExpired as exc:
174
+ raise ClaudeError(f"claude timed out after {timeout}s") from exc
175
+
176
+ # The CLI usually still prints a JSON result (with is_error=true) on failure;
177
+ # prefer that over the raw exit code so the caller gets a structured answer.
178
+ if proc.stdout.strip():
179
+ return _normalise_result(proc.stdout)
180
+
181
+ if proc.returncode != 0:
182
+ detail = proc.stderr.strip() or f"claude exited with status {proc.returncode}"
183
+ raise ClaudeError(detail)
184
+ raise ClaudeError("claude produced no output")
185
+
186
+
187
+ def encode_project_dir(path: str | os.PathLike[str]) -> str:
188
+ """Encode a working directory the way Claude Code names its transcript dir.
189
+
190
+ The project subdirectory under ``~/.claude/projects`` is the absolute path
191
+ with every non-alphanumeric character replaced by ``-``.
192
+ """
193
+ absolute = os.path.abspath(os.fspath(path))
194
+ return re.sub(r"[^a-zA-Z0-9]", "-", absolute)
195
+
196
+
197
+ def _transcripts_root(config_dir: str | os.PathLike[str] | None) -> Path:
198
+ """Resolve the ``projects`` root, honouring ``CLAUDE_CONFIG_DIR``."""
199
+ if config_dir is not None:
200
+ base = Path(config_dir)
201
+ else:
202
+ env = os.environ.get("CLAUDE_CONFIG_DIR")
203
+ base = Path(env) if env else Path.home() / ".claude"
204
+ return base / "projects"
205
+
206
+
207
+ def _sniff_title(transcript: Path) -> str | None:
208
+ """Best-effort human-readable title: the first user message text.
209
+
210
+ The JSONL line schema is undocumented, so this is defensive — it returns
211
+ ``None`` rather than raising on anything unexpected.
212
+ """
213
+ try:
214
+ with transcript.open(encoding="utf-8", errors="replace") as fh:
215
+ for line in fh:
216
+ line = line.strip()
217
+ if not line:
218
+ continue
219
+ try:
220
+ obj = json.loads(line)
221
+ except json.JSONDecodeError:
222
+ continue
223
+ if not isinstance(obj, dict) or obj.get("type") != "user":
224
+ continue
225
+ message = obj.get("message")
226
+ content = message.get("content") if isinstance(message, dict) else None
227
+ text = _content_text(content)
228
+ if text:
229
+ return text[:120]
230
+ except OSError:
231
+ return None
232
+ return None
233
+
234
+
235
+ def _content_text(content: Any) -> str | None:
236
+ """Pull plain text out of a message ``content`` (string or content blocks)."""
237
+ if isinstance(content, str):
238
+ return content.strip() or None
239
+ if isinstance(content, list):
240
+ for block in content:
241
+ if isinstance(block, dict) and block.get("type") == "text":
242
+ text = str(block.get("text", "")).strip()
243
+ if text:
244
+ return text
245
+ return None
246
+
247
+
248
+ def list_sessions(
249
+ *,
250
+ cwd: str | None = None,
251
+ config_dir: str | os.PathLike[str] | None = None,
252
+ limit: int = 20,
253
+ ) -> list[dict[str, Any]]:
254
+ """List recent Claude Code sessions for ``cwd`` (default: current directory).
255
+
256
+ Reads ``~/.claude/projects/<encoded-cwd>/<session-id>.jsonl`` (an
257
+ undocumented but stable layout) and returns the most-recently-modified
258
+ sessions first: ``{"session_id", "modified", "title", "path"}``. Returns an
259
+ empty list when the directory does not exist. ``title`` may be ``None``.
260
+ """
261
+ project_dir = _transcripts_root(config_dir) / encode_project_dir(cwd or os.getcwd())
262
+ if not project_dir.is_dir():
263
+ return []
264
+
265
+ transcripts = sorted(
266
+ project_dir.glob("*.jsonl"),
267
+ key=lambda p: p.stat().st_mtime,
268
+ reverse=True,
269
+ )
270
+ sessions: list[dict[str, Any]] = []
271
+ for transcript in transcripts[: max(limit, 0)]:
272
+ sessions.append(
273
+ {
274
+ "session_id": transcript.stem,
275
+ "modified": transcript.stat().st_mtime,
276
+ "title": _sniff_title(transcript),
277
+ "path": str(transcript),
278
+ }
279
+ )
280
+ return sessions
@@ -0,0 +1,83 @@
1
+ #:schema https://runspec.app/runspec.schema.json
2
+
3
+ # runspec-claude — drive the Claude Code CLI (`claude`) headlessly from runspec.
4
+ #
5
+ # A single runnable, `claude-run`, with four verbs (subcommands) that mirror the
6
+ # way you talk to Claude in a chat app:
7
+ #
8
+ # claude-run ask "<prompt>" start a fresh headless turn / session
9
+ # claude-run continue "<prompt>" continue the most recent session here
10
+ # claude-run resume --session <id> "..." continue a specific session by id
11
+ # claude-run sessions list recent sessions for this directory
12
+ #
13
+ # Each verb shells out to `claude -p ... --output-format json` and returns the
14
+ # structured result (text + session id + cost + turns). Because `runspec serve`
15
+ # exposes each leaf subcommand as its own MCP tool, the runspec-console agent
16
+ # (itself Claude) can call these to delegate a bounded sub-task to a headless
17
+ # Claude Code — "Claude driving Claude" — with a human confirm gate by default.
18
+
19
+ [config]
20
+ # Spawning an agent that can read/run/edit is a confirm-by-default action.
21
+ autonomy-default = "confirm"
22
+
23
+ [config.logging]
24
+ # One log file per invocation — multi-writer safe for shared venvs.
25
+ store = "per-run"
26
+
27
+ [claude-run]
28
+ description = "Drive the Claude Code CLI headlessly (ask / continue / resume / sessions)"
29
+ autonomy = "confirm"
30
+ output = "json"
31
+ require-command = true
32
+
33
+ examples = [
34
+ {cmd = "claude-run ask 'summarise the failures in build.log'", description = "One-shot headless turn (read-only by default)"},
35
+ {cmd = "claude-run --permission-mode acceptEdits ask 'fix the lint errors'", description = "Let the delegated run edit files + run safe commands"},
36
+ {cmd = "claude-run continue 'now write a test for that fix'", description = "Continue the most recent session in this directory"},
37
+ {cmd = "claude-run resume --session 8f2c... 'and update the changelog'", description = "Continue a specific prior session by id"},
38
+ {cmd = "claude-run sessions", description = "List recent sessions you can resume"},
39
+ ]
40
+
41
+ # ---- Global arguments (inherited by every subcommand) --------------------
42
+ [claude-run.args]
43
+ model = {type = "str", short = "-m", required = false, description = "Model for the headless run: an alias (sonnet, opus, haiku, fable, opusplan) or a full id like claude-opus-4-8. Default: the CLI's configured model."}
44
+ dir = {type = "path", short = "-C", required = false, description = "Working directory to run claude in (scopes file access, --continue, and the sessions list). Default: current directory."}
45
+ add-dir = {type = "path", required = false, description = "Extra directory to grant the run access to (passed to --add-dir)."}
46
+ permission-mode = {type = "choice", short = "-P", default = "default", options = ["default", "plan", "acceptEdits", "auto", "dontAsk", "bypassPermissions"], description = "How the headless run handles tool permissions. Safe 'default' blocks on edits/commands; opt up to acceptEdits/auto for autonomous work. 'bypassPermissions' skips all checks — use with care."}
47
+ allowed-tools = {type = "str", required = false, description = "Comma-separated tool allowlist passed to --allowedTools, e.g. 'Read,Bash(git diff *)'."}
48
+ max-turns = {type = "int", short = "-t", default = 12, description = "Cap on agent turns before the run stops (bounds cost/runaway). 0 leaves it uncapped."}
49
+ append-system-prompt = {type = "str", required = false, description = "Extra system-prompt text appended for this run (passed to --append-system-prompt)."}
50
+ bare = {type = "flag", default = false, description = "Reproducible run: skip auto-discovery of CLAUDE.md, hooks, skills, plugins, and MCP servers (--bare)."}
51
+
52
+ # ---- ask: start a fresh headless turn ------------------------------------
53
+ [claude-run.commands.ask]
54
+ description = "Send a prompt to a fresh headless Claude Code run"
55
+ autonomy = "confirm"
56
+
57
+ [claude-run.commands.ask.args]
58
+ prompt = {type = "str", required = true, description = "The instruction/question for Claude Code."}
59
+
60
+ # ---- continue: continue the most recent session in this directory --------
61
+ [claude-run.commands.continue]
62
+ description = "Continue the most recent session in this directory with a follow-up message"
63
+ autonomy = "confirm"
64
+
65
+ [claude-run.commands.continue.args]
66
+ prompt = {type = "str", required = true, description = "The follow-up message to send to the most recent session."}
67
+
68
+ # ---- resume: continue a specific session by id ---------------------------
69
+ [claude-run.commands.resume]
70
+ description = "Resume a specific prior session by id and send a follow-up message"
71
+ autonomy = "confirm"
72
+
73
+ [claude-run.commands.resume.args]
74
+ session = {type = "str", short = "-s", required = true, description = "Session id to resume (see `claude-run sessions`)."}
75
+ prompt = {type = "str", required = true, description = "The follow-up message to send to the resumed session."}
76
+
77
+ # ---- sessions: list recent sessions for this directory -------------------
78
+ [claude-run.commands.sessions]
79
+ description = "List recent Claude Code sessions for this directory (ids, last-active time, best-effort title). Read-only; reads local transcript files."
80
+ autonomy = "autonomous"
81
+
82
+ [claude-run.commands.sessions.args]
83
+ limit = {type = "int", short = "-n", default = 20, description = "Maximum number of recent sessions to list."}
@@ -0,0 +1,250 @@
1
+ """Tests for the pure runner logic (no `claude` binary or runspec parsing)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+
8
+ import pytest
9
+
10
+ from runspec_claude.runner import (
11
+ ClaudeError,
12
+ ClaudeNotFoundError,
13
+ _build_command,
14
+ _normalise_result,
15
+ encode_project_dir,
16
+ list_sessions,
17
+ run_claude,
18
+ )
19
+
20
+
21
+ def test_build_command_ask_minimal():
22
+ cmd = _build_command(
23
+ prompt="hello",
24
+ mode="ask",
25
+ session=None,
26
+ model=None,
27
+ add_dir=None,
28
+ permission_mode=None,
29
+ allowed_tools=None,
30
+ max_turns=None,
31
+ append_system_prompt=None,
32
+ bare=False,
33
+ claude_bin="claude",
34
+ )
35
+ assert cmd == ["claude", "-p", "hello", "--output-format", "json"]
36
+
37
+
38
+ def test_build_command_all_flags():
39
+ cmd = _build_command(
40
+ prompt="do it",
41
+ mode="ask",
42
+ session=None,
43
+ model="opus",
44
+ add_dir="/extra",
45
+ permission_mode="acceptEdits",
46
+ allowed_tools="Read,Bash",
47
+ max_turns=5,
48
+ append_system_prompt="be terse",
49
+ bare=True,
50
+ claude_bin="claude",
51
+ )
52
+ assert "--model" in cmd and cmd[cmd.index("--model") + 1] == "opus"
53
+ assert "--add-dir" in cmd and cmd[cmd.index("--add-dir") + 1] == "/extra"
54
+ assert "--permission-mode" in cmd and cmd[cmd.index("--permission-mode") + 1] == "acceptEdits"
55
+ assert "--allowedTools" in cmd and cmd[cmd.index("--allowedTools") + 1] == "Read,Bash"
56
+ assert "--max-turns" in cmd and cmd[cmd.index("--max-turns") + 1] == "5"
57
+ assert "--append-system-prompt" in cmd
58
+ assert "--bare" in cmd
59
+
60
+
61
+ def test_build_command_continue_and_resume():
62
+ cont = _build_command(
63
+ prompt="more",
64
+ mode="continue",
65
+ session=None,
66
+ model=None,
67
+ add_dir=None,
68
+ permission_mode=None,
69
+ allowed_tools=None,
70
+ max_turns=None,
71
+ append_system_prompt=None,
72
+ bare=False,
73
+ claude_bin="claude",
74
+ )
75
+ assert "--continue" in cont
76
+
77
+ res = _build_command(
78
+ prompt="more",
79
+ mode="resume",
80
+ session="abc123",
81
+ model=None,
82
+ add_dir=None,
83
+ permission_mode=None,
84
+ allowed_tools=None,
85
+ max_turns=None,
86
+ append_system_prompt=None,
87
+ bare=False,
88
+ claude_bin="claude",
89
+ )
90
+ assert res[res.index("--resume") + 1] == "abc123"
91
+
92
+
93
+ def test_build_command_resume_requires_session():
94
+ with pytest.raises(ClaudeError, match="session id"):
95
+ _build_command(
96
+ prompt="x",
97
+ mode="resume",
98
+ session=None,
99
+ model=None,
100
+ add_dir=None,
101
+ permission_mode=None,
102
+ allowed_tools=None,
103
+ max_turns=None,
104
+ append_system_prompt=None,
105
+ bare=False,
106
+ claude_bin="claude",
107
+ )
108
+
109
+
110
+ def test_max_turns_zero_is_uncapped():
111
+ cmd = _build_command(
112
+ prompt="x",
113
+ mode="ask",
114
+ session=None,
115
+ model=None,
116
+ add_dir=None,
117
+ permission_mode=None,
118
+ allowed_tools=None,
119
+ max_turns=0,
120
+ append_system_prompt=None,
121
+ bare=False,
122
+ claude_bin="claude",
123
+ )
124
+ assert "--max-turns" not in cmd
125
+
126
+
127
+ def test_normalise_result_picks_known_keys():
128
+ raw = {
129
+ "type": "result",
130
+ "subtype": "success",
131
+ "is_error": False,
132
+ "num_turns": 3,
133
+ "result": "done",
134
+ "session_id": "s1",
135
+ "total_cost_usd": 0.01,
136
+ "extra": "ignored-at-top-level",
137
+ }
138
+ out = _normalise_result(json.dumps(raw))
139
+ assert out["result"] == "done"
140
+ assert out["session_id"] == "s1"
141
+ assert out["is_error"] is False
142
+ assert out["raw"]["extra"] == "ignored-at-top-level"
143
+
144
+
145
+ def test_normalise_result_falls_back_to_last_json_line():
146
+ stream = '{"type":"system"}\nnot json\n{"result":"final","session_id":"s2"}\n'
147
+ out = _normalise_result(stream)
148
+ assert out["result"] == "final"
149
+ assert out["session_id"] == "s2"
150
+
151
+
152
+ def test_normalise_result_empty_raises():
153
+ with pytest.raises(ClaudeError, match="no output"):
154
+ _normalise_result(" ")
155
+
156
+
157
+ def test_normalise_result_unparseable_raises():
158
+ with pytest.raises(ClaudeError, match="could not parse"):
159
+ _normalise_result("definitely not json at all")
160
+
161
+
162
+ def test_encode_project_dir():
163
+ assert encode_project_dir("/Users/me/proj") == "-Users-me-proj"
164
+ assert encode_project_dir("/a.b/c_d") == "-a-b-c-d"
165
+
166
+
167
+ def test_run_claude_missing_binary(monkeypatch):
168
+ def boom(*a, **k):
169
+ raise FileNotFoundError("claude")
170
+
171
+ monkeypatch.setattr(subprocess, "run", boom)
172
+ with pytest.raises(ClaudeNotFoundError):
173
+ run_claude("hi", claude_bin="claude")
174
+
175
+
176
+ def test_run_claude_parses_stdout(monkeypatch):
177
+ class FakeProc:
178
+ returncode = 0
179
+ stdout = '{"result":"ok","session_id":"s9","is_error":false}'
180
+ stderr = ""
181
+
182
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: FakeProc())
183
+ out = run_claude("hi")
184
+ assert out["result"] == "ok"
185
+ assert out["session_id"] == "s9"
186
+
187
+
188
+ def test_run_claude_timeout(monkeypatch):
189
+ def boom(*a, **k):
190
+ raise subprocess.TimeoutExpired(cmd="claude", timeout=1)
191
+
192
+ monkeypatch.setattr(subprocess, "run", boom)
193
+ with pytest.raises(ClaudeError, match="timed out"):
194
+ run_claude("hi", timeout=1)
195
+
196
+
197
+ def test_run_claude_nonzero_no_output(monkeypatch):
198
+ class FakeProc:
199
+ returncode = 1
200
+ stdout = ""
201
+ stderr = "auth failed"
202
+
203
+ monkeypatch.setattr(subprocess, "run", lambda *a, **k: FakeProc())
204
+ with pytest.raises(ClaudeError, match="auth failed"):
205
+ run_claude("hi")
206
+
207
+
208
+ def test_list_sessions_empty_when_missing(tmp_path):
209
+ sessions = list_sessions(cwd=str(tmp_path / "nope"), config_dir=tmp_path / "cfg")
210
+ assert sessions == []
211
+
212
+
213
+ def test_list_sessions_reads_and_sorts(tmp_path):
214
+ cwd = "/work/project"
215
+ config_dir = tmp_path / "claude"
216
+ project_dir = config_dir / "projects" / encode_project_dir(cwd)
217
+ project_dir.mkdir(parents=True)
218
+
219
+ old = project_dir / "old-session.jsonl"
220
+ old.write_text(
221
+ json.dumps({"type": "user", "message": {"content": "first question"}}) + "\n",
222
+ encoding="utf-8",
223
+ )
224
+ new = project_dir / "new-session.jsonl"
225
+ new.write_text(
226
+ json.dumps({"type": "user", "message": {"content": [{"type": "text", "text": "newer question"}]}}) + "\n",
227
+ encoding="utf-8",
228
+ )
229
+ # Make `new` more recent.
230
+ import os
231
+
232
+ os.utime(old, (1000, 1000))
233
+ os.utime(new, (2000, 2000))
234
+
235
+ sessions = list_sessions(cwd=cwd, config_dir=config_dir)
236
+ assert [s["session_id"] for s in sessions] == ["new-session", "old-session"]
237
+ assert sessions[0]["title"] == "newer question"
238
+ assert sessions[1]["title"] == "first question"
239
+
240
+
241
+ def test_list_sessions_limit(tmp_path):
242
+ cwd = "/work/x"
243
+ config_dir = tmp_path / "claude"
244
+ project_dir = config_dir / "projects" / encode_project_dir(cwd)
245
+ project_dir.mkdir(parents=True)
246
+ for i in range(5):
247
+ (project_dir / f"s{i}.jsonl").write_text("{}\n", encoding="utf-8")
248
+
249
+ sessions = list_sessions(cwd=cwd, config_dir=config_dir, limit=2)
250
+ assert len(sessions) == 2
@@ -0,0 +1,27 @@
1
+ """The bundled runspec.toml must load and expose the four subcommands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import runspec as rs
8
+
9
+ _SPEC = Path(__file__).resolve().parent.parent / "runspec_claude" / "runspec.toml"
10
+
11
+
12
+ def test_spec_loads():
13
+ spec = rs.load_spec("claude-run", config_path=_SPEC)
14
+ assert spec.runspec_runnable == "claude-run"
15
+
16
+
17
+ def test_subcommands_present():
18
+ spec = rs.load_spec("claude-run", config_path=_SPEC)
19
+ commands = set(spec.runspec_spec.get("commands", {}))
20
+ assert commands == {"ask", "continue", "resume", "sessions"}
21
+
22
+
23
+ def test_global_args_inherited():
24
+ spec = rs.load_spec("claude-run", config_path=_SPEC)
25
+ args = spec.runspec_spec.get("args", {})
26
+ assert "permission-mode" in args
27
+ assert args["permission-mode"]["default"] == "default"