nasde-toolkit 0.3.2__py3-none-any.whl

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,172 @@
1
+ """Claude Code CLI subprocess evaluator backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import shutil
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ from rich.console import Console
13
+
14
+ from nasde_toolkit.config import EvaluationConfig
15
+
16
+ console = Console()
17
+
18
+
19
+ class ClaudeSubprocessBackend:
20
+ """Evaluator backend that spawns `claude -p` as a subprocess.
21
+
22
+ Uses `--output-format json` to get structured output with a `result`
23
+ field containing the agent's text response. Authenticates via whatever
24
+ credentials are configured (ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN).
25
+
26
+ Does NOT use `--bare` mode — preserves OAuth/keychain auth reads
27
+ (required for subscription-based billing) and auto-discovery of user-level
28
+ skills. Trades a slightly slower startup for full compatibility with
29
+ subscription accounts.
30
+ """
31
+
32
+ async def run_evaluation(
33
+ self,
34
+ prompt: str,
35
+ workspace_path: Path,
36
+ eval_config: EvaluationConfig,
37
+ project_root: Path,
38
+ trial_dir: Path | None = None,
39
+ ) -> str:
40
+ self.validate_cli_installed()
41
+ self.validate_auth()
42
+ cmd, temp_dir = self._build_command_with_skills(workspace_path, eval_config, project_root, trial_dir)
43
+ env = self._build_env()
44
+ cwd = temp_dir if temp_dir else workspace_path
45
+ try:
46
+ return await self._run_subprocess(cmd, prompt, cwd, env)
47
+ finally:
48
+ if temp_dir:
49
+ shutil.rmtree(temp_dir, ignore_errors=True)
50
+
51
+ def validate_cli_installed(self) -> None:
52
+ if shutil.which("claude") is not None:
53
+ return
54
+ console.print(
55
+ "[red]ERROR: `claude` CLI not found on PATH.[/red]\n"
56
+ "[yellow]Assessment evaluation with the Claude backend requires the Claude Code CLI.[/yellow]\n"
57
+ "Install it from https://docs.claude.com/en/docs/claude-code/setup, "
58
+ 'then re-run. To use a different backend, set \\[evaluation] backend = "codex" '
59
+ "in nasde.toml, or pass --without-eval to skip assessment evaluation."
60
+ )
61
+ raise SystemExit(1)
62
+
63
+ def validate_auth(self) -> None:
64
+ has_api_key = bool(os.environ.get("ANTHROPIC_API_KEY"))
65
+ has_oauth = bool(os.environ.get("CLAUDE_CODE_OAUTH_TOKEN"))
66
+ if not has_api_key and not has_oauth:
67
+ console.print("[red]ERROR: Set ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN[/red]")
68
+ raise SystemExit(1)
69
+
70
+ def _build_command_with_skills(
71
+ self,
72
+ workspace_path: Path,
73
+ eval_config: EvaluationConfig,
74
+ project_root: Path,
75
+ trial_dir: Path | None,
76
+ ) -> tuple[list[str], Path | None]:
77
+ cmd = self._build_command(workspace_path, eval_config, project_root, trial_dir)
78
+ temp_dir: Path | None = None
79
+
80
+ if eval_config.skills_dir:
81
+ skills_source = _resolve_path(eval_config.skills_dir, project_root)
82
+ temp_dir = Path(tempfile.mkdtemp(prefix="nasde_eval_"))
83
+ target = temp_dir / ".claude" / "skills"
84
+ if skills_source.is_dir():
85
+ shutil.copytree(skills_source, target)
86
+ cmd.extend(["--add-dir", str(workspace_path)])
87
+
88
+ return cmd, temp_dir
89
+
90
+ def _build_command(
91
+ self,
92
+ workspace_path: Path,
93
+ eval_config: EvaluationConfig,
94
+ project_root: Path,
95
+ trial_dir: Path | None,
96
+ ) -> list[str]:
97
+ cmd = [
98
+ "claude",
99
+ "-p",
100
+ "--output-format",
101
+ "json",
102
+ "--no-session-persistence",
103
+ "--model",
104
+ eval_config.model,
105
+ "--max-turns",
106
+ str(eval_config.max_turns),
107
+ ]
108
+
109
+ allowed_tools = eval_config.allowed_tools or ["Read", "Glob", "Grep"]
110
+ cmd.extend(["--allowedTools", ",".join(allowed_tools)])
111
+
112
+ if eval_config.include_trajectory and trial_dir:
113
+ cmd.extend(["--add-dir", str(trial_dir)])
114
+
115
+ if eval_config.mcp_config:
116
+ mcp_path = _resolve_path(eval_config.mcp_config, project_root)
117
+ cmd.extend(["--mcp-config", str(mcp_path)])
118
+
119
+ if eval_config.append_system_prompt:
120
+ cmd.extend(["--append-system-prompt", eval_config.append_system_prompt])
121
+
122
+ return cmd
123
+
124
+ def _build_env(self) -> dict[str, str]:
125
+ env = dict(os.environ)
126
+ env.pop("CLAUDECODE", None)
127
+ return env
128
+
129
+ async def _run_subprocess(
130
+ self,
131
+ cmd: list[str],
132
+ prompt: str,
133
+ workspace_path: Path,
134
+ env: dict[str, str],
135
+ ) -> str:
136
+ process = await asyncio.create_subprocess_exec(
137
+ *cmd,
138
+ stdin=asyncio.subprocess.PIPE,
139
+ stdout=asyncio.subprocess.PIPE,
140
+ stderr=asyncio.subprocess.PIPE,
141
+ cwd=str(workspace_path),
142
+ env=env,
143
+ )
144
+ stdout_bytes, stderr_bytes = await process.communicate(input=prompt.encode())
145
+
146
+ if process.returncode != 0:
147
+ stderr_text = stderr_bytes.decode(errors="replace").strip()
148
+ last_lines = "\n".join(stderr_text.splitlines()[-10:]) if stderr_text else ""
149
+ model = cmd[cmd.index("--model") + 1]
150
+ raise RuntimeError(
151
+ f"Claude Code process failed (exit code {process.returncode}). "
152
+ f"Model: {model}, cwd: {workspace_path}. "
153
+ f"stderr (last 10 lines):\n{last_lines}"
154
+ )
155
+
156
+ return _extract_result_text(stdout_bytes.decode())
157
+
158
+
159
+ def _resolve_path(relative_or_absolute: str, project_root: Path) -> Path:
160
+ path = Path(relative_or_absolute)
161
+ if path.is_absolute():
162
+ return path
163
+ return project_root / path
164
+
165
+
166
+ def _extract_result_text(stdout: str) -> str:
167
+ try:
168
+ data = json.loads(stdout)
169
+ except json.JSONDecodeError:
170
+ return stdout
171
+ result = data.get("result", "")
172
+ return result if isinstance(result, str) else ""
@@ -0,0 +1,152 @@
1
+ """Codex CLI subprocess evaluator backend."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import os
8
+ import shutil
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+
13
+ from nasde_toolkit.config import EvaluationConfig
14
+
15
+ console = Console()
16
+
17
+
18
+ class CodexSubprocessBackend:
19
+ """Evaluator backend that spawns `codex exec` as a subprocess.
20
+
21
+ Uses `--json` for JSONL event streaming on stdout. The CLI writes clean
22
+ NDJSON to stdout (banner and diagnostics go to stderr), so parsing is
23
+ straightforward. Extracts the final `agent_message` items as the
24
+ evaluation response. Authenticates via OPENAI_API_KEY, CODEX_API_KEY, or
25
+ ChatGPT OAuth (`~/.codex/auth.json` created by `codex login`).
26
+ """
27
+
28
+ async def run_evaluation(
29
+ self,
30
+ prompt: str,
31
+ workspace_path: Path,
32
+ eval_config: EvaluationConfig,
33
+ project_root: Path,
34
+ trial_dir: Path | None = None,
35
+ ) -> str:
36
+ self.validate_cli_installed()
37
+ self.validate_auth()
38
+ cmd = self._build_command(workspace_path, eval_config)
39
+ cmd.append("-")
40
+ env = self._build_env()
41
+ return await self._run_subprocess(cmd, prompt, workspace_path, env)
42
+
43
+ def _build_env(self) -> dict[str, str]:
44
+ env = dict(os.environ)
45
+ has_openai_key = bool(env.get("OPENAI_API_KEY"))
46
+ has_codex_key = bool(env.get("CODEX_API_KEY"))
47
+ if not has_openai_key and not has_codex_key:
48
+ return env
49
+ if _has_chatgpt_oauth() and not has_openai_key:
50
+ env.pop("CODEX_API_KEY", None)
51
+ return env
52
+ if has_codex_key and not has_openai_key:
53
+ env["OPENAI_API_KEY"] = env["CODEX_API_KEY"]
54
+ return env
55
+
56
+ def validate_cli_installed(self) -> None:
57
+ if shutil.which("codex") is not None:
58
+ return
59
+ console.print(
60
+ "[red]ERROR: `codex` CLI not found on PATH.[/red]\n"
61
+ "[yellow]Assessment evaluation with the Codex backend requires the Codex CLI.[/yellow]\n"
62
+ "Install it from https://github.com/openai/codex, then re-run. "
63
+ 'To use a different backend, set \\[evaluation] backend = "claude" in nasde.toml, '
64
+ "or pass --without-eval to skip assessment evaluation."
65
+ )
66
+ raise SystemExit(1)
67
+
68
+ def validate_auth(self) -> None:
69
+ has_openai_key = bool(os.environ.get("OPENAI_API_KEY"))
70
+ has_codex_key = bool(os.environ.get("CODEX_API_KEY"))
71
+ has_chatgpt_oauth = _has_chatgpt_oauth()
72
+ if not has_openai_key and not has_codex_key and not has_chatgpt_oauth:
73
+ console.print("[red]ERROR: Set OPENAI_API_KEY, CODEX_API_KEY, or log in via `codex login`[/red]")
74
+ raise SystemExit(1)
75
+
76
+ def _build_command(
77
+ self,
78
+ workspace_path: Path,
79
+ eval_config: EvaluationConfig,
80
+ ) -> list[str]:
81
+ cmd = [
82
+ "codex",
83
+ "exec",
84
+ "--json",
85
+ "--full-auto",
86
+ "--skip-git-repo-check",
87
+ "--color",
88
+ "never",
89
+ "--model",
90
+ eval_config.model,
91
+ ]
92
+ return cmd
93
+
94
+ async def _run_subprocess(
95
+ self,
96
+ cmd: list[str],
97
+ prompt: str,
98
+ workspace_path: Path,
99
+ env: dict[str, str],
100
+ ) -> str:
101
+ process = await asyncio.create_subprocess_exec(
102
+ *cmd,
103
+ stdin=asyncio.subprocess.PIPE,
104
+ stdout=asyncio.subprocess.PIPE,
105
+ stderr=asyncio.subprocess.PIPE,
106
+ cwd=str(workspace_path),
107
+ env=env,
108
+ )
109
+ stdout_bytes, stderr_bytes = await process.communicate(input=prompt.encode())
110
+
111
+ stdout_text = stdout_bytes.decode(errors="replace")
112
+ if process.returncode != 0:
113
+ stderr_text = stderr_bytes.decode(errors="replace").strip()
114
+ stderr_tail = "\n".join(stderr_text.splitlines()[-10:]) or "<empty>"
115
+ stdout_tail = "\n".join(stdout_text.strip().splitlines()[-10:]) or "<empty>"
116
+ raise RuntimeError(
117
+ f"Codex process failed (exit code {process.returncode}). "
118
+ f"cwd: {workspace_path}. "
119
+ f"stderr (last 10 lines):\n{stderr_tail}\n"
120
+ f"stdout (last 10 lines):\n{stdout_tail}"
121
+ )
122
+
123
+ return _extract_agent_messages(stdout_text)
124
+
125
+
126
+ def _has_chatgpt_oauth() -> bool:
127
+ auth_path = Path.home() / ".codex" / "auth.json"
128
+ if not auth_path.exists():
129
+ return False
130
+ try:
131
+ raw = json.loads(auth_path.read_text())
132
+ except (json.JSONDecodeError, OSError):
133
+ return False
134
+ return raw.get("auth_mode") == "chatgpt" and bool(raw.get("tokens", {}).get("access_token"))
135
+
136
+
137
+ def _extract_agent_messages(stdout: str) -> str:
138
+ messages: list[str] = []
139
+ for line in stdout.strip().splitlines():
140
+ if not line.strip():
141
+ continue
142
+ try:
143
+ event = json.loads(line)
144
+ except json.JSONDecodeError:
145
+ continue
146
+ if event.get("type") == "item.completed":
147
+ item = event.get("item", {})
148
+ if item.get("type") == "agent_message":
149
+ text = item.get("text", "")
150
+ if text:
151
+ messages.append(text)
152
+ return "\n".join(messages)
@@ -0,0 +1,30 @@
1
+ """Evaluator backend protocol — defines the interface for CLI-based evaluators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Protocol, runtime_checkable
7
+
8
+ from nasde_toolkit.config import EvaluationConfig
9
+
10
+
11
+ @runtime_checkable
12
+ class EvaluatorBackend(Protocol):
13
+ """Interface for subprocess-based evaluator backends.
14
+
15
+ Each backend spawns a CLI agent (claude, codex, etc.) as a subprocess,
16
+ passes the evaluation prompt, and returns the agent's text response.
17
+ """
18
+
19
+ async def run_evaluation(
20
+ self,
21
+ prompt: str,
22
+ workspace_path: Path,
23
+ eval_config: EvaluationConfig,
24
+ project_root: Path,
25
+ trial_dir: Path | None = None,
26
+ ) -> str: ...
27
+
28
+ def validate_cli_installed(self) -> None: ...
29
+
30
+ def validate_auth(self) -> None: ...