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.
- nasde_toolkit/__init__.py +5 -0
- nasde_toolkit/_bundled_skills/nasde-benchmark-creator/SKILL.md +321 -0
- nasde_toolkit/_bundled_skills/nasde-benchmark-from-history/SKILL.md +298 -0
- nasde_toolkit/_bundled_skills/nasde-benchmark-from-public-repos/SKILL.md +303 -0
- nasde_toolkit/_bundled_skills/nasde-benchmark-runner/SKILL.md +388 -0
- nasde_toolkit/_version.py +24 -0
- nasde_toolkit/agents/__init__.py +1 -0
- nasde_toolkit/agents/configurable_claude.py +121 -0
- nasde_toolkit/agents/configurable_codex.py +101 -0
- nasde_toolkit/agents/configurable_gemini.py +194 -0
- nasde_toolkit/banner.py +82 -0
- nasde_toolkit/cli.py +513 -0
- nasde_toolkit/config.py +206 -0
- nasde_toolkit/docker.py +221 -0
- nasde_toolkit/evaluator.py +663 -0
- nasde_toolkit/evaluator_backends/__init__.py +21 -0
- nasde_toolkit/evaluator_backends/claude_subprocess.py +172 -0
- nasde_toolkit/evaluator_backends/codex_subprocess.py +152 -0
- nasde_toolkit/evaluator_backends/protocol.py +30 -0
- nasde_toolkit/runner.py +739 -0
- nasde_toolkit/scaffold/__init__.py +161 -0
- nasde_toolkit/skills_installer.py +83 -0
- nasde_toolkit/update_check.py +166 -0
- nasde_toolkit-0.3.2.dist-info/METADATA +701 -0
- nasde_toolkit-0.3.2.dist-info/RECORD +28 -0
- nasde_toolkit-0.3.2.dist-info/WHEEL +4 -0
- nasde_toolkit-0.3.2.dist-info/entry_points.txt +2 -0
- nasde_toolkit-0.3.2.dist-info/licenses/LICENSE +21 -0
|
@@ -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]
|
nasde_toolkit/banner.py
ADDED
|
@@ -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()
|