From 872f4f89b516d3054a66810b2c67dbf9f705b174 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Fri, 17 Apr 2026 05:15:17 +0200 Subject: [PATCH 01/18] Implemented the next missing parity slice around agent configurations. --- PARITY_CHECKLIST.md | 8 +- README.md | 32 +++- TESTING_GUIDE.md | 6 + src/__init__.py | 14 ++ src/agent_registry.py | 292 +++++++++++++++++++++++++++++ src/agent_runtime.py | 58 ++++++ src/agent_slash_commands.py | 104 +++++++++- src/main.py | 131 +++++++++++++ tests/test_agent_registry.py | 53 +++++- tests/test_agent_slash_commands.py | 27 +++ tests/test_main.py | 51 ++++- 11 files changed, 770 insertions(+), 6 deletions(-) diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 77bd421..eca435c 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -117,6 +117,7 @@ Done: - [x] Filesystem-backed custom agent discovery from `~/.claude/agents` and `./.claude/agents` - [x] Active agent override precedence across built-in, user, and project agent definitions - [x] Custom agent resolution in the `Agent` tool with model, tool-filter, and initial-prompt support +- [x] Local custom-agent file creation, update, and deletion flows for project/user agent definitions Missing: @@ -145,6 +146,9 @@ Done: - [x] `agent-context-raw` command - [x] `token-budget` command - [x] `agents` command +- [x] `agents-create` command +- [x] `agents-update` command +- [x] `agents-delete` command - [x] Local background session mode - [x] Local background session listing (`agent-ps`) - [x] Local background session logs (`agent-logs`) @@ -317,7 +321,7 @@ Done (53 slash command names in 37 specs): Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/add-dir` — Add a new working directory -- [x] `/agents` — Inspect local agent configurations and show active definitions +- [x] `/agents` — Inspect, create, update, and delete local agent definitions - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions - [ ] `/btw` — Quick side question without interrupting main conversation @@ -366,7 +370,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. - [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. - [x] `/commit` — Create a git commit (prompt-type with injected git context) -- [ ] Full `/agents` parity for create/edit/delete flows and multi-source management UI +- [ ] Full `/agents` parity for interactive TUI/editor flows, plugin sources, and full multi-source management UX ## 6. Built-in Tools diff --git a/README.md b/README.md index e8a3c62..2f67bd1 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ Built on the public porting workspace from [instructkr/claw-code](https://github - [x] Nested agent delegation with dependency-aware topological batching - [x] Agent manager with lineage tracking and group membership - [x] Filesystem-backed custom agent profiles with built-in/user/project precedence +- [x] Local custom-agent create/update/delete flows via CLI and `/agents` - [x] Local daemon-style background command family - [x] Local background session workflows: `agent-bg`, `agent-ps`, `agent-logs`, `agent-attach`, `agent-kill` - [x] Local remote runtime: manifest discovery, profile listing, connect/disconnect persistence, and CLI/slash flows @@ -451,6 +452,9 @@ python3 -m src.main agent \ | `agent-context-raw` | Show the raw context snapshot | | `token-budget` | Show prompt-window budget, reserves, and soft/hard input limits | | `agents [agent_type]` | List active local agent definitions or show one agent profile | +| `agents-create ` | Create a project or user agent definition markdown file | +| `agents-update ` | Update an existing project or user agent definition | +| `agents-delete ` | Delete an existing project or user agent definition | | `agent-resume ` | Resume a saved session | ### Runtime Utility Commands @@ -550,7 +554,7 @@ These are handled **locally** before the model loop: | `/permissions` | — | Show active tool permission mode | | `/model` | — | Show or update the active model | | `/tools` | — | List registered tools with permission status | -| `/agents` | — | List active local agent definitions or show one profile | +| `/agents` | — | List, show, create, update, or delete local agent definitions | | `/memory` | — | Show loaded CLAUDE.md memory bundle | | `/status` | `/session` | Show runtime/session status summary | | `/clear` | — | Clear ephemeral runtime state | @@ -596,6 +600,32 @@ python3 -m src.main agent "/agents" --cwd . python3 -m src.main agent "/agents show reviewer" --cwd . ``` +Create, update, or delete agent files from the CLI: + +```bash +python3 -m src.main agents-create reviewer \ + --cwd . \ + --description "Review implementation changes carefully." \ + --prompt "Inspect code changes and summarize risks." \ + --tools read_file,grep_search \ + --model Qwen/Qwen3-Coder-30B-A3B-Instruct + +python3 -m src.main agents-update reviewer \ + --cwd . \ + --description "Review implementation changes and tests carefully." \ + --prompt "Focus on regressions, missing tests, and risky diffs." + +python3 -m src.main agents-delete reviewer --cwd . --source project +``` + +Or use the local slash command management forms: + +```bash +python3 -m src.main agent "/agents create reviewer :: Review implementation changes carefully. :: Inspect code changes and summarize risks." --cwd . +python3 -m src.main agent "/agents update reviewer Updated review description :: Focus on regressions and missing tests." --cwd . +python3 -m src.main agent "/agents delete reviewer" --cwd . +``` + ### Utility Commands ```bash diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 9dd88f4..575f20a 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -621,6 +621,9 @@ python3 -m src.main agent "/model demo-model" --cwd ./test_cases python3 -m src.main agent "/tools" --cwd ./test_cases python3 -m src.main agent "/agents" --cwd ./test_cases_agents python3 -m src.main agent "/agents show reviewer" --cwd ./test_cases_agents +python3 -m src.main agent "/agents create reviewer-temp :: Review temporary code changes. :: Inspect code and summarize risks." --cwd ./test_cases_agents +python3 -m src.main agent "/agents update reviewer-temp Updated temp reviewer :: Focus on regressions and missing tests." --cwd ./test_cases_agents +python3 -m src.main agent "/agents delete reviewer-temp" --cwd ./test_cases_agents python3 -m src.main agent "/memory" --cwd ./test_cases python3 -m src.main agent "/status" --cwd ./test_cases python3 -m src.main agent "/session" --cwd ./test_cases @@ -700,6 +703,9 @@ python3 -m src.main agent-context-raw --cwd ./test_cases python3 -m src.main token-budget --cwd ./test_cases python3 -m src.main agents --cwd ./test_cases_agents python3 -m src.main agents reviewer --cwd ./test_cases_agents +python3 -m src.main agents-create reviewer-cli --cwd ./test_cases_agents --description "CLI-created reviewer agent." --prompt "Inspect code changes and summarize risks." +python3 -m src.main agents-update reviewer-cli --cwd ./test_cases_agents --description "Updated CLI reviewer." --prompt "Focus on regressions and missing tests." +python3 -m src.main agents-delete reviewer-cli --cwd ./test_cases_agents --source project ``` ### 6.2 Extra working directories and `CLAUDE.md` toggle diff --git a/src/__init__.py b/src/__init__.py index f195de4..4230318 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -13,11 +13,18 @@ from .agent_context import ( from .agent_manager import AgentManager from .agent_registry import ( AgentLoadError, + AgentMutationResult, AgentRegistrySnapshot, + create_agent_definition, + delete_agent_definition, find_agent_definition, load_agent_registry, + normalize_mutable_source, render_agent_detail, + render_agent_mutation, render_agents_report, + scaffold_agent_definition, + update_agent_definition, ) from .agent_runtime import LocalCodingAgent from .agent_session import AgentMessage, AgentSessionState @@ -51,6 +58,7 @@ __all__ = [ 'AgentContextSnapshot', 'AgentManager', 'AgentLoadError', + 'AgentMutationResult', 'AgentPermissions', 'AgentRegistrySnapshot', 'AgentRunResult', @@ -119,8 +127,10 @@ __all__ = [ 'clear_context_caches', 'clear_token_counter_cache', 'count_tokens', + 'create_agent_definition', 'calculate_token_budget', 'default_tool_registry', + 'delete_agent_definition', 'describe_token_counter', 'estimate_chat_overhead', 'execute_tool', @@ -130,9 +140,13 @@ __all__ = [ 'get_user_context', 'load_agent_registry', 'load_session', + 'normalize_mutable_source', 'render_agent_detail', + 'render_agent_mutation', 'render_agents_report', 'run_parity_audit', + 'scaffold_agent_definition', 'save_session', 'set_system_prompt_injection', + 'update_agent_definition', ] diff --git a/src/agent_registry.py b/src/agent_registry.py index cd8f359..4190c9d 100644 --- a/src/agent_registry.py +++ b/src/agent_registry.py @@ -16,6 +16,13 @@ _SOURCE_ORDER = { 'userSettings': 1, 'projectSettings': 2, } +_MUTABLE_SOURCE_ALIASES = { + 'project': 'projectSettings', + 'projectSettings': 'projectSettings', + 'user': 'userSettings', + 'userSettings': 'userSettings', +} +_UNSET = object() @dataclass(frozen=True) @@ -33,6 +40,15 @@ class AgentRegistrySnapshot: failed_files: tuple[AgentLoadError, ...] +@dataclass(frozen=True) +class AgentMutationResult: + action: str + agent_type: str + source: str + file_path: str + overwritten: bool = False + + def load_agent_registry(cwd: Path) -> AgentRegistrySnapshot: builtin_agents = tuple(get_builtin_agents()) loaded_agents: list[AgentDefinition] = list(builtin_agents) @@ -258,6 +274,274 @@ def render_agent_detail(snapshot: AgentRegistrySnapshot, agent_type: str) -> str return '\n'.join(lines) +def normalize_mutable_source(source: str | None, *, allow_auto: bool = False) -> str: + if source is None: + return 'auto' if allow_auto else 'projectSettings' + normalized = source.strip() + if allow_auto and normalized in {'', 'auto'}: + return 'auto' + resolved = _MUTABLE_SOURCE_ALIASES.get(normalized) + if resolved is None: + choices = ', '.join(sorted(_MUTABLE_SOURCE_ALIASES)) + if allow_auto: + choices = 'auto, ' + choices + raise ValueError(f'Unsupported agent source: {source}. Expected one of: {choices}') + return resolved + + +def format_agent_markdown( + *, + agent_type: str, + description: str, + system_prompt: str, + tools: tuple[str, ...] | None = None, + model: str | None = None, + color: str | None = None, + permission_mode: str | None = None, + max_turns: int | None = None, + initial_prompt: str | None = None, + background: bool = False, + one_shot: bool = False, + omit_claude_md: bool = False, +) -> str: + lines = [ + '---', + f'name: {agent_type}', + f'description: "{_escape_frontmatter_text(description)}"', + ] + if tools is not None: + if tools: + lines.append(f'tools: {", ".join(tools)}') + else: + lines.append('tools: []') + if model: + lines.append(f'model: {model}') + if color: + lines.append(f'color: {color}') + if permission_mode: + lines.append(f'permissionMode: {permission_mode}') + if max_turns is not None: + lines.append(f'maxTurns: {max_turns}') + if initial_prompt: + lines.append(f'initialPrompt: "{_escape_frontmatter_text(initial_prompt)}"') + if background: + lines.append('background: true') + if one_shot: + lines.append('oneShot: true') + if omit_claude_md: + lines.append('omitClaudeMd: true') + lines.extend(['---', '', system_prompt.strip(), '']) + return '\n'.join(lines) + + +def create_agent_definition( + cwd: Path, + *, + agent_type: str, + description: str, + system_prompt: str, + source: str = 'projectSettings', + overwrite: bool = False, + tools: tuple[str, ...] | None = None, + model: str | None = None, + color: str | None = None, + permission_mode: str | None = None, + max_turns: int | None = None, + initial_prompt: str | None = None, + background: bool = False, + one_shot: bool = False, + omit_claude_md: bool = False, +) -> AgentMutationResult: + resolved_source = normalize_mutable_source(source) + file_path = get_agent_file_path(cwd, resolved_source, agent_type) + file_path.parent.mkdir(parents=True, exist_ok=True) + existed_before = file_path.exists() + if existed_before and not overwrite: + raise ValueError(f'Agent file already exists: {file_path}') + file_path.write_text( + format_agent_markdown( + agent_type=agent_type, + description=description, + system_prompt=system_prompt, + tools=tools, + model=model, + color=color, + permission_mode=permission_mode, + max_turns=max_turns, + initial_prompt=initial_prompt, + background=background, + one_shot=one_shot, + omit_claude_md=omit_claude_md, + ), + encoding='utf-8', + ) + return AgentMutationResult( + action='created', + agent_type=agent_type, + source=resolved_source, + file_path=str(file_path), + overwritten=existed_before and overwrite, + ) + + +def update_agent_definition( + cwd: Path, + *, + agent_type: str, + source: str = 'auto', + description: str | object = _UNSET, + system_prompt: str | object = _UNSET, + tools: tuple[str, ...] | None | object = _UNSET, + model: str | None | object = _UNSET, + color: str | None | object = _UNSET, + permission_mode: str | None | object = _UNSET, + max_turns: int | None | object = _UNSET, + initial_prompt: str | None | object = _UNSET, + background: bool | object = _UNSET, + one_shot: bool | object = _UNSET, + omit_claude_md: bool | object = _UNSET, +) -> AgentMutationResult: + resolved_source = normalize_mutable_source(source, allow_auto=True) + snapshot = load_agent_registry(cwd) + target = find_mutable_agent(snapshot, agent_type, source=resolved_source) + if target is None: + raise ValueError(f'No editable agent definition found for: {agent_type}') + file_path = get_agent_file_path( + cwd, + target.source, + target.agent_type, + filename=target.filename, + ) + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text( + format_agent_markdown( + agent_type=target.agent_type, + description=target.when_to_use if description is _UNSET else str(description), + system_prompt=target.system_prompt if system_prompt is _UNSET else str(system_prompt), + tools=target.tools if tools is _UNSET else tools, + model=target.model if model is _UNSET else model, + color=target.color if color is _UNSET else color, + permission_mode=( + target.permission_mode if permission_mode is _UNSET else permission_mode + ), + max_turns=target.max_turns if max_turns is _UNSET else max_turns, + initial_prompt=target.initial_prompt if initial_prompt is _UNSET else initial_prompt, + background=target.background if background is _UNSET else bool(background), + one_shot=target.one_shot if one_shot is _UNSET else bool(one_shot), + omit_claude_md=( + target.omit_claude_md if omit_claude_md is _UNSET else bool(omit_claude_md) + ), + ), + encoding='utf-8', + ) + return AgentMutationResult( + action='updated', + agent_type=target.agent_type, + source=target.source, + file_path=str(file_path), + ) + + +def delete_agent_definition( + cwd: Path, + *, + agent_type: str, + source: str = 'auto', +) -> AgentMutationResult: + resolved_source = normalize_mutable_source(source, allow_auto=True) + snapshot = load_agent_registry(cwd) + target = find_mutable_agent(snapshot, agent_type, source=resolved_source) + if target is None: + raise ValueError(f'No editable agent definition found for: {agent_type}') + file_path = get_agent_file_path( + cwd, + target.source, + target.agent_type, + filename=target.filename, + ) + if not file_path.exists(): + raise ValueError(f'Agent file does not exist: {file_path}') + file_path.unlink() + return AgentMutationResult( + action='deleted', + agent_type=target.agent_type, + source=target.source, + file_path=str(file_path), + ) + + +def scaffold_agent_definition( + cwd: Path, + *, + agent_type: str, + source: str = 'projectSettings', + overwrite: bool = False, + description: str | None = None, + system_prompt: str | None = None, +) -> AgentMutationResult: + resolved_description = description or f'Use this agent when the task calls for {agent_type}.' + resolved_prompt = system_prompt or ( + f'You are the {agent_type} agent.\n' + 'Read the task carefully, use the available tools deliberately, and return a concise result.' + ) + return create_agent_definition( + cwd, + agent_type=agent_type, + description=resolved_description, + system_prompt=resolved_prompt, + source=source, + overwrite=overwrite, + ) + + +def find_mutable_agent( + snapshot: AgentRegistrySnapshot, + agent_type: str, + *, + source: str = 'auto', +) -> AgentDefinition | None: + if source == 'auto': + candidates = [ + agent + for agent in snapshot.all_agents + if agent.agent_type == agent_type and agent.source in _MUTABLE_SOURCE_ALIASES.values() + ] + if not candidates: + return None + return max(candidates, key=lambda agent: _source_rank(agent.source)) + for agent in snapshot.all_agents: + if agent.agent_type == agent_type and agent.source == source: + return agent + return None + + +def get_agent_file_path( + cwd: Path, + source: str, + agent_type: str, + *, + filename: str | None = None, +) -> Path: + resolved_source = normalize_mutable_source(source) + directories = dict(iter_agent_directories(cwd)) + directory = directories[resolved_source] + return directory / f'{filename or agent_type}.md' + + +def render_agent_mutation(result: AgentMutationResult) -> str: + return '\n'.join( + [ + '# Agent', + '', + f'action={result.action}', + f'agent_type={result.agent_type}', + f'source={result.source}', + f'file_path={result.file_path}', + f'overwritten={result.overwritten}', + ] + ) + + def _split_frontmatter(text: str) -> tuple[dict[str, Any], str]: normalized = text.replace('\r\n', '\n') match = _FRONTMATTER_RE.match(normalized) @@ -303,6 +587,14 @@ def _parse_frontmatter_value(value: str) -> Any: return value +def _escape_frontmatter_text(value: str) -> str: + return ( + value.replace('\\', '\\\\') + .replace('"', '\\"') + .replace('\n', '\\\\n') + ) + + def _parse_tool_list(value: Any) -> tuple[str, ...] | None: if value is None or value == '': return None diff --git a/src/agent_runtime.py b/src/agent_runtime.py index b94dd91..bad2e70 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -15,10 +15,15 @@ from .agent_context_usage import collect_context_usage, estimate_tokens, format_ from .compact import compact_conversation from .ask_user_runtime import AskUserRuntime from .agent_registry import ( + delete_agent_definition, find_agent_definition, + normalize_mutable_source, load_agent_registry, + render_agent_mutation, render_agent_detail, render_agents_report, + scaffold_agent_definition, + update_agent_definition, ) from .config_runtime import ConfigRuntime from .hook_policy import HookPolicyRuntime @@ -3418,6 +3423,59 @@ class LocalCodingAgent: snapshot = self.load_agent_registry() return render_agent_detail(snapshot, agent_type) + def render_agent_create_report( + self, + agent_type: str, + *, + source: str = 'projectSettings', + description: str | None = None, + system_prompt: str | None = None, + overwrite: bool = False, + ) -> str: + result = scaffold_agent_definition( + self.runtime_config.cwd, + agent_type=agent_type, + source=normalize_mutable_source(source), + overwrite=overwrite, + description=description, + system_prompt=system_prompt, + ) + return render_agent_mutation(result) + + def render_agent_update_report( + self, + agent_type: str, + *, + source: str = 'auto', + description: str | None = None, + system_prompt: str | None = None, + ) -> str: + update_kwargs: dict[str, object] = {} + if description is not None: + update_kwargs['description'] = description + if system_prompt is not None: + update_kwargs['system_prompt'] = system_prompt + result = update_agent_definition( + self.runtime_config.cwd, + agent_type=agent_type, + source=normalize_mutable_source(source, allow_auto=True), + **update_kwargs, + ) + return render_agent_mutation(result) + + def render_agent_delete_report( + self, + agent_type: str, + *, + source: str = 'auto', + ) -> str: + result = delete_agent_definition( + self.runtime_config.cwd, + agent_type=agent_type, + source=normalize_mutable_source(source, allow_auto=True), + ) + return render_agent_mutation(result) + def render_memory_report(self) -> str: prompt_context = self.build_prompt_context() claude_md = prompt_context.user_context.get('claudeMd') diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index fd72ccc..1c5eabb 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -809,14 +809,116 @@ def _handle_agents(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sla return _local_result(input_text, agent.render_agents_report()) if trimmed == 'all': return _local_result(input_text, agent.render_agents_report(show_all=True)) + if trimmed.startswith('create '): + try: + source, agent_type, description, system_prompt = _parse_agent_mutation_payload( + trimmed[7:].strip(), + default_source='projectSettings', + mode='create', + ) + except ValueError as exc: + return _local_result(input_text, str(exc)) + return _local_result( + input_text, + agent.render_agent_create_report( + agent_type, + source=source, + description=description, + system_prompt=system_prompt, + ), + ) + if trimmed.startswith('update '): + try: + source, agent_type, description, system_prompt = _parse_agent_mutation_payload( + trimmed[7:].strip(), + default_source='auto', + mode='update', + ) + except ValueError as exc: + return _local_result(input_text, str(exc)) + if description is None and system_prompt is None: + return _local_result( + input_text, + 'Usage: /agents update [user|project] [description] [:: system prompt]', + ) + return _local_result( + input_text, + agent.render_agent_update_report( + agent_type, + source=source, + description=description, + system_prompt=system_prompt, + ), + ) + if trimmed.startswith('delete '): + try: + source, agent_type = _parse_agent_target(trimmed[7:].strip(), default_source='auto') + except ValueError as exc: + return _local_result(input_text, str(exc)) + return _local_result( + input_text, + agent.render_agent_delete_report(agent_type, source=source), + ) if trimmed.startswith('show '): agent_type = trimmed[5:].strip() if not agent_type: - return _local_result(input_text, 'Usage: /agents [all|active|show ]') + return _local_result(input_text, _agents_usage()) return _local_result(input_text, agent.render_agent_detail_report(agent_type)) return _local_result(input_text, agent.render_agent_detail_report(trimmed)) +def _agents_usage() -> str: + return ( + 'Usage: /agents [all|active|show |' + 'create [user|project] [description] [:: system prompt]|' + 'update [user|project] [description] [:: system prompt]|' + 'delete [user|project] ]' + ) + + +def _parse_agent_target(payload: str, *, default_source: str) -> tuple[str, str]: + tokens = payload.split() + if not tokens: + raise ValueError(_agents_usage()) + source = default_source + if tokens[0] in {'user', 'project', 'userSettings', 'projectSettings', 'auto'}: + source = tokens.pop(0) + if not tokens: + raise ValueError(_agents_usage()) + return source, tokens[0] + + +def _parse_agent_mutation_payload( + payload: str, + *, + default_source: str, + mode: str, +) -> tuple[str, str, str | None, str | None]: + parts = [part.strip() for part in payload.split('::')] + source, agent_type = _parse_agent_target(parts[0], default_source=default_source) + head_tokens = parts[0].split() + if head_tokens and head_tokens[0] in {'user', 'project', 'userSettings', 'projectSettings', 'auto'}: + head_tokens = head_tokens[1:] + trailing_description = ' '.join(head_tokens[1:]).strip() if len(head_tokens) > 1 else '' + description = trailing_description or None + system_prompt = None + if mode == 'create': + if len(parts) >= 2 and parts[1]: + description = parts[1] + if len(parts) >= 3 and parts[2]: + system_prompt = parts[2] + else: + if len(parts) >= 2 and parts[1]: + if trailing_description: + system_prompt = parts[1] + else: + system_prompt = parts[1] + if len(parts) >= 3: + description = parts[1] or description + system_prompt = parts[2] or system_prompt + return source, agent_type, description, system_prompt + + def _handle_memory(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: return _local_result(input_text, agent.render_memory_report()) diff --git a/src/main.py b/src/main.py index 1d1cae9..7b038a5 100644 --- a/src/main.py +++ b/src/main.py @@ -11,6 +11,13 @@ from typing import Callable from .background_runtime import BackgroundSessionRuntime, build_background_worker_command from .account_runtime import AccountRuntime from .ask_user_runtime import AskUserRuntime +from .agent_registry import ( + create_agent_definition, + delete_agent_definition, + normalize_mutable_source, + render_agent_mutation, + update_agent_definition, +) from .agent_runtime import LocalCodingAgent from .agent_types import ( AgentPermissions, @@ -163,6 +170,47 @@ def _load_output_schema_config(args: argparse.Namespace) -> OutputSchemaConfig | ) +def _add_agent_management_args(parser: argparse.ArgumentParser) -> None: + parser.add_argument('--cwd', default='.') + parser.add_argument( + '--source', + default='project', + choices=('project', 'user', 'auto', 'projectSettings', 'userSettings'), + ) + parser.add_argument('--description') + parser.add_argument('--prompt') + parser.add_argument('--prompt-file') + parser.add_argument('--tools') + parser.add_argument('--model') + parser.add_argument('--color') + parser.add_argument('--permission-mode') + parser.add_argument('--max-turns', type=int) + parser.add_argument('--initial-prompt') + parser.add_argument('--background', action='store_true') + parser.add_argument('--one-shot', action='store_true') + parser.add_argument('--omit-claude-md', action='store_true') + parser.add_argument('--overwrite', action='store_true') + + +def _resolve_agent_prompt_text(args: argparse.Namespace) -> str | None: + prompt_text = getattr(args, 'prompt', None) + prompt_file = getattr(args, 'prompt_file', None) + if prompt_text and prompt_file: + raise ValueError('Specify only one of --prompt or --prompt-file') + if prompt_file: + return Path(prompt_file).read_text(encoding='utf-8') + return prompt_text + + +def _parse_tools_flag(raw_tools: str | None) -> tuple[str, ...] | None: + if raw_tools is None: + return None + cleaned = raw_tools.strip() + if not cleaned or cleaned == '*': + return None + return tuple(item.strip() for item in cleaned.split(',') if item.strip()) + + def _build_agent(args: argparse.Namespace) -> LocalCodingAgent: return LocalCodingAgent( model_config=_build_model_config(args), @@ -879,6 +927,23 @@ def build_parser() -> argparse.ArgumentParser: agents_parser.add_argument('agent_type', nargs='?') agents_parser.add_argument('--all', action='store_true') _add_agent_common_args(agents_parser, include_backend=False) + + agents_create_parser = subparsers.add_parser('agents-create', help='create a local agent definition markdown file') + agents_create_parser.add_argument('agent_type') + _add_agent_management_args(agents_create_parser) + + agents_update_parser = subparsers.add_parser('agents-update', help='update an existing local agent definition markdown file') + agents_update_parser.add_argument('agent_type') + _add_agent_management_args(agents_update_parser) + + agents_delete_parser = subparsers.add_parser('agents-delete', help='delete an existing local agent definition markdown file') + agents_delete_parser.add_argument('agent_type') + agents_delete_parser.add_argument('--cwd', default='.') + agents_delete_parser.add_argument( + '--source', + default='auto', + choices=('project', 'user', 'auto', 'projectSettings', 'userSettings'), + ) return parser @@ -1519,6 +1584,72 @@ def main(argv: list[str] | None = None) -> int: else: print(agent.render_agents_report(show_all=bool(args.all))) return 0 + if args.command == 'agents-create': + prompt_text = _resolve_agent_prompt_text(args) + result = create_agent_definition( + Path(args.cwd).resolve(), + agent_type=args.agent_type, + description=args.description or f'Use this agent when the task calls for {args.agent_type}.', + system_prompt=prompt_text + or ( + f'You are the {args.agent_type} agent.\n' + 'Read the task carefully, use the available tools deliberately, and return a concise result.' + ), + source=normalize_mutable_source(args.source), + overwrite=bool(args.overwrite), + tools=_parse_tools_flag(args.tools), + model=args.model, + color=args.color, + permission_mode=args.permission_mode, + max_turns=args.max_turns, + initial_prompt=args.initial_prompt, + background=bool(args.background), + one_shot=bool(args.one_shot), + omit_claude_md=bool(args.omit_claude_md), + ) + print(render_agent_mutation(result)) + return 0 + if args.command == 'agents-update': + prompt_text = _resolve_agent_prompt_text(args) + update_kwargs: dict[str, object] = {} + if args.description is not None: + update_kwargs['description'] = args.description + if prompt_text is not None: + update_kwargs['system_prompt'] = prompt_text + if args.tools is not None: + update_kwargs['tools'] = _parse_tools_flag(args.tools) + if args.model is not None: + update_kwargs['model'] = args.model + if args.color is not None: + update_kwargs['color'] = args.color + if args.permission_mode is not None: + update_kwargs['permission_mode'] = args.permission_mode + if args.max_turns is not None: + update_kwargs['max_turns'] = args.max_turns + if args.initial_prompt is not None: + update_kwargs['initial_prompt'] = args.initial_prompt + if args.background: + update_kwargs['background'] = True + if args.one_shot: + update_kwargs['one_shot'] = True + if args.omit_claude_md: + update_kwargs['omit_claude_md'] = True + result = update_agent_definition( + Path(args.cwd).resolve(), + agent_type=args.agent_type, + source=normalize_mutable_source(args.source, allow_auto=True), + **update_kwargs, + ) + print(render_agent_mutation(result)) + return 0 + if args.command == 'agents-delete': + result = delete_agent_definition( + Path(args.cwd).resolve(), + agent_type=args.agent_type, + source=normalize_mutable_source(args.source, allow_auto=True), + ) + print(render_agent_mutation(result)) + return 0 parser.error(f'unknown command: {args.command}') return 2 diff --git a/tests/test_agent_registry.py b/tests/test_agent_registry.py index f8966ff..d2d02e4 100644 --- a/tests/test_agent_registry.py +++ b/tests/test_agent_registry.py @@ -6,7 +6,14 @@ import unittest from pathlib import Path from unittest.mock import patch -from src.agent_registry import load_agent_registry, render_agent_detail, render_agents_report +from src.agent_registry import ( + create_agent_definition, + delete_agent_definition, + load_agent_registry, + render_agent_detail, + render_agents_report, + update_agent_definition, +) def _write_agent(path: Path, *, name: str, description: str, body: str, extra: str = '') -> None: @@ -22,6 +29,50 @@ def _write_agent(path: Path, *, name: str, description: str, body: str, extra: s class AgentRegistryTests(unittest.TestCase): + def test_create_update_delete_agent_definition_roundtrip(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + with patch.dict(os.environ, {'HOME': home_dir}): + created = create_agent_definition( + workspace, + agent_type='reviewer', + description='Review implementation changes.', + system_prompt='Inspect diffs and summarize risks.', + source='project', + tools=('read_file', 'grep_search'), + model='demo-model', + initial_prompt='Start with changed files.', + ) + self.assertEqual(created.action, 'created') + self.assertTrue(Path(created.file_path).exists()) + + snapshot = load_agent_registry(workspace) + detail = render_agent_detail(snapshot, 'reviewer') + self.assertIn('demo-model', detail) + self.assertIn('Start with changed files.', detail) + + updated = update_agent_definition( + workspace, + agent_type='reviewer', + description='Review code and tests carefully.', + system_prompt='Focus on regressions and missing coverage.', + source='project', + ) + self.assertEqual(updated.action, 'updated') + + snapshot = load_agent_registry(workspace) + detail = render_agent_detail(snapshot, 'reviewer') + self.assertIn('Review code and tests carefully.', detail) + self.assertIn('Focus on regressions and missing coverage.', detail) + + deleted = delete_agent_definition( + workspace, + agent_type='reviewer', + source='project', + ) + self.assertEqual(deleted.action, 'deleted') + self.assertFalse(Path(deleted.file_path).exists()) + def test_project_agent_overrides_built_in_agent(self) -> None: with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_agent_slash_commands.py b/tests/test_agent_slash_commands.py index 2ec63c7..b7534c4 100644 --- a/tests/test_agent_slash_commands.py +++ b/tests/test_agent_slash_commands.py @@ -149,6 +149,33 @@ class AgentSlashCommandTests(unittest.TestCase): self.assertIn('# Agent: reviewer', detail_result.final_output) self.assertIn('Inspect code changes and summarize risks.', detail_result.final_output) + def test_agents_command_can_create_update_and_delete_project_agent(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + with patch.dict(os.environ, {'HOME': home_dir}): + agent = LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=workspace), + ) + create_result = agent.run( + '/agents create reviewer :: Review code changes carefully. :: Inspect code changes and summarize risks.' + ) + self.assertIn('action=created', create_result.final_output) + self.assertTrue((workspace / '.claude' / 'agents' / 'reviewer.md').exists()) + + update_result = agent.run( + '/agents update reviewer Updated review description :: Focus on regressions and missing tests.' + ) + self.assertIn('action=updated', update_result.final_output) + + detail_result = agent.run('/agents reviewer') + self.assertIn('Updated review description', detail_result.final_output) + self.assertIn('Focus on regressions and missing tests.', detail_result.final_output) + + delete_result = agent.run('/agents delete reviewer') + self.assertIn('action=deleted', delete_result.final_output) + self.assertFalse((workspace / '.claude' / 'agents' / 'reviewer.md').exists()) + def test_mcp_and_resource_commands_render_local_reports(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: workspace = Path(tmp_dir) diff --git a/tests/test_main.py b/tests/test_main.py index a66c8e0..5f028d4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -7,7 +7,7 @@ from dataclasses import replace from pathlib import Path from unittest.mock import patch -from src.main import _build_runtime_config, _build_agent, _run_agent_chat_loop, build_parser +from src.main import _build_runtime_config, _build_agent, _run_agent_chat_loop, build_parser, main class FakeHTTPResponse: @@ -221,6 +221,55 @@ class MainCliTests(unittest.TestCase): self.assertTrue(args.all) self.assertEqual(args.cwd, '.') + def test_parser_accepts_agent_management_commands(self) -> None: + parser = build_parser() + create_args = parser.parse_args( + ['agents-create', 'reviewer', '--cwd', '.', '--description', 'Review code', '--prompt', 'Inspect diffs'] + ) + self.assertEqual(create_args.command, 'agents-create') + self.assertEqual(create_args.agent_type, 'reviewer') + self.assertEqual(create_args.description, 'Review code') + + update_args = parser.parse_args(['agents-update', 'reviewer', '--cwd', '.', '--source', 'auto']) + self.assertEqual(update_args.command, 'agents-update') + self.assertEqual(update_args.source, 'auto') + + delete_args = parser.parse_args(['agents-delete', 'reviewer', '--cwd', '.', '--source', 'project']) + self.assertEqual(delete_args.command, 'agents-delete') + self.assertEqual(delete_args.source, 'project') + + def test_main_can_create_and_delete_agent_definition(self) -> None: + with tempfile.TemporaryDirectory() as home_dir, tempfile.TemporaryDirectory() as tmp_dir: + workspace = Path(tmp_dir) + with patch.dict('os.environ', {'HOME': home_dir}): + exit_code = main( + [ + 'agents-create', + 'reviewer', + '--cwd', + str(workspace), + '--description', + 'Review code carefully', + '--prompt', + 'Inspect code and summarize risks.', + ] + ) + self.assertEqual(exit_code, 0) + self.assertTrue((workspace / '.claude' / 'agents' / 'reviewer.md').exists()) + + exit_code = main( + [ + 'agents-delete', + 'reviewer', + '--cwd', + str(workspace), + '--source', + 'project', + ] + ) + self.assertEqual(exit_code, 0) + self.assertFalse((workspace / '.claude' / 'agents' / 'reviewer.md').exists()) + def test_parser_accepts_team_runtime_commands(self) -> None: parser = build_parser() args = parser.parse_args(['team-create', 'reviewers', '--member', 'alice', '--cwd', '.']) From 10727934875695ee5a6a6a6385ad38ca3df594d1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Sun, 19 Apr 2026 23:39:07 +0200 Subject: [PATCH 02/18] Implemented the next missing parity slice around informational slash commands. Adds 15 commands ported from npm src/commands/: /output-style, /release-notes, /feedback (alias /bug), /upgrade, /stickers, /mobile (aliases /ios, /android), /desktop (alias /app), /install-github-app, /install-slack-app, /privacy-settings, /extra-usage, /passes, /rate-limit-options, /chrome, and /reload-plugins. Browser launches honor CI / CLAUDE_CODE_NO_BROWSER and fall back to a printed link when a browser is unavailable. Co-Authored-By: Claude Opus 4.7 --- PARITY_CHECKLIST.md | 30 +-- src/agent_slash_commands.py | 360 ++++++++++++++++++++++++++ tests/test_external_slash_commands.py | 145 +++++++++++ 3 files changed, 520 insertions(+), 15 deletions(-) create mode 100644 tests/test_external_slash_commands.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index eca435c..a84b0f8 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -325,34 +325,34 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions - [ ] `/btw` — Quick side question without interrupting main conversation -- [ ] `/chrome` — Chrome extension settings +- [x] `/chrome` — Chrome extension settings - [x] `/color` — Set the prompt bar color for this session - [x] `/compact` — Clear history but keep a summary in context - [x] `/copy` — Copy Claude's last response to clipboard - [x] `/cost` — Show total cost and duration of session -- [ ] `/desktop` — Continue session in Claude Desktop +- [x] `/desktop` — Continue session in Claude Desktop - [x] `/diff` — View uncommitted changes and per-turn diffs - [x] `/doctor` — Diagnose and verify installation and settings - [x] `/effort` — Set effort level for model usage - [x] `/exit` — Exit the REPL - [x] `/export` — Export conversation to file or clipboard -- [ ] `/extra-usage` — Configure extra usage for rate limits +- [x] `/extra-usage` — Configure extra usage for rate limits - [x] `/fast` — Toggle fast mode -- [ ] `/feedback` — Submit feedback +- [x] `/feedback` — Submit feedback (alias `/bug`) - [x] `/files` — List all files currently in context - [ ] `/ide` — Manage IDE integrations and show status -- [ ] `/install-github-app` — Set up GitHub Actions -- [ ] `/install-slack-app` — Install Slack app +- [x] `/install-github-app` — Set up GitHub Actions +- [x] `/install-slack-app` — Install Slack app - [ ] `/keybindings` — Open keybindings config file -- [ ] `/mobile` — QR code for mobile app -- [ ] `/output-style` — Change output style -- [ ] `/passes` — Passes management +- [x] `/mobile` — Mobile app store links (aliases `/ios`, `/android`) +- [x] `/output-style` — Deprecation pointer to `/config` +- [x] `/passes` — Passes management - [ ] `/plugin` — Plugin management - [x] `/pr-comments`, `/pr_comments` — Get comments from a GitHub PR (prompt-type) -- [ ] `/privacy-settings` — View/update privacy settings -- [ ] `/rate-limit-options` — Show options when rate limited -- [ ] `/release-notes` — View release notes -- [ ] `/reload-plugins` — Activate pending plugin changes +- [x] `/privacy-settings` — View/update privacy settings +- [x] `/rate-limit-options` — Show options when rate limited +- [x] `/release-notes` — View release notes +- [x] `/reload-plugins` — Activate pending plugin changes - [ ] `/remote-env` — Configure default remote environment - [ ] `/remote-setup` — Remote setup configuration - [x] `/rename` — Rename current conversation @@ -361,10 +361,10 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] `/sandbox-toggle` — Toggle sandbox mode - [x] `/skills` — List available skills - [x] `/stats` — Usage statistics and activity -- [ ] `/stickers` — Order stickers +- [x] `/stickers` — Order stickers - [x] `/tag` — Toggle a searchable tag on the session - [ ] `/theme` — Change the theme -- [ ] `/upgrade` — Upgrade to Max +- [x] `/upgrade` — Upgrade to Max - [x] `/vim` — Toggle Vim/Normal editing modes - [ ] `/voice` — Toggle voice mode - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 1c5eabb..156fea8 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -414,6 +414,81 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Restore the conversation to a previous point.', handler=_handle_rewind, ), + SlashCommandSpec( + names=('output-style',), + description='Deprecated: use /config to change your output style.', + handler=_handle_output_style, + ), + SlashCommandSpec( + names=('release-notes',), + description='Show local release notes or a link to the changelog.', + handler=_handle_release_notes, + ), + SlashCommandSpec( + names=('feedback', 'bug'), + description='Open the Claude Code feedback page in a browser.', + handler=_handle_feedback, + ), + SlashCommandSpec( + names=('upgrade',), + description='Open the Claude.ai upgrade page in a browser.', + handler=_handle_upgrade, + ), + SlashCommandSpec( + names=('stickers',), + description='Open the Claude Code sticker order page in a browser.', + handler=_handle_stickers, + ), + SlashCommandSpec( + names=('mobile', 'ios', 'android'), + description='Show download links for the Claude mobile apps.', + handler=_handle_mobile, + ), + SlashCommandSpec( + names=('desktop', 'app'), + description='Show the Claude Desktop handoff page link.', + handler=_handle_desktop, + ), + SlashCommandSpec( + names=('install-github-app',), + description='Open the Claude GitHub Actions setup page.', + handler=_handle_install_github_app, + ), + SlashCommandSpec( + names=('install-slack-app',), + description='Open the Claude Slack app installation page.', + handler=_handle_install_slack_app, + ), + SlashCommandSpec( + names=('privacy-settings',), + description='Open the Claude.ai privacy controls page.', + handler=_handle_privacy_settings, + ), + SlashCommandSpec( + names=('extra-usage',), + description='Show extra-usage configuration link.', + handler=_handle_extra_usage, + ), + SlashCommandSpec( + names=('passes',), + description='Show Claude Code guest passes information.', + handler=_handle_passes, + ), + SlashCommandSpec( + names=('rate-limit-options',), + description='Show options when the active account hits a rate limit.', + handler=_handle_rate_limit_options, + ), + SlashCommandSpec( + names=('chrome',), + description='Open the Claude Chrome extension page.', + handler=_handle_chrome, + ), + SlashCommandSpec( + names=('reload-plugins',), + description='Reload local plugin manifests and report counts.', + handler=_handle_reload_plugins, + ), ) @@ -1616,6 +1691,291 @@ def _handle_rewind(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sla ) +_FEEDBACK_URL = 'https://github.com/anthropics/claude-code/issues' +_UPGRADE_URL = 'https://claude.ai/upgrade/max' +_STICKERS_URL = 'https://www.stickermule.com/claudecode' +_MOBILE_IOS_URL = 'https://apps.apple.com/app/claude-by-anthropic/id6473753684' +_MOBILE_ANDROID_URL = 'https://play.google.com/store/apps/details?id=com.anthropic.claude' +_DESKTOP_URL = 'https://claude.ai/download' +_GITHUB_APP_URL = 'https://github.com/apps/claude' +_SLACK_APP_URL = 'https://slack.com/marketplace/A08SF47R6P4-claude' +_PRIVACY_URL = 'https://claude.ai/settings/data-privacy-controls' +_CHROME_EXTENSION_URL = 'https://claude.ai/chrome' +_CHANGELOG_URL = 'https://github.com/anthropics/claude-code/blob/main/CHANGELOG.md' + + +def _try_open_browser(url: str) -> bool: + import os + import webbrowser + + # Avoid spawning a browser in CI / non-interactive environments. + if os.environ.get('CLAUDE_CODE_NO_BROWSER') or os.environ.get('CI'): + return False + try: + return webbrowser.open(url, new=2) + except Exception: + return False + + +def _open_or_link(url: str, *, opening_message: str, fallback_message: str) -> str: + if _try_open_browser(url): + return f'{opening_message}\n {url}' + return f'{fallback_message}\n {url}' + + +def _changelog_path(agent: 'LocalCodingAgent') -> 'Path': + from pathlib import Path + + cwd = Path(agent.runtime_config.cwd) + for candidate in (cwd / 'CHANGELOG.md', cwd / 'docs' / 'CHANGELOG.md'): + if candidate.exists(): + return candidate + return cwd / 'CHANGELOG.md' + + +def _handle_output_style( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + '/output-style has been deprecated. Use /config to change your output style, ' + 'or set it in your settings file. Changes take effect on the next session.', + ) + + +def _handle_release_notes( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + path = _changelog_path(agent) + if path.exists(): + try: + content = path.read_text(encoding='utf-8').strip() + except OSError as exc: + return _local_result(input_text, f'Could not read {path}: {exc}') + # Show only the most recent release block (everything up to the second + # second-level heading, mirroring how the npm command surfaces a single + # version chunk by default). + lines = content.splitlines() + chunk: list[str] = [] + seen_heading = False + for line in lines: + if line.startswith('## '): + if seen_heading: + break + seen_heading = True + chunk.append(line) + return _local_result(input_text, '\n'.join(chunk).strip() or content) + return _local_result( + input_text, + f'No local CHANGELOG.md found. See the full changelog at:\n {_CHANGELOG_URL}', + ) + + +def _handle_feedback( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + note = args.strip() + body = _open_or_link( + _FEEDBACK_URL, + opening_message='Opening the Claude Code feedback tracker in your browser…', + fallback_message='Submit feedback at:', + ) + if note: + body += f'\n\nDraft note (copy into the report form):\n{note}' + return _local_result(input_text, body) + + +def _handle_upgrade( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _UPGRADE_URL, + opening_message='Opening the Claude.ai upgrade page in your browser…', + fallback_message='Upgrade your account at:', + ), + ) + + +def _handle_stickers( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _STICKERS_URL, + opening_message='Opening the Claude Code sticker page in your browser…', + fallback_message='Order Claude Code stickers at:', + ), + ) + + +def _handle_mobile( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + lines = [ + 'Download the Claude mobile app:', + f' iOS: {_MOBILE_IOS_URL}', + f' Android: {_MOBILE_ANDROID_URL}', + ] + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_desktop( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + import platform + + system = platform.system() + if system not in {'Darwin', 'Windows'}: + return _local_result( + input_text, + f'Claude Desktop is currently available on macOS and Windows only ' + f'(detected: {system}). Download:\n {_DESKTOP_URL}', + ) + return _local_result( + input_text, + _open_or_link( + _DESKTOP_URL, + opening_message='Opening the Claude Desktop download page in your browser…', + fallback_message='Download Claude Desktop at:', + ), + ) + + +def _handle_install_github_app( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _GITHUB_APP_URL, + opening_message='Opening the Claude GitHub App installation page in your browser…', + fallback_message='Set up Claude GitHub Actions at:', + ), + ) + + +def _handle_install_slack_app( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _SLACK_APP_URL, + opening_message='Opening the Claude Slack app marketplace page in your browser…', + fallback_message="Couldn't open browser. Visit:", + ), + ) + + +def _handle_privacy_settings( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + f'Review and manage your privacy settings at:\n {_PRIVACY_URL}', + ) + + +def _handle_extra_usage( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + 'Configure extra usage on a Claude.ai account at:\n' + f' {_UPGRADE_URL}\n' + 'After upgrading, run /login to refresh your local credentials.', + ) + + +def _handle_passes( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + 'Claude Code guest passes are managed in your Claude.ai account.\n' + ' Visit https://claude.ai to sign in and view remaining passes.', + ) + + +def _handle_rate_limit_options( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + lines = [ + 'When the current account hits a rate limit, you can:', + ' - Run /upgrade to move to a higher Claude.ai plan.', + ' - Run /extra-usage to enable per-message billing on a Claude.ai plan.', + ' - Run /login to switch to an API-key billed account.', + f'See {_UPGRADE_URL} for plan details.', + ] + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_chrome( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _local_result( + input_text, + _open_or_link( + _CHROME_EXTENSION_URL, + opening_message='Opening the Claude Chrome extension page in your browser…', + fallback_message='Install the Claude Chrome extension at:', + ), + ) + + +def _handle_reload_plugins( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + from pathlib import Path + from .plugin_runtime import PluginRuntime + + runtime = PluginRuntime.from_workspace(Path(agent.runtime_config.cwd)) + agent.plugin_runtime = runtime + plugin_count = len(runtime.manifests) + tool_count = sum(len(manifest.tool_names) for manifest in runtime.manifests) + hook_count = sum(len(manifest.hook_names) for manifest in runtime.manifests) + virtual_count = sum(len(manifest.virtual_tools) for manifest in runtime.manifests) + return _local_result( + input_text, + 'Reloaded plugins: ' + f'{plugin_count} plugin(s) · {tool_count} tool(s) · {hook_count} hook(s) · ' + f'{virtual_count} virtual tool(s)', + ) + + def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: """Return a prompt-type result — the prompt gets sent to the model.""" return SlashCommandResult( diff --git a/tests/test_external_slash_commands.py b/tests/test_external_slash_commands.py new file mode 100644 index 0000000..fa376a4 --- /dev/null +++ b/tests/test_external_slash_commands.py @@ -0,0 +1,145 @@ +"""Tests for the informational slash commands ported from the npm source. + +Covers /output-style, /release-notes, /feedback, /upgrade, /stickers, /mobile, +/desktop, /install-github-app, /install-slack-app, /privacy-settings, +/extra-usage, /passes, /rate-limit-options, /chrome, /reload-plugins. +""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path + +from src.agent_runtime import LocalCodingAgent +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +class ExternalSlashCommandsTest(unittest.TestCase): + """Each test runs with CLAUDE_CODE_NO_BROWSER=1 so no browser opens.""" + + def setUp(self) -> None: + os.environ['CLAUDE_CODE_NO_BROWSER'] = '1' + + def tearDown(self) -> None: + os.environ.pop('CLAUDE_CODE_NO_BROWSER', None) + + def _run(self, cmd: str) -> str: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + return agent.run(cmd).final_output + + def test_output_style_is_deprecated(self) -> None: + out = self._run('/output-style') + self.assertIn('deprecated', out.lower()) + self.assertIn('/config', out) + + def test_release_notes_falls_back_to_link(self) -> None: + out = self._run('/release-notes') + self.assertIn('CHANGELOG.md', out) + self.assertIn('https://github.com/anthropics/claude-code', out) + + def test_release_notes_reads_local_changelog(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text( + '# Changelog\n\n## 1.2.3\n- did a thing\n\n## 1.2.2\n- old\n', + encoding='utf-8', + ) + agent = _make_agent(tmp) + out = agent.run('/release-notes').final_output + self.assertIn('1.2.3', out) + self.assertIn('did a thing', out) + self.assertNotIn('1.2.2', out) + + def test_feedback_returns_link(self) -> None: + out = self._run('/feedback') + self.assertIn('https://github.com/anthropics/claude-code/issues', out) + + def test_bug_aliases_to_feedback(self) -> None: + out = self._run('/bug') + self.assertIn('https://github.com/anthropics/claude-code/issues', out) + + def test_feedback_includes_user_note(self) -> None: + out = self._run('/feedback the wrap selector keeps eating my prompt') + self.assertIn('Draft note', out) + self.assertIn('wrap selector', out) + + def test_upgrade_returns_link(self) -> None: + out = self._run('/upgrade') + self.assertIn('https://claude.ai/upgrade/max', out) + + def test_stickers_returns_link(self) -> None: + out = self._run('/stickers') + self.assertIn('stickermule.com/claudecode', out) + + def test_mobile_lists_both_stores(self) -> None: + out = self._run('/mobile') + self.assertIn('apps.apple.com', out) + self.assertIn('play.google.com', out) + + def test_ios_alias(self) -> None: + out = self._run('/ios') + self.assertIn('apps.apple.com', out) + + def test_android_alias(self) -> None: + out = self._run('/android') + self.assertIn('play.google.com', out) + + def test_desktop_returns_link(self) -> None: + out = self._run('/desktop') + self.assertIn('claude.ai/download', out) + + def test_app_aliases_to_desktop(self) -> None: + out = self._run('/app') + self.assertIn('claude.ai/download', out) + + def test_install_github_app(self) -> None: + out = self._run('/install-github-app') + self.assertIn('github.com/apps/claude', out) + + def test_install_slack_app(self) -> None: + out = self._run('/install-slack-app') + self.assertIn('slack.com/marketplace/A08SF47R6P4-claude', out) + + def test_privacy_settings(self) -> None: + out = self._run('/privacy-settings') + self.assertIn('claude.ai/settings/data-privacy-controls', out) + + def test_extra_usage_points_to_upgrade(self) -> None: + out = self._run('/extra-usage') + self.assertIn('claude.ai/upgrade/max', out) + self.assertIn('/login', out) + + def test_passes_mentions_claude_ai(self) -> None: + out = self._run('/passes') + self.assertIn('claude.ai', out.lower()) + self.assertIn('passes', out.lower()) + + def test_rate_limit_options_lists_actions(self) -> None: + out = self._run('/rate-limit-options') + self.assertIn('/upgrade', out) + self.assertIn('/extra-usage', out) + self.assertIn('/login', out) + + def test_chrome_returns_link(self) -> None: + out = self._run('/chrome') + self.assertIn('claude.ai/chrome', out) + + def test_reload_plugins_reports_counts(self) -> None: + out = self._run('/reload-plugins') + self.assertIn('Reloaded plugins', out) + self.assertIn('plugin(s)', out) + self.assertIn('tool(s)', out) + self.assertIn('hook(s)', out) + + +if __name__ == '__main__': + unittest.main() From e3d9472113a8cb121d22487d23562a615d389141 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Sun, 19 Apr 2026 23:50:11 +0200 Subject: [PATCH 03/18] Implemented the next missing parity slice around settings slash commands. Adds /theme, /voice, /sandbox-toggle (alias /sandbox), /keybindings, and /btw ported from npm src/commands/. Settings-touching commands write through the existing ConfigRuntime to .claude/settings.local.json (theme, voiceEnabled, sandbox.excludedCommands). /keybindings creates a JSON template on first invocation. /btw flips into a prompt-style result that asks the model to answer the side question without mutating workspace state. Co-Authored-By: Claude Opus 4.7 --- PARITY_CHECKLIST.md | 10 +- src/agent_slash_commands.py | 213 ++++++++++++++++++++++++++ tests/test_settings_slash_commands.py | 179 ++++++++++++++++++++++ 3 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 tests/test_settings_slash_commands.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index a84b0f8..1288953 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -324,7 +324,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/agents` — Inspect, create, update, and delete local agent definitions - [x] `/branch` — Create a branch of the current conversation - [ ] `/bridge` — Connect for remote-control sessions -- [ ] `/btw` — Quick side question without interrupting main conversation +- [x] `/btw` — Quick side question without interrupting main conversation - [x] `/chrome` — Chrome extension settings - [x] `/color` — Set the prompt bar color for this session - [x] `/compact` — Clear history but keep a summary in context @@ -343,7 +343,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [ ] `/ide` — Manage IDE integrations and show status - [x] `/install-github-app` — Set up GitHub Actions - [x] `/install-slack-app` — Install Slack app -- [ ] `/keybindings` — Open keybindings config file +- [x] `/keybindings` — Open keybindings config file - [x] `/mobile` — Mobile app store links (aliases `/ios`, `/android`) - [x] `/output-style` — Deprecation pointer to `/config` - [x] `/passes` — Passes management @@ -358,15 +358,15 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/rename` — Rename current conversation - [x] `/resume`, `/continue` — Resume a previous conversation - [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point -- [ ] `/sandbox-toggle` — Toggle sandbox mode +- [x] `/sandbox-toggle` — Toggle sandbox mode (alias `/sandbox`) - [x] `/skills` — List available skills - [x] `/stats` — Usage statistics and activity - [x] `/stickers` — Order stickers - [x] `/tag` — Toggle a searchable tag on the session -- [ ] `/theme` — Change the theme +- [x] `/theme` — Change the theme - [x] `/upgrade` — Upgrade to Max - [x] `/vim` — Toggle Vim/Normal editing modes -- [ ] `/voice` — Toggle voice mode +- [x] `/voice` — Toggle voice mode - [ ] Feature-gated: `/buddy`, `/fork`, `/peers`, `/proactive`, `/torch`, `/workflows` (full), etc. - [ ] Internal: `/backfill-sessions`, `/break-cache`, `/bughunter`, `/commit-push-pr`, `/init-verifiers`, `/mock-limits`, `/version`, `/ultraplan`, `/autofix-pr`, etc. - [x] `/commit` — Create a git commit (prompt-type with injected git context) diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 156fea8..135c235 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -489,6 +489,31 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Reload local plugin manifests and report counts.', handler=_handle_reload_plugins, ), + SlashCommandSpec( + names=('theme',), + description='List available themes or set the current theme.', + handler=_handle_theme, + ), + SlashCommandSpec( + names=('voice',), + description='Toggle voice-mode setting for this workspace.', + handler=_handle_voice, + ), + SlashCommandSpec( + names=('sandbox-toggle', 'sandbox'), + description='Show sandbox status or exclude a command pattern.', + handler=_handle_sandbox_toggle, + ), + SlashCommandSpec( + names=('keybindings',), + description='Print or create the local keybindings file.', + handler=_handle_keybindings, + ), + SlashCommandSpec( + names=('btw',), + description='Ask Claude a quick side question without altering state.', + handler=_handle_btw, + ), ) @@ -1976,6 +2001,194 @@ def _handle_reload_plugins( ) +_AVAILABLE_THEMES = ( + 'light', + 'dark', + 'light-daltonized', + 'dark-daltonized', + 'light-ansi', + 'dark-ansi', +) + + +def _config_get(runtime, key_path: str, default=None): + try: + return runtime.get_value(key_path) + except KeyError: + return default + + +def _handle_theme( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.config_runtime + if runtime is None: + return _local_result(input_text, 'Config runtime is unavailable.') + requested = args.strip() + current = _config_get(runtime, 'theme') or 'light' + if not requested: + lines = ['Available themes:'] + for name in _AVAILABLE_THEMES: + marker = ' (current)' if name == current else '' + lines.append(f' - {name}{marker}') + lines.append('') + lines.append('Usage: /theme ') + return _local_result(input_text, '\n'.join(lines)) + if requested not in _AVAILABLE_THEMES: + return _local_result( + input_text, + f'Unknown theme "{requested}". Available: {", ".join(_AVAILABLE_THEMES)}.', + ) + mutation = runtime.set_value('theme', requested, source='local') + return _local_result( + input_text, + f'Theme set to {requested} (saved to {mutation.store_path}).', + ) + + +def _handle_voice( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.config_runtime + if runtime is None: + return _local_result(input_text, 'Config runtime is unavailable.') + arg = args.strip().lower() + current = bool(_config_get(runtime, 'voiceEnabled', False)) + if arg in {'on', 'enable', 'true'}: + new_value = True + elif arg in {'off', 'disable', 'false'}: + new_value = False + elif not arg: + new_value = not current + else: + return _local_result(input_text, 'Usage: /voice [on|off]') + mutation = runtime.set_value('voiceEnabled', new_value, source='local') + state = 'enabled' if new_value else 'disabled' + extra = '' + if new_value: + import platform + + if platform.system() == 'Linux': + extra = ( + '\nLinux note: confirm microphone access in your audio settings ' + 'before holding to talk.' + ) + return _local_result( + input_text, + f'Voice mode {state} (saved to {mutation.store_path}).{extra}', + ) + + +def _handle_sandbox_toggle( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.config_runtime + if runtime is None: + return _local_result(input_text, 'Config runtime is unavailable.') + trimmed = args.strip() + if not trimmed: + enabled = bool(_config_get(runtime, 'sandbox.enabled', False)) + excluded = _config_get(runtime, 'sandbox.excludedCommands') or [] + lines = [ + f'Sandbox: {"enabled" if enabled else "disabled"}', + f'Excluded commands ({len(excluded)}):', + ] + for pattern in excluded: + lines.append(f' - {pattern}') + lines.append('') + lines.append( + 'Usage: /sandbox-toggle exclude "" ' + '— append a command pattern to the local sandbox excludes.' + ) + return _local_result(input_text, '\n'.join(lines)) + + parts = trimmed.split(None, 1) + subcommand = parts[0].lower() + if subcommand != 'exclude': + return _local_result( + input_text, + f'Unknown subcommand "{subcommand}". Available: exclude.', + ) + if len(parts) < 2 or not parts[1].strip(): + return _local_result( + input_text, + 'Usage: /sandbox-toggle exclude "" ' + '(e.g., /sandbox-toggle exclude "npm run test:*").', + ) + pattern = parts[1].strip().strip('"').strip("'") + existing = list(_config_get(runtime, 'sandbox.excludedCommands') or []) + if pattern in existing: + return _local_result( + input_text, + f'Pattern "{pattern}" is already in sandbox.excludedCommands.', + ) + existing.append(pattern) + mutation = runtime.set_value( + 'sandbox.excludedCommands', existing, source='local', + ) + return _local_result( + input_text, + f'Added "{pattern}" to sandbox.excludedCommands in {mutation.store_path}.', + ) + + +_KEYBINDINGS_TEMPLATE = ( + '{\n' + ' "$schema": "https://claude.ai/schemas/keybindings.json",\n' + ' "bindings": {\n' + ' // "chat:submit": "ctrl+enter"\n' + ' }\n' + '}\n' +) + + +def _handle_keybindings( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + from pathlib import Path + + cwd = Path(agent.runtime_config.cwd) + path = cwd / '.claude' / 'keybindings.json' + created = False + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(_KEYBINDINGS_TEMPLATE, encoding='utf-8') + created = True + verb = 'Created' if created else 'Found' + import os + + editor = os.environ.get('EDITOR') or os.environ.get('VISUAL') or '' + return _local_result( + input_text, + f'{verb} {path}.\n' + f'Edit it with: {editor} {path}', + ) + + +def _handle_btw( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + question = args.strip() + if not question: + return _local_result(input_text, 'Usage: /btw ') + prompt = ( + 'Answer the following side question concisely. Do NOT modify any files, ' + "run shell commands, or alter the workspace — just answer in text.\n\n" + f'Side question: {question}' + ) + return _prompt_result(input_text, prompt) + + def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: """Return a prompt-type result — the prompt gets sent to the model.""" return SlashCommandResult( diff --git a/tests/test_settings_slash_commands.py b/tests/test_settings_slash_commands.py new file mode 100644 index 0000000..2674bf0 --- /dev/null +++ b/tests/test_settings_slash_commands.py @@ -0,0 +1,179 @@ +"""Tests for settings-touching slash commands ported from the npm source. + +Covers /theme, /voice, /sandbox-toggle (alias /sandbox), /keybindings, /btw. +""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path + +from src.agent_runtime import LocalCodingAgent +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +def _local_settings(tmp_dir: str) -> dict: + path = Path(tmp_dir) / '.claude' / 'settings.local.json' + if not path.exists(): + return {} + return json.loads(path.read_text(encoding='utf-8')) + + +class ThemeCommandTest(unittest.TestCase): + def test_lists_themes_when_no_arg(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/theme').final_output + self.assertIn('Available themes', out) + self.assertIn('light', out) + self.assertIn('dark', out) + self.assertIn('Usage: /theme ', out) + + def test_rejects_unknown_theme(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/theme neon').final_output + self.assertIn('Unknown theme', out) + self.assertIn('neon', out) + + def test_sets_theme_and_persists(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/theme dark').final_output + self.assertIn('Theme set to dark', out) + settings = _local_settings(tmp) + self.assertEqual(settings.get('theme'), 'dark') + + def test_marks_current_theme(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/theme dark') + out = agent.run('/theme').final_output + self.assertIn('dark (current)', out) + + +class VoiceCommandTest(unittest.TestCase): + def test_toggle_enables_when_unset(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/voice').final_output + self.assertIn('Voice mode enabled', out) + self.assertEqual(_local_settings(tmp).get('voiceEnabled'), True) + + def test_toggle_disables_when_enabled(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/voice on') + out = agent.run('/voice').final_output + self.assertIn('Voice mode disabled', out) + self.assertEqual(_local_settings(tmp).get('voiceEnabled'), False) + + def test_explicit_on_off(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + self.assertIn('enabled', agent.run('/voice on').final_output) + self.assertIn('disabled', agent.run('/voice off').final_output) + + def test_rejects_unknown_arg(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/voice maybe').final_output + self.assertIn('Usage', out) + + +class SandboxToggleCommandTest(unittest.TestCase): + def test_status_with_no_args(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/sandbox-toggle').final_output + self.assertIn('Sandbox:', out) + self.assertIn('Excluded commands', out) + self.assertIn('Usage:', out) + + def test_alias_sandbox(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/sandbox').final_output + self.assertIn('Sandbox:', out) + + def test_exclude_appends_pattern(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/sandbox-toggle exclude "npm run test:*"').final_output + self.assertIn('Added "npm run test:*"', out) + settings = _local_settings(tmp) + excluded = settings.get('sandbox', {}).get('excludedCommands', []) + self.assertIn('npm run test:*', excluded) + + def test_exclude_dedupes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/sandbox-toggle exclude "rm -rf /"') + out = agent.run('/sandbox-toggle exclude "rm -rf /"').final_output + self.assertIn('already in', out) + + def test_exclude_requires_pattern(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/sandbox-toggle exclude').final_output + self.assertIn('Usage', out) + + def test_unknown_subcommand(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/sandbox-toggle wat').final_output + self.assertIn('Unknown subcommand', out) + + +class KeybindingsCommandTest(unittest.TestCase): + def test_creates_template_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/keybindings').final_output + path = Path(tmp) / '.claude' / 'keybindings.json' + self.assertTrue(path.exists()) + self.assertIn('Created', out) + self.assertIn(str(path), out) + # Template is valid JSON-ish (has braces); strict json.loads would + # choke on the "//" comment, so just sanity-check structure. + text = path.read_text(encoding='utf-8') + self.assertIn('"bindings"', text) + + def test_reports_existing_file(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + agent.run('/keybindings') + out = agent.run('/keybindings').final_output + self.assertIn('Found', out) + + +class BtwCommandTest(unittest.TestCase): + def test_no_question_shows_usage(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = agent.run('/btw').final_output + self.assertIn('Usage: /btw', result) + + def test_question_returns_prompt_result(self) -> None: + from src.agent_slash_commands import preprocess_slash_command + + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = preprocess_slash_command(agent, '/btw what does this codebase do?') + self.assertTrue(result.handled) + self.assertTrue(result.should_query) + self.assertIn('side question', (result.prompt or '').lower()) + self.assertIn('what does this codebase do?', result.prompt or '') + + +if __name__ == '__main__': + unittest.main() From a6a67d4c55506bee00cf7f8a323d2d767fe379fb Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:16:47 +0200 Subject: [PATCH 04/18] Implemented the next missing parity slice around discovery slash commands. --- PARITY_CHECKLIST.md | 6 +- src/agent_slash_commands.py | 262 ++++++++++++++++++++++++++++++ tests/test_misc_slash_commands.py | 143 ++++++++++++++++ 3 files changed, 408 insertions(+), 3 deletions(-) create mode 100644 tests/test_misc_slash_commands.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 1288953..c221706 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -340,20 +340,20 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/fast` — Toggle fast mode - [x] `/feedback` — Submit feedback (alias `/bug`) - [x] `/files` — List all files currently in context -- [ ] `/ide` — Manage IDE integrations and show status +- [x] `/ide` — Manage IDE integrations and show status - [x] `/install-github-app` — Set up GitHub Actions - [x] `/install-slack-app` — Install Slack app - [x] `/keybindings` — Open keybindings config file - [x] `/mobile` — Mobile app store links (aliases `/ios`, `/android`) - [x] `/output-style` — Deprecation pointer to `/config` - [x] `/passes` — Passes management -- [ ] `/plugin` — Plugin management +- [x] `/plugin` — Plugin management (read-only listing) - [x] `/pr-comments`, `/pr_comments` — Get comments from a GitHub PR (prompt-type) - [x] `/privacy-settings` — View/update privacy settings - [x] `/rate-limit-options` — Show options when rate limited - [x] `/release-notes` — View release notes - [x] `/reload-plugins` — Activate pending plugin changes -- [ ] `/remote-env` — Configure default remote environment +- [x] `/remote-env` — Configure default remote environment - [ ] `/remote-setup` — Remote setup configuration - [x] `/rename` — Rename current conversation - [x] `/resume`, `/continue` — Resume a previous conversation diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 135c235..364c88b 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -514,6 +514,31 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='Ask Claude a quick side question without altering state.', handler=_handle_btw, ), + SlashCommandSpec( + names=('version',), + description='Print the running version of the agent.', + handler=_handle_version, + ), + SlashCommandSpec( + names=('init',), + description='Initialize a CLAUDE.md file with codebase documentation.', + handler=_handle_init, + ), + SlashCommandSpec( + names=('ide',), + description='Show detected IDE/terminal integration status.', + handler=_handle_ide, + ), + SlashCommandSpec( + names=('plugin',), + description='List installed plugins or show plugin subcommand usage.', + handler=_handle_plugin, + ), + SlashCommandSpec( + names=('remote-env',), + description='List remote environments or set the default profile.', + handler=_handle_remote_env, + ), ) @@ -2189,6 +2214,243 @@ def _handle_btw( return _prompt_result(input_text, prompt) +def _read_package_version() -> str: + try: + from importlib.metadata import PackageNotFoundError, version + + return version('claw-code-agent') + except Exception: + return 'unknown' + + +def _handle_version( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + import platform + import sys + + pkg_version = _read_package_version() + py_version = platform.python_version() + impl = sys.implementation.name + return _local_result( + input_text, + f'claw-code-agent {pkg_version} (Python {py_version}, {impl}).', + ) + + +_INIT_PROMPT = ( + 'Please analyze this codebase and create a CLAUDE.md file, which will be ' + 'given to future instances of Claude Code to operate in this repository.\n' + '\n' + 'What to add:\n' + '1. Commands that will be commonly used, such as how to build, lint, and ' + 'run tests. Include the necessary commands to develop in this codebase, ' + 'such as how to run a single test.\n' + '2. High-level code architecture and structure so that future instances ' + 'can be productive more quickly. Focus on the "big picture" architecture ' + 'that requires reading multiple files to understand.\n' + '\n' + 'Usage notes:\n' + "- If there's already a CLAUDE.md, suggest improvements to it.\n" + '- When you make the initial CLAUDE.md, do not repeat yourself and do not ' + 'include obvious instructions like "Provide helpful error messages to ' + 'users", "Write unit tests for all new utilities", "Never include ' + 'sensitive information (API keys, tokens) in code or commits".\n' + '- Avoid listing every component or file structure that can be easily ' + 'discovered.\n' + "- Don't include generic development practices.\n" + '- If there are Cursor rules (in .cursor/rules/ or .cursorrules) or ' + 'Copilot rules (in .github/copilot-instructions.md), make sure to include ' + 'the important parts.\n' + '- If there is a README.md, make sure to include the important parts.\n' + '- Do not make up information such as "Common Development Tasks", "Tips ' + 'for Development", "Support and Documentation" unless this is expressly ' + 'included in other files that you read.\n' + '- Be sure to prefix the file with the following text:\n' + '\n' + '```\n' + '# CLAUDE.md\n' + '\n' + 'This file provides guidance to Claude Code (claude.ai/code) when ' + 'working with code in this repository.\n' + '```' +) + + +def _handle_init( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + return _prompt_result(input_text, _INIT_PROMPT) + + +def _detect_ide_environment() -> tuple[str, list[str]]: + """Return a (label, details) summary of the IDE/terminal integration.""" + import os + + details: list[str] = [] + label = 'No IDE detected' + term_program = os.environ.get('TERM_PROGRAM') + if term_program: + details.append(f'TERM_PROGRAM={term_program}') + if os.environ.get('VSCODE_INJECTION') or os.environ.get('VSCODE_PID'): + label = 'Visual Studio Code' + for key in ('VSCODE_PID', 'VSCODE_IPC_HOOK', 'VSCODE_GIT_IPC_HANDLE'): + value = os.environ.get(key) + if value: + details.append(f'{key}={value}') + elif os.environ.get('JETBRAINS_IDE') or os.environ.get('TERMINAL_EMULATOR', '').startswith('JetBrains'): + label = 'JetBrains IDE' + for key in ('JETBRAINS_IDE', 'TERMINAL_EMULATOR', 'IDEA_INITIAL_DIRECTORY'): + value = os.environ.get(key) + if value: + details.append(f'{key}={value}') + elif term_program == 'iTerm.app': + label = 'iTerm2 (no IDE integration)' + elif term_program == 'Apple_Terminal': + label = 'Terminal.app (no IDE integration)' + elif term_program == 'tmux': + label = 'tmux session (no IDE integration)' + elif os.environ.get('SSH_CONNECTION'): + label = 'SSH session (no IDE integration)' + details.append('SSH_CONNECTION present') + return label, details + + +def _handle_ide( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + label, details = _detect_ide_environment() + lines = [f'IDE/terminal integration: {label}'] + for detail in details: + lines.append(f' - {detail}') + if not details and label.startswith('No IDE'): + lines.append(' (No relevant TERM_PROGRAM/VSCODE/JETBRAINS env vars found.)') + lines.append('') + lines.append( + 'IDE auto-connect dialogs are not implemented in the Python runtime; ' + 'launch the agent from inside your IDE terminal to inherit its env.' + ) + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_plugin( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.plugin_runtime + if runtime is None: + return _local_result(input_text, 'Plugin runtime is unavailable.') + sub = args.strip().split(None, 1) + action = sub[0].lower() if sub else 'list' + if action in {'help', '--help', '-h'}: + return _local_result( + input_text, + 'Usage: /plugin [list]\n' + ' list Show installed plugin manifests (default).\n' + '\n' + 'Marketplace install/uninstall/enable/disable flows are not ' + 'implemented in the Python runtime — edit plugin manifests on ' + 'disk and run /reload-plugins to pick up changes.', + ) + if action != 'list': + return _local_result( + input_text, + f'Unknown plugin subcommand "{action}". Try /plugin help.', + ) + manifests = runtime.manifests + if not manifests: + return _local_result( + input_text, + 'No installed plugins.\n' + 'Drop a plugin manifest under .claude/plugins//manifest.json ' + 'and run /reload-plugins.', + ) + lines = [f'Installed plugins ({len(manifests)}):'] + for manifest in manifests: + version_str = f' v{manifest.version}' if manifest.version else '' + lines.append(f'- {manifest.name}{version_str}') + if manifest.description: + lines.append(f' {manifest.description}') + if manifest.tool_names: + lines.append(f' tools: {", ".join(manifest.tool_names)}') + if manifest.hook_names: + lines.append(f' hooks: {", ".join(manifest.hook_names)}') + if manifest.virtual_tools: + lines.append( + f' virtual tools: ' + f'{", ".join(tool.name for tool in manifest.virtual_tools)}' + ) + return _local_result(input_text, '\n'.join(lines)) + + +def _handle_remote_env( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.remote_runtime + if runtime is None: + return _local_result(input_text, 'Remote runtime is unavailable.') + config = agent.config_runtime + requested = args.strip() + current_default = ( + _config_get(config, 'defaultRemoteEnvironment') if config else None + ) + if not requested: + lines = ['Available remote environments:'] + if not runtime.profiles: + lines.append(' (no profiles found in .claude/remote.json)') + for profile in runtime.profiles: + marker = ' (default)' if profile.name == current_default else '' + lines.append( + f' - {profile.name} [{profile.mode}] -> {profile.target}{marker}' + ) + if current_default: + lines.append('') + lines.append(f'Current default: {current_default}') + lines.append('') + lines.append('Usage: /remote-env — set the default profile') + lines.append('Usage: /remote-env clear — unset the default profile') + return _local_result(input_text, '\n'.join(lines)) + + if requested.lower() == 'clear': + if config is None: + return _local_result(input_text, 'Config runtime is unavailable.') + if current_default is None: + return _local_result(input_text, 'No default remote environment was set.') + # set_value with None — write null to settings + mutation = config.set_value('defaultRemoteEnvironment', None, source='local') + return _local_result( + input_text, + f'Cleared default remote environment (saved to {mutation.store_path}).', + ) + + profile = runtime.get_profile(requested) + if profile is None: + return _local_result( + input_text, + f'Unknown remote environment "{requested}". ' + 'Run /remote-env to list available profiles.', + ) + if config is None: + return _local_result(input_text, 'Config runtime is unavailable.') + mutation = config.set_value( + 'defaultRemoteEnvironment', profile.name, source='local', + ) + return _local_result( + input_text, + f'Default remote environment set to {profile.name} ' + f'[{profile.mode}] -> {profile.target} (saved to {mutation.store_path}).', + ) + + def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: """Return a prompt-type result — the prompt gets sent to the model.""" return SlashCommandResult( diff --git a/tests/test_misc_slash_commands.py b/tests/test_misc_slash_commands.py new file mode 100644 index 0000000..6edf73c --- /dev/null +++ b/tests/test_misc_slash_commands.py @@ -0,0 +1,143 @@ +"""Tests for discovery slash commands ported from the npm source. + +Covers /version, /init, /ide, /plugin, /remote-env. +""" + +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src.agent_runtime import LocalCodingAgent +from src.agent_slash_commands import preprocess_slash_command +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +def _local_settings(tmp_dir: str) -> dict: + path = Path(tmp_dir) / '.claude' / 'settings.local.json' + if not path.exists(): + return {} + return json.loads(path.read_text(encoding='utf-8')) + + +class VersionCommandTest(unittest.TestCase): + def test_prints_python_runtime_version(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/version').final_output + self.assertIn('claw-code-agent', out) + self.assertIn('Python', out) + + +class InitCommandTest(unittest.TestCase): + def test_returns_prompt_with_claude_md_instructions(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + result = preprocess_slash_command(agent, '/init') + self.assertTrue(result.handled) + self.assertTrue(result.should_query) + self.assertIn('CLAUDE.md', result.prompt or '') + self.assertIn('analyze this codebase', (result.prompt or '').lower()) + + +class IdeCommandTest(unittest.TestCase): + def test_no_ide_when_env_clean(self) -> None: + clean = {k: v for k, v in os.environ.items() if k not in { + 'TERM_PROGRAM', 'VSCODE_INJECTION', 'VSCODE_PID', + 'JETBRAINS_IDE', 'TERMINAL_EMULATOR', 'SSH_CONNECTION', + }} + with mock.patch.dict(os.environ, clean, clear=True): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/ide').final_output + self.assertIn('No IDE detected', out) + self.assertIn('IDE auto-connect', out) + + def test_detects_vscode(self) -> None: + env = {'VSCODE_PID': '1234', 'TERM_PROGRAM': 'vscode'} + with mock.patch.dict(os.environ, env, clear=True): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/ide').final_output + self.assertIn('Visual Studio Code', out) + self.assertIn('VSCODE_PID=1234', out) + + +class PluginCommandTest(unittest.TestCase): + def test_lists_no_plugins_when_empty(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/plugin').final_output + self.assertIn('No installed plugins', out) + + def test_help_describes_usage(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/plugin help').final_output + self.assertIn('Usage: /plugin', out) + self.assertIn('list', out) + + def test_unknown_subcommand(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/plugin bogus').final_output + self.assertIn('Unknown plugin subcommand', out) + + +class RemoteEnvCommandTest(unittest.TestCase): + def test_lists_empty_profiles(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-env').final_output + self.assertIn('Available remote environments', out) + self.assertIn('no profiles found', out) + self.assertIn('Usage:', out) + + def test_clear_when_no_default_set(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-env clear').final_output + self.assertIn('No default remote environment', out) + + def test_unknown_profile_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-env nope').final_output + self.assertIn('Unknown remote environment', out) + self.assertIn('nope', out) + + def test_set_then_clear_persists(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.remote.json').write_text(json.dumps({ + 'profiles': [ + {'name': 'sandbox', 'mode': 'ssh', 'target': 'user@host'}, + ], + }), encoding='utf-8') + agent = _make_agent(tmp) + set_out = agent.run('/remote-env sandbox').final_output + self.assertIn('Default remote environment set to sandbox', set_out) + self.assertEqual(_local_settings(tmp).get('defaultRemoteEnvironment'), 'sandbox') + + agent2 = _make_agent(tmp) + list_out = agent2.run('/remote-env').final_output + self.assertIn('sandbox', list_out) + self.assertIn('(default)', list_out) + + clear_out = agent2.run('/remote-env clear').final_output + self.assertIn('Cleared default remote environment', clear_out) + self.assertIsNone(_local_settings(tmp).get('defaultRemoteEnvironment')) + + +if __name__ == '__main__': + unittest.main() From 266e1ac5635aa50996f866b9f985f3d4e7c32f62 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:19:40 +0200 Subject: [PATCH 05/18] Implemented the next missing parity slice around remote-control slash commands. --- PARITY_CHECKLIST.md | 4 +- src/agent_slash_commands.py | 111 ++++++++++++++++++++++++++++ tests/test_remote_slash_commands.py | 109 +++++++++++++++++++++++++++ 3 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 tests/test_remote_slash_commands.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index c221706..7c907c3 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -323,7 +323,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/add-dir` — Add a new working directory - [x] `/agents` — Inspect, create, update, and delete local agent definitions - [x] `/branch` — Create a branch of the current conversation -- [ ] `/bridge` — Connect for remote-control sessions +- [x] `/bridge` — Connect for remote-control sessions (read-only status in this runtime) - [x] `/btw` — Quick side question without interrupting main conversation - [x] `/chrome` — Chrome extension settings - [x] `/color` — Set the prompt bar color for this session @@ -354,7 +354,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/release-notes` — View release notes - [x] `/reload-plugins` — Activate pending plugin changes - [x] `/remote-env` — Configure default remote environment -- [ ] `/remote-setup` — Remote setup configuration +- [x] `/remote-setup` — Remote setup configuration (gh auth status + Claude.ai/code link) - [x] `/rename` — Rename current conversation - [x] `/resume`, `/continue` — Resume a previous conversation - [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 364c88b..2654b3e 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -539,6 +539,16 @@ def get_slash_command_specs() -> tuple[SlashCommandSpec, ...]: description='List remote environments or set the default profile.', handler=_handle_remote_env, ), + SlashCommandSpec( + names=('bridge', 'remote-control', 'rc'), + description='Report remote-control bridge status (read-only in this runtime).', + handler=_handle_bridge, + ), + SlashCommandSpec( + names=('remote-setup', 'web-setup'), + description='Report Claude Code on the web setup readiness (gh + sign-in checks).', + handler=_handle_remote_setup, + ), ) @@ -2451,6 +2461,107 @@ def _handle_remote_env( ) +def _handle_bridge( + agent: 'LocalCodingAgent', + args: str, + input_text: str, +) -> SlashCommandResult: + runtime = agent.remote_runtime + requested_name = args.strip() or None + lines = ['Remote-control bridge: not implemented in the Python runtime.'] + lines.append( + ' The npm CLI hosts an interactive bridge against claude.ai; this ' + 'runtime only inspects the local remote-runtime state.' + ) + if runtime is None: + lines.append(' (Remote runtime is unavailable.)') + return _local_result(input_text, '\n'.join(lines)) + + connection = runtime.active_connection + if connection is not None: + lines.append('') + lines.append('Active local remote connection:') + lines.append(f' - mode: {connection.mode}') + lines.append(f' - target: {connection.target}') + if connection.profile_name: + lines.append(f' - profile: {connection.profile_name}') + if connection.session_url: + lines.append(f' - session URL: {connection.session_url}') + if connection.workspace_cwd: + lines.append(f' - workspace: {connection.workspace_cwd}') + else: + lines.append('') + lines.append('No active local remote connection.') + if requested_name: + profile = runtime.get_profile(requested_name) + lines.append('') + if profile is None: + lines.append( + f'No matching remote profile for "{requested_name}". ' + 'Run /remote-env to list available profiles.' + ) + else: + lines.append( + f'Matched remote profile "{profile.name}" ' + f'({profile.mode} -> {profile.target}). ' + 'Use the npm CLI bridge to actually connect.' + ) + return _local_result(input_text, '\n'.join(lines)) + + +def _gh_auth_status() -> tuple[str, str]: + """Return (status, detail) — status is one of 'not_installed', + 'authenticated', 'not_authenticated', 'unknown'.""" + import shutil + import subprocess + + if shutil.which('gh') is None: + return ('not_installed', 'gh CLI not on PATH') + try: + result = subprocess.run( + ['gh', 'auth', 'status'], + capture_output=True, + text=True, + timeout=5, + ) + except (subprocess.SubprocessError, OSError) as exc: + return ('unknown', f'gh auth status failed: {exc}') + detail = (result.stderr or result.stdout or '').strip().splitlines() + summary = detail[0] if detail else '' + if result.returncode == 0: + return ('authenticated', summary or 'Authenticated to GitHub') + return ('not_authenticated', summary or 'Not authenticated to GitHub') + + +def _handle_remote_setup( + agent: 'LocalCodingAgent', + _args: str, + input_text: str, +) -> SlashCommandResult: + code_web_url = 'https://claude.ai/code' + gh_status, gh_detail = _gh_auth_status() + lines = [ + 'Claude Code on the web setup:', + f' Visit {code_web_url} to manage your environments.', + '', + f'GitHub CLI: {gh_status}', + f' {gh_detail}', + ] + if gh_status == 'not_installed': + lines.append(' Install gh from https://cli.github.com to import a GitHub token.') + elif gh_status == 'not_authenticated': + lines.append(' Run `gh auth login` to authenticate before importing your token.') + elif gh_status == 'authenticated': + lines.append(' You can run `gh auth token` to retrieve the token to import on the web.') + lines.append('') + lines.append( + 'Token import / default-environment provisioning is not implemented ' + 'in the Python runtime — complete remote setup from the npm CLI or ' + 'directly on claude.ai/code.' + ) + return _local_result(input_text, '\n'.join(lines)) + + def _prompt_result(input_text: str, prompt: str) -> SlashCommandResult: """Return a prompt-type result — the prompt gets sent to the model.""" return SlashCommandResult( diff --git a/tests/test_remote_slash_commands.py b/tests/test_remote_slash_commands.py new file mode 100644 index 0000000..fd383f8 --- /dev/null +++ b/tests/test_remote_slash_commands.py @@ -0,0 +1,109 @@ +"""Tests for remote/bridge slash commands ported from the npm source. + +Covers /bridge (aliases /remote-control, /rc) and /remote-setup +(alias /web-setup). +""" + +from __future__ import annotations + +import json +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src.agent_runtime import LocalCodingAgent +from src.agent_types import AgentRuntimeConfig, ModelConfig + + +def _make_agent(tmp_dir: str) -> LocalCodingAgent: + return LocalCodingAgent( + model_config=ModelConfig(model='test-model'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + + +class BridgeCommandTest(unittest.TestCase): + def test_reports_unsupported_status(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/bridge').final_output + self.assertIn('not implemented', out.lower()) + self.assertIn('No active local remote connection', out) + + def test_remote_control_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-control').final_output + self.assertIn('Remote-control bridge', out) + + def test_rc_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/rc').final_output + self.assertIn('Remote-control bridge', out) + + def test_named_lookup_misses_unknown_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/bridge nope').final_output + self.assertIn('No matching remote profile for "nope"', out) + + def test_named_lookup_matches_known_profile(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.remote.json').write_text(json.dumps({ + 'profiles': [ + {'name': 'edge', 'mode': 'ssh', 'target': 'user@edge.example'}, + ], + }), encoding='utf-8') + agent = _make_agent(tmp) + out = agent.run('/bridge edge').final_output + self.assertIn('Matched remote profile "edge"', out) + self.assertIn('user@edge.example', out) + + +class RemoteSetupCommandTest(unittest.TestCase): + def test_includes_web_url(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('https://claude.ai/code', out) + self.assertIn('GitHub CLI', out) + + def test_web_setup_alias(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/web-setup').final_output + self.assertIn('https://claude.ai/code', out) + + def test_handles_missing_gh(self) -> None: + with mock.patch('shutil.which', return_value=None): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('not_installed', out) + self.assertIn('cli.github.com', out) + + def test_handles_authenticated_gh(self) -> None: + fake = mock.Mock(returncode=0, stdout='Logged in to github.com as octo', stderr='') + with mock.patch('shutil.which', return_value='/usr/bin/gh'), \ + mock.patch('subprocess.run', return_value=fake): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('authenticated', out) + self.assertIn('gh auth token', out) + + def test_handles_unauthenticated_gh(self) -> None: + fake = mock.Mock(returncode=1, stdout='', stderr='You are not logged into any GitHub hosts') + with mock.patch('shutil.which', return_value='/usr/bin/gh'), \ + mock.patch('subprocess.run', return_value=fake): + with tempfile.TemporaryDirectory() as tmp: + agent = _make_agent(tmp) + out = agent.run('/remote-setup').final_output + self.assertIn('not_authenticated', out) + self.assertIn('gh auth login', out) + + +if __name__ == '__main__': + unittest.main() From b2a5a431c6d2178dc2882bf832c940a190441de5 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:23:45 +0200 Subject: [PATCH 06/18] Implemented the next missing parity slice around setup-time runtime checks. --- PARITY_CHECKLIST.md | 4 +- src/release_notes.py | 134 +++++++++++++++++++++++++++++ src/setup.py | 79 ++++++++++++++++- tests/test_release_notes.py | 101 ++++++++++++++++++++++ tests/test_setup_runtime_checks.py | 64 ++++++++++++++ 5 files changed, 378 insertions(+), 4 deletions(-) create mode 100644 src/release_notes.py create mode 100644 tests/test_release_notes.py create mode 100644 tests/test_setup_runtime_checks.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 7c907c3..8ff0717 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -175,10 +175,10 @@ Missing: - [ ] Self-hosted runner mode - [ ] tmux fast paths - [ ] Worktree fast paths at the CLI entrypoint level -- [ ] Node.js version check and platform setup from `setup.ts` +- [x] Python (Node.js equivalent) version check and platform detection from `setup.ts` - [ ] Worktree creation/setup from `setup.ts` - [ ] Terminal backup/restore from `setup.ts` -- [ ] Release notes checking from `setup.ts` +- [x] Release notes checking from `setup.ts` (local CHANGELOG.md, no network/cache layer) - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) - [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) diff --git a/src/release_notes.py b/src/release_notes.py new file mode 100644 index 0000000..318180d --- /dev/null +++ b/src/release_notes.py @@ -0,0 +1,134 @@ +"""Local release-notes parsing — Python port of utils/releaseNotes.ts. + +The Python runtime has no network/cache layer, so this module only reads a +local CHANGELOG.md (typically in the project root). The npm version fetches +from GitHub and caches under ~/.claude/cache/changelog.md; here, callers +provide the changelog text or pass the project cwd so we can read it. +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import Iterable + +MAX_RELEASE_NOTES_SHOWN = 5 + + +def parse_changelog(content: str) -> dict[str, list[str]]: + """Parse a markdown CHANGELOG into {version: [bullet, ...]}. + + Recognises sections starting with `## ` (optionally followed by + ` - YYYY-MM-DD`). Bullet lines starting with `- ` become entries. + """ + if not content: + return {} + notes: dict[str, list[str]] = {} + sections = re.split(r'^## ', content, flags=re.MULTILINE)[1:] + for section in sections: + lines = section.strip().splitlines() + if not lines: + continue + version = lines[0].split(' - ')[0].strip() + if not version: + continue + bullets: list[str] = [] + for line in lines[1:]: + stripped = line.strip() + if stripped.startswith('- '): + text = stripped[2:].strip() + if text: + bullets.append(text) + if bullets: + notes[version] = bullets + return notes + + +def _coerce_version(value: str | None) -> tuple[int, ...] | None: + if value is None: + return None + match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', value.strip()) + if match is None: + return None + return tuple(int(part) if part else 0 for part in match.groups()) + + +def _gt(a: str, b: str) -> bool: + parsed_a = _coerce_version(a) + parsed_b = _coerce_version(b) + if parsed_a is None or parsed_b is None: + return False + return parsed_a > parsed_b + + +def get_recent_release_notes( + current_version: str, + previous_version: str | None, + changelog_content: str, +) -> list[str]: + """Return up to MAX_RELEASE_NOTES_SHOWN bullets newer than previous_version.""" + notes = parse_changelog(changelog_content) + base_current = _coerce_version(current_version) + base_previous = _coerce_version(previous_version) + if base_previous is not None and base_current is not None and base_current <= base_previous: + return [] + relevant = [ + (version, bullets) + for version, bullets in notes.items() + if base_previous is None or _gt(version, previous_version or '0') + ] + relevant.sort(key=lambda item: _coerce_version(item[0]) or (), reverse=True) + flat: list[str] = [] + for _, bullets in relevant: + flat.extend(bullets) + return flat[:MAX_RELEASE_NOTES_SHOWN] + + +def get_all_release_notes(changelog_content: str) -> list[tuple[str, list[str]]]: + """Return all [(version, bullets)] entries sorted oldest-first.""" + notes = parse_changelog(changelog_content) + versions = sorted(notes.keys(), key=lambda v: _coerce_version(v) or ()) + return [(version, notes[version]) for version in versions if notes[version]] + + +def read_local_changelog(cwd: Path) -> str: + """Read CHANGELOG.md from the workspace root, returning '' if missing.""" + path = cwd / 'CHANGELOG.md' + try: + return path.read_text(encoding='utf-8') + except (OSError, UnicodeDecodeError): + return '' + + +def check_for_release_notes( + current_version: str, + last_seen_version: str | None, + cwd: Path | None = None, + changelog_content: str | None = None, +) -> dict: + """Return {'hasReleaseNotes': bool, 'releaseNotes': [...]}. + + If `changelog_content` is supplied it is used directly; otherwise the + workspace CHANGELOG.md is read. Mirrors the npm `checkForReleaseNotes` + return shape (without the network fetch — no cache update). + """ + content = ( + changelog_content + if changelog_content is not None + else read_local_changelog(cwd or Path.cwd()) + ) + bullets = get_recent_release_notes(current_version, last_seen_version, content) + return { + 'hasReleaseNotes': bool(bullets), + 'releaseNotes': bullets, + } + + +__all__ = [ + 'MAX_RELEASE_NOTES_SHOWN', + 'parse_changelog', + 'get_recent_release_notes', + 'get_all_release_notes', + 'read_local_changelog', + 'check_for_release_notes', +] diff --git a/src/setup.py b/src/setup.py index 9a23934..75d055d 100644 --- a/src/setup.py +++ b/src/setup.py @@ -2,11 +2,61 @@ from __future__ import annotations import platform import sys -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path from .deferred_init import DeferredInitResult, run_deferred_init from .prefetch import PrefetchResult, start_keychain_prefetch, start_mdm_raw_read, start_project_scan +from .release_notes import check_for_release_notes + + +MIN_PYTHON_VERSION: tuple[int, int] = (3, 10) + + +@dataclass(frozen=True) +class RuntimeRequirementCheck: + name: str + ok: bool + detail: str + + +def check_runtime_requirements() -> tuple[RuntimeRequirementCheck, ...]: + checks: list[RuntimeRequirementCheck] = [] + py_major, py_minor = sys.version_info[:2] + py_ok = (py_major, py_minor) >= MIN_PYTHON_VERSION + checks.append(RuntimeRequirementCheck( + name='python_version', + ok=py_ok, + detail=( + f'Python {py_major}.{py_minor} >= {MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}' + if py_ok + else f'Python {py_major}.{py_minor} is below required ' + f'{MIN_PYTHON_VERSION[0]}.{MIN_PYTHON_VERSION[1]}' + ), + )) + impl = platform.python_implementation() + checks.append(RuntimeRequirementCheck( + name='python_implementation', + ok=True, + detail=impl, + )) + machine = platform.machine() or 'unknown' + system = platform.system() or 'unknown' + checks.append(RuntimeRequirementCheck( + name='platform', + ok=True, + detail=f'{system} on {machine}', + )) + return tuple(checks) + + +def _read_package_version() -> str: + try: + from importlib.metadata import version + + return version('claw-code-agent') + except Exception: + return '0.0.0' @dataclass(frozen=True) @@ -34,6 +84,11 @@ class SetupReport: deferred_init: DeferredInitResult trusted: bool cwd: Path + runtime_checks: tuple[RuntimeRequirementCheck, ...] = field(default_factory=tuple) + release_notes: tuple[str, ...] = field(default_factory=tuple) + + def has_blocking_issues(self) -> bool: + return any(not check.ok for check in self.runtime_checks) def as_markdown(self) -> str: lines = [ @@ -44,12 +99,21 @@ class SetupReport: f'- Trusted mode: {self.trusted}', f'- CWD: {self.cwd}', '', + 'Runtime checks:', + *( + f'- {check.name}: {"ok" if check.ok else "FAIL"} — {check.detail}' + for check in self.runtime_checks + ), + '', 'Prefetches:', *(f'- {prefetch.name}: {prefetch.detail}' for prefetch in self.prefetches), '', 'Deferred init:', *self.deferred_init.as_lines(), ] + if self.release_notes: + lines.extend(['', 'Release notes (newer than last seen):']) + lines.extend(f'- {note}' for note in self.release_notes) return '\n'.join(lines) @@ -61,17 +125,28 @@ def build_workspace_setup() -> WorkspaceSetup: ) -def run_setup(cwd: Path | None = None, trusted: bool = True) -> SetupReport: +def run_setup( + cwd: Path | None = None, + trusted: bool = True, + last_seen_version: str | None = None, +) -> SetupReport: root = cwd or Path(__file__).resolve().parent.parent prefetches = [ start_mdm_raw_read(), start_keychain_prefetch(), start_project_scan(root), ] + release_notes_payload = check_for_release_notes( + current_version=_read_package_version(), + last_seen_version=last_seen_version, + cwd=root, + ) return SetupReport( setup=build_workspace_setup(), prefetches=tuple(prefetches), deferred_init=run_deferred_init(trusted=trusted), trusted=trusted, cwd=root, + runtime_checks=check_runtime_requirements(), + release_notes=tuple(release_notes_payload['releaseNotes']), ) diff --git a/tests/test_release_notes.py b/tests/test_release_notes.py new file mode 100644 index 0000000..8836ad0 --- /dev/null +++ b/tests/test_release_notes.py @@ -0,0 +1,101 @@ +"""Tests for the local release-notes parser ported from utils/releaseNotes.ts.""" + +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path + +from src.release_notes import ( + MAX_RELEASE_NOTES_SHOWN, + check_for_release_notes, + get_all_release_notes, + get_recent_release_notes, + parse_changelog, + read_local_changelog, +) + + +SAMPLE = ( + '# Changelog\n\n' + '## 1.3.0 - 2026-04-15\n' + '- new shiny\n' + '- another bullet\n\n' + '## 1.2.0\n' + '- mid bullet\n\n' + '## 1.1.0\n' + '- oldest bullet\n' +) + + +class ParseChangelogTest(unittest.TestCase): + def test_returns_empty_for_blank(self) -> None: + self.assertEqual(parse_changelog(''), {}) + + def test_extracts_versions_and_bullets(self) -> None: + parsed = parse_changelog(SAMPLE) + self.assertEqual(set(parsed.keys()), {'1.3.0', '1.2.0', '1.1.0'}) + self.assertEqual(parsed['1.3.0'], ['new shiny', 'another bullet']) + self.assertEqual(parsed['1.2.0'], ['mid bullet']) + + def test_skips_versions_without_bullets(self) -> None: + parsed = parse_changelog('# X\n\n## 1.0.0\nplain text\n') + self.assertEqual(parsed, {}) + + +class RecentNotesTest(unittest.TestCase): + def test_returns_only_newer_versions(self) -> None: + notes = get_recent_release_notes('1.3.0', '1.2.0', SAMPLE) + self.assertEqual(notes, ['new shiny', 'another bullet']) + + def test_first_run_returns_all(self) -> None: + notes = get_recent_release_notes('1.3.0', None, SAMPLE) + self.assertEqual(notes[0], 'new shiny') + self.assertIn('oldest bullet', notes) + + def test_no_new_when_at_or_below_previous(self) -> None: + self.assertEqual(get_recent_release_notes('1.1.0', '1.3.0', SAMPLE), []) + + def test_caps_at_max_shown(self) -> None: + big_changelog = '# Changelog\n\n' + ''.join( + f'## 9.9.{i}\n- bullet {i}\n\n' for i in range(20) + ) + notes = get_recent_release_notes('9.9.19', '0.0.1', big_changelog) + self.assertEqual(len(notes), MAX_RELEASE_NOTES_SHOWN) + + +class AllNotesTest(unittest.TestCase): + def test_sorted_oldest_first(self) -> None: + all_notes = get_all_release_notes(SAMPLE) + versions = [version for version, _ in all_notes] + self.assertEqual(versions, ['1.1.0', '1.2.0', '1.3.0']) + + +class ReadLocalChangelogTest(unittest.TestCase): + def test_reads_when_present(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8') + self.assertIn('## 1.3.0', read_local_changelog(Path(tmp))) + + def test_returns_empty_when_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertEqual(read_local_changelog(Path(tmp)), '') + + +class CheckForReleaseNotesTest(unittest.TestCase): + def test_signals_when_changelog_present_and_newer(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text(SAMPLE, encoding='utf-8') + payload = check_for_release_notes('1.3.0', '1.2.0', cwd=Path(tmp)) + self.assertTrue(payload['hasReleaseNotes']) + self.assertEqual(payload['releaseNotes'][0], 'new shiny') + + def test_silent_when_missing_changelog(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + payload = check_for_release_notes('1.3.0', None, cwd=Path(tmp)) + self.assertFalse(payload['hasReleaseNotes']) + self.assertEqual(payload['releaseNotes'], []) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_setup_runtime_checks.py b/tests/test_setup_runtime_checks.py new file mode 100644 index 0000000..02de29a --- /dev/null +++ b/tests/test_setup_runtime_checks.py @@ -0,0 +1,64 @@ +"""Tests for setup-time runtime checks ported from setup.ts.""" + +from __future__ import annotations + +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src.setup import ( + MIN_PYTHON_VERSION, + SetupReport, + check_runtime_requirements, + run_setup, +) + + +class RuntimeRequirementCheckTest(unittest.TestCase): + def test_python_check_passes_on_current_runtime(self) -> None: + checks = {check.name: check for check in check_runtime_requirements()} + self.assertTrue(checks['python_version'].ok) + self.assertGreaterEqual(sys.version_info[:2], MIN_PYTHON_VERSION) + + def test_python_check_fails_when_below_minimum(self) -> None: + # Force a lower version_info via patching to verify the failure branch. + fake_version = mock.Mock() + fake_version.__getitem__ = lambda self, idx: (3, 8)[idx] if isinstance(idx, int) else (3, 8)[idx] + with mock.patch('src.setup.sys') as fake_sys: + fake_sys.version_info = (3, 8, 0) + checks = {check.name: check for check in check_runtime_requirements()} + self.assertFalse(checks['python_version'].ok) + self.assertIn('below required', checks['python_version'].detail) + + def test_includes_platform_and_implementation(self) -> None: + names = {check.name for check in check_runtime_requirements()} + self.assertIn('python_implementation', names) + self.assertIn('platform', names) + + +class SetupReportTest(unittest.TestCase): + def test_run_setup_reports_runtime_and_release_notes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / 'CHANGELOG.md').write_text( + '# Changelog\n\n## 9.9.9\n- big release\n', encoding='utf-8', + ) + report = run_setup(cwd=Path(tmp), trusted=True, last_seen_version='0.0.1') + self.assertIsInstance(report, SetupReport) + self.assertGreaterEqual(len(report.runtime_checks), 3) + self.assertIn('big release', report.release_notes) + markdown = report.as_markdown() + self.assertIn('Runtime checks', markdown) + self.assertIn('Release notes', markdown) + self.assertFalse(report.has_blocking_issues()) + + def test_no_release_notes_when_changelog_missing(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + report = run_setup(cwd=Path(tmp), trusted=True) + self.assertEqual(report.release_notes, ()) + self.assertNotIn('Release notes', report.as_markdown()) + + +if __name__ == '__main__': + unittest.main() From 9541d5d701e69a17a0c9c8233b1acdbf651b6a55 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:25:14 +0200 Subject: [PATCH 07/18] Implemented the next missing parity slice around sandbox configuration types. --- PARITY_CHECKLIST.md | 2 +- src/sandbox_types.py | 230 ++++++++++++++++++++++++++++++++++++ tests/test_sandbox_types.py | 110 +++++++++++++++++ 3 files changed, 341 insertions(+), 1 deletion(-) create mode 100644 src/sandbox_types.py create mode 100644 tests/test_sandbox_types.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 8ff0717..9294501 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -182,7 +182,7 @@ Missing: - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) - [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) -- [ ] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) +- [x] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) ## 3. Prompt Assembly diff --git a/src/sandbox_types.py b/src/sandbox_types.py new file mode 100644 index 0000000..f428ec4 --- /dev/null +++ b/src/sandbox_types.py @@ -0,0 +1,230 @@ +"""Sandbox configuration types — Python port of entrypoints/sandboxTypes.ts. + +The npm version uses Zod schemas with `.passthrough()` to forward unknown +fields. Here we mirror the same fields as dataclasses, with `from_dict` +classmethods that accept and round-trip arbitrary extra keys. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + + +def _str_list(value: Any) -> list[str] | None: + if value is None: + return None + if not isinstance(value, list): + raise ValueError(f'expected list[str], got {type(value).__name__}') + return [str(item) for item in value] + + +def _opt_bool(value: Any) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise ValueError(f'expected bool, got {type(value).__name__}') + return value + + +def _opt_int(value: Any) -> int | None: + if value is None: + return None + if isinstance(value, bool): + raise ValueError('expected number, got bool') + if not isinstance(value, (int, float)): + raise ValueError(f'expected number, got {type(value).__name__}') + return int(value) + + +def _drop_none(d: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in d.items() if v is not None} + + +@dataclass(frozen=True) +class SandboxNetworkConfig: + allowed_domains: list[str] | None = None + allow_managed_domains_only: bool | None = None + allow_unix_sockets: list[str] | None = None + allow_all_unix_sockets: bool | None = None + allow_local_binding: bool | None = None + http_proxy_port: int | None = None + socks_proxy_port: int | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxNetworkConfig': + return cls( + allowed_domains=_str_list(data.get('allowedDomains')), + allow_managed_domains_only=_opt_bool(data.get('allowManagedDomainsOnly')), + allow_unix_sockets=_str_list(data.get('allowUnixSockets')), + allow_all_unix_sockets=_opt_bool(data.get('allowAllUnixSockets')), + allow_local_binding=_opt_bool(data.get('allowLocalBinding')), + http_proxy_port=_opt_int(data.get('httpProxyPort')), + socks_proxy_port=_opt_int(data.get('socksProxyPort')), + ) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({ + 'allowedDomains': self.allowed_domains, + 'allowManagedDomainsOnly': self.allow_managed_domains_only, + 'allowUnixSockets': self.allow_unix_sockets, + 'allowAllUnixSockets': self.allow_all_unix_sockets, + 'allowLocalBinding': self.allow_local_binding, + 'httpProxyPort': self.http_proxy_port, + 'socksProxyPort': self.socks_proxy_port, + }) + + +@dataclass(frozen=True) +class SandboxFilesystemConfig: + allow_write: list[str] | None = None + deny_write: list[str] | None = None + deny_read: list[str] | None = None + allow_read: list[str] | None = None + allow_managed_read_paths_only: bool | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxFilesystemConfig': + return cls( + allow_write=_str_list(data.get('allowWrite')), + deny_write=_str_list(data.get('denyWrite')), + deny_read=_str_list(data.get('denyRead')), + allow_read=_str_list(data.get('allowRead')), + allow_managed_read_paths_only=_opt_bool( + data.get('allowManagedReadPathsOnly') + ), + ) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({ + 'allowWrite': self.allow_write, + 'denyWrite': self.deny_write, + 'denyRead': self.deny_read, + 'allowRead': self.allow_read, + 'allowManagedReadPathsOnly': self.allow_managed_read_paths_only, + }) + + +@dataclass(frozen=True) +class SandboxRipgrepConfig: + command: str + args: list[str] | None = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxRipgrepConfig': + command = data.get('command') + if not isinstance(command, str) or not command: + raise ValueError('ripgrep.command must be a non-empty string') + return cls(command=command, args=_str_list(data.get('args'))) + + def to_dict(self) -> dict[str, Any]: + return _drop_none({'command': self.command, 'args': self.args}) + + +@dataclass(frozen=True) +class SandboxSettings: + enabled: bool | None = None + fail_if_unavailable: bool | None = None + auto_allow_bash_if_sandboxed: bool | None = None + allow_unsandboxed_commands: bool | None = None + network: SandboxNetworkConfig | None = None + filesystem: SandboxFilesystemConfig | None = None + ignore_violations: dict[str, list[str]] | None = None + enable_weaker_nested_sandbox: bool | None = None + enable_weaker_network_isolation: bool | None = None + excluded_commands: list[str] | None = None + ripgrep: SandboxRipgrepConfig | None = None + extra: dict[str, Any] = field(default_factory=dict) + + _KNOWN_KEYS = frozenset({ + 'enabled', 'failIfUnavailable', 'autoAllowBashIfSandboxed', + 'allowUnsandboxedCommands', 'network', 'filesystem', + 'ignoreViolations', 'enableWeakerNestedSandbox', + 'enableWeakerNetworkIsolation', 'excludedCommands', 'ripgrep', + }) + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'SandboxSettings': + if not isinstance(data, dict): + raise ValueError(f'expected mapping, got {type(data).__name__}') + network_raw = data.get('network') + filesystem_raw = data.get('filesystem') + ripgrep_raw = data.get('ripgrep') + ignore_raw = data.get('ignoreViolations') + ignore: dict[str, list[str]] | None = None + if ignore_raw is not None: + if not isinstance(ignore_raw, dict): + raise ValueError('ignoreViolations must be a mapping') + ignore = {} + for key, value in ignore_raw.items(): + items = _str_list(value) + ignore[str(key)] = items if items is not None else [] + extra = {k: v for k, v in data.items() if k not in cls._KNOWN_KEYS} + return cls( + enabled=_opt_bool(data.get('enabled')), + fail_if_unavailable=_opt_bool(data.get('failIfUnavailable')), + auto_allow_bash_if_sandboxed=_opt_bool( + data.get('autoAllowBashIfSandboxed') + ), + allow_unsandboxed_commands=_opt_bool( + data.get('allowUnsandboxedCommands') + ), + network=( + SandboxNetworkConfig.from_dict(network_raw) + if isinstance(network_raw, dict) else None + ), + filesystem=( + SandboxFilesystemConfig.from_dict(filesystem_raw) + if isinstance(filesystem_raw, dict) else None + ), + ignore_violations=ignore, + enable_weaker_nested_sandbox=_opt_bool( + data.get('enableWeakerNestedSandbox') + ), + enable_weaker_network_isolation=_opt_bool( + data.get('enableWeakerNetworkIsolation') + ), + excluded_commands=_str_list(data.get('excludedCommands')), + ripgrep=( + SandboxRipgrepConfig.from_dict(ripgrep_raw) + if isinstance(ripgrep_raw, dict) else None + ), + extra=extra, + ) + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = _drop_none({ + 'enabled': self.enabled, + 'failIfUnavailable': self.fail_if_unavailable, + 'autoAllowBashIfSandboxed': self.auto_allow_bash_if_sandboxed, + 'allowUnsandboxedCommands': self.allow_unsandboxed_commands, + 'enableWeakerNestedSandbox': self.enable_weaker_nested_sandbox, + 'enableWeakerNetworkIsolation': self.enable_weaker_network_isolation, + 'excludedCommands': self.excluded_commands, + }) + if self.network is not None: + out['network'] = self.network.to_dict() + if self.filesystem is not None: + out['filesystem'] = self.filesystem.to_dict() + if self.ignore_violations is not None: + out['ignoreViolations'] = { + key: list(value) for key, value in self.ignore_violations.items() + } + if self.ripgrep is not None: + out['ripgrep'] = self.ripgrep.to_dict() + # passthrough — preserve unknown keys after serialization + for key, value in self.extra.items(): + out.setdefault(key, value) + return out + + +SandboxIgnoreViolations = dict[str, list[str]] + + +__all__ = [ + 'SandboxNetworkConfig', + 'SandboxFilesystemConfig', + 'SandboxRipgrepConfig', + 'SandboxSettings', + 'SandboxIgnoreViolations', +] diff --git a/tests/test_sandbox_types.py b/tests/test_sandbox_types.py new file mode 100644 index 0000000..e6934a9 --- /dev/null +++ b/tests/test_sandbox_types.py @@ -0,0 +1,110 @@ +"""Tests for sandbox configuration types ported from sandboxTypes.ts.""" + +from __future__ import annotations + +import unittest + +from src.sandbox_types import ( + SandboxFilesystemConfig, + SandboxNetworkConfig, + SandboxRipgrepConfig, + SandboxSettings, +) + + +class SandboxNetworkConfigTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'allowedDomains': ['example.com'], + 'allowManagedDomainsOnly': True, + 'allowUnixSockets': ['/tmp/sock'], + 'allowAllUnixSockets': False, + 'allowLocalBinding': True, + 'httpProxyPort': 8080, + 'socksProxyPort': 1080, + } + parsed = SandboxNetworkConfig.from_dict(raw) + self.assertEqual(parsed.allowed_domains, ['example.com']) + self.assertEqual(parsed.http_proxy_port, 8080) + self.assertEqual(parsed.to_dict(), raw) + + def test_strips_none(self) -> None: + parsed = SandboxNetworkConfig.from_dict({'allowedDomains': ['a']}) + self.assertEqual(parsed.to_dict(), {'allowedDomains': ['a']}) + + def test_rejects_wrong_type(self) -> None: + with self.assertRaises(ValueError): + SandboxNetworkConfig.from_dict({'allowedDomains': 'nope'}) + + +class SandboxFilesystemConfigTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'allowWrite': ['/tmp'], + 'denyWrite': ['/etc'], + 'denyRead': ['/etc/secrets'], + 'allowRead': ['/etc/secrets/public'], + 'allowManagedReadPathsOnly': False, + } + parsed = SandboxFilesystemConfig.from_dict(raw) + self.assertEqual(parsed.allow_write, ['/tmp']) + self.assertEqual(parsed.to_dict(), raw) + + +class SandboxRipgrepConfigTest(unittest.TestCase): + def test_requires_command(self) -> None: + with self.assertRaises(ValueError): + SandboxRipgrepConfig.from_dict({'args': ['-i']}) + + def test_round_trip(self) -> None: + parsed = SandboxRipgrepConfig.from_dict({'command': 'rg', 'args': ['--no-ignore']}) + self.assertEqual(parsed.command, 'rg') + self.assertEqual(parsed.to_dict(), {'command': 'rg', 'args': ['--no-ignore']}) + + +class SandboxSettingsTest(unittest.TestCase): + def test_full_round_trip_preserves_passthrough(self) -> None: + raw = { + 'enabled': True, + 'failIfUnavailable': False, + 'autoAllowBashIfSandboxed': True, + 'allowUnsandboxedCommands': True, + 'network': {'allowedDomains': ['x.com']}, + 'filesystem': {'allowWrite': ['/tmp']}, + 'ignoreViolations': {'NetworkViolation': ['y.com']}, + 'enableWeakerNestedSandbox': False, + 'enableWeakerNetworkIsolation': False, + 'excludedCommands': ['rm -rf /'], + 'ripgrep': {'command': 'rg'}, + 'enabledPlatforms': ['macos'], + 'somethingFuture': 42, + } + parsed = SandboxSettings.from_dict(raw) + self.assertTrue(parsed.enabled) + self.assertEqual(parsed.network.allowed_domains, ['x.com']) + self.assertEqual(parsed.ignore_violations, {'NetworkViolation': ['y.com']}) + self.assertEqual(parsed.extra['enabledPlatforms'], ['macos']) + self.assertEqual(parsed.extra['somethingFuture'], 42) + + back = parsed.to_dict() + self.assertEqual(back['enabled'], True) + self.assertEqual(back['enabledPlatforms'], ['macos']) + self.assertEqual(back['somethingFuture'], 42) + + def test_empty_returns_defaults(self) -> None: + parsed = SandboxSettings.from_dict({}) + self.assertIsNone(parsed.enabled) + self.assertIsNone(parsed.network) + self.assertEqual(parsed.to_dict(), {}) + + def test_rejects_non_mapping(self) -> None: + with self.assertRaises(ValueError): + SandboxSettings.from_dict('nope') # type: ignore[arg-type] + + def test_ignore_violations_must_be_mapping(self) -> None: + with self.assertRaises(ValueError): + SandboxSettings.from_dict({'ignoreViolations': ['nope']}) + + +if __name__ == '__main__': + unittest.main() From f770b794aea1b0503ce895b8b0d721089b91828d Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:27:41 +0200 Subject: [PATCH 08/18] Implemented the next missing parity slice around SDK core types. --- PARITY_CHECKLIST.md | 2 +- src/sdk_core_types.py | 319 +++++++++++++++++++++++++++++++++++ tests/test_sdk_core_types.py | 175 +++++++++++++++++++ 3 files changed, 495 insertions(+), 1 deletion(-) create mode 100644 src/sdk_core_types.py create mode 100644 tests/test_sdk_core_types.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9294501..1ef7a33 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -181,7 +181,7 @@ Missing: - [x] Release notes checking from `setup.ts` (local CHANGELOG.md, no network/cache layer) - [ ] Full `entrypoints/cli.tsx` parity (version flag, feature flags, env setup, dynamic imports) - [ ] Full `entrypoints/init.ts` parity (settings validation, OAuth, policy limits, telemetry, cleanup handlers) -- [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) +- [ ] SDK entrypoint (`entrypoints/sdk/` — controlTypes, coreTypes, runtimeTypes, settingsTypes, toolTypes) — partial: HOOK_EVENTS, EXIT_REASONS, ModelUsage, ThinkingConfig, MCP server configs, JsonSchemaOutputFormat ported in `src/sdk_core_types.py` - [x] Sandbox types/network config schema (`entrypoints/sandboxTypes.ts`) ## 3. Prompt Assembly diff --git a/src/sdk_core_types.py b/src/sdk_core_types.py new file mode 100644 index 0000000..f2d8e18 --- /dev/null +++ b/src/sdk_core_types.py @@ -0,0 +1,319 @@ +"""Foundational SDK types — Python port of entrypoints/sdk/coreTypes.ts. + +Mirrors the public constants and a starter set of dataclasses for the +serializable types defined in `entrypoints/sdk/coreSchemas.ts`. The full +schema set (1800+ lines of Zod) is too large to port wholesale; this file +covers the foundational pieces other modules already reference and gives +later slices a place to extend. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any, Literal + + +# --------------------------------------------------------------------------- +# Const arrays (HOOK_EVENTS, EXIT_REASONS) — mirror coreTypes.ts top-level +# --------------------------------------------------------------------------- + +HOOK_EVENTS: tuple[str, ...] = ( + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'Notification', + 'UserPromptSubmit', + 'SessionStart', + 'SessionEnd', + 'Stop', + 'StopFailure', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'PermissionRequest', + 'PermissionDenied', + 'Setup', + 'TeammateIdle', + 'TaskCreated', + 'TaskCompleted', + 'Elicitation', + 'ElicitationResult', + 'ConfigChange', + 'WorktreeCreate', + 'WorktreeRemove', + 'InstructionsLoaded', + 'CwdChanged', + 'FileChanged', +) + +EXIT_REASONS: tuple[str, ...] = ( + 'clear', + 'resume', + 'logout', + 'prompt_input_exit', + 'other', + 'bypass_permissions_disabled', +) + +API_KEY_SOURCES: tuple[str, ...] = ('user', 'project', 'org', 'temporary', 'oauth') +CONFIG_SCOPES: tuple[str, ...] = ('local', 'user', 'project') +SDK_BETAS: tuple[str, ...] = ('context-1m-2025-08-07',) + + +ApiKeySource = Literal['user', 'project', 'org', 'temporary', 'oauth'] +ConfigScope = Literal['local', 'user', 'project'] +SdkBeta = Literal['context-1m-2025-08-07'] + + +# --------------------------------------------------------------------------- +# Usage / model +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ModelUsage: + input_tokens: int + output_tokens: int + cache_read_input_tokens: int + cache_creation_input_tokens: int + web_search_requests: int + cost_usd: float + context_window: int + max_output_tokens: int + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'ModelUsage': + return cls( + input_tokens=int(data['inputTokens']), + output_tokens=int(data['outputTokens']), + cache_read_input_tokens=int(data['cacheReadInputTokens']), + cache_creation_input_tokens=int(data['cacheCreationInputTokens']), + web_search_requests=int(data['webSearchRequests']), + cost_usd=float(data['costUSD']), + context_window=int(data['contextWindow']), + max_output_tokens=int(data['maxOutputTokens']), + ) + + def to_dict(self) -> dict[str, Any]: + return { + 'inputTokens': self.input_tokens, + 'outputTokens': self.output_tokens, + 'cacheReadInputTokens': self.cache_read_input_tokens, + 'cacheCreationInputTokens': self.cache_creation_input_tokens, + 'webSearchRequests': self.web_search_requests, + 'costUSD': self.cost_usd, + 'contextWindow': self.context_window, + 'maxOutputTokens': self.max_output_tokens, + } + + +# --------------------------------------------------------------------------- +# Thinking config +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class ThinkingAdaptive: + type: Literal['adaptive'] = 'adaptive' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type} + + +@dataclass(frozen=True) +class ThinkingEnabled: + budget_tokens: int | None = None + type: Literal['enabled'] = 'enabled' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type} + if self.budget_tokens is not None: + out['budgetTokens'] = self.budget_tokens + return out + + +@dataclass(frozen=True) +class ThinkingDisabled: + type: Literal['disabled'] = 'disabled' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type} + + +ThinkingConfig = ThinkingAdaptive | ThinkingEnabled | ThinkingDisabled + + +def thinking_config_from_dict(data: dict[str, Any]) -> ThinkingConfig: + kind = data.get('type') + if kind == 'adaptive': + return ThinkingAdaptive() + if kind == 'enabled': + budget = data.get('budgetTokens') + return ThinkingEnabled( + budget_tokens=int(budget) if budget is not None else None, + ) + if kind == 'disabled': + return ThinkingDisabled() + raise ValueError(f'unknown thinking config type: {kind!r}') + + +# --------------------------------------------------------------------------- +# MCP server configs +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class McpStdioServerConfig: + command: str + args: list[str] | None = None + env: dict[str, str] | None = None + type: Literal['stdio'] = 'stdio' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'command': self.command} + if self.args is not None: + out['args'] = list(self.args) + if self.env is not None: + out['env'] = dict(self.env) + return out + + +@dataclass(frozen=True) +class McpSSEServerConfig: + url: str + headers: dict[str, str] | None = None + type: Literal['sse'] = 'sse' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'url': self.url} + if self.headers is not None: + out['headers'] = dict(self.headers) + return out + + +@dataclass(frozen=True) +class McpHttpServerConfig: + url: str + headers: dict[str, str] | None = None + type: Literal['http'] = 'http' + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = {'type': self.type, 'url': self.url} + if self.headers is not None: + out['headers'] = dict(self.headers) + return out + + +@dataclass(frozen=True) +class McpSdkServerConfig: + name: str + type: Literal['sdk'] = 'sdk' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'name': self.name} + + +@dataclass(frozen=True) +class McpClaudeAIProxyServerConfig: + url: str + id: str + type: Literal['claudeai-proxy'] = 'claudeai-proxy' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'url': self.url, 'id': self.id} + + +McpServerConfigForProcessTransport = ( + McpStdioServerConfig + | McpSSEServerConfig + | McpHttpServerConfig + | McpSdkServerConfig +) +McpServerStatusConfig = ( + McpServerConfigForProcessTransport | McpClaudeAIProxyServerConfig +) + + +def mcp_server_config_from_dict(data: dict[str, Any]) -> McpServerStatusConfig: + """Discriminate by `type` and route to the matching dataclass.""" + raw_type = data.get('type') + if raw_type is None or raw_type == 'stdio': + command = data.get('command') + if not isinstance(command, str): + raise ValueError('stdio server config requires a string command') + return McpStdioServerConfig( + command=command, + args=list(data['args']) if isinstance(data.get('args'), list) else None, + env=dict(data['env']) if isinstance(data.get('env'), dict) else None, + ) + if raw_type == 'sse': + return McpSSEServerConfig( + url=str(data['url']), + headers=dict(data['headers']) if isinstance(data.get('headers'), dict) else None, + ) + if raw_type == 'http': + return McpHttpServerConfig( + url=str(data['url']), + headers=dict(data['headers']) if isinstance(data.get('headers'), dict) else None, + ) + if raw_type == 'sdk': + return McpSdkServerConfig(name=str(data['name'])) + if raw_type == 'claudeai-proxy': + return McpClaudeAIProxyServerConfig( + url=str(data['url']), + id=str(data['id']), + ) + raise ValueError(f'unknown MCP server config type: {raw_type!r}') + + +# --------------------------------------------------------------------------- +# Output format +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class JsonSchemaOutputFormat: + schema: dict[str, Any] = field(default_factory=dict) + type: Literal['json_schema'] = 'json_schema' + + def to_dict(self) -> dict[str, Any]: + return {'type': self.type, 'schema': dict(self.schema)} + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> 'JsonSchemaOutputFormat': + if data.get('type') != 'json_schema': + raise ValueError( + f'expected output format type "json_schema", got {data.get("type")!r}' + ) + schema = data.get('schema') + if not isinstance(schema, dict): + raise ValueError('json_schema output format requires a "schema" mapping') + return cls(schema=dict(schema)) + + +OutputFormat = JsonSchemaOutputFormat + + +__all__ = [ + 'HOOK_EVENTS', + 'EXIT_REASONS', + 'API_KEY_SOURCES', + 'CONFIG_SCOPES', + 'SDK_BETAS', + 'ApiKeySource', + 'ConfigScope', + 'SdkBeta', + 'ModelUsage', + 'ThinkingAdaptive', + 'ThinkingEnabled', + 'ThinkingDisabled', + 'ThinkingConfig', + 'thinking_config_from_dict', + 'McpStdioServerConfig', + 'McpSSEServerConfig', + 'McpHttpServerConfig', + 'McpSdkServerConfig', + 'McpClaudeAIProxyServerConfig', + 'McpServerConfigForProcessTransport', + 'McpServerStatusConfig', + 'mcp_server_config_from_dict', + 'JsonSchemaOutputFormat', + 'OutputFormat', +] diff --git a/tests/test_sdk_core_types.py b/tests/test_sdk_core_types.py new file mode 100644 index 0000000..596039b --- /dev/null +++ b/tests/test_sdk_core_types.py @@ -0,0 +1,175 @@ +"""Tests for SDK core types ported from entrypoints/sdk/coreTypes.ts.""" + +from __future__ import annotations + +import unittest + +from src.sdk_core_types import ( + API_KEY_SOURCES, + CONFIG_SCOPES, + EXIT_REASONS, + HOOK_EVENTS, + SDK_BETAS, + JsonSchemaOutputFormat, + McpClaudeAIProxyServerConfig, + McpHttpServerConfig, + McpSdkServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, + ModelUsage, + ThinkingAdaptive, + ThinkingDisabled, + ThinkingEnabled, + mcp_server_config_from_dict, + thinking_config_from_dict, +) + + +class HookEventsTest(unittest.TestCase): + def test_includes_known_events(self) -> None: + for required in ( + 'PreToolUse', + 'PostToolUse', + 'UserPromptSubmit', + 'SessionStart', + 'PreCompact', + 'WorktreeCreate', + 'CwdChanged', + 'FileChanged', + ): + self.assertIn(required, HOOK_EVENTS) + + def test_no_duplicates(self) -> None: + self.assertEqual(len(HOOK_EVENTS), len(set(HOOK_EVENTS))) + + +class ExitReasonsTest(unittest.TestCase): + def test_known_exit_reasons(self) -> None: + for required in ( + 'clear', 'resume', 'logout', 'prompt_input_exit', + 'other', 'bypass_permissions_disabled', + ): + self.assertIn(required, EXIT_REASONS) + + +class EnumLiteralsTest(unittest.TestCase): + def test_api_key_sources(self) -> None: + self.assertEqual(set(API_KEY_SOURCES), {'user', 'project', 'org', 'temporary', 'oauth'}) + + def test_config_scopes(self) -> None: + self.assertEqual(set(CONFIG_SCOPES), {'local', 'user', 'project'}) + + def test_sdk_betas_known(self) -> None: + self.assertIn('context-1m-2025-08-07', SDK_BETAS) + + +class ModelUsageTest(unittest.TestCase): + def test_round_trip(self) -> None: + raw = { + 'inputTokens': 100, 'outputTokens': 50, + 'cacheReadInputTokens': 10, 'cacheCreationInputTokens': 5, + 'webSearchRequests': 0, 'costUSD': 0.001, + 'contextWindow': 200000, 'maxOutputTokens': 8192, + } + usage = ModelUsage.from_dict(raw) + self.assertEqual(usage.input_tokens, 100) + self.assertEqual(usage.cost_usd, 0.001) + self.assertEqual(usage.to_dict(), raw) + + +class ThinkingConfigTest(unittest.TestCase): + def test_adaptive(self) -> None: + cfg = thinking_config_from_dict({'type': 'adaptive'}) + self.assertIsInstance(cfg, ThinkingAdaptive) + self.assertEqual(cfg.to_dict(), {'type': 'adaptive'}) + + def test_enabled_with_budget(self) -> None: + cfg = thinking_config_from_dict({'type': 'enabled', 'budgetTokens': 1024}) + self.assertIsInstance(cfg, ThinkingEnabled) + self.assertEqual(cfg.to_dict(), {'type': 'enabled', 'budgetTokens': 1024}) + + def test_enabled_without_budget(self) -> None: + cfg = thinking_config_from_dict({'type': 'enabled'}) + self.assertEqual(cfg.to_dict(), {'type': 'enabled'}) + + def test_disabled(self) -> None: + cfg = thinking_config_from_dict({'type': 'disabled'}) + self.assertIsInstance(cfg, ThinkingDisabled) + + def test_unknown_type_raises(self) -> None: + with self.assertRaises(ValueError): + thinking_config_from_dict({'type': 'something-else'}) + + +class McpServerConfigTest(unittest.TestCase): + def test_stdio_default_type(self) -> None: + cfg = mcp_server_config_from_dict({'command': 'npx'}) + self.assertIsInstance(cfg, McpStdioServerConfig) + self.assertEqual(cfg.command, 'npx') + + def test_stdio_with_args_env(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'stdio', + 'command': 'node', + 'args': ['./mcp.js'], + 'env': {'TOKEN': 'xyz'}, + }) + out = cfg.to_dict() + self.assertEqual(out['command'], 'node') + self.assertEqual(out['args'], ['./mcp.js']) + self.assertEqual(out['env'], {'TOKEN': 'xyz'}) + + def test_sse(self) -> None: + cfg = mcp_server_config_from_dict({'type': 'sse', 'url': 'https://x.example'}) + self.assertIsInstance(cfg, McpSSEServerConfig) + + def test_http_with_headers(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'http', + 'url': 'https://x.example', + 'headers': {'Auth': 'Bearer 1'}, + }) + self.assertIsInstance(cfg, McpHttpServerConfig) + self.assertEqual(cfg.headers, {'Auth': 'Bearer 1'}) + + def test_sdk(self) -> None: + cfg = mcp_server_config_from_dict({'type': 'sdk', 'name': 'my-sdk'}) + self.assertIsInstance(cfg, McpSdkServerConfig) + + def test_claudeai_proxy(self) -> None: + cfg = mcp_server_config_from_dict({ + 'type': 'claudeai-proxy', + 'url': 'https://claude.ai/p', + 'id': 'abc', + }) + self.assertIsInstance(cfg, McpClaudeAIProxyServerConfig) + + def test_unknown_type_raises(self) -> None: + with self.assertRaises(ValueError): + mcp_server_config_from_dict({'type': 'mystery'}) + + def test_stdio_requires_command(self) -> None: + with self.assertRaises(ValueError): + mcp_server_config_from_dict({'type': 'stdio'}) + + +class JsonSchemaOutputFormatTest(unittest.TestCase): + def test_round_trip(self) -> None: + fmt = JsonSchemaOutputFormat.from_dict({ + 'type': 'json_schema', + 'schema': {'type': 'object', 'properties': {}}, + }) + self.assertEqual(fmt.schema['type'], 'object') + self.assertEqual(fmt.to_dict()['type'], 'json_schema') + + def test_rejects_wrong_type(self) -> None: + with self.assertRaises(ValueError): + JsonSchemaOutputFormat.from_dict({'type': 'text', 'schema': {}}) + + def test_requires_schema_mapping(self) -> None: + with self.assertRaises(ValueError): + JsonSchemaOutputFormat.from_dict({'type': 'json_schema'}) + + +if __name__ == '__main__': + unittest.main() From 78f3c6ec6815db43d5cd8369cf2f0ad69ee8ebcb Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:29:47 +0200 Subject: [PATCH 09/18] Implemented the next missing parity slice around model pricing tiers. --- PARITY_CHECKLIST.md | 2 +- src/model_cost.py | 226 +++++++++++++++++++++++++++++++++++++++ tests/test_model_cost.py | 167 +++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/model_cost.py create mode 100644 tests/test_model_cost.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 1ef7a33..3e5b6e0 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -712,7 +712,7 @@ Missing major utility categories: - [ ] Shell utilities (`utils/bash/`, `utils/shell/`, `Shell.ts`, `ShellCommand.ts`) - [ ] Git operations (`utils/git.ts`, `utils/gitDiff.ts`, `utils/gitSettings.ts`, `utils/commitAttribution.ts`) - [ ] File operations (`utils/file.ts`, `utils/fileRead.ts`, `utils/fileHistory.ts`, `utils/fileStateCache.ts`, `utils/fsOperations.ts`, `utils/ripgrep.ts`, `utils/glob.ts`) -- [ ] AI/Model utilities (`utils/modelCost.ts`, `utils/model/`, `utils/context.ts`, `utils/queryContext.ts`) +- [ ] AI/Model utilities (`utils/modelCost.ts`, `utils/model/`, `utils/context.ts`, `utils/queryContext.ts`) — partial: modelCost ported in `src/model_cost.py` - [ ] Config/Settings (`utils/config.ts`, `utils/settings/`) - [ ] Message handling (`utils/messages.ts`, `utils/messages/`, `utils/messageQueueManager.ts`) - [ ] API/Network (`utils/api.ts`, `utils/http.ts`, `utils/proxy.ts`, `utils/auth.ts`) diff --git a/src/model_cost.py b/src/model_cost.py new file mode 100644 index 0000000..fa842a5 --- /dev/null +++ b/src/model_cost.py @@ -0,0 +1,226 @@ +"""Model pricing — Python port of utils/modelCost.ts. + +Pricing values mirror the upstream tiers exactly. The npm version logs an +analytics event on unknown models; here we just fall back to the +DEFAULT_UNKNOWN_MODEL_COST tier and let callers decide what to track. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ModelCosts: + """USD per million input/output tokens (cache + web-search per request).""" + + input_tokens: float + output_tokens: float + prompt_cache_write_tokens: float + prompt_cache_read_tokens: float + web_search_requests: float + + +# Standard pricing tier for Sonnet models: $3 input / $15 output per Mtok +COST_TIER_3_15 = ModelCosts( + input_tokens=3.0, + output_tokens=15.0, + prompt_cache_write_tokens=3.75, + prompt_cache_read_tokens=0.3, + web_search_requests=0.01, +) + +# Pricing tier for Opus 4 / 4.1: $15 input / $75 output per Mtok +COST_TIER_15_75 = ModelCosts( + input_tokens=15.0, + output_tokens=75.0, + prompt_cache_write_tokens=18.75, + prompt_cache_read_tokens=1.5, + web_search_requests=0.01, +) + +# Pricing tier for Opus 4.5 (also default Opus 4.6): $5 input / $25 output per Mtok +COST_TIER_5_25 = ModelCosts( + input_tokens=5.0, + output_tokens=25.0, + prompt_cache_write_tokens=6.25, + prompt_cache_read_tokens=0.5, + web_search_requests=0.01, +) + +# Fast-mode pricing for Opus 4.6: $30 input / $150 output per Mtok +COST_TIER_30_150 = ModelCosts( + input_tokens=30.0, + output_tokens=150.0, + prompt_cache_write_tokens=37.5, + prompt_cache_read_tokens=3.0, + web_search_requests=0.01, +) + +# Pricing for Haiku 3.5: $0.80 input / $4 output per Mtok +COST_HAIKU_35 = ModelCosts( + input_tokens=0.8, + output_tokens=4.0, + prompt_cache_write_tokens=1.0, + prompt_cache_read_tokens=0.08, + web_search_requests=0.01, +) + +# Pricing for Haiku 4.5: $1 input / $5 output per Mtok +COST_HAIKU_45 = ModelCosts( + input_tokens=1.0, + output_tokens=5.0, + prompt_cache_write_tokens=1.25, + prompt_cache_read_tokens=0.1, + web_search_requests=0.01, +) + +DEFAULT_UNKNOWN_MODEL_COST = COST_TIER_5_25 + + +# Canonical short-name → cost tier. Lookup uses substring matching so that +# version-suffixed model IDs (`claude-opus-4-6-20251015`) resolve correctly. +MODEL_COSTS: dict[str, ModelCosts] = { + 'claude-3-5-haiku': COST_HAIKU_35, + 'claude-haiku-4-5': COST_HAIKU_45, + 'claude-3-5-sonnet': COST_TIER_3_15, + 'claude-3-7-sonnet': COST_TIER_3_15, + 'claude-sonnet-4': COST_TIER_3_15, + 'claude-sonnet-4-5': COST_TIER_3_15, + 'claude-sonnet-4-6': COST_TIER_3_15, + 'claude-opus-4': COST_TIER_15_75, + 'claude-opus-4-1': COST_TIER_15_75, + 'claude-opus-4-5': COST_TIER_5_25, + 'claude-opus-4-6': COST_TIER_5_25, +} + + +def _resolve_model_costs(model: str) -> ModelCosts | None: + """Return MODEL_COSTS entry for `model`, matching by longest prefix.""" + canonical = model.lower() + matches = [ + (key, costs) + for key, costs in MODEL_COSTS.items() + if canonical.startswith(key) or key in canonical + ] + if not matches: + return None + # Prefer the most specific (longest) key match so `claude-opus-4-6` wins + # over `claude-opus-4`. + matches.sort(key=lambda item: len(item[0]), reverse=True) + return matches[0][1] + + +def get_opus_4_6_cost_tier(fast_mode: bool) -> ModelCosts: + """Return the right tier for Opus 4.6 — fast mode is more expensive.""" + return COST_TIER_30_150 if fast_mode else COST_TIER_5_25 + + +def get_model_costs(model: str, *, fast_mode: bool = False) -> ModelCosts: + """Return ModelCosts for `model`, applying the Opus 4.6 fast-mode tier.""" + canonical = model.lower() + if 'claude-opus-4-6' in canonical: + return get_opus_4_6_cost_tier(fast_mode) + costs = _resolve_model_costs(model) + return costs if costs is not None else DEFAULT_UNKNOWN_MODEL_COST + + +def tokens_to_usd_cost( + costs: ModelCosts, + *, + input_tokens: int, + output_tokens: int, + cache_read_input_tokens: int = 0, + cache_creation_input_tokens: int = 0, + web_search_requests: int = 0, +) -> float: + """Compute USD cost from token counts and a ModelCosts tier.""" + return ( + (input_tokens / 1_000_000) * costs.input_tokens + + (output_tokens / 1_000_000) * costs.output_tokens + + (cache_read_input_tokens / 1_000_000) * costs.prompt_cache_read_tokens + + (cache_creation_input_tokens / 1_000_000) * costs.prompt_cache_write_tokens + + web_search_requests * costs.web_search_requests + ) + + +def calculate_usd_cost( + model: str, + *, + input_tokens: int, + output_tokens: int, + cache_read_input_tokens: int = 0, + cache_creation_input_tokens: int = 0, + web_search_requests: int = 0, + fast_mode: bool = False, +) -> float: + """USD cost for a query — looks up the tier and applies tokens_to_usd_cost.""" + costs = get_model_costs(model, fast_mode=fast_mode) + return tokens_to_usd_cost( + costs, + input_tokens=input_tokens, + output_tokens=output_tokens, + cache_read_input_tokens=cache_read_input_tokens, + cache_creation_input_tokens=cache_creation_input_tokens, + web_search_requests=web_search_requests, + ) + + +def calculate_cost_from_tokens( + model: str, + tokens: dict, + *, + fast_mode: bool = False, +) -> float: + """Mirror calculateCostFromTokens — accepts the same camelCase token dict.""" + return calculate_usd_cost( + model, + input_tokens=int(tokens.get('inputTokens', 0)), + output_tokens=int(tokens.get('outputTokens', 0)), + cache_read_input_tokens=int(tokens.get('cacheReadInputTokens', 0)), + cache_creation_input_tokens=int(tokens.get('cacheCreationInputTokens', 0)), + web_search_requests=int(tokens.get('webSearchRequests', 0)), + fast_mode=fast_mode, + ) + + +def _format_price(price: float) -> str: + if float(price).is_integer(): + return f'${int(price)}' + return f'${price:.2f}' + + +def format_model_pricing(costs: ModelCosts) -> str: + """Return a human-readable pricing label like '$3/$15 per Mtok'.""" + return ( + f'{_format_price(costs.input_tokens)}/' + f'{_format_price(costs.output_tokens)} per Mtok' + ) + + +def get_model_pricing_string(model: str) -> str | None: + """Return formatted pricing string for `model`, or None if unknown.""" + costs = _resolve_model_costs(model) + if costs is None: + return None + return format_model_pricing(costs) + + +__all__ = [ + 'ModelCosts', + 'COST_TIER_3_15', + 'COST_TIER_15_75', + 'COST_TIER_5_25', + 'COST_TIER_30_150', + 'COST_HAIKU_35', + 'COST_HAIKU_45', + 'DEFAULT_UNKNOWN_MODEL_COST', + 'MODEL_COSTS', + 'get_opus_4_6_cost_tier', + 'get_model_costs', + 'tokens_to_usd_cost', + 'calculate_usd_cost', + 'calculate_cost_from_tokens', + 'format_model_pricing', + 'get_model_pricing_string', +] diff --git a/tests/test_model_cost.py b/tests/test_model_cost.py new file mode 100644 index 0000000..5fc2031 --- /dev/null +++ b/tests/test_model_cost.py @@ -0,0 +1,167 @@ +"""Tests for model pricing utilities ported from utils/modelCost.ts.""" + +from __future__ import annotations + +import unittest + +from src.model_cost import ( + COST_HAIKU_35, + COST_HAIKU_45, + COST_TIER_3_15, + COST_TIER_5_25, + COST_TIER_15_75, + COST_TIER_30_150, + DEFAULT_UNKNOWN_MODEL_COST, + calculate_cost_from_tokens, + calculate_usd_cost, + format_model_pricing, + get_model_costs, + get_model_pricing_string, + get_opus_4_6_cost_tier, + tokens_to_usd_cost, +) + + +class TierConstantsTest(unittest.TestCase): + def test_sonnet_tier(self) -> None: + self.assertEqual(COST_TIER_3_15.input_tokens, 3.0) + self.assertEqual(COST_TIER_3_15.output_tokens, 15.0) + + def test_opus_4_tier(self) -> None: + self.assertEqual(COST_TIER_15_75.input_tokens, 15.0) + + def test_opus_4_5_tier(self) -> None: + self.assertEqual(COST_TIER_5_25.input_tokens, 5.0) + + def test_fast_mode_tier(self) -> None: + self.assertEqual(COST_TIER_30_150.input_tokens, 30.0) + + def test_haiku_tiers(self) -> None: + self.assertAlmostEqual(COST_HAIKU_35.input_tokens, 0.8) + self.assertEqual(COST_HAIKU_45.input_tokens, 1.0) + + +class GetModelCostsTest(unittest.TestCase): + def test_opus_4_6_default(self) -> None: + self.assertIs(get_model_costs('claude-opus-4-6'), COST_TIER_5_25) + + def test_opus_4_6_fast_mode(self) -> None: + self.assertIs( + get_model_costs('claude-opus-4-6', fast_mode=True), + COST_TIER_30_150, + ) + + def test_versioned_model_name_resolves(self) -> None: + self.assertIs( + get_model_costs('claude-opus-4-6-20251015'), + COST_TIER_5_25, + ) + + def test_sonnet_models_use_3_15(self) -> None: + for name in ('claude-sonnet-4-6', 'claude-sonnet-4-5', 'claude-sonnet-4'): + self.assertIs(get_model_costs(name), COST_TIER_3_15) + + def test_opus_4_and_4_1_use_15_75(self) -> None: + self.assertIs(get_model_costs('claude-opus-4'), COST_TIER_15_75) + self.assertIs(get_model_costs('claude-opus-4-1'), COST_TIER_15_75) + + def test_haiku_4_5(self) -> None: + self.assertIs(get_model_costs('claude-haiku-4-5-20251001'), COST_HAIKU_45) + + def test_haiku_3_5(self) -> None: + self.assertIs(get_model_costs('claude-3-5-haiku-20241022'), COST_HAIKU_35) + + def test_unknown_falls_back_to_default(self) -> None: + self.assertIs(get_model_costs('mystery-llm-3000'), DEFAULT_UNKNOWN_MODEL_COST) + + def test_get_opus_4_6_helper_matches_fast_mode(self) -> None: + self.assertIs(get_opus_4_6_cost_tier(False), COST_TIER_5_25) + self.assertIs(get_opus_4_6_cost_tier(True), COST_TIER_30_150) + + +class TokensToUsdCostTest(unittest.TestCase): + def test_simple_input_output(self) -> None: + cost = tokens_to_usd_cost( + COST_TIER_3_15, input_tokens=1_000_000, output_tokens=500_000, + ) + self.assertAlmostEqual(cost, 3.0 + 7.5) + + def test_includes_cache_tokens(self) -> None: + cost = tokens_to_usd_cost( + COST_TIER_3_15, + input_tokens=0, + output_tokens=0, + cache_read_input_tokens=1_000_000, + cache_creation_input_tokens=1_000_000, + ) + self.assertAlmostEqual( + cost, + COST_TIER_3_15.prompt_cache_read_tokens + + COST_TIER_3_15.prompt_cache_write_tokens, + ) + + def test_includes_web_search(self) -> None: + cost = tokens_to_usd_cost( + COST_TIER_3_15, + input_tokens=0, + output_tokens=0, + web_search_requests=10, + ) + self.assertAlmostEqual(cost, 0.10) + + +class CalculateUsdCostTest(unittest.TestCase): + def test_resolves_model_then_costs(self) -> None: + cost = calculate_usd_cost( + 'claude-sonnet-4-6', + input_tokens=1_000_000, + output_tokens=500_000, + ) + self.assertAlmostEqual(cost, 10.5) + + def test_fast_mode_changes_opus_46_cost(self) -> None: + normal = calculate_usd_cost( + 'claude-opus-4-6', input_tokens=1_000_000, output_tokens=0, + ) + fast = calculate_usd_cost( + 'claude-opus-4-6', input_tokens=1_000_000, output_tokens=0, + fast_mode=True, + ) + self.assertGreater(fast, normal) + self.assertAlmostEqual(normal, 5.0) + self.assertAlmostEqual(fast, 30.0) + + +class CalculateCostFromTokensTest(unittest.TestCase): + def test_camel_case_dict_input(self) -> None: + cost = calculate_cost_from_tokens( + 'claude-opus-4-1', + { + 'inputTokens': 1_000_000, + 'outputTokens': 0, + 'cacheReadInputTokens': 0, + 'cacheCreationInputTokens': 0, + }, + ) + self.assertAlmostEqual(cost, 15.0) + + +class FormatPricingTest(unittest.TestCase): + def test_integers_no_decimals(self) -> None: + self.assertEqual(format_model_pricing(COST_TIER_3_15), '$3/$15 per Mtok') + + def test_haiku_decimals(self) -> None: + self.assertEqual(format_model_pricing(COST_HAIKU_35), '$0.80/$4 per Mtok') + + def test_get_pricing_string_known(self) -> None: + self.assertEqual( + get_model_pricing_string('claude-opus-4-6'), + '$5/$25 per Mtok', + ) + + def test_get_pricing_string_unknown(self) -> None: + self.assertIsNone(get_model_pricing_string('unknown-model')) + + +if __name__ == '__main__': + unittest.main() From 83d93cca85b7720c6beafe07a93525a271c44c60 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:35:01 +0200 Subject: [PATCH 10/18] Implemented the next missing parity slice around small portable utilities. --- PARITY_CHECKLIST.md | 1 + src/small_utils.py | 166 ++++++++++++++++++++++++++++++++++++++ tests/test_small_utils.py | 138 +++++++++++++++++++++++++++++++ 3 files changed, 305 insertions(+) create mode 100644 src/small_utils.py create mode 100644 tests/test_small_utils.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 3e5b6e0..9ea0763 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -706,6 +706,7 @@ Done: - [x] Basic file operations in tool implementations - [x] Basic git status snapshot - [x] Basic shell/subprocess handling +- [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` Missing major utility categories: diff --git a/src/small_utils.py b/src/small_utils.py new file mode 100644 index 0000000..ebd81a3 --- /dev/null +++ b/src/small_utils.py @@ -0,0 +1,166 @@ +"""Bundled portable utilities — Python ports of small npm `utils/` files. + +This module collects narrow, dependency-free helpers from +``utils/array.ts``, ``utils/set.ts``, ``utils/objectGroupBy.ts``, +``utils/xml.ts``, and ``utils/uuid.ts``. Keeping them in one file mirrors +how a user of the npm SDK would reach for these as a small toolbox. + +Design notes: +- Function names mirror upstream where idiomatic in Python; ``every`` is + renamed ``every_in`` to avoid shadowing the built-in name in callers. +- ``object_group_by`` returns a plain ``dict`` rather than a ``Mapping`` so + callers can mutate it the same way the JS object would behave. +- ``create_agent_id`` mirrors the upstream format exactly: + ``a{label-}{16 hex chars}``. +""" + +from __future__ import annotations + +import re +import secrets +from collections.abc import Callable, Iterable +from typing import TypeVar + +A = TypeVar('A') +T = TypeVar('T') +K = TypeVar('K') + + +# --------------------------------------------------------------------------- +# array.ts +# --------------------------------------------------------------------------- + +def intersperse(items: Iterable[A], separator: Callable[[int], A]) -> list[A]: + """Insert ``separator(i)`` between consecutive items. + + Mirrors ``utils/array.ts`` ``intersperse``: the separator callable + receives the 1-based index of the item it precedes. + """ + out: list[A] = [] + for i, item in enumerate(items): + if i: + out.append(separator(i)) + out.append(item) + return out + + +def count(items: Iterable[T], predicate: Callable[[T], object]) -> int: + """Count items where ``predicate(item)`` is truthy.""" + return sum(1 for x in items if predicate(x)) + + +def uniq(items: Iterable[T]) -> list[T]: + """Return unique items preserving first-seen order. + + Note: upstream JS uses ``[...new Set(xs)]`` which preserves insertion + order for primitive values; this matches that behavior. + """ + seen: set[T] = set() + out: list[T] = [] + for item in items: + if item not in seen: + seen.add(item) + out.append(item) + return out + + +# --------------------------------------------------------------------------- +# objectGroupBy.ts +# --------------------------------------------------------------------------- + +def object_group_by( + items: Iterable[T], + key_selector: Callable[[T, int], K], +) -> dict[K, list[T]]: + """Group items by ``key_selector(item, index)``. + + Mirrors ``Object.groupBy`` semantics from the TC39 proposal. + """ + out: dict[K, list[T]] = {} + for index, item in enumerate(items): + key = key_selector(item, index) + bucket = out.get(key) + if bucket is None: + bucket = [] + out[key] = bucket + bucket.append(item) + return out + + +# --------------------------------------------------------------------------- +# set.ts +# --------------------------------------------------------------------------- + +def difference(a: set[T], b: set[T]) -> set[T]: + """Items in ``a`` but not ``b``.""" + return {item for item in a if item not in b} + + +def intersects(a: set[T], b: set[T]) -> bool: + """Whether ``a`` and ``b`` share at least one element.""" + if not a or not b: + return False + return any(item in b for item in a) + + +def every_in(a: set[T], b: set[T]) -> bool: + """Whether every element of ``a`` is in ``b`` (renamed from ``every``).""" + return all(item in b for item in a) + + +def union(a: set[T], b: set[T]) -> set[T]: + """Set union.""" + return a | b + + +# --------------------------------------------------------------------------- +# xml.ts +# --------------------------------------------------------------------------- + +def escape_xml(value: str) -> str: + """Escape ``& < >`` for safe interpolation between XML/HTML tags.""" + return value.replace('&', '&').replace('<', '<').replace('>', '>') + + +def escape_xml_attr(value: str) -> str: + """Escape ``& < > " '`` for safe interpolation into an attribute value.""" + return escape_xml(value).replace('"', '"').replace("'", ''') + + +# --------------------------------------------------------------------------- +# uuid.ts +# --------------------------------------------------------------------------- + +_UUID_RE = re.compile( + r'^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$', + re.IGNORECASE, +) + + +def validate_uuid(maybe_uuid: object) -> str | None: + """Return the input as a UUID string if it matches the canonical format.""" + if not isinstance(maybe_uuid, str): + return None + return maybe_uuid if _UUID_RE.match(maybe_uuid) else None + + +def create_agent_id(label: str | None = None) -> str: + """Generate an agent ID with the upstream ``a{label-}{hex16}`` format.""" + suffix = secrets.token_hex(8) + return f'a{label}-{suffix}' if label else f'a{suffix}' + + +__all__ = [ + 'intersperse', + 'count', + 'uniq', + 'object_group_by', + 'difference', + 'intersects', + 'every_in', + 'union', + 'escape_xml', + 'escape_xml_attr', + 'validate_uuid', + 'create_agent_id', +] diff --git a/tests/test_small_utils.py b/tests/test_small_utils.py new file mode 100644 index 0000000..8598ced --- /dev/null +++ b/tests/test_small_utils.py @@ -0,0 +1,138 @@ +"""Tests for the bundled small utilities ported in ``src/small_utils.py``.""" + +from __future__ import annotations + +import unittest + +from src.small_utils import ( + count, + create_agent_id, + difference, + escape_xml, + escape_xml_attr, + every_in, + intersects, + intersperse, + object_group_by, + union, + uniq, + validate_uuid, +) + + +class IntersperseTest(unittest.TestCase): + def test_empty(self) -> None: + self.assertEqual(intersperse([], lambda i: ','), []) + + def test_single_item_no_separator(self) -> None: + self.assertEqual(intersperse(['a'], lambda i: ','), ['a']) + + def test_separator_receives_index_starting_at_one(self) -> None: + seen: list[int] = [] + + def sep(i: int) -> str: + seen.append(i) + return f'-{i}-' + + out = intersperse(['a', 'b', 'c'], sep) + self.assertEqual(out, ['a', '-1-', 'b', '-2-', 'c']) + self.assertEqual(seen, [1, 2]) + + +class CountTest(unittest.TestCase): + def test_counts_truthy(self) -> None: + self.assertEqual(count([1, 2, 3, 4], lambda x: x % 2 == 0), 2) + + def test_predicate_returning_objects_treated_as_truthy(self) -> None: + self.assertEqual(count(['', 'x', 'y'], lambda s: s), 2) + + +class UniqTest(unittest.TestCase): + def test_preserves_first_seen_order(self) -> None: + self.assertEqual(uniq([3, 1, 2, 1, 3, 4]), [3, 1, 2, 4]) + + +class ObjectGroupByTest(unittest.TestCase): + def test_groups_by_key(self) -> None: + out = object_group_by(['apple', 'banana', 'avocado'], lambda s, _i: s[0]) + self.assertEqual(out, {'a': ['apple', 'avocado'], 'b': ['banana']}) + + def test_passes_index_to_selector(self) -> None: + out = object_group_by( + ['x', 'y', 'z'], lambda _s, i: 'even' if i % 2 == 0 else 'odd', + ) + self.assertEqual(out, {'even': ['x', 'z'], 'odd': ['y']}) + + +class SetOpsTest(unittest.TestCase): + def test_difference(self) -> None: + self.assertEqual(difference({1, 2, 3}, {2}), {1, 3}) + + def test_intersects_true(self) -> None: + self.assertTrue(intersects({1, 2}, {2, 3})) + + def test_intersects_false(self) -> None: + self.assertFalse(intersects({1, 2}, {3, 4})) + + def test_intersects_empty_short_circuits(self) -> None: + self.assertFalse(intersects(set(), {1})) + self.assertFalse(intersects({1}, set())) + + def test_every_in(self) -> None: + self.assertTrue(every_in({1, 2}, {1, 2, 3})) + self.assertFalse(every_in({1, 4}, {1, 2, 3})) + self.assertTrue(every_in(set(), {1, 2})) + + def test_union(self) -> None: + self.assertEqual(union({1, 2}, {2, 3}), {1, 2, 3}) + + +class XmlEscapeTest(unittest.TestCase): + def test_escape_xml(self) -> None: + self.assertEqual( + escape_xml('a & b < c > d'), 'a & b < c > d', + ) + + def test_escape_xml_amp_first_no_double_escape(self) -> None: + self.assertEqual(escape_xml('<&>'), '<&>') + + def test_escape_xml_attr_includes_quotes(self) -> None: + self.assertEqual( + escape_xml_attr('he said "hi" & \'bye\''), + 'he said "hi" & 'bye'', + ) + + +class ValidateUuidTest(unittest.TestCase): + def test_valid_lowercase(self) -> None: + u = '12345678-1234-1234-1234-123456789012' + self.assertEqual(validate_uuid(u), u) + + def test_valid_uppercase(self) -> None: + u = 'ABCDEF12-1234-5678-90AB-CDEF12345678' + self.assertEqual(validate_uuid(u), u) + + def test_invalid_format(self) -> None: + self.assertIsNone(validate_uuid('not-a-uuid')) + + def test_non_string_returns_none(self) -> None: + self.assertIsNone(validate_uuid(123)) + self.assertIsNone(validate_uuid(None)) + + +class CreateAgentIdTest(unittest.TestCase): + def test_no_label(self) -> None: + agent_id = create_agent_id() + self.assertRegex(agent_id, r'^a[0-9a-f]{16}$') + + def test_with_label(self) -> None: + agent_id = create_agent_id('compact') + self.assertRegex(agent_id, r'^acompact-[0-9a-f]{16}$') + + def test_unique_across_calls(self) -> None: + ids = {create_agent_id() for _ in range(50)} + self.assertEqual(len(ids), 50) + + +if __name__ == '__main__': + unittest.main() From de6f02781b5c872a99c4a15b95b4368bde64c5f4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:37:01 +0200 Subject: [PATCH 11/18] Implemented the next missing parity slice around platform detection and system directories. --- PARITY_CHECKLIST.md | 2 +- src/platform_info.py | 232 ++++++++++++++++++++++++++++++++++++ tests/test_platform_info.py | 210 ++++++++++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 1 deletion(-) create mode 100644 src/platform_info.py create mode 100644 tests/test_platform_info.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9ea0763..bf832a0 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -721,7 +721,7 @@ Missing major utility categories: - [ ] Plugin/Skill utilities (`utils/plugins/`, `utils/skills/`) - [ ] Memory/Context (`utils/memory/`, `utils/claudemd.ts`, `utils/contextAnalysis.ts`) - [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) -- [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) +- [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) — partial: platform detection (`getPlatform`, `getWslVersion`, `getLinuxDistroInfo`, `detectVcs`) and `getSystemDirectories` ported in `src/platform_info.py` - [ ] Debugging (`utils/debug.ts`, `utils/diagLogs.ts`, `utils/log.ts`, `utils/profilerBase.ts`) - [ ] Telemetry (`utils/telemetry/`) - [ ] Deep link utilities (`utils/deepLink/`) diff --git a/src/platform_info.py b/src/platform_info.py new file mode 100644 index 0000000..1178497 --- /dev/null +++ b/src/platform_info.py @@ -0,0 +1,232 @@ +"""Platform detection and system directories — Python ports of +``utils/platform.ts`` and ``utils/systemDirectories.ts``. + +The npm functions are memoized via lodash; here a module-level cache plus +``_reset_cache`` (test-only) provides equivalent behavior. Detection is +cheap enough that callers can also bypass the cache by passing explicit +overrides to ``get_system_directories``. +""" + +from __future__ import annotations + +import os +import platform as _stdlib_platform +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Literal + +Platform = Literal['macos', 'windows', 'wsl', 'linux', 'unknown'] + +SUPPORTED_PLATFORMS: tuple[Platform, ...] = ('macos', 'wsl') + + +_UNSET = object() +_platform_cache: Platform | None = None +_wsl_version_cache: object = _UNSET # sentinel until first computation + + +def _reset_cache() -> None: + """Clear cached platform detection — only used by tests.""" + global _platform_cache, _wsl_version_cache + _platform_cache = None + _wsl_version_cache = _UNSET + + +def _read_proc_version() -> str: + return Path('/proc/version').read_text(encoding='utf-8') + + +def get_platform() -> Platform: + """Return the current platform identifier (memoized).""" + global _platform_cache + if _platform_cache is not None: + return _platform_cache + + if sys.platform == 'darwin': + _platform_cache = 'macos' + elif sys.platform.startswith('win'): + _platform_cache = 'windows' + elif sys.platform.startswith('linux'): + try: + proc_version = _read_proc_version().lower() + if 'microsoft' in proc_version or 'wsl' in proc_version: + _platform_cache = 'wsl' + else: + _platform_cache = 'linux' + except OSError: + _platform_cache = 'linux' + else: + _platform_cache = 'unknown' + return _platform_cache + + +def get_wsl_version() -> str | None: + """Return the WSL major version (`'1'`/`'2'`/...), or None if not WSL.""" + global _wsl_version_cache + if _wsl_version_cache is not _UNSET: + return _wsl_version_cache # type: ignore[return-value] + + result: str | None = None + if sys.platform.startswith('linux'): + try: + proc_version = _read_proc_version() + except OSError: + proc_version = '' + if proc_version: + import re + match = re.search(r'WSL(\d+)', proc_version, re.IGNORECASE) + if match: + result = match.group(1) + elif 'microsoft' in proc_version.lower(): + result = '1' + _wsl_version_cache = result + return result + + +@dataclass(frozen=True) +class LinuxDistroInfo: + linux_distro_id: str | None = None + linux_distro_version: str | None = None + linux_kernel: str | None = None + + def to_dict(self) -> dict[str, str]: + out: dict[str, str] = {} + if self.linux_distro_id is not None: + out['linuxDistroId'] = self.linux_distro_id + if self.linux_distro_version is not None: + out['linuxDistroVersion'] = self.linux_distro_version + if self.linux_kernel is not None: + out['linuxKernel'] = self.linux_kernel + return out + + +def get_linux_distro_info() -> LinuxDistroInfo | None: + """Return distro id/version/kernel on Linux, or None on other platforms.""" + if not sys.platform.startswith('linux'): + return None + + distro_id: str | None = None + distro_version: str | None = None + try: + content = Path('/etc/os-release').read_text(encoding='utf-8') + except OSError: + content = '' + for line in content.splitlines(): + if '=' not in line: + continue + key, _, value = line.partition('=') + value = value.strip().strip('"') + if key == 'ID': + distro_id = value + elif key == 'VERSION_ID': + distro_version = value + + return LinuxDistroInfo( + linux_distro_id=distro_id, + linux_distro_version=distro_version, + linux_kernel=_stdlib_platform.release() or None, + ) + + +_VCS_MARKERS: tuple[tuple[str, str], ...] = ( + ('.git', 'git'), + ('.hg', 'mercurial'), + ('.svn', 'svn'), + ('.p4config', 'perforce'), + ('$tf', 'tfs'), + ('.tfvc', 'tfs'), + ('.jj', 'jujutsu'), + ('.sl', 'sapling'), +) + + +def detect_vcs(directory: str | os.PathLike[str] | None = None) -> list[str]: + """Detect VCS systems by marker files in ``directory`` (defaults to cwd).""" + detected: set[str] = set() + if os.environ.get('P4PORT'): + detected.add('perforce') + + target = Path(directory) if directory is not None else Path.cwd() + try: + entries = {entry.name for entry in target.iterdir()} + except OSError: + entries = set() + + for marker, vcs in _VCS_MARKERS: + if marker in entries: + detected.add(vcs) + + return sorted(detected) + + +# --------------------------------------------------------------------------- +# systemDirectories.ts +# --------------------------------------------------------------------------- + +@dataclass(frozen=True) +class SystemDirectories: + HOME: str + DESKTOP: str + DOCUMENTS: str + DOWNLOADS: str + + def to_dict(self) -> dict[str, str]: + return { + 'HOME': self.HOME, + 'DESKTOP': self.DESKTOP, + 'DOCUMENTS': self.DOCUMENTS, + 'DOWNLOADS': self.DOWNLOADS, + } + + +def get_system_directories( + *, + env: dict[str, str] | None = None, + home_dir: str | None = None, + platform: Platform | None = None, +) -> SystemDirectories: + """Cross-platform system directories matching ``getSystemDirectories``.""" + chosen_platform: Platform = platform if platform is not None else get_platform() + chosen_home = home_dir if home_dir is not None else str(Path.home()) + chosen_env = env if env is not None else dict(os.environ) + + defaults = SystemDirectories( + HOME=chosen_home, + DESKTOP=str(Path(chosen_home) / 'Desktop'), + DOCUMENTS=str(Path(chosen_home) / 'Documents'), + DOWNLOADS=str(Path(chosen_home) / 'Downloads'), + ) + + if chosen_platform == 'windows': + user_profile = chosen_env.get('USERPROFILE') or chosen_home + return SystemDirectories( + HOME=chosen_home, + DESKTOP=str(Path(user_profile) / 'Desktop'), + DOCUMENTS=str(Path(user_profile) / 'Documents'), + DOWNLOADS=str(Path(user_profile) / 'Downloads'), + ) + + if chosen_platform in ('linux', 'wsl'): + return SystemDirectories( + HOME=chosen_home, + DESKTOP=chosen_env.get('XDG_DESKTOP_DIR') or defaults.DESKTOP, + DOCUMENTS=chosen_env.get('XDG_DOCUMENTS_DIR') or defaults.DOCUMENTS, + DOWNLOADS=chosen_env.get('XDG_DOWNLOAD_DIR') or defaults.DOWNLOADS, + ) + + # macOS and unknown both use the defaults. + return defaults + + +__all__ = [ + 'Platform', + 'SUPPORTED_PLATFORMS', + 'get_platform', + 'get_wsl_version', + 'LinuxDistroInfo', + 'get_linux_distro_info', + 'detect_vcs', + 'SystemDirectories', + 'get_system_directories', +] diff --git a/tests/test_platform_info.py b/tests/test_platform_info.py new file mode 100644 index 0000000..e80540e --- /dev/null +++ b/tests/test_platform_info.py @@ -0,0 +1,210 @@ +"""Tests for ``src/platform_info.py`` — platform detection and system dirs.""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from src import platform_info +from src.platform_info import ( + SUPPORTED_PLATFORMS, + LinuxDistroInfo, + SystemDirectories, + detect_vcs, + get_linux_distro_info, + get_platform, + get_system_directories, + get_wsl_version, +) + + +class GetPlatformTest(unittest.TestCase): + def setUp(self) -> None: + platform_info._reset_cache() + + def tearDown(self) -> None: + platform_info._reset_cache() + + def test_macos(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertEqual(get_platform(), 'macos') + + def test_windows(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'win32'): + self.assertEqual(get_platform(), 'windows') + + def test_linux_no_wsl(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 5.10 (gcc)', + ): + self.assertEqual(get_platform(), 'linux') + + def test_linux_wsl_microsoft_marker(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 5.10 microsoft-standard-WSL2', + ): + self.assertEqual(get_platform(), 'wsl') + + def test_linux_proc_version_unreadable(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + side_effect=FileNotFoundError(), + ): + self.assertEqual(get_platform(), 'linux') + + def test_unknown_platform(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'sunos5'): + self.assertEqual(get_platform(), 'unknown') + + def test_memoized(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertEqual(get_platform(), 'macos') + # Second call should hit cache, not re-evaluate sys.platform + with mock.patch('src.platform_info.sys.platform', 'win32'): + self.assertEqual(get_platform(), 'macos') + + def test_supported_platforms_contains_expected(self) -> None: + self.assertIn('macos', SUPPORTED_PLATFORMS) + self.assertIn('wsl', SUPPORTED_PLATFORMS) + + +class GetWslVersionTest(unittest.TestCase): + def setUp(self) -> None: + platform_info._reset_cache() + + def tearDown(self) -> None: + platform_info._reset_cache() + + def test_explicit_wsl2(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='5.15.123-microsoft-standard-WSL2', + ): + self.assertEqual(get_wsl_version(), '2') + + def test_wsl1_fallback(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='4.4.0-19041-Microsoft (Microsoft@Microsoft.com)', + ): + self.assertEqual(get_wsl_version(), '1') + + def test_non_linux(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertIsNone(get_wsl_version()) + + def test_linux_no_microsoft_marker(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info._read_proc_version', + return_value='Linux version 6.5.0 (gcc)', + ): + self.assertIsNone(get_wsl_version()) + + +class GetLinuxDistroInfoTest(unittest.TestCase): + def test_non_linux_returns_none(self) -> None: + with mock.patch('src.platform_info.sys.platform', 'darwin'): + self.assertIsNone(get_linux_distro_info()) + + def test_parses_id_and_version(self) -> None: + os_release = 'NAME="Ubuntu"\nID=ubuntu\nVERSION_ID="22.04"\n' + with mock.patch('src.platform_info.sys.platform', 'linux'), \ + mock.patch( + 'src.platform_info.Path.read_text', return_value=os_release, + ): + info = get_linux_distro_info() + assert info is not None + self.assertEqual(info.linux_distro_id, 'ubuntu') + self.assertEqual(info.linux_distro_version, '22.04') + self.assertIsNotNone(info.linux_kernel) + + def test_to_dict_skips_none(self) -> None: + info = LinuxDistroInfo(linux_distro_id='fedora') + self.assertEqual(info.to_dict(), {'linuxDistroId': 'fedora'}) + + +class DetectVcsTest(unittest.TestCase): + def test_detects_git(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + self.assertEqual(detect_vcs(tmp), ['git']) + + def test_detects_multiple(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + (Path(tmp) / '.hg').mkdir() + self.assertEqual(detect_vcs(tmp), ['git', 'mercurial']) + + def test_perforce_via_env(self) -> None: + with tempfile.TemporaryDirectory() as tmp, \ + mock.patch.dict(os.environ, {'P4PORT': '1666'}): + self.assertIn('perforce', detect_vcs(tmp)) + + def test_unreadable_directory_returns_empty(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(detect_vcs('/nonexistent/path/abc/xyz'), []) + + +class GetSystemDirectoriesTest(unittest.TestCase): + def test_macos_defaults(self) -> None: + dirs = get_system_directories( + home_dir='/Users/x', platform='macos', env={}, + ) + self.assertEqual(dirs.HOME, '/Users/x') + self.assertEqual(dirs.DESKTOP, '/Users/x/Desktop') + self.assertEqual(dirs.DOCUMENTS, '/Users/x/Documents') + self.assertEqual(dirs.DOWNLOADS, '/Users/x/Downloads') + + def test_windows_uses_userprofile(self) -> None: + dirs = get_system_directories( + home_dir='C:/Users/old', + platform='windows', + env={'USERPROFILE': 'C:/Users/new'}, + ) + # Path normalizes to forward slashes on linux test runs; just check + # USERPROFILE was used as the base, not home_dir. + self.assertIn('Users/new', dirs.DESKTOP.replace('\\', '/')) + self.assertIn('Users/new', dirs.DOWNLOADS.replace('\\', '/')) + # HOME stays as the explicit home_dir + self.assertEqual(dirs.HOME, 'C:/Users/old') + + def test_linux_xdg_overrides(self) -> None: + dirs = get_system_directories( + home_dir='/home/u', + platform='linux', + env={'XDG_DOWNLOAD_DIR': '/data/dl'}, + ) + self.assertEqual(dirs.DOWNLOADS, '/data/dl') + self.assertEqual(dirs.DESKTOP, '/home/u/Desktop') + + def test_wsl_xdg_overrides(self) -> None: + dirs = get_system_directories( + home_dir='/home/u', + platform='wsl', + env={'XDG_DESKTOP_DIR': '/mnt/c/Users/x/Desktop'}, + ) + self.assertEqual(dirs.DESKTOP, '/mnt/c/Users/x/Desktop') + + def test_to_dict_round_trip(self) -> None: + dirs = SystemDirectories( + HOME='/h', DESKTOP='/h/D', DOCUMENTS='/h/Doc', DOWNLOADS='/h/Dn', + ) + self.assertEqual( + dirs.to_dict(), + {'HOME': '/h', 'DESKTOP': '/h/D', 'DOCUMENTS': '/h/Doc', 'DOWNLOADS': '/h/Dn'}, + ) + + +if __name__ == '__main__': + unittest.main() From 1b98fb36fd08344836ce1f54ac6e92f42607e7ea Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:39:01 +0200 Subject: [PATCH 12/18] Implemented the next missing parity slice around portable git utilities. --- PARITY_CHECKLIST.md | 2 +- src/git_utils.py | 185 ++++++++++++++++++++++++++++++++++++++++ tests/test_git_utils.py | 164 +++++++++++++++++++++++++++++++++++ 3 files changed, 350 insertions(+), 1 deletion(-) create mode 100644 src/git_utils.py create mode 100644 tests/test_git_utils.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index bf832a0..9fc5d8e 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -711,7 +711,7 @@ Done: Missing major utility categories: - [ ] Shell utilities (`utils/bash/`, `utils/shell/`, `Shell.ts`, `ShellCommand.ts`) -- [ ] Git operations (`utils/git.ts`, `utils/gitDiff.ts`, `utils/gitSettings.ts`, `utils/commitAttribution.ts`) +- [ ] Git operations (`utils/git.ts`, `utils/gitDiff.ts`, `utils/gitSettings.ts`, `utils/commitAttribution.ts`) — partial: `findGitRoot`, `normalizeGitRemoteUrl`, `getRepoRemoteHash`, and `shouldIncludeGitInstructions` ported in `src/git_utils.py` - [ ] File operations (`utils/file.ts`, `utils/fileRead.ts`, `utils/fileHistory.ts`, `utils/fileStateCache.ts`, `utils/fsOperations.ts`, `utils/ripgrep.ts`, `utils/glob.ts`) - [ ] AI/Model utilities (`utils/modelCost.ts`, `utils/model/`, `utils/context.ts`, `utils/queryContext.ts`) — partial: modelCost ported in `src/model_cost.py` - [ ] Config/Settings (`utils/config.ts`, `utils/settings/`) diff --git a/src/git_utils.py b/src/git_utils.py new file mode 100644 index 0000000..86e8f80 --- /dev/null +++ b/src/git_utils.py @@ -0,0 +1,185 @@ +"""Git utility ports — subset of ``utils/git.ts`` plus ``utils/gitSettings.ts``. + +This module covers the pure / filesystem-only pieces: + +- ``find_git_root`` — walks up from ``start_path`` looking for ``.git`` +- ``normalize_git_remote_url`` — canonicalizes SSH/HTTPS remote URLs +- ``get_repo_remote_hash`` — sha256[:16] of the normalized remote URL +- ``should_include_git_instructions`` — env-var override + settings opt-out + +The shell-driven git operations (``getHead``, ``getBranch``, ``getDefaultBranch``, +``getChangedFiles``, etc.) are intentionally left for a later slice — they +need the full settings/cache plumbing the npm version uses. +""" + +from __future__ import annotations + +import hashlib +import os +import re +from collections import OrderedDict +from pathlib import Path +from typing import Callable + +# --------------------------------------------------------------------------- +# Tiny LRU helper that mirrors lodash memoizeWithLRU semantics +# --------------------------------------------------------------------------- + +def _lru_memoize( + fn: Callable[[str], str | None], max_size: int, +) -> Callable[[str], str | None]: + cache: OrderedDict[str, str | None] = OrderedDict() + + def wrapper(key: str) -> str | None: + if key in cache: + cache.move_to_end(key) + return cache[key] + value = fn(key) + cache[key] = value + cache.move_to_end(key) + if len(cache) > max_size: + cache.popitem(last=False) + return value + + wrapper.cache_clear = cache.clear # type: ignore[attr-defined] + return wrapper + + +# --------------------------------------------------------------------------- +# find_git_root +# --------------------------------------------------------------------------- + +def _find_git_root_uncached(start_path: str) -> str | None: + current = Path(start_path).resolve() + while True: + git_path = current / '.git' + try: + stat = git_path.stat() + if stat.st_mode and ( + git_path.is_dir() or git_path.is_file() + ): + return str(current) + except OSError: + pass + parent = current.parent + if parent == current: + return None + current = parent + + +find_git_root = _lru_memoize(_find_git_root_uncached, max_size=50) +"""Walk up from ``start_path`` to find the first directory containing ``.git``. + +Returns the absolute path of that directory, or ``None`` if not in a repo. +Memoized per ``start_path`` with an LRU cache (max 50 entries). +""" + + +# --------------------------------------------------------------------------- +# normalize_git_remote_url +# --------------------------------------------------------------------------- + +_SSH_RE = re.compile(r'^git@([^:]+):(.+?)(?:\.git)?$') +_URL_RE = re.compile( + r'^(?:https?|ssh)://(?:[^@]+@)?([^/]+)/(.+?)(?:\.git)?$', +) +_LOCAL_HOST_IPV4 = re.compile(r'^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$') + + +def _is_local_host(host: str) -> bool: + host_no_port = host.split(':', 1)[0] + return host_no_port == 'localhost' or bool(_LOCAL_HOST_IPV4.match(host_no_port)) + + +def normalize_git_remote_url(url: str) -> str | None: + """Canonicalize a git remote URL to ``host/owner/repo`` lowercased. + + Returns ``None`` if the URL doesn't match a recognized SSH/HTTPS shape. + """ + trimmed = url.strip() + if not trimmed: + return None + + ssh = _SSH_RE.match(trimmed) + if ssh: + return f'{ssh.group(1)}/{ssh.group(2)}'.lower() + + url_match = _URL_RE.match(trimmed) + if url_match: + host = url_match.group(1) + path = url_match.group(2) + + # CCR git proxy: http://...@127.0.0.1:PORT/git/[host/]owner/repo + if _is_local_host(host) and path.startswith('git/'): + proxy_path = path[len('git/'):] + segments = proxy_path.split('/') + if len(segments) >= 3 and '.' in segments[0]: + return proxy_path.lower() + return f'github.com/{proxy_path}'.lower() + + return f'{host}/{path}'.lower() + + return None + + +def get_repo_remote_hash(remote_url: str | None) -> str | None: + """Return sha256[:16] of the normalized remote URL, or None. + + Unlike the npm version this takes the URL as a parameter rather than + invoking ``git remote get-url`` itself, so it can be called from + contexts where git binary access is unavailable. + """ + if not remote_url: + return None + normalized = normalize_git_remote_url(remote_url) + if not normalized: + return None + return hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:16] + + +# --------------------------------------------------------------------------- +# gitSettings.ts — env-var + settings opt-out for git instructions +# --------------------------------------------------------------------------- + +_TRUTHY_ENV = frozenset({'1', 'true', 'yes', 'on'}) +_FALSY_ENV = frozenset({'0', 'false', 'no', 'off'}) + + +def _env_truthy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in _TRUTHY_ENV + + +def _env_defined_falsy(value: str | None) -> bool: + if value is None: + return False + return value.strip().lower() in _FALSY_ENV + + +def should_include_git_instructions( + *, + settings_value: bool | None = None, + env: dict[str, str] | None = None, +) -> bool: + """Whether to surface git-aware prompt sections. + + Mirrors ``utils/gitSettings.ts``: env var + ``CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS`` overrides + ``settings.includeGitInstructions``; default is ``True``. + """ + chosen_env = env if env is not None else os.environ + raw = chosen_env.get('CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS') + if _env_truthy(raw): + return False + if _env_defined_falsy(raw): + return True + return True if settings_value is None else settings_value + + +__all__ = [ + 'find_git_root', + 'normalize_git_remote_url', + 'get_repo_remote_hash', + 'should_include_git_instructions', +] diff --git a/tests/test_git_utils.py b/tests/test_git_utils.py new file mode 100644 index 0000000..b92912d --- /dev/null +++ b/tests/test_git_utils.py @@ -0,0 +1,164 @@ +"""Tests for ``src/git_utils.py``.""" + +from __future__ import annotations + +import hashlib +import tempfile +import unittest +from pathlib import Path + +from src import git_utils +from src.git_utils import ( + find_git_root, + get_repo_remote_hash, + normalize_git_remote_url, + should_include_git_instructions, +) + + +class FindGitRootTest(unittest.TestCase): + def setUp(self) -> None: + find_git_root.cache_clear() + + def test_finds_git_directory(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + self.assertEqual(find_git_root(tmp), str(Path(tmp).resolve())) + + def test_walks_up_from_subdirectory(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + sub = Path(tmp) / 'a' / 'b' / 'c' + sub.mkdir(parents=True) + self.assertEqual(find_git_root(str(sub)), str(Path(tmp).resolve())) + + def test_finds_when_git_is_a_file(self) -> None: + # Worktrees and submodules use a .git file containing 'gitdir: ...' + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').write_text('gitdir: /elsewhere/.git/worktrees/x') + self.assertEqual(find_git_root(tmp), str(Path(tmp).resolve())) + + def test_returns_none_when_not_in_repo(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + self.assertIsNone(find_git_root(tmp)) + + def test_memoizes(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + (Path(tmp) / '.git').mkdir() + first = find_git_root(tmp) + # Now remove .git — second lookup should still hit cache + (Path(tmp) / '.git').rmdir() + self.assertEqual(find_git_root(tmp), first) + + +class NormalizeGitRemoteUrlTest(unittest.TestCase): + def test_ssh_url(self) -> None: + self.assertEqual( + normalize_git_remote_url('git@github.com:org/repo.git'), + 'github.com/org/repo', + ) + + def test_ssh_url_no_dot_git_suffix(self) -> None: + self.assertEqual( + normalize_git_remote_url('git@github.com:org/repo'), + 'github.com/org/repo', + ) + + def test_https_url(self) -> None: + self.assertEqual( + normalize_git_remote_url('https://github.com/org/repo.git'), + 'github.com/org/repo', + ) + + def test_https_with_user(self) -> None: + self.assertEqual( + normalize_git_remote_url('https://user@github.com/org/repo.git'), + 'github.com/org/repo', + ) + + def test_ssh_protocol_url(self) -> None: + self.assertEqual( + normalize_git_remote_url('ssh://git@github.com/org/repo.git'), + 'github.com/org/repo', + ) + + def test_lowercases_result(self) -> None: + self.assertEqual( + normalize_git_remote_url('git@GitHub.com:Org/Repo.git'), + 'github.com/org/repo', + ) + + def test_ccr_proxy_legacy_assumes_github(self) -> None: + self.assertEqual( + normalize_git_remote_url('http://x@127.0.0.1:8080/git/org/repo'), + 'github.com/org/repo', + ) + + def test_ccr_proxy_ghe_uses_first_segment_as_host(self) -> None: + self.assertEqual( + normalize_git_remote_url( + 'http://x@127.0.0.1:8080/git/ghe.example.com/org/repo', + ), + 'ghe.example.com/org/repo', + ) + + def test_returns_none_on_garbage(self) -> None: + self.assertIsNone(normalize_git_remote_url('')) + self.assertIsNone(normalize_git_remote_url(' ')) + self.assertIsNone(normalize_git_remote_url('not-a-url')) + + def test_localhost_alias(self) -> None: + self.assertEqual( + normalize_git_remote_url('http://localhost:8080/git/org/repo'), + 'github.com/org/repo', + ) + + +class GetRepoRemoteHashTest(unittest.TestCase): + def test_hashes_normalized_url(self) -> None: + url = 'git@github.com:Org/Repo.git' + normalized = 'github.com/org/repo' + expected = hashlib.sha256(normalized.encode('utf-8')).hexdigest()[:16] + self.assertEqual(get_repo_remote_hash(url), expected) + + def test_returns_none_for_empty(self) -> None: + self.assertIsNone(get_repo_remote_hash(None)) + self.assertIsNone(get_repo_remote_hash('')) + + def test_returns_none_for_unparseable(self) -> None: + self.assertIsNone(get_repo_remote_hash('not-a-url')) + + +class ShouldIncludeGitInstructionsTest(unittest.TestCase): + def test_default_true_when_nothing_set(self) -> None: + self.assertTrue(should_include_git_instructions(env={})) + + def test_env_truthy_disables(self) -> None: + for value in ('1', 'true', 'yes', 'on'): + self.assertFalse( + should_include_git_instructions( + settings_value=True, + env={'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS': value}, + ), + ) + + def test_env_falsy_overrides_settings(self) -> None: + # Settings says off, but env explicitly says don't disable → on + self.assertTrue( + should_include_git_instructions( + settings_value=False, + env={'CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS': '0'}, + ), + ) + + def test_settings_value_used_when_env_unset(self) -> None: + self.assertFalse( + should_include_git_instructions(settings_value=False, env={}), + ) + self.assertTrue( + should_include_git_instructions(settings_value=True, env={}), + ) + + +if __name__ == '__main__': + unittest.main() From dc8687464157083ee94929a33ec2d0ea06a108a1 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:40:05 +0200 Subject: [PATCH 13/18] =?UTF-8?q?Implemented=20the=20next=20missing=20pari?= =?UTF-8?q?ty=20slice=20around=20IDE=20path=20conversion=20(Windows=20?= =?UTF-8?q?=E2=86=94=20WSL).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PARITY_CHECKLIST.md | 2 +- src/ide_path_conversion.py | 91 ++++++++++++++++++++ tests/test_ide_path_conversion.py | 133 ++++++++++++++++++++++++++++++ 3 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 src/ide_path_conversion.py create mode 100644 tests/test_ide_path_conversion.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 9fc5d8e..915cab8 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -720,7 +720,7 @@ Missing major utility categories: - [ ] Session management (`utils/sessionStorage.ts`, `utils/sessionState.ts`, `utils/sessionStart.ts`, `utils/sessionRestore.ts`) - [ ] Plugin/Skill utilities (`utils/plugins/`, `utils/skills/`) - [ ] Memory/Context (`utils/memory/`, `utils/claudemd.ts`, `utils/contextAnalysis.ts`) -- [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) +- [ ] IDE integration (`utils/ide.ts`, `utils/jetbrains.ts`) — partial: `utils/idePathConversion.ts` ported in `src/ide_path_conversion.py` (`WindowsToWSLConverter`, `checkWSLDistroMatch`) - [ ] Platform/OS (`utils/platform.ts`, `utils/terminal.ts`, `utils/systemDirectories.ts`) — partial: platform detection (`getPlatform`, `getWslVersion`, `getLinuxDistroInfo`, `detectVcs`) and `getSystemDirectories` ported in `src/platform_info.py` - [ ] Debugging (`utils/debug.ts`, `utils/diagLogs.ts`, `utils/log.ts`, `utils/profilerBase.ts`) - [ ] Telemetry (`utils/telemetry/`) diff --git a/src/ide_path_conversion.py b/src/ide_path_conversion.py new file mode 100644 index 0000000..c91af59 --- /dev/null +++ b/src/ide_path_conversion.py @@ -0,0 +1,91 @@ +"""IDE path conversion — Python port of ``utils/idePathConversion.ts``. + +Used when Claude runs under WSL but the IDE (VS Code, JetBrains) is on the +host Windows side. Outgoing paths need to be converted to ``\\\\wsl$\\...`` +form for the IDE; incoming paths from the IDE need to be converted back to +``/mnt/c/...`` form for Claude. +""" + +from __future__ import annotations + +import re +import subprocess +from typing import Protocol + + +_WSL_UNC_RE = re.compile(r'^\\\\wsl(?:\.localhost|\$)\\([^\\]+)(.*)$') +_DRIVE_RE = re.compile(r'^([A-Za-z]):') + + +class IDEPathConverter(Protocol): + """Bidirectional path mapping between IDE-side and Claude-side paths.""" + + def to_local_path(self, ide_path: str) -> str: ... + + def to_ide_path(self, local_path: str) -> str: ... + + +def _run_wslpath(flag: str, path: str) -> str: + """Invoke ``wslpath`` and return the stripped stdout. Raises on failure.""" + completed = subprocess.run( + ['wslpath', flag, path], + capture_output=True, + text=True, + check=True, + ) + return completed.stdout.strip() + + +def _manual_windows_to_wsl(windows_path: str) -> str: + """Fallback when ``wslpath`` is unavailable: ``C:\\foo`` → ``/mnt/c/foo``.""" + converted = windows_path.replace('\\', '/') + + def _replace_drive(match: re.Match[str]) -> str: + return f'/mnt/{match.group(1).lower()}' + + return _DRIVE_RE.sub(_replace_drive, converted) + + +class WindowsToWSLConverter: + """Converter for the Windows IDE + WSL Claude scenario.""" + + def __init__(self, wsl_distro_name: str | None) -> None: + self.wsl_distro_name = wsl_distro_name + + def to_local_path(self, windows_path: str) -> str: + if not windows_path: + return windows_path + + if self.wsl_distro_name: + unc = _WSL_UNC_RE.match(windows_path) + if unc and unc.group(1) != self.wsl_distro_name: + # Path belongs to a different distro — wslpath would fail. + return windows_path + + try: + return _run_wslpath('-u', windows_path) + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return _manual_windows_to_wsl(windows_path) + + def to_ide_path(self, wsl_path: str) -> str: + if not wsl_path: + return wsl_path + try: + return _run_wslpath('-w', wsl_path) + except (subprocess.CalledProcessError, FileNotFoundError, OSError): + return wsl_path + + +def check_wsl_distro_match(windows_path: str, wsl_distro_name: str) -> bool: + """True if ``windows_path`` isn't a WSL UNC path or names this distro.""" + unc = _WSL_UNC_RE.match(windows_path) + if unc: + return unc.group(1) == wsl_distro_name + return True + + +__all__ = [ + 'IDEPathConverter', + 'WindowsToWSLConverter', + 'check_wsl_distro_match', +] diff --git a/tests/test_ide_path_conversion.py b/tests/test_ide_path_conversion.py new file mode 100644 index 0000000..8996832 --- /dev/null +++ b/tests/test_ide_path_conversion.py @@ -0,0 +1,133 @@ +"""Tests for ``src/ide_path_conversion.py``.""" + +from __future__ import annotations + +import subprocess +import unittest +from unittest import mock + +from src.ide_path_conversion import ( + WindowsToWSLConverter, + check_wsl_distro_match, +) + + +class CheckWslDistroMatchTest(unittest.TestCase): + def test_matches_named_distro(self) -> None: + self.assertTrue( + check_wsl_distro_match(r'\\wsl$\Ubuntu\home\me', 'Ubuntu'), + ) + + def test_matches_localhost_form(self) -> None: + self.assertTrue( + check_wsl_distro_match( + r'\\wsl.localhost\Ubuntu\home\me', 'Ubuntu', + ), + ) + + def test_mismatch(self) -> None: + self.assertFalse( + check_wsl_distro_match(r'\\wsl$\Debian\home\me', 'Ubuntu'), + ) + + def test_non_unc_path_returns_true(self) -> None: + self.assertTrue(check_wsl_distro_match(r'C:\Users\me', 'Ubuntu')) + + +class WindowsToWSLConverterToLocalPathTest(unittest.TestCase): + def test_empty_path_passthrough(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + self.assertEqual(conv.to_local_path(''), '') + + def test_uses_wslpath_when_available(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout='/mnt/c/Users/me\n', stderr='', + ), + ) as run: + self.assertEqual(conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me') + run.assert_called_once() + args = run.call_args[0][0] + self.assertEqual(args, ['wslpath', '-u', r'C:\Users\me']) + + def test_falls_back_to_manual_when_wslpath_missing(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=FileNotFoundError(), + ): + self.assertEqual( + conv.to_local_path(r'C:\Users\me'), '/mnt/c/Users/me', + ) + + def test_falls_back_to_manual_on_called_process_error(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=subprocess.CalledProcessError(1, 'wslpath'), + ): + self.assertEqual( + conv.to_local_path(r'D:\path\to\file.txt'), + '/mnt/d/path/to/file.txt', + ) + + def test_different_distro_path_returned_as_is(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + ) as run: + self.assertEqual( + conv.to_local_path(r'\\wsl$\Debian\home\me'), + r'\\wsl$\Debian\home\me', + ) + run.assert_not_called() + + def test_same_distro_unc_uses_wslpath(self) -> None: + conv = WindowsToWSLConverter('Ubuntu') + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout='/home/me\n', stderr='', + ), + ) as run: + self.assertEqual( + conv.to_local_path(r'\\wsl$\Ubuntu\home\me'), + '/home/me', + ) + run.assert_called_once() + + +class WindowsToWSLConverterToIdePathTest(unittest.TestCase): + def test_empty_passthrough(self) -> None: + conv = WindowsToWSLConverter(None) + self.assertEqual(conv.to_ide_path(''), '') + + def test_uses_wslpath(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + return_value=subprocess.CompletedProcess( + args=[], returncode=0, stdout=r'\\wsl$\Ubuntu\home\me' + '\n', + stderr='', + ), + ) as run: + self.assertEqual( + conv.to_ide_path('/home/me'), r'\\wsl$\Ubuntu\home\me', + ) + run.assert_called_once() + args = run.call_args[0][0] + self.assertEqual(args, ['wslpath', '-w', '/home/me']) + + def test_returns_original_on_failure(self) -> None: + conv = WindowsToWSLConverter(None) + with mock.patch( + 'src.ide_path_conversion.subprocess.run', + side_effect=FileNotFoundError(), + ): + self.assertEqual(conv.to_ide_path('/home/me'), '/home/me') + + +if __name__ == '__main__': + unittest.main() From ce59ead1cec76326ce6889885a737aeb96c1458e Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:42:39 +0200 Subject: [PATCH 14/18] Implemented the next missing parity slice around session-scoped environment variables. --- PARITY_CHECKLIST.md | 1 + src/session_env_vars.py | 46 +++++++++++++++++++++++++ tests/test_session_env_vars.py | 63 ++++++++++++++++++++++++++++++++++ 3 files changed, 110 insertions(+) create mode 100644 src/session_env_vars.py create mode 100644 tests/test_session_env_vars.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 915cab8..1e7c4a4 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -707,6 +707,7 @@ Done: - [x] Basic git status snapshot - [x] Basic shell/subprocess handling - [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` +- [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` Missing major utility categories: diff --git a/src/session_env_vars.py b/src/session_env_vars.py new file mode 100644 index 0000000..9d97e23 --- /dev/null +++ b/src/session_env_vars.py @@ -0,0 +1,46 @@ +"""Session-scoped environment variables — Python port of +``utils/sessionEnvVars.ts``. + +These are env vars set during a session (via the upstream ``/env`` slash +command in npm) and applied only to spawned child processes — not to the +host Python REPL/agent process itself. Bash and similar tool providers +read this map to merge into ``subprocess`` environments. + +Mirrors the upstream module-level singleton: callers import the helpers +directly rather than passing a registry around. +""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType + +_session_env_vars: dict[str, str] = {} + + +def get_session_env_vars() -> Mapping[str, str]: + """Return a read-only view of the current session env vars.""" + return MappingProxyType(_session_env_vars) + + +def set_session_env_var(name: str, value: str) -> None: + """Set ``name=value`` for the rest of this session's child processes.""" + _session_env_vars[name] = value + + +def delete_session_env_var(name: str) -> None: + """Remove ``name`` from the session env (no-op if absent).""" + _session_env_vars.pop(name, None) + + +def clear_session_env_vars() -> None: + """Drop every session-scoped env var.""" + _session_env_vars.clear() + + +__all__ = [ + 'get_session_env_vars', + 'set_session_env_var', + 'delete_session_env_var', + 'clear_session_env_vars', +] diff --git a/tests/test_session_env_vars.py b/tests/test_session_env_vars.py new file mode 100644 index 0000000..062798f --- /dev/null +++ b/tests/test_session_env_vars.py @@ -0,0 +1,63 @@ +"""Tests for ``src/session_env_vars.py``.""" + +from __future__ import annotations + +import unittest + +from src.session_env_vars import ( + clear_session_env_vars, + delete_session_env_var, + get_session_env_vars, + set_session_env_var, +) + + +class SessionEnvVarsTest(unittest.TestCase): + def setUp(self) -> None: + clear_session_env_vars() + + def tearDown(self) -> None: + clear_session_env_vars() + + def test_starts_empty(self) -> None: + self.assertEqual(dict(get_session_env_vars()), {}) + + def test_set_and_get(self) -> None: + set_session_env_var('FOO', 'bar') + self.assertEqual(get_session_env_vars()['FOO'], 'bar') + + def test_set_overwrites_existing(self) -> None: + set_session_env_var('FOO', 'one') + set_session_env_var('FOO', 'two') + self.assertEqual(get_session_env_vars()['FOO'], 'two') + + def test_delete_removes(self) -> None: + set_session_env_var('FOO', 'bar') + delete_session_env_var('FOO') + self.assertNotIn('FOO', get_session_env_vars()) + + def test_delete_missing_is_noop(self) -> None: + delete_session_env_var('NEVER_SET') + self.assertEqual(dict(get_session_env_vars()), {}) + + def test_clear_drops_everything(self) -> None: + set_session_env_var('A', '1') + set_session_env_var('B', '2') + clear_session_env_vars() + self.assertEqual(dict(get_session_env_vars()), {}) + + def test_returned_mapping_is_read_only(self) -> None: + set_session_env_var('FOO', 'bar') + view = get_session_env_vars() + with self.assertRaises(TypeError): + view['FOO'] = 'mutated' # type: ignore[index] + + def test_view_reflects_subsequent_mutations(self) -> None: + view = get_session_env_vars() + self.assertNotIn('FOO', view) + set_session_env_var('FOO', 'bar') + self.assertEqual(view['FOO'], 'bar') + + +if __name__ == '__main__': + unittest.main() From 66920a33602fc41ec775c932136467834e82d7a4 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:43:56 +0200 Subject: [PATCH 15/18] Implemented the next missing parity slice around display formatters. --- PARITY_CHECKLIST.md | 1 + src/format_utils.py | 147 +++++++++++++++++++++++++++++++++++++ tests/test_format_utils.py | 114 ++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) create mode 100644 src/format_utils.py create mode 100644 tests/test_format_utils.py diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 1e7c4a4..528e6af 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -708,6 +708,7 @@ Done: - [x] Basic shell/subprocess handling - [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` - [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` +- [x] Display formatters from `utils/format.ts` (`formatFileSize`, `formatSecondsShort`, `formatDuration`, `formatNumber`, `formatTokens`) ported in `src/format_utils.py` Missing major utility categories: diff --git a/src/format_utils.py b/src/format_utils.py new file mode 100644 index 0000000..1c4361d --- /dev/null +++ b/src/format_utils.py @@ -0,0 +1,147 @@ +"""Display formatters — Python port of pure helpers from ``utils/format.ts``. + +Only the leaf-safe formatters are ported here (no Intl dependencies, no +Ink-specific layout). These mirror the upstream output exactly so existing +golden snapshots and tests of formatted strings stay aligned. + +Ported: +- ``format_file_size`` — bytes → ``"1.5KB"`` / ``"2MB"`` / ``"3.4GB"`` +- ``format_seconds_short`` — ms → ``"1.2s"`` +- ``format_duration`` — ms → ``"3h 4m 5s"`` with hide/most-significant flags +- ``format_number`` — compact notation (``"1.3k"``, ``"2.5m"``) +- ``format_tokens`` — like ``format_number`` but trims trailing ``.0`` + +Not ported (stay in TypeScript-only paths): +``formatRelativeTime`` / ``formatRelativeTimeAgo`` / ``formatLogMetadata`` +/ ``formatResetTime`` / ``formatResetText`` — they depend on +``intl.ts`` and the Ink reset-time UX. +""" + +from __future__ import annotations + + +def _trim_trailing_zero(value: str) -> str: + return value[:-2] if value.endswith('.0') else value + + +def format_file_size(size_in_bytes: float) -> str: + """Bytes to a human-readable string, mirroring the JS thresholds.""" + kb = size_in_bytes / 1024 + if kb < 1: + return f'{int(size_in_bytes)} bytes' + if kb < 1024: + return f'{_trim_trailing_zero(f"{kb:.1f}")}KB' + mb = kb / 1024 + if mb < 1024: + return f'{_trim_trailing_zero(f"{mb:.1f}")}MB' + gb = mb / 1024 + return f'{_trim_trailing_zero(f"{gb:.1f}")}GB' + + +def format_seconds_short(ms: float) -> str: + """Milliseconds → ``"1.2s"`` (always one decimal).""" + return f'{ms / 1000:.1f}s' + + +def format_duration( + ms: float, + *, + hide_trailing_zeros: bool = False, + most_significant_only: bool = False, +) -> str: + """Format a millisecond duration with d/h/m/s components. + + Mirrors ``utils/format.ts#formatDuration`` including the rounding + carry-over (``59.5s`` rounds up to the next minute). + """ + if ms < 60_000: + if ms == 0: + return '0s' + if ms < 1: + return f'{ms / 1000:.1f}s' + return f'{int(ms // 1000)}s' + + days = int(ms // 86_400_000) + hours = int((ms % 86_400_000) // 3_600_000) + minutes = int((ms % 3_600_000) // 60_000) + seconds = int(round((ms % 60_000) / 1000)) + + if seconds == 60: + seconds = 0 + minutes += 1 + if minutes == 60: + minutes = 0 + hours += 1 + if hours == 24: + hours = 0 + days += 1 + + if most_significant_only: + if days > 0: + return f'{days}d' + if hours > 0: + return f'{hours}h' + if minutes > 0: + return f'{minutes}m' + return f'{seconds}s' + + hide = hide_trailing_zeros + + if days > 0: + if hide and hours == 0 and minutes == 0: + return f'{days}d' + if hide and minutes == 0: + return f'{days}d {hours}h' + return f'{days}d {hours}h {minutes}m' + if hours > 0: + if hide and minutes == 0 and seconds == 0: + return f'{hours}h' + if hide and seconds == 0: + return f'{hours}h {minutes}m' + return f'{hours}h {minutes}m {seconds}s' + if minutes > 0: + if hide and seconds == 0: + return f'{minutes}m' + return f'{minutes}m {seconds}s' + return f'{seconds}s' + + +_COMPACT_SUFFIXES = ( + (1_000_000_000_000, 't'), + (1_000_000_000, 'b'), + (1_000_000, 'm'), + (1_000, 'k'), +) + + +def format_number(number: float) -> str: + """Compact notation matching ``Intl.NumberFormat`` with one fraction digit. + + The npm version emits e.g. ``"1.3k"`` from 1321 and ``"900"`` from 900. + For values < 1000 the integer is returned with no separator. For larger + values one fraction digit is shown when ``number >= 1000`` to mirror the + ``minimumFractionDigits: 1`` consistent-decimal branch. + """ + if number < 1000: + return str(int(number)) + + for threshold, suffix in _COMPACT_SUFFIXES: + if number >= threshold: + scaled = number / threshold + return f'{scaled:.1f}{suffix}' + + return str(int(number)) + + +def format_tokens(count: float) -> str: + """Like ``format_number`` but trims a trailing ``.0`` (e.g. ``"1k"``).""" + return format_number(count).replace('.0', '') + + +__all__ = [ + 'format_file_size', + 'format_seconds_short', + 'format_duration', + 'format_number', + 'format_tokens', +] diff --git a/tests/test_format_utils.py b/tests/test_format_utils.py new file mode 100644 index 0000000..54bd19f --- /dev/null +++ b/tests/test_format_utils.py @@ -0,0 +1,114 @@ +"""Tests for ``src/format_utils.py``.""" + +from __future__ import annotations + +import unittest + +from src.format_utils import ( + format_duration, + format_file_size, + format_number, + format_seconds_short, + format_tokens, +) + + +class FormatFileSizeTest(unittest.TestCase): + def test_bytes(self) -> None: + self.assertEqual(format_file_size(0), '0 bytes') + self.assertEqual(format_file_size(512), '512 bytes') + + def test_kb_with_decimal(self) -> None: + self.assertEqual(format_file_size(1536), '1.5KB') + + def test_kb_trims_trailing_zero(self) -> None: + self.assertEqual(format_file_size(2048), '2KB') + + def test_mb(self) -> None: + self.assertEqual(format_file_size(5 * 1024 * 1024), '5MB') + + def test_gb(self) -> None: + self.assertEqual(format_file_size(2 * 1024 * 1024 * 1024), '2GB') + + +class FormatSecondsShortTest(unittest.TestCase): + def test_basic(self) -> None: + self.assertEqual(format_seconds_short(1234), '1.2s') + + def test_under_one(self) -> None: + self.assertEqual(format_seconds_short(450), '0.5s') + + +class FormatDurationTest(unittest.TestCase): + def test_zero(self) -> None: + self.assertEqual(format_duration(0), '0s') + + def test_sub_second(self) -> None: + self.assertEqual(format_duration(0.5), '0.0s') + + def test_seconds_only(self) -> None: + self.assertEqual(format_duration(5_000), '5s') + + def test_minutes_seconds(self) -> None: + self.assertEqual(format_duration(125_000), '2m 5s') + + def test_hours(self) -> None: + self.assertEqual(format_duration(3_725_000), '1h 2m 5s') + + def test_days(self) -> None: + # 1d 2h 3m + ms = 86_400_000 + 2 * 3_600_000 + 3 * 60_000 + 0 + self.assertEqual(format_duration(ms), '1d 2h 3m') + + def test_hide_trailing_zeros_minutes(self) -> None: + self.assertEqual( + format_duration(120_000, hide_trailing_zeros=True), '2m', + ) + + def test_hide_trailing_zeros_hours(self) -> None: + self.assertEqual( + format_duration(3_600_000, hide_trailing_zeros=True), '1h', + ) + + def test_most_significant_only_picks_largest_unit(self) -> None: + self.assertEqual(format_duration(125_000, most_significant_only=True), '2m') + self.assertEqual( + format_duration(3_725_000, most_significant_only=True), '1h', + ) + + def test_rounding_carry_over(self) -> None: + # 59,500 ms rounds seconds=60 → carries to 1m 0s + self.assertEqual(format_duration(59_500 + 60_000), '2m 0s') + + +class FormatNumberTest(unittest.TestCase): + def test_below_thousand(self) -> None: + self.assertEqual(format_number(900), '900') + self.assertEqual(format_number(0), '0') + + def test_thousands_with_decimal(self) -> None: + self.assertEqual(format_number(1321), '1.3k') + + def test_thousand_keeps_decimal(self) -> None: + self.assertEqual(format_number(1000), '1.0k') + + def test_millions(self) -> None: + self.assertEqual(format_number(2_500_000), '2.5m') + + def test_billions(self) -> None: + self.assertEqual(format_number(3_700_000_000), '3.7b') + + +class FormatTokensTest(unittest.TestCase): + def test_trims_decimal_zero(self) -> None: + self.assertEqual(format_tokens(1000), '1k') + + def test_keeps_meaningful_decimal(self) -> None: + self.assertEqual(format_tokens(1321), '1.3k') + + def test_below_thousand(self) -> None: + self.assertEqual(format_tokens(450), '450') + + +if __name__ == '__main__': + unittest.main() From 72e6fffb341d9daca76678bc0f3d4334bb200eb8 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:51:11 +0200 Subject: [PATCH 16/18] Implemented the next missing parity slice around session env var subprocess merging. Wires session_env_vars.py into agent_tools._build_subprocess_env so that variables set via the session registry now reach spawned children, matching utils/shell/bashProvider.ts:249. Per-call extra_env still wins so explicit tool overrides take precedence over session defaults. --- PARITY_CHECKLIST.md | 2 +- src/agent_tools.py | 6 +++++ tests/test_agent_tools_security.py | 35 ++++++++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 528e6af..284b437 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -707,7 +707,7 @@ Done: - [x] Basic git status snapshot - [x] Basic shell/subprocess handling - [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` -- [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` +- [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` and merged into spawned subprocess env via `_build_subprocess_env` (mirrors `utils/shell/bashProvider.ts`) - [x] Display formatters from `utils/format.ts` (`formatFileSize`, `formatSecondsShort`, `formatDuration`, `formatNumber`, `formatTokens`) ported in `src/format_utils.py` Missing major utility categories: diff --git a/src/agent_tools.py b/src/agent_tools.py index ec43107..dc7c25b 100644 --- a/src/agent_tools.py +++ b/src/agent_tools.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Callable, Iterator, Union from .agent_types import AgentPermissions, AgentRuntimeConfig, ToolExecutionResult +from .session_env_vars import get_session_env_vars if TYPE_CHECKING: from .account_runtime import AccountRuntime @@ -3321,6 +3322,11 @@ def _build_subprocess_env(context: ToolExecutionContext) -> dict[str, str]: for key, value in os.environ.items() if not _is_sensitive_env_var(key) } + # Mirror utils/shell/bashProvider.ts: session env vars (set via /env) + # apply to spawned children, layered above the parent env but below + # explicit per-call extras. + for key, value in get_session_env_vars().items(): + env[key] = value env.update(context.extra_env) return env diff --git a/tests/test_agent_tools_security.py b/tests/test_agent_tools_security.py index 5f7ea67..1c3d3ed 100644 --- a/tests/test_agent_tools_security.py +++ b/tests/test_agent_tools_security.py @@ -9,6 +9,7 @@ from pathlib import Path from src.agent_tools import ( ToolExecutionError, ToolPermissionError, + _build_subprocess_env, _ensure_shell_allowed, _is_sensitive_env_var, _resolve_path, @@ -16,6 +17,10 @@ from src.agent_tools import ( default_tool_registry, ) from src.agent_types import AgentPermissions, AgentRuntimeConfig +from src.session_env_vars import ( + clear_session_env_vars, + set_session_env_var, +) def _make_context( @@ -230,5 +235,35 @@ class TestIsSensitiveEnvVar(unittest.TestCase): self.assertTrue(_is_sensitive_env_var("db_password")) +# --------------------------------------------------------------------------- +# _build_subprocess_env – session env var merging +# --------------------------------------------------------------------------- +class TestBuildSubprocessEnv(unittest.TestCase): + def setUp(self): + clear_session_env_vars() + + def tearDown(self): + clear_session_env_vars() + + def test_session_env_var_appears_in_subprocess_env(self): + with tempfile.TemporaryDirectory() as tmp: + ctx = _make_context(tmp) + set_session_env_var("CLAW_SESSION_FOO", "from-session") + env = _build_subprocess_env(ctx) + self.assertEqual(env["CLAW_SESSION_FOO"], "from-session") + + def test_extra_env_overrides_session_env(self): + with tempfile.TemporaryDirectory() as tmp: + config = AgentRuntimeConfig(cwd=Path(tmp)) + ctx = build_tool_context( + config, + tool_registry=default_tool_registry(), + extra_env={"CLAW_OVERRIDE_ME": "from-extra"}, + ) + set_session_env_var("CLAW_OVERRIDE_ME", "from-session") + env = _build_subprocess_env(ctx) + self.assertEqual(env["CLAW_OVERRIDE_ME"], "from-extra") + + if __name__ == "__main__": unittest.main() From cfd3bb9ca778ab4fb75b41c17225faf36d8a1c38 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 00:52:28 +0200 Subject: [PATCH 17/18] Implemented the next missing parity slice around clearing session env vars on /clear. Wires clear_session_env_vars() into LocalCodingAgent.clear_runtime_state so the /clear slash command now drops session-scoped env vars alongside the rest of the ephemeral runtime state, matching commands/clear/caches.ts:127. --- PARITY_CHECKLIST.md | 2 +- src/agent_runtime.py | 3 +++ tests/test_agent_runtime.py | 21 +++++++++++++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 284b437..0813056 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -707,7 +707,7 @@ Done: - [x] Basic git status snapshot - [x] Basic shell/subprocess handling - [x] Bundled small portable utilities — `utils/array.ts`, `utils/set.ts`, `utils/objectGroupBy.ts`, `utils/xml.ts`, `utils/uuid.ts` ported in `src/small_utils.py` -- [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py` and merged into spawned subprocess env via `_build_subprocess_env` (mirrors `utils/shell/bashProvider.ts`) +- [x] Session-scoped env-var registry (`utils/sessionEnvVars.ts`) ported in `src/session_env_vars.py`, merged into spawned subprocess env via `_build_subprocess_env` (mirrors `utils/shell/bashProvider.ts`), and dropped on `/clear` via `clear_runtime_state` (mirrors `commands/clear/caches.ts`) - [x] Display formatters from `utils/format.ts` (`formatFileSize`, `formatSecondsShort`, `formatDuration`, `formatNumber`, `formatTokens`) ported in `src/format_utils.py` Missing major utility categories: diff --git a/src/agent_runtime.py b/src/agent_runtime.py index bad2e70..60c6ec3 100644 --- a/src/agent_runtime.py +++ b/src/agent_runtime.py @@ -67,6 +67,7 @@ from .team_runtime import TeamRuntime from .tokenizer_runtime import describe_token_counter from .workflow_runtime import WorkflowRuntime from .worktree_runtime import WorktreeRuntime +from .session_env_vars import clear_session_env_vars from .session_store import ( StoredAgentSession, load_agent_session, @@ -249,6 +250,8 @@ class LocalCodingAgent: self.resume_source_session_id = None if self.plugin_runtime is not None: self.plugin_runtime.restore_session_state({}) + # Mirror commands/clear/caches.ts: drop session-scoped env vars on /clear. + clear_session_env_vars() def build_prompt_context(self, scratchpad_directory: Path | None = None): return build_prompt_context( diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index b2a12d2..e0d953f 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -318,6 +318,27 @@ class AgentRuntimeTests(unittest.TestCase): self.assertEqual(result.tool_calls, 0) self.assertIn('# Permissions', result.final_output) + def test_clear_runtime_state_drops_session_env_vars(self) -> None: + from src.session_env_vars import ( + clear_session_env_vars, + get_session_env_vars, + set_session_env_var, + ) + + clear_session_env_vars() + try: + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + set_session_env_var('CLAW_CLEAR_TEST', 'before') + self.assertEqual(get_session_env_vars()['CLAW_CLEAR_TEST'], 'before') + agent.clear_runtime_state() + self.assertNotIn('CLAW_CLEAR_TEST', get_session_env_vars()) + finally: + clear_session_env_vars() + def test_agent_persists_session_and_can_resume(self) -> None: responses = [ { From 20aaad62b3cd352a3425c1519d7bd27c28120fee Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Mon, 20 Apr 2026 01:09:13 +0200 Subject: [PATCH 18/18] Implemented the next missing parity slice around the /skills slash command listing. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /skills now enumerates bundled_skills.BUNDLED_SKILLS (matching the npm SkillsMenu component, which lists skills — not slash commands). Previously it mislabeled the slash-command catalog as "Available Skills". Co-Authored-By: Claude Opus 4.7 --- PARITY_CHECKLIST.md | 2 +- src/agent_slash_commands.py | 16 ++++++++++++---- tests/test_agent_runtime.py | 19 +++++++++++++++++++ 3 files changed, 32 insertions(+), 5 deletions(-) diff --git a/PARITY_CHECKLIST.md b/PARITY_CHECKLIST.md index 0813056..0e6edb6 100644 --- a/PARITY_CHECKLIST.md +++ b/PARITY_CHECKLIST.md @@ -359,7 +359,7 @@ Missing npm slash commands (from `src/commands/` — 80+ commands total): - [x] `/resume`, `/continue` — Resume a previous conversation - [x] `/rewind`, `/checkpoint` — Restore code/conversation to a previous point - [x] `/sandbox-toggle` — Toggle sandbox mode (alias `/sandbox`) -- [x] `/skills` — List available skills +- [x] `/skills` — List available bundled skills (mirrors `commands/skills/SkillsMenu.tsx`; lists `bundled_skills.BUNDLED_SKILLS`, not slash commands) - [x] `/stats` — Usage statistics and activity - [x] `/stickers` — Order stickers - [x] `/tag` — Toggle a searchable tag on the session diff --git a/src/agent_slash_commands.py b/src/agent_slash_commands.py index 2654b3e..e917076 100644 --- a/src/agent_slash_commands.py +++ b/src/agent_slash_commands.py @@ -1667,11 +1667,19 @@ def _handle_add_dir(agent: 'LocalCodingAgent', args: str, input_text: str) -> Sl def _handle_skills(agent: 'LocalCodingAgent', _args: str, input_text: str) -> SlashCommandResult: - """List available skills.""" + """List bundled skills (mirrors npm `/skills` SkillsMenu listing).""" + from .bundled_skills import get_bundled_skills + lines = ['## Available Skills', ''] - for spec in get_slash_command_specs(): - primary = f'/{spec.names[0]}' - lines.append(f'- `{primary}`: {spec.description}') + for skill in get_bundled_skills(): + if not skill.user_invocable: + continue + header = f'- `{skill.name}`' + if skill.aliases: + header += f' (aliases: {", ".join(skill.aliases)})' + lines.append(f'{header}: {skill.description}') + if skill.when_to_use: + lines.append(f' - When to use: {skill.when_to_use}') lines.extend(['', 'Use the Skill tool to invoke skills programmatically.']) return _local_result(input_text, '\n'.join(lines)) diff --git a/tests/test_agent_runtime.py b/tests/test_agent_runtime.py index e0d953f..ecdbc0d 100644 --- a/tests/test_agent_runtime.py +++ b/tests/test_agent_runtime.py @@ -318,6 +318,25 @@ class AgentRuntimeTests(unittest.TestCase): self.assertEqual(result.tool_calls, 0) self.assertIn('# Permissions', result.final_output) + def test_slash_skills_lists_bundled_skills(self) -> None: + from src.bundled_skills import get_bundled_skills + + with tempfile.TemporaryDirectory() as tmp_dir: + agent = LocalCodingAgent( + model_config=ModelConfig(model='Qwen/Qwen3-Coder-30B-A3B-Instruct'), + runtime_config=AgentRuntimeConfig(cwd=Path(tmp_dir)), + ) + result = agent.run('/skills') + self.assertEqual(result.turns, 0) + self.assertEqual(result.tool_calls, 0) + self.assertIn('Available Skills', result.final_output) + for skill in get_bundled_skills(): + if skill.user_invocable: + self.assertIn(skill.name, result.final_output) + # Slash command names (e.g. 'permissions') must NOT appear — that was + # the old buggy behavior the upstream `/skills` command never did. + self.assertNotIn('/permissions', result.final_output) + def test_clear_runtime_state_drops_session_env_vars(self) -> None: from src.session_env_vars import ( clear_session_env_vars,