From 872f4f89b516d3054a66810b2c67dbf9f705b174 Mon Sep 17 00:00:00 2001 From: Abdelrahman Abdallah Date: Fri, 17 Apr 2026 05:15:17 +0200 Subject: [PATCH] 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', '.'])