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.
- runspec_claude-0.1.0/.gitignore +61 -0
- runspec_claude-0.1.0/CHANGELOG.md +61 -0
- runspec_claude-0.1.0/PKG-INFO +10 -0
- runspec_claude-0.1.0/pyproject.toml +39 -0
- runspec_claude-0.1.0/runspec_claude/__init__.py +24 -0
- runspec_claude-0.1.0/runspec_claude/cli.py +82 -0
- runspec_claude-0.1.0/runspec_claude/runner.py +280 -0
- runspec_claude-0.1.0/runspec_claude/runspec.toml +83 -0
- runspec_claude-0.1.0/tests/test_runner.py +250 -0
- runspec_claude-0.1.0/tests/test_spec.py +27 -0
|
@@ -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"
|