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,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.3.2'
22
+ __version_tuple__ = version_tuple = (0, 3, 2)
23
+
24
+ __commit_id__ = commit_id = None
@@ -0,0 +1 @@
1
+ """Harbor agent implementations for nasde."""
@@ -0,0 +1,121 @@
1
+ """Configurable Claude Code agent for Harbor evaluations.
2
+
3
+ Harbor's built-in ClaudeCode agent handles MCP servers declared in the JSON
4
+ config and reads authentication credentials from the environment natively.
5
+ However, it has no mechanism to inject arbitrary files (e.g. CLAUDE.md) into
6
+ the sandbox before the agent starts.
7
+
8
+ ConfigurableClaude fills that single gap: it accepts a sandbox_files mapping
9
+ via AgentConfig.kwargs and uploads each file into the container during setup().
10
+ Everything else (auth, MCP servers, model selection) stays in the JSON config.
11
+
12
+ Usage in harbor_config.json::
13
+
14
+ {
15
+ "agents": [{
16
+ "import_path": "nasde_toolkit.agents.configurable_claude:ConfigurableClaude",
17
+ "model_name": "claude-sonnet-4-6",
18
+ "kwargs": {
19
+ "sandbox_files": {
20
+ "/app/CLAUDE.md": "path/to/CLAUDE.md"
21
+ }
22
+ }
23
+ }]
24
+ }
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ from harbor.agents.installed.claude_code import ClaudeCode
34
+ from harbor.environments.base import BaseEnvironment
35
+
36
+ logger = logging.getLogger(__name__)
37
+
38
+ _DNS_FIX_COMMAND = (
39
+ "timeout 5 getent hosts claude.ai >/dev/null 2>&1"
40
+ " || printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf"
41
+ )
42
+
43
+
44
+ class ConfigurableClaude(ClaudeCode):
45
+ """ClaudeCode with declarative file injection into the sandbox.
46
+
47
+ Args:
48
+ sandbox_files: Mapping of container paths to host-relative file paths.
49
+ Each entry is uploaded into the sandbox during setup() before
50
+ the agent starts. Paths are resolved relative to CWD.
51
+ """
52
+
53
+ def __init__(
54
+ self,
55
+ sandbox_files: dict[str, str] | None = None,
56
+ *args: Any,
57
+ **kwargs: Any,
58
+ ) -> None:
59
+ super().__init__(*args, **kwargs)
60
+ self._sandbox_files = sandbox_files or {}
61
+
62
+ @staticmethod
63
+ def name() -> str:
64
+ return "configurable-claude-code"
65
+
66
+ async def setup(self, environment: BaseEnvironment) -> None:
67
+ """Fix cloud DNS, upload files, then run base setup.
68
+
69
+ Files must be uploaded BEFORE super().setup() because Harbor's
70
+ ClaudeCode.setup() copies skills from ~/.claude/skills/ into
71
+ the Claude config directory. Our sandbox_files that target
72
+ ~/.claude/skills/ need to be in place before that copy happens.
73
+ """
74
+ await self._ensure_dns_resolution(environment)
75
+ await self._upload_sandbox_files(environment)
76
+ await super().setup(environment)
77
+
78
+ async def _ensure_dns_resolution(self, environment: BaseEnvironment) -> None:
79
+ """Prepend 1.1.1.1 to resolv.conf if missing.
80
+
81
+ Daytona cloud sandboxes may land on runners whose DNS resolvers
82
+ cannot reach whitelisted domains (e.g. claude.ai). Prepending
83
+ Cloudflare's public resolver fixes this without affecting local
84
+ Docker environments (Docker regenerates resolv.conf on start).
85
+ """
86
+ result = await environment.exec(command=_DNS_FIX_COMMAND)
87
+ if result.return_code == 0:
88
+ logger.debug("DNS resolution fix applied")
89
+ else:
90
+ logger.warning("DNS resolution fix failed: %s", result.stderr)
91
+
92
+ async def _upload_sandbox_files(self, environment: BaseEnvironment) -> None:
93
+ for target_path, source_path in self._sandbox_files.items():
94
+ resolved = Path(source_path).resolve()
95
+ if not resolved.is_file():
96
+ raise FileNotFoundError(
97
+ f"sandbox_files: source '{source_path}' (resolved to '{resolved}') does not exist"
98
+ )
99
+ upload_targets = _expand_skill_targets(target_path)
100
+ for upload_path in upload_targets:
101
+ parent_dir = str(Path(upload_path).parent)
102
+ await environment.exec(command=f"mkdir -p {parent_dir}")
103
+ await environment.upload_file(
104
+ source_path=resolved,
105
+ target_path=upload_path,
106
+ )
107
+
108
+
109
+ def _expand_skill_targets(target_path: str) -> list[str]:
110
+ """Expand a skill target path to include both /app/ and ~/.claude/ locations.
111
+
112
+ Harbor's ClaudeCode.setup() copies skills from ~/.claude/skills/ into
113
+ its internal config directory. Skills placed only under /app/.claude/
114
+ won't be discovered. This ensures skills are available in both locations.
115
+ """
116
+ app_skills_prefix = "/app/.claude/skills/"
117
+ if target_path.startswith(app_skills_prefix):
118
+ relative = target_path[len(app_skills_prefix) :]
119
+ home_path = f"/root/.claude/skills/{relative}"
120
+ return [target_path, home_path]
121
+ return [target_path]
@@ -0,0 +1,101 @@
1
+ """Configurable Codex agent for Harbor evaluations.
2
+
3
+ Harbor's built-in Codex agent handles CLI installation, command construction,
4
+ token/trajectory parsing, and — since harbor 0.4 — ChatGPT OAuth auth.json
5
+ injection natively (via ``~/.codex/auth.json`` or ``CODEX_AUTH_JSON_PATH``).
6
+
7
+ ``ConfigurableCodex`` fills two remaining gaps:
8
+
9
+ 1. A ``sandbox_files`` mapping (via ``AgentConfig.kwargs``) that uploads
10
+ arbitrary files (e.g. ``AGENTS.md``) into the container during ``setup()``.
11
+ 2. A DNS-resolver fallback for cloud sandboxes whose default resolver cannot
12
+ reach ``api.openai.com``.
13
+
14
+ Everything else (auth, model selection, reasoning effort) stays in Harbor.
15
+
16
+ Usage in harbor_config.json::
17
+
18
+ {
19
+ "agents": [{
20
+ "import_path": "nasde_toolkit.agents.configurable_codex:ConfigurableCodex",
21
+ "model_name": "o3",
22
+ "kwargs": {
23
+ "sandbox_files": {
24
+ "/app/AGENTS.md": "path/to/AGENTS.md"
25
+ }
26
+ }
27
+ }]
28
+ }
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import logging
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from harbor.agents.installed.codex import Codex
38
+ from harbor.environments.base import BaseEnvironment
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ _DNS_FIX_COMMAND = (
43
+ "timeout 5 getent hosts api.openai.com >/dev/null 2>&1"
44
+ " || printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf"
45
+ )
46
+
47
+
48
+ class ConfigurableCodex(Codex):
49
+ """Codex with declarative file injection into the sandbox.
50
+
51
+ Args:
52
+ sandbox_files: Mapping of container paths to host-relative file paths.
53
+ Each entry is uploaded into the sandbox during setup() before
54
+ the agent starts. Paths are resolved relative to CWD.
55
+ """
56
+
57
+ def __init__(
58
+ self,
59
+ sandbox_files: dict[str, str] | None = None,
60
+ *args: Any,
61
+ **kwargs: Any,
62
+ ) -> None:
63
+ super().__init__(*args, **kwargs)
64
+ self._sandbox_files = sandbox_files or {}
65
+
66
+ @staticmethod
67
+ def name() -> str:
68
+ return "configurable-codex"
69
+
70
+ async def setup(self, environment: BaseEnvironment) -> None:
71
+ """Fix cloud DNS, run base setup, then upload configured files."""
72
+ await self._ensure_dns_resolution(environment)
73
+ await super().setup(environment)
74
+ await self._upload_sandbox_files(environment)
75
+
76
+ async def _ensure_dns_resolution(self, environment: BaseEnvironment) -> None:
77
+ """Prepend public DNS resolvers if cloud sandbox cannot reach OpenAI.
78
+
79
+ Cloud sandboxes (Daytona, Modal, etc.) may land on runners whose DNS
80
+ resolvers cannot reach whitelisted domains. Prepending Google/Cloudflare
81
+ public resolvers fixes this without affecting local Docker environments.
82
+ """
83
+ result = await environment.exec(command=_DNS_FIX_COMMAND)
84
+ if result.return_code == 0:
85
+ logger.debug("DNS resolution fix applied")
86
+ else:
87
+ logger.warning("DNS resolution fix failed: %s", result.stderr)
88
+
89
+ async def _upload_sandbox_files(self, environment: BaseEnvironment) -> None:
90
+ for target_path, source_path in self._sandbox_files.items():
91
+ resolved = Path(source_path).resolve()
92
+ if not resolved.is_file():
93
+ raise FileNotFoundError(
94
+ f"sandbox_files: source '{source_path}' (resolved to '{resolved}') does not exist"
95
+ )
96
+ parent_dir = str(Path(target_path).parent)
97
+ await environment.exec(command=f"mkdir -p {parent_dir}")
98
+ await environment.upload_file(
99
+ source_path=resolved,
100
+ target_path=target_path,
101
+ )
@@ -0,0 +1,194 @@
1
+ """Configurable Gemini CLI agent for Harbor evaluations.
2
+
3
+ Harbor's built-in GeminiCli agent handles CLI installation, command construction,
4
+ trajectory parsing, and MCP server configuration natively. However, it has no
5
+ mechanism to inject arbitrary files (e.g. GEMINI.md, skills) into the sandbox
6
+ before the agent starts.
7
+
8
+ ConfigurableGemini fills that gap: it accepts a sandbox_files mapping via
9
+ AgentConfig.kwargs and uploads each file into the container during setup().
10
+
11
+ Additionally, when the user has authenticated via ``gemini login`` (Google
12
+ account), ConfigurableGemini auto-detects ``~/.gemini/oauth_creds.json`` and
13
+ injects the credentials into the sandbox — allowing benchmarks to run on a
14
+ Google account instead of requiring a ``GEMINI_API_KEY``. API key env vars
15
+ always take priority over OAuth.
16
+
17
+ Usage in harbor_config.json::
18
+
19
+ {
20
+ "agents": [{
21
+ "import_path": "nasde_toolkit.agents.configurable_gemini:ConfigurableGemini",
22
+ "model_name": "google/gemini-3-flash-preview",
23
+ "kwargs": {
24
+ "sandbox_files": {
25
+ "/app/GEMINI.md": "path/to/GEMINI.md"
26
+ }
27
+ }
28
+ }]
29
+ }
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import logging
36
+ import os
37
+ from pathlib import Path
38
+ from typing import Any
39
+
40
+ from harbor.agents.installed.gemini_cli import GeminiCli
41
+ from harbor.environments.base import BaseEnvironment
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ _DNS_FIX_COMMAND = (
46
+ "timeout 5 getent hosts generativelanguage.googleapis.com >/dev/null 2>&1"
47
+ " || printf 'nameserver 8.8.8.8\\nnameserver 1.1.1.1\\n' > /etc/resolv.conf"
48
+ )
49
+
50
+ _API_KEY_VARS = ("GEMINI_API_KEY", "GOOGLE_API_KEY", "GOOGLE_APPLICATION_CREDENTIALS")
51
+
52
+
53
+ def _read_gemini_oauth_creds() -> str | None:
54
+ """Return serialised ``~/.gemini/oauth_creds.json`` when it holds OAuth tokens."""
55
+ creds_path = Path.home() / ".gemini" / "oauth_creds.json"
56
+ if not creds_path.exists():
57
+ return None
58
+ try:
59
+ raw = json.loads(creds_path.read_text())
60
+ except (json.JSONDecodeError, OSError):
61
+ return None
62
+ if not raw:
63
+ return None
64
+ return json.dumps(raw)
65
+
66
+
67
+ async def _write_oauth_to_sandbox(environment: BaseEnvironment, oauth_creds_json: str) -> None:
68
+ """Write OAuth credentials and auth settings into the sandbox.
69
+
70
+ Gemini CLI requires both:
71
+ 1. ``~/.gemini/oauth_creds.json`` — the actual OAuth tokens
72
+ 2. ``~/.gemini/settings.json`` — with ``security.auth.selectedType`` set
73
+ to ``"oauth-personal"`` so the CLI knows which auth method to use.
74
+
75
+ This runs AFTER Harbor's setup which creates a minimal settings.json.
76
+ We merge our auth config into it rather than overwriting.
77
+ """
78
+ import tempfile
79
+
80
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
81
+ f.write(oauth_creds_json)
82
+ tmp_creds = Path(f.name)
83
+
84
+ settings_json = json.dumps(
85
+ {
86
+ "experimental": {"skills": True},
87
+ "security": {"auth": {"selectedType": "oauth-personal"}},
88
+ },
89
+ indent=2,
90
+ )
91
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
92
+ f.write(settings_json)
93
+ tmp_settings = Path(f.name)
94
+
95
+ try:
96
+ await environment.exec(command="mkdir -p /root/.gemini")
97
+ await environment.upload_file(source_path=tmp_creds, target_path="/root/.gemini/oauth_creds.json")
98
+ await environment.upload_file(source_path=tmp_settings, target_path="/root/.gemini/settings.json")
99
+ finally:
100
+ tmp_creds.unlink(missing_ok=True)
101
+ tmp_settings.unlink(missing_ok=True)
102
+
103
+
104
+ class ConfigurableGemini(GeminiCli):
105
+ """GeminiCli with declarative file injection into the sandbox.
106
+
107
+ Args:
108
+ sandbox_files: Mapping of container paths to host-relative file paths.
109
+ Each entry is uploaded into the sandbox during setup() before
110
+ the agent starts. Paths are resolved relative to CWD.
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ sandbox_files: dict[str, str] | None = None,
116
+ *args: Any,
117
+ **kwargs: Any,
118
+ ) -> None:
119
+ super().__init__(*args, **kwargs)
120
+ self._sandbox_files = sandbox_files or {}
121
+
122
+ @staticmethod
123
+ def name() -> str:
124
+ return "configurable-gemini-cli"
125
+
126
+ async def setup(self, environment: BaseEnvironment) -> None:
127
+ """Fix cloud DNS, upload files, run base setup, then inject OAuth.
128
+
129
+ Order matters:
130
+ 1. DNS fix — cloud sandboxes may not resolve Google APIs
131
+ 2. Upload sandbox files — skills must be in place before Harbor
132
+ discovers them during setup
133
+ 3. super().setup() — Harbor installs Gemini CLI and writes a
134
+ minimal ~/.gemini/settings.json (with experimental.skills)
135
+ 4. OAuth injection — AFTER super().setup() because Harbor's install
136
+ script overwrites ~/.gemini/settings.json. We merge our auth
137
+ config into the file that Harbor created.
138
+ """
139
+ await self._ensure_dns_resolution(environment)
140
+ await self._upload_sandbox_files(environment)
141
+ await super().setup(environment)
142
+ await self._inject_oauth_if_needed(environment)
143
+
144
+ async def _inject_oauth_if_needed(self, environment: BaseEnvironment) -> None:
145
+ """Write OAuth creds and update settings.json in the sandbox after setup."""
146
+ has_api_key = any(os.environ.get(var) for var in _API_KEY_VARS)
147
+ if has_api_key:
148
+ return
149
+
150
+ oauth_creds_json = _read_gemini_oauth_creds()
151
+ if not oauth_creds_json:
152
+ return
153
+
154
+ logger.info("Injecting Gemini OAuth credentials into sandbox")
155
+ await _write_oauth_to_sandbox(environment, oauth_creds_json)
156
+ logger.info("Gemini OAuth injection complete")
157
+
158
+ async def _ensure_dns_resolution(self, environment: BaseEnvironment) -> None:
159
+ """Prepend public DNS resolvers if cloud sandbox cannot reach Google APIs."""
160
+ result = await environment.exec(command=_DNS_FIX_COMMAND)
161
+ if result.return_code == 0:
162
+ logger.debug("DNS resolution fix applied")
163
+ else:
164
+ logger.warning("DNS resolution fix failed: %s", result.stderr)
165
+
166
+ async def _upload_sandbox_files(self, environment: BaseEnvironment) -> None:
167
+ for target_path, source_path in self._sandbox_files.items():
168
+ resolved = Path(source_path).resolve()
169
+ if not resolved.is_file():
170
+ raise FileNotFoundError(
171
+ f"sandbox_files: source '{source_path}' (resolved to '{resolved}') does not exist"
172
+ )
173
+ upload_targets = _expand_skill_targets(target_path)
174
+ for upload_path in upload_targets:
175
+ parent_dir = str(Path(upload_path).parent)
176
+ await environment.exec(command=f"mkdir -p {parent_dir}")
177
+ await environment.upload_file(
178
+ source_path=resolved,
179
+ target_path=upload_path,
180
+ )
181
+
182
+
183
+ def _expand_skill_targets(target_path: str) -> list[str]:
184
+ """Expand a skill target path to include both /app/ and ~/.gemini/ locations.
185
+
186
+ Gemini CLI discovers skills in both workspace (.gemini/skills/) and user
187
+ (~/.gemini/skills/) directories. This ensures skills are available in both.
188
+ """
189
+ app_skills_prefix = "/app/.gemini/skills/"
190
+ if target_path.startswith(app_skills_prefix):
191
+ relative = target_path[len(app_skills_prefix) :]
192
+ home_path = f"/root/.gemini/skills/{relative}"
193
+ return [target_path, home_path]
194
+ return [target_path]
@@ -0,0 +1,82 @@
1
+ """ASCII art banner for nasde CLI with Noesis brand colors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.text import Text
7
+
8
+ from nasde_toolkit import __version__
9
+
10
+ GREEN = "#1B5E20"
11
+ GREEN_LIGHT = "#2E7D32"
12
+ BLUE = "#1565C0"
13
+ BLUE_LIGHT = "#1E88E5"
14
+ ACCENT = "#4CAF50"
15
+
16
+ _INDENT = " "
17
+ _LOGO_LINES = [
18
+ f"{_INDENT} __",
19
+ f"{_INDENT} ____ ____ _________ / /__",
20
+ f"{_INDENT} / __ \\/ __ `/ ___/ __ / _ \\",
21
+ f"{_INDENT} / / / / /_/ (__ ) /_/ / __/",
22
+ f"{_INDENT}/_/ /_/\\__,_/____/\\__,_/\\___/",
23
+ ]
24
+
25
+ _LINE_STYLES: list[list[tuple[str, str]]] = [
26
+ [(GREEN, "full")],
27
+ [(GREEN, "full")],
28
+ [(GREEN_LIGHT, "full")],
29
+ [(BLUE, "full")],
30
+ [(BLUE_LIGHT, "full")],
31
+ ]
32
+
33
+
34
+ def _render_logo() -> Text:
35
+ text = Text()
36
+ for i, line in enumerate(_LOGO_LINES):
37
+ color = _LINE_STYLES[i][0][0]
38
+ text.append(line, style=f"bold {color}")
39
+ if i < len(_LOGO_LINES) - 1:
40
+ text.append("\n")
41
+ return text
42
+
43
+
44
+ def _render_tagline() -> Text:
45
+ text = Text()
46
+ text.append(f"{_INDENT}Noesis ", style=f"bold {GREEN}")
47
+ text.append("Agentic Software Development Evals", style=f"{BLUE_LIGHT}")
48
+ return text
49
+
50
+
51
+ def _render_version_line() -> Text:
52
+ text = Text()
53
+ text.append(f"{_INDENT}v{__version__}", style=f"bold {ACCENT}")
54
+ return text
55
+
56
+
57
+ _banner_suppressed = False
58
+
59
+
60
+ def suppress_banner() -> None:
61
+ global _banner_suppressed # noqa: PLW0603
62
+ _banner_suppressed = True
63
+
64
+
65
+ def is_banner_suppressed() -> bool:
66
+ if _banner_suppressed:
67
+ return True
68
+ import os
69
+
70
+ return os.environ.get("NASDE_NO_BANNER", "").strip() not in ("", "0", "false")
71
+
72
+
73
+ def print_banner(console: Console | None = None) -> None:
74
+ if is_banner_suppressed():
75
+ return
76
+ c = console or Console()
77
+ c.print()
78
+ c.print(_render_logo())
79
+ c.print()
80
+ c.print(_render_tagline())
81
+ c.print(_render_version_line())
82
+ c.print()