diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index a66eee4..bb07215 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -19,7 +19,8 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - pip install pyyaml pytest + pip install -e . + pip install pytest export PYTHONPATH=. - name: Run tests run: PYTHONPATH=. python3 -m pytest tests/ -v diff --git a/agent_notes/commands/wizard/execute.py b/agent_notes/commands/wizard/execute.py index 9422114..cd35e13 100644 --- a/agent_notes/commands/wizard/execute.py +++ b/agent_notes/commands/wizard/execute.py @@ -28,7 +28,7 @@ def install_agents_filtered(clis: Set[str], scope: str, copy_mode: bool = False, folder_overrides: dict = None, global_home_override: str = "") -> None: """Install agents for selected CLIs (filtered by the wizard).""" from ...services import installer - from ...services.installer import _apply_overrides + from ...services.installer import _apply_overrides, _agent_glob from ...registries.cli_registry import load_registry registry = load_registry() @@ -43,11 +43,12 @@ def install_agents_filtered(clis: Set[str], scope: str, copy_mode: bool = False, if dst is None: continue - files = list(src.glob("*.md")) + glob = _agent_glob(effective) + files = list(src.glob(glob)) if not files: continue - place_dir_contents(src, dst, "*.md", copy_mode) + place_dir_contents(src, dst, glob, copy_mode) def install_config_filtered(clis: Set[str], scope: str, copy_mode: bool = False, @@ -161,15 +162,16 @@ def _execute_install( if _cmd_names: print(f" {Color.GREEN}✓{Color.NC} Commands {', '.join(sorted(_cmd_names))}") - # SessionStart hook (Claude Code only) + # SessionStart hooks — for every backend with features.session_hook == true from ...services.installer import _install_session_hook - try: - _claude = _registry.get("claude") - if _claude.name in clis: - _claude_eff = _apply_overrides(_claude, folder_overrides, global_home_override or None) - _install_session_hook(_claude_eff, scope, memory_backend=memory_backend, memory_path=memory_path or "") - except (KeyError, Exception): - pass + for _hook_backend in _registry.with_feature("session_hook"): + if _hook_backend.name not in clis: + continue + try: + _hook_eff = _apply_overrides(_hook_backend, folder_overrides, global_home_override or None) + _install_session_hook(_hook_eff, scope, memory_backend=memory_backend, memory_path=memory_path or "") + except Exception: + pass _fs.silent_file_ops = False diff --git a/agent_notes/data/agents/agents.yaml b/agent_notes/data/agents/agents.yaml index 6c0cdaa..43854da 100644 --- a/agent_notes/data/agents/agents.yaml +++ b/agent_notes/data/agents/agents.yaml @@ -9,6 +9,7 @@ agents: color: purple effort: high claude_exclude: true + codex_exclude: true claude: tools: "Agent(coder, reviewer, security-auditor, test-writer, test-runner, system-auditor, database-specialist, performance-profiler, api-reviewer, tech-writer, devops, explorer), Read, Grep, Glob, Bash" memory: user diff --git a/agent_notes/data/cli/claude.yaml b/agent_notes/data/cli/claude.yaml index 51e9dad..b6eb250 100644 --- a/agent_notes/data/cli/claude.yaml +++ b/agent_notes/data/cli/claude.yaml @@ -10,6 +10,7 @@ layout: config: CLAUDE.md memory: agent-memory/ settings: settings.json + agent_extension: md features: agents: true skills: true @@ -20,7 +21,11 @@ features: config_style: inline settings_template: false supports_symlink: true + session_hook: true + stop_hook: true + allow_entries: true global_template: global-claude.md exclude_flag: claude_exclude accepted_providers: [anthropic, bedrock, vertex] use_model_class: true +preferred_family: claude diff --git a/agent_notes/data/cli/codex.yaml b/agent_notes/data/cli/codex.yaml new file mode 100644 index 0000000..ccb4129 --- /dev/null +++ b/agent_notes/data/cli/codex.yaml @@ -0,0 +1,29 @@ +name: codex +label: Codex CLI +global_home: ~/.codex +local_dir: .codex +layout: + agents: agents/ + skills: skills/ + config: AGENTS.md + hooks: hooks.json + agent_extension: toml +features: + agents: true + skills: true + rules: false + commands: false + memory: false + frontmatter: codex + config_style: inline + settings_template: false + supports_symlink: true + session_hook: true + stop_hook: false + allow_entries: false +global_template: global-codex.md +exclude_flag: codex_exclude +strip_memory_section: true +accepted_providers: [openai] +use_model_class: false +preferred_family: gpt diff --git a/agent_notes/data/cli/opencode.yaml b/agent_notes/data/cli/opencode.yaml index 54ce34b..57a3a4d 100644 --- a/agent_notes/data/cli/opencode.yaml +++ b/agent_notes/data/cli/opencode.yaml @@ -6,6 +6,7 @@ layout: agents: agents/ skills: skills/ config: AGENTS.md + agent_extension: md features: agents: true skills: true @@ -16,7 +17,11 @@ features: config_style: inline settings_template: false supports_symlink: true + session_hook: false + stop_hook: false + allow_entries: false global_template: global-opencode.md exclude_flag: opencode_exclude strip_memory_section: true accepted_providers: [github-copilot, anthropic, openrouter, openai, google, moonshot] +preferred_family: claude diff --git a/agent_notes/data/global-codex.md b/agent_notes/data/global-codex.md new file mode 100644 index 0000000..66f2b0f --- /dev/null +++ b/agent_notes/data/global-codex.md @@ -0,0 +1,40 @@ +# Global Instructions + +## Coding philosophy + +- Read existing code before writing new code. Match project patterns. +- Minimal changes: only what was requested. Do not refactor beyond scope. +- Fix root causes, not symptoms. +- One approach, commit to it. Course-correct only on new evidence. + +## Behavior + +- Investigate before answering. Never speculate about code you haven't read. +- No over-engineering: no extra features, abstractions, or configs beyond scope. +- No comments or docs on code you didn't change. +- When the task is unclear, ask one clarifying question instead of guessing. + +## Safety + +- Confirm before: `git push --force`, `rm -rf`, `DROP TABLE`, branch deletion. +- Never commit: `.env`, `*.pem`, credentials, API keys, secrets. +- Never bypass: `--no-verify`, `--force` without explicit user request. +- Never force-push to main/master. + +## Commits + +- Load the `git` skill when asked to commit and follow its workflow. +- Analyze all changes, group into logical chunks, make small focused commits. +- Format: `# type(scope): short description` — title only, no body. +- Extract ticket number from branch name when available. +- Types: feat, fix, refactor, test, docs, chore, style, perf + +## Agent delegation + +- Use subagents when tasks can run in parallel or require isolated context. +- For simple tasks, sequential operations, or single-file edits, work directly. +- Use `explorer` for quick lookups to save context tokens. +- Use `database-specialist` for schema, indexes, and query analysis. +- Use `performance-profiler` for bottleneck identification. +- Use `api-reviewer` for API design and consistency checks. +- Use `lead` for complex multi-step tasks requiring coordination. diff --git a/agent_notes/data/models/gpt-5-4-mini.yaml b/agent_notes/data/models/gpt-5-4-mini.yaml new file mode 100644 index 0000000..65e02c2 --- /dev/null +++ b/agent_notes/data/models/gpt-5-4-mini.yaml @@ -0,0 +1,14 @@ +id: gpt-5-4-mini +label: GPT-5.4 Mini +family: gpt +class: haiku +aliases: + openai: gpt-5.4-mini +pricing: + input: 0.15 + output: 0.60 + cache: 0.02 +capabilities: + vision: true + long_context: false + tool_use: true diff --git a/agent_notes/data/models/gpt-5-4.yaml b/agent_notes/data/models/gpt-5-4.yaml new file mode 100644 index 0000000..4d21514 --- /dev/null +++ b/agent_notes/data/models/gpt-5-4.yaml @@ -0,0 +1,14 @@ +id: gpt-5-4 +label: GPT-5.4 +family: gpt +class: sonnet +aliases: + openai: gpt-5.4 +pricing: + input: 3.00 + output: 15.00 + cache: 0.30 +capabilities: + vision: true + long_context: true + tool_use: true diff --git a/agent_notes/data/models/gpt-5-5.yaml b/agent_notes/data/models/gpt-5-5.yaml new file mode 100644 index 0000000..d3c9a74 --- /dev/null +++ b/agent_notes/data/models/gpt-5-5.yaml @@ -0,0 +1,14 @@ +id: gpt-5-5 +label: GPT-5.5 +family: gpt +class: opus +aliases: + openai: gpt-5.5 +pricing: + input: 10.00 + output: 40.00 + cache: 1.00 +capabilities: + vision: true + long_context: true + tool_use: true diff --git a/agent_notes/data/templates/frontmatter/codex.py b/agent_notes/data/templates/frontmatter/codex.py new file mode 100644 index 0000000..109c571 --- /dev/null +++ b/agent_notes/data/templates/frontmatter/codex.py @@ -0,0 +1,106 @@ +"""Codex CLI agent file generator — emits whole-file TOML, not frontmatter+markdown.""" + +import tomli_w + + +_EFFORT_MAP = { + "minimal": "minimal", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", +} + +_STRIP_PREFIXES = ("## Memory", "## Cost reporting") + + +def render(ctx: dict) -> str: + """Not used for Codex — emit_file supersedes render. + Kept so the module is consistent with other frontmatter templates.""" + return "" + + +def emit_file(ctx: dict, body: str) -> tuple[str, str]: + """Return (filename, full_file_content) for a Codex agent TOML file. + + The returned content is a complete TOML document — not a frontmatter snippet. + rendering.py writes this verbatim in place of the normal frontmatter+body output. + """ + agent_name = ctx['agent_name'] + agent_config = ctx['agent_config'] + model_str = ctx.get('model_str') or '' + + doc: dict = { + "name": agent_name, + "description": agent_config["description"], + "developer_instructions": body, + } + + if model_str: + doc["model"] = model_str + + effort_key = agent_config.get("effort", "medium") + doc["model_reasoning_effort"] = _EFFORT_MAP.get(effort_key, "medium") + + doc["sandbox_mode"] = _sandbox_mode(agent_config) + + filename = f"{agent_name}.toml" + content = tomli_w.dumps(doc) + return filename, content + + +def post_process(prompt: str, ctx: dict) -> str: + """Strip Codex-irrelevant sections from the agent prompt body. + + Strips: + - ## Memory* sections (Codex has no agent memory) + - ## Cost reporting section (Claude Code CLI tool, not available in Codex) + """ + return _strip_sections(prompt, _STRIP_PREFIXES) + + +# --- helpers --- + +def _sandbox_mode(agent_config: dict) -> str: + """Derive Codex sandbox_mode from the agent's tool/permission config. + + Writer agents (those that can Write or Edit files) -> "workspace-write" + All others -> "read-only" + """ + claude_config = agent_config.get('claude', {}) or {} + tools = claude_config.get('tools', '') or '' + if isinstance(tools, str): + if 'Write' in tools or 'Edit' in tools: + return "workspace-write" + elif isinstance(tools, list): + if 'Write' in tools or 'Edit' in tools: + return "workspace-write" + + opencode_config = agent_config.get('opencode', {}) or {} + permission = opencode_config.get('permission', {}) or {} + if permission.get('edit') == 'allow': + return "workspace-write" + + return "read-only" + + +def _strip_sections(content: str, strip_prefixes: tuple) -> str: + """Strip ## sections whose heading starts with any of the given prefixes.""" + lines = content.split('\n') + result_lines = [] + in_stripped_section = False + + for line in lines: + if any(line.startswith(prefix) for prefix in strip_prefixes): + in_stripped_section = True + continue + elif line.startswith('## ') and in_stripped_section: + in_stripped_section = False + result_lines.append(line) + elif not in_stripped_section: + result_lines.append(line) + + while result_lines and result_lines[-1].strip() == '': + result_lines.pop() + + return '\n'.join(result_lines) diff --git a/agent_notes/domain/cli_backend.py b/agent_notes/domain/cli_backend.py index be51431..f1664cf 100644 --- a/agent_notes/domain/cli_backend.py +++ b/agent_notes/domain/cli_backend.py @@ -22,6 +22,7 @@ class CLIBackend: settings_template: Optional[str] = None accepted_providers: tuple[str, ...] = () # new use_model_class: bool = False + preferred_family: Optional[str] = None # "claude", "gpt", etc. — preferred model family for step-2 fallback def supports(self, feature: str) -> bool: """Return True if the backend has that feature enabled.""" diff --git a/agent_notes/registries/cli_registry.py b/agent_notes/registries/cli_registry.py index da131a3..1a143f1 100644 --- a/agent_notes/registries/cli_registry.py +++ b/agent_notes/registries/cli_registry.py @@ -71,7 +71,8 @@ def load_registry(cli_dir: Optional[Path] = None) -> CLIRegistry: strip_memory_section=data.get("strip_memory_section", False), settings_template=data.get("settings_template"), accepted_providers=tuple(data.get("accepted_providers", [])), - use_model_class=data.get("use_model_class", False) + use_model_class=data.get("use_model_class", False), + preferred_family=data.get("preferred_family"), ) backends.append(backend) diff --git a/agent_notes/services/credentials.py b/agent_notes/services/credentials.py index 3f436f4..2a0b265 100644 --- a/agent_notes/services/credentials.py +++ b/agent_notes/services/credentials.py @@ -21,6 +21,8 @@ "tomli is required on Python < 3.11. Install it: pip install tomli" ) from exc +import tomli_w + CONFIG_PATH = Path.home() / ".agent-notes" / "credentials.toml" SAFE_MODE = 0o600 @@ -95,7 +97,7 @@ def _write(data: dict) -> None: tmp_pathobj = Path(tmp_path) try: with os.fdopen(fd, "w") as fh: - _dump_toml(data, fh) + fh.write(_dump_toml(data)) tmp_pathobj.chmod(SAFE_MODE) tmp_pathobj.replace(CONFIG_PATH) finally: @@ -103,22 +105,18 @@ def _write(data: dict) -> None: tmp_pathobj.unlink() -def _dump_toml(data: dict, fh) -> None: - """Minimal TOML writer for the credentials schema.""" +def _dump_toml(data: dict) -> str: + """Serialize credentials dict to TOML using tomli_w. + + None values are filtered out before serialization — TOML has no null type. + The hand-rolled predecessor would have written Python's ``None`` repr, which + is not valid TOML and would fail on read-back anyway. + """ providers = data.get("providers", {}) - for name in sorted(providers): - block = providers[name] - fh.write(f"[providers.{name}]\n") - for k in sorted(block): - v = block[k] - if isinstance(v, bool): - fh.write(f"{k} = {'true' if v else 'false'}\n") - elif isinstance(v, str): - escaped = v.replace('\\', '\\\\').replace('"', '\\"') - fh.write(f'{k} = "{escaped}"\n') - else: - fh.write(f"{k} = {v!r}\n") - fh.write("\n") + clean: dict = {"providers": {}} + for name, block in providers.items(): + clean["providers"][name] = {k: v for k, v in block.items() if v is not None} + return tomli_w.dumps(clean) def list_providers() -> list: diff --git a/agent_notes/services/installer.py b/agent_notes/services/installer.py index 99a938f..86a7fbf 100644 --- a/agent_notes/services/installer.py +++ b/agent_notes/services/installer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import shlex from pathlib import Path from typing import List, NamedTuple, Optional @@ -37,7 +38,12 @@ def _apply_overrides( folder_overrides: Optional[dict] = None, global_home_override: Optional[str] = None, ) -> CLIBackend: - """Return a backend with folder/global_home overrides applied.""" + """Return a backend with folder/global_home overrides applied. + + global_home_override and folder_overrides are Claude-profile features. + They are applied only to the claude backend so that codex/opencode/copilot + global homes are never silently redirected. + """ effective = backend if folder_overrides and backend.name in folder_overrides: effective = effective.with_local_dir(folder_overrides[backend.name]) @@ -46,6 +52,12 @@ def _apply_overrides( return effective +def _agent_glob(backend: CLIBackend) -> str: + """Return the glob pattern for agent files in the dist agents directory.""" + ext = backend.layout.get("agent_extension", "md") + return f"*.{ext}" + + def dist_source_for(backend: CLIBackend, component: str) -> Optional[Path]: """Return dist source path for a (backend, component) pair, or None if N/A. @@ -127,13 +139,14 @@ def install_component_for_backend( print(f"Installing {backend.label} config to {dst} ...") place_file(src_file, dst / filename, copy_mode) elif component in ("agents", "rules", "commands"): - # Directory of *.md files — flat copy + # Directory of agent/rule/command files — flat copy # Only print if there are files to install - files = list(src.glob("*.md")) + glob = _agent_glob(backend) if component == "agents" else "*.md" + files = list(src.glob(glob)) if not files: return print(f"Installing {backend.label} {component} to {dst} ...") - place_dir_contents(src, dst, "*.md", copy_mode) + place_dir_contents(src, dst, glob, copy_mode) elif component == "skills": # Each top-level subdir of src is a skill — install each as a directory # Only print if there are skills to install @@ -206,7 +219,8 @@ def _plan_component( return [] actions.append(_plan_file(src_file, dst / filename, copy_mode)) elif component in ("agents", "rules", "commands"): - for src_file in sorted(src.glob("*.md")): + glob = _agent_glob(backend) if component == "agents" else "*.md" + for src_file in sorted(src.glob(glob)): if src_file.exists(): actions.append(_plan_file(src_file, dst / src_file.name, copy_mode)) elif component == "skills": @@ -307,14 +321,12 @@ def plan_install( if skill_dir: actions.append(_plan_file(skill_dir, target / name, copy_mode)) - # SessionStart hook for Claude Code — always planned when claude backend is selected - try: - claude_backend = registry.get("claude") - if selected_clis is None or claude_backend.name in selected_clis: - claude_backend = _apply_overrides(claude_backend, folder_overrides, global_home_override) - actions.extend(_plan_session_hook(claude_backend, scope)) - except KeyError: - pass + # SessionStart hooks — planned for every backend with features.session_hook == true + for _hook_backend in registry.with_feature("session_hook"): + if selected_clis is not None and _hook_backend.name not in selected_clis: + continue + _hook_backend = _apply_overrides(_hook_backend, folder_overrides, global_home_override) + actions.extend(_plan_session_hook(_hook_backend, scope)) return actions @@ -336,13 +348,10 @@ def install_all(scope: str, copy_mode: bool, registry: Optional[CLIRegistry] = N if scope == "global": _install_universal_skills(copy_mode, registry) - # SessionStart hook for Claude Code only - try: - claude_backend = registry.get("claude") - claude_backend = _apply_overrides(claude_backend, folder_overrides, global_home_override) - _install_session_hook(claude_backend, scope) - except KeyError: - pass + # SessionStart hooks — installed for every backend with features.session_hook == true + for _hook_backend in registry.with_feature("session_hook"): + _hook_backend = _apply_overrides(_hook_backend, folder_overrides, global_home_override) + _install_session_hook(_hook_backend, scope) def _install_universal_skills(copy_mode: bool, registry: CLIRegistry) -> None: @@ -412,13 +421,10 @@ def uninstall_all(scope: str, registry: Optional[CLIRegistry] = None, display = path_str.replace(str(home), "~") print(f" Cleaned: {display}/ ({count} files)") - # Remove SessionStart hook for Claude Code only - try: - claude_backend = registry.get("claude") - claude_backend = _apply_overrides(claude_backend, folder_overrides, global_home_override) - _uninstall_session_hook(claude_backend, scope) - except KeyError: - pass + # Remove SessionStart hooks — for every backend with features.session_hook == true + for _hook_backend in registry.with_feature("session_hook"): + _hook_backend = _apply_overrides(_hook_backend, folder_overrides, global_home_override) + _uninstall_session_hook(_hook_backend, scope) def _uninstall_universal_skills(copy_mode: bool = False) -> int: @@ -431,15 +437,23 @@ def _uninstall_universal_skills(copy_mode: bool = False) -> int: return count +def _hooks_filename(backend) -> str: + """Return the hooks/settings filename for a given backend. + + Claude uses 'settings.json' (stored in layout["settings"]). + Codex uses 'hooks.json' (stored in layout["hooks"]). + Falls back to 'settings.json' for unknown backends. + """ + return backend.layout.get("hooks") or backend.layout.get("settings") or "settings.json" + + def _session_hook_paths(backend, scope: str): """Return (settings_path, context_file, hook_command) for the given scope.""" home = backend.global_home if scope == "global" else Path(backend.local_dir) - settings_path = home / "settings.json" + hooks_file = _hooks_filename(backend) + settings_path = home / hooks_file context_file = home / "agent-notes-context.md" - if scope == "global": - hook_command = f"cat {home}/agent-notes-context.md 2>/dev/null || true" - else: - hook_command = f"cat {backend.local_dir}/agent-notes-context.md 2>/dev/null || true" + hook_command = f"cat {shlex.quote(str(context_file))} 2>/dev/null || true" return settings_path, context_file, hook_command @@ -456,7 +470,12 @@ def _filter_skills_by_backend(skills, memory_backend: str): def _install_session_hook(backend, scope: str, memory_backend: str = "", memory_path: str = "") -> None: - """Install the SessionStart hook and write the context file for Claude Code.""" + """Install the SessionStart hook and write the context file. + + Behaviour is driven by the backend's feature flags: + stop_hook — whether to install the Stop/cost-report hook (claude: true, codex: false) + allow_entries — whether to install Bash permission allow-list entries (claude: true, codex: false) + """ from .settings_writer import install_hook, install_allow_entry, remove_allow_entry, remove_matching_allow_entries, remove_hook from ..constants import Hooks from .session_context import write_context @@ -466,11 +485,11 @@ def _install_session_hook(backend, scope: str, memory_backend: str = "", memory_ settings_path, context_file, hook_command = _session_hook_paths(backend, scope) - # Gather installed agent names from dist directory + # Gather installed agent names from dist directory using the backend's agent extension agents: list[str] = [] agents_dist = config.DIST_DIR / backend.name / backend.layout.get("agents", "agents") if agents_dist.exists(): - agents = sorted(p.stem for p in agents_dist.glob("*.md")) + agents = sorted(p.stem for p in agents_dist.glob(_agent_glob(backend))) if not memory_backend: state = load_state() @@ -480,80 +499,92 @@ def _install_session_hook(backend, scope: str, memory_backend: str = "", memory_ skills = _filter_skills_by_backend(default_skill_registry().all(), memory_backend) version = config.get_version() - print(f"Installing Claude Code SessionStart hook ...") + print(f"Installing {backend.label} SessionStart hook ...") write_context(context_file, agents, version, skills) install_hook(settings_path, "SessionStart", hook_command) - # Memory-bridge hook: load agent-notes index at session start. - # Only useful for Obsidian-backed modes (obsidian, wiki). - if memory_backend in ("obsidian", "wiki"): - install_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) - else: - remove_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) - - # Stop hook: emit cost report at end of session - install_hook(settings_path, "Stop", Hooks.COST_REPORT) - - # Clean up stale PostToolUse hooks from previous versions - remove_hook(settings_path, "PostToolUse", Hooks.MEMORY_BRIDGE) - - # Remove ALL agent-notes Bash permission entries (covers stale entries from - # any previous install, not just the immediately preceding one) - remove_matching_allow_entries(settings_path, "Bash(agent-notes") - remove_allow_entry(settings_path, "Bash(cost-report)") - install_allow_entry(settings_path, "Bash(agent-notes cost-report)") - install_allow_entry(settings_path, "Bash(agent-notes memory *)") - if memory_backend in ("obsidian", "wiki"): - install_allow_entry(settings_path, f"Bash({Hooks.MEMORY_BRIDGE})") - - # Remove memory path permissions for all known backend default paths so that - # stale entries from previous installs (even ones before the last state save) - # are cleaned up before adding fresh entries. - for bk in ("wiki", "obsidian"): - default_path = memory_dir_for_backend(bk, "") - if default_path: - p = str(default_path) + "/**" - remove_allow_entry(settings_path, f"Read({p})") - remove_allow_entry(settings_path, f"Write({p})") - remove_allow_entry(settings_path, f"Edit({p})") - - # Also remove any custom path recorded in old state - old_state = load_state() - if old_state and old_state.memory.backend in ("wiki", "obsidian") and old_state.memory.path: - old_resolved = memory_dir_for_backend(old_state.memory.backend, old_state.memory.path) - if old_resolved: - old_pattern = str(old_resolved) + "/**" - remove_allow_entry(settings_path, f"Read({old_pattern})") - remove_allow_entry(settings_path, f"Write({old_pattern})") - remove_allow_entry(settings_path, f"Edit({old_pattern})") - - # Add read/write/edit permissions for the new memory vault path - if memory_backend in ("wiki", "obsidian"): - resolved_path = memory_dir_for_backend(memory_backend, memory_path) - if resolved_path: - path_pattern = str(resolved_path) + "/**" - install_allow_entry(settings_path, f"Read({path_pattern})") - install_allow_entry(settings_path, f"Write({path_pattern})") - install_allow_entry(settings_path, f"Edit({path_pattern})") + # Memory-bridge hook and allow entries — only for backends that support them + if backend.supports("stop_hook"): + # Memory-bridge hook: load agent-notes index at session start. + # Only useful for Obsidian-backed modes (obsidian, wiki). + if memory_backend in ("obsidian", "wiki"): + install_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) + else: + remove_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) + + # Stop hook: emit cost report at end of session + install_hook(settings_path, "Stop", Hooks.COST_REPORT) + + # Clean up stale PostToolUse hooks from previous versions + remove_hook(settings_path, "PostToolUse", Hooks.MEMORY_BRIDGE) + + if backend.supports("allow_entries"): + # Remove ALL agent-notes Bash permission entries (covers stale entries from + # any previous install, not just the immediately preceding one) + remove_matching_allow_entries(settings_path, "Bash(agent-notes") + remove_allow_entry(settings_path, "Bash(cost-report)") + install_allow_entry(settings_path, "Bash(agent-notes cost-report)") + install_allow_entry(settings_path, "Bash(agent-notes memory *)") + if memory_backend in ("obsidian", "wiki"): + install_allow_entry(settings_path, f"Bash({Hooks.MEMORY_BRIDGE})") + + # Remove memory path permissions for all known backend default paths so that + # stale entries from previous installs (even ones before the last state save) + # are cleaned up before adding fresh entries. + for bk in ("wiki", "obsidian"): + default_path = memory_dir_for_backend(bk, "") + if default_path: + p = str(default_path) + "/**" + remove_allow_entry(settings_path, f"Read({p})") + remove_allow_entry(settings_path, f"Write({p})") + remove_allow_entry(settings_path, f"Edit({p})") + + # Also remove any custom path recorded in old state + old_state = load_state() + if old_state and old_state.memory.backend in ("wiki", "obsidian") and old_state.memory.path: + old_resolved = memory_dir_for_backend(old_state.memory.backend, old_state.memory.path) + if old_resolved: + old_pattern = str(old_resolved) + "/**" + remove_allow_entry(settings_path, f"Read({old_pattern})") + remove_allow_entry(settings_path, f"Write({old_pattern})") + remove_allow_entry(settings_path, f"Edit({old_pattern})") + + # Add read/write/edit permissions for the new memory vault path + if memory_backend in ("wiki", "obsidian"): + resolved_path = memory_dir_for_backend(memory_backend, memory_path) + if resolved_path: + path_pattern = str(resolved_path) + "/**" + install_allow_entry(settings_path, f"Read({path_pattern})") + install_allow_entry(settings_path, f"Write({path_pattern})") + install_allow_entry(settings_path, f"Edit({path_pattern})") def _uninstall_session_hook(backend, scope: str, memory_backend: str = "", memory_path: str = "") -> None: - """Remove the SessionStart hook and context file for Claude Code.""" + """Remove the SessionStart hook and context file. + + Behaviour is driven by the backend's feature flags: + stop_hook — whether to remove the Stop/cost-report hook (claude: true, codex: false) + allow_entries — whether to remove Bash permission allow-list entries (claude: true, codex: false) + """ from .settings_writer import remove_hook, remove_allow_entry, remove_matching_allow_entries from ..constants import Hooks settings_path, context_file, hook_command = _session_hook_paths(backend, scope) - print(f"Removing Claude Code SessionStart hook ...") + print(f"Removing {backend.label} SessionStart hook ...") context_file.unlink(missing_ok=True) remove_hook(settings_path, "SessionStart", hook_command) - remove_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) - remove_hook(settings_path, "Stop", Hooks.COST_REPORT) - remove_hook(settings_path, "PostToolUse", Hooks.MEMORY_BRIDGE) - # Remove ALL agent-notes Bash permission entries (covers old naming too) - remove_matching_allow_entries(settings_path, "Bash(agent-notes") - remove_allow_entry(settings_path, "Bash(cost-report)") - # Read/Write/Edit entries for memory vault paths are intentionally kept — - # the user may still want Claude to access their vault without agent-notes. + + if backend.supports("stop_hook"): + remove_hook(settings_path, "SessionStart", Hooks.MEMORY_BRIDGE) + remove_hook(settings_path, "Stop", Hooks.COST_REPORT) + remove_hook(settings_path, "PostToolUse", Hooks.MEMORY_BRIDGE) + + if backend.supports("allow_entries"): + # Remove ALL agent-notes Bash permission entries (covers old naming too) + remove_matching_allow_entries(settings_path, "Bash(agent-notes") + remove_allow_entry(settings_path, "Bash(cost-report)") + # Read/Write/Edit entries for memory vault paths are intentionally kept — + # the user may still want Claude to access their vault without agent-notes. diff --git a/agent_notes/services/rendering.py b/agent_notes/services/rendering.py index 0218a71..31c50a5 100644 --- a/agent_notes/services/rendering.py +++ b/agent_notes/services/rendering.py @@ -233,14 +233,36 @@ def generate_agent_files(agents_config: Dict[str, Any], tiers: Dict[str, Any], # Prefer newer model IDs when multiple match the class. # Registries are sorted ascending by id, so iterate reversed # to pick e.g. claude-opus-4-7 over claude-opus-4-6. - for model in reversed(model_registry.all()): - if model.model_class != role.typical_class: - continue - resolved = model.resolve_for_providers(list(backend.accepted_providers)) - if resolved is not None: - _provider, alias_str = resolved - model_str = model.model_class if backend.use_model_class else alias_str - break + # + # When preferred_family is set, first try to find a model + # whose class matches AND whose family matches preferred_family. + # Only fall back to any-family if no preferred-family match found. + # This prevents gpt models (openai-aliased) from hijacking + # opencode's step-2 fallback, since opencode has preferred_family=claude. + all_models_reversed = list(reversed(model_registry.all())) + preferred_family = backend.preferred_family + + def _find_class_match(models, family_filter=None): + for model in models: + if model.model_class != role.typical_class: + continue + if family_filter is not None and model.family != family_filter: + continue + resolved = model.resolve_for_providers(list(backend.accepted_providers)) + if resolved is not None: + return model, resolved + return None, None + + if preferred_family is not None: + matched_model, resolved = _find_class_match(all_models_reversed, preferred_family) + if matched_model is None: + matched_model, resolved = _find_class_match(all_models_reversed) + else: + matched_model, resolved = _find_class_match(all_models_reversed) + + if matched_model is not None and resolved is not None: + _provider, alias_str = resolved + model_str = matched_model.model_class if backend.use_model_class else alias_str # Step 3: legacy tier fallback (for pre-v1.1 agents.yaml files that # still declare `tier:` instead of `role:`). @@ -268,28 +290,46 @@ def generate_agent_files(agents_config: Dict[str, Any], tiers: Dict[str, Any], 'backend': backend, } - # Generate frontmatter using template - frontmatter = template.render(ctx) - # Apply post-processing transformation (e.g., strip memory section) - content = template.post_process(prompt_content, ctx) + body = template.post_process(prompt_content, ctx) # Apply user patch if present patch = get_patch(agent_name, user_config) if patch: - content = content.rstrip() + "\n\n" + patch.strip() + body = body.rstrip() + "\n\n" + patch.strip() - # Combine and write - full_content = f"{frontmatter}\n\n{content}" - # Write to backend's agents directory from ..services import installer agents_dir = installer.dist_source_for(backend, "agents") if agents_dir is None: agents_dir = DIST_DIR / backend.name / "agents" - - agent_file = agents_dir / f'{agent_name}.md' - agent_file.parent.mkdir(parents=True, exist_ok=True) + agents_dir.mkdir(parents=True, exist_ok=True) + + if hasattr(template, 'emit_file'): + # Whole-file emitters (e.g. Codex TOML): filename and content + # are determined entirely by the template. + filename, full_content = template.emit_file(ctx, body) + agent_file = agents_dir / filename + else: + # Standard frontmatter+markdown path (claude, opencode, …) + frontmatter = template.render(ctx) + full_content = f"{frontmatter}\n\n{body}" + agent_file = agents_dir / f'{agent_name}.md' + + if agent_file.exists() and not agent_file.is_symlink(): + from ..services.fs import handle_existing as _handle_existing + import tempfile as _tempfile + _fd, _tmp_path = _tempfile.mkstemp(suffix=agent_file.suffix) + try: + import os as _os + _os.close(_fd) + Path(_tmp_path).write_text(full_content) + _proceed = _handle_existing(Path(_tmp_path), agent_file) + finally: + Path(_tmp_path).unlink(missing_ok=True) + if not _proceed: + generated_files.append(agent_file) + continue agent_file.write_text(full_content) generated_files.append(agent_file) @@ -450,7 +490,30 @@ def render_globals() -> list[Path]: copilot_global.parent.mkdir(parents=True, exist_ok=True) copilot_global.write_text(copilot_content) copied_files.append(copilot_global) - + + # Render global templates for any additional registry-driven backends that + # have a global_template and a config layout entry (e.g. codex -> AGENTS.md). + # Claude, opencode, and copilot are handled explicitly above; skip them here. + _handled = {"claude", "opencode", "copilot"} + from ..registries.cli_registry import load_registry as _load_cli_registry + from ..config import global_template_path, global_output_path, DATA_DIR as _DATA_DIR + for _backend in _load_cli_registry().all(): + if _backend.name in _handled: + continue + _tmpl_path = global_template_path(_backend) + _out_path = global_output_path(_backend) + if _tmpl_path is None or _out_path is None: + continue + if not _tmpl_path.exists(): + continue + _content = expand_includes(_tmpl_path.read_text(), AGENTS_DIR / "shared", skip=_include_skip) + # Substitute {{MEMORY_INSTRUCTIONS}} to empty string for memory-less backends + # (e.g. codex has features.memory=false) so no literal placeholder leaks. + _content = _content.replace("{{MEMORY_INSTRUCTIONS}}", "") + _out_path.parent.mkdir(parents=True, exist_ok=True) + _out_path.write_text(_content) + copied_files.append(_out_path) + return copied_files diff --git a/pyproject.toml b/pyproject.toml index 01d4ad5..352af68 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" authors = [{ name = "Eugene Naumov", email = "min.verkligheten@gmail.com" }] license = "MIT" requires-python = ">=3.10" -dependencies = ["pyyaml>=6.0", "tomli>=1.1.0; python_version < '3.11'"] +dependencies = ["pyyaml>=6.0", "tomli>=1.1.0; python_version < '3.11'", "tomli-w>=1.0.0"] keywords = ["claude", "claude-code", "opencode", "ai-agent", "agent", "configuration", "claude-cli", "github-copilot"] classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/plugins/codex/__init__.py b/tests/plugins/codex/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/plugins/codex/test_agents.py b/tests/plugins/codex/test_agents.py new file mode 100644 index 0000000..8de2cf9 --- /dev/null +++ b/tests/plugins/codex/test_agents.py @@ -0,0 +1,138 @@ +"""Parametrized tests for every agent file in dist/codex/agents/.""" +import sys +import pytest +from pathlib import Path + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from agent_notes.config import DIST_DIR + +_CODEX_AGENTS_DIR = DIST_DIR / "codex" / "agents" + +_VALID_EFFORT_VALUES = {"minimal", "low", "medium", "high", "xhigh"} +_VALID_SANDBOX_MODES = {"read-only", "workspace-write", "danger-full-access"} + + +def _agent_files(): + if not _CODEX_AGENTS_DIR.is_dir(): + return [] + return sorted(_CODEX_AGENTS_DIR.glob("*.toml")) + + +AGENT_FILES = _agent_files() + +if not AGENT_FILES: + raise RuntimeError( + f"test_agents.py: no codex agent files found under {_CODEX_AGENTS_DIR}. " + f"The session-scoped built_dist fixture should have populated it. " + f"Run `python3 -m agent_notes build` manually if running outside pytest." + ) + + +@pytest.fixture(scope="module", autouse=True) +def require_built_dist(built_dist): + """Ensure the build has run before collecting agent files.""" + pass + + +# --- lead.toml must NOT exist --- + +def test_lead_toml_not_present(): + """lead agent is excluded from codex (codex_exclude: true).""" + assert not (_CODEX_AGENTS_DIR / "lead.toml").exists(), ( + "lead.toml must not exist — lead is codex_exclude: true" + ) + + +# --- Parametrized per-file tests --- + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_file_non_empty(agent_file): + assert agent_file.stat().st_size > 0, f"{agent_file.name} is empty" + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_file_is_valid_toml(agent_file): + raw = agent_file.read_bytes() + try: + tomllib.loads(raw.decode()) + except Exception as exc: + pytest.fail(f"{agent_file.name} is not valid TOML: {exc}") + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_has_name(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + assert data.get("name", "").strip(), f"{agent_file.name} missing non-empty 'name'" + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_has_description(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + assert data.get("description", "").strip(), f"{agent_file.name} missing non-empty 'description'" + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_has_developer_instructions(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + assert data.get("developer_instructions", "").strip(), ( + f"{agent_file.name} missing non-empty 'developer_instructions'" + ) + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_model_contains_gpt_if_present(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + model = data.get("model") + if model is not None: + assert "gpt" in model.lower(), ( + f"{agent_file.name}: model '{model}' does not contain 'gpt' — " + f"codex only supports openai models" + ) + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_model_reasoning_effort_valid_if_present(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + effort = data.get("model_reasoning_effort") + if effort is not None: + assert effort in _VALID_EFFORT_VALUES, ( + f"{agent_file.name}: model_reasoning_effort '{effort}' not in {_VALID_EFFORT_VALUES}" + ) + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_sandbox_mode_valid_if_present(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + sandbox = data.get("sandbox_mode") + if sandbox is not None: + assert sandbox in _VALID_SANDBOX_MODES, ( + f"{agent_file.name}: sandbox_mode '{sandbox}' not in {_VALID_SANDBOX_MODES}" + ) + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_developer_instructions_no_memory_section(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + instructions = data.get("developer_instructions", "") + for line in instructions.split("\n"): + stripped = line.strip() + assert not stripped.startswith("## Memory"), ( + f"{agent_file.name}: developer_instructions contains a '## Memory' section " + f"(should have been stripped by post_process)" + ) + + +@pytest.mark.parametrize("agent_file", AGENT_FILES, ids=[f.name for f in AGENT_FILES]) +def test_agent_developer_instructions_no_cost_reporting_section(agent_file): + data = tomllib.loads(agent_file.read_bytes().decode()) + instructions = data.get("developer_instructions", "") + for line in instructions.split("\n"): + stripped = line.strip() + assert not stripped.startswith("## Cost reporting"), ( + f"{agent_file.name}: developer_instructions contains a '## Cost reporting' section " + f"(should have been stripped by post_process)" + ) diff --git a/tests/unit/registries/test_cli_registry.py b/tests/unit/registries/test_cli_registry.py new file mode 100644 index 0000000..e3b3375 --- /dev/null +++ b/tests/unit/registries/test_cli_registry.py @@ -0,0 +1,184 @@ +"""Tests for the CLI registry, focusing on codex backend.""" +import pytest +from pathlib import Path + +from agent_notes.registries.cli_registry import load_registry + + +# --------------------------------------------------------------------------- +# codex backend resolves +# --------------------------------------------------------------------------- + +class TestCodexBackendExists: + def test_registry_resolves_codex(self): + registry = load_registry() + backend = registry.get("codex") + assert backend is not None + + def test_codex_name(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.name == "codex" + + def test_codex_global_home_ends_with_dot_codex(self): + registry = load_registry() + backend = registry.get("codex") + assert str(backend.global_home).endswith(".codex"), ( + f"global_home '{backend.global_home}' should end with '.codex'" + ) + + +# --------------------------------------------------------------------------- +# Feature flags +# --------------------------------------------------------------------------- + +class TestCodexFeatures: + def test_supports_agents(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.supports("agents") is True + + def test_supports_skills(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.supports("skills") is True + + def test_does_not_support_rules(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.supports("rules") is False + + def test_does_not_support_commands(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.supports("commands") is False + + def test_does_not_support_memory(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.supports("memory") is False + + def test_frontmatter_feature_is_codex(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.features.get("frontmatter") == "codex" + + +# --------------------------------------------------------------------------- +# Layout +# --------------------------------------------------------------------------- + +class TestCodexLayout: + def test_config_is_agents_md(self): + registry = load_registry() + backend = registry.get("codex") + assert backend.layout.get("config") == "AGENTS.md" + + def test_layout_has_hooks(self): + registry = load_registry() + backend = registry.get("codex") + assert "hooks" in backend.layout, "codex layout must include 'hooks' key" + assert backend.layout["hooks"] == "hooks.json" + + +# --------------------------------------------------------------------------- +# Providers +# --------------------------------------------------------------------------- + +class TestCodexProviders: + def test_accepted_providers_is_openai(self): + registry = load_registry() + backend = registry.get("codex") + assert "openai" in backend.accepted_providers + + def test_accepted_providers_tuple(self): + registry = load_registry() + backend = registry.get("codex") + assert isinstance(backend.accepted_providers, tuple) + + +# --------------------------------------------------------------------------- +# Model resolution: codex resolves openai models, not claude models +# --------------------------------------------------------------------------- + +class TestModelResolutionForCodex: + def test_worker_role_resolves_to_gpt_model_for_codex(self): + """Worker role (typical_class=sonnet) should resolve to a gpt-* model for codex.""" + from agent_notes.registries.model_registry import load_model_registry + from agent_notes.registries.role_registry import load_role_registry + + model_registry = load_model_registry() + role_registry = load_role_registry() + registry = load_registry() + codex = registry.get("codex") + + role = role_registry.get("worker") + resolved_model = None + for model in reversed(model_registry.all()): + if model.model_class != role.typical_class: + continue + result = model.resolve_for_providers(list(codex.accepted_providers)) + if result is not None: + _provider, alias_str = result + resolved_model = alias_str + break + + assert resolved_model is not None, ( + "No model resolved for worker role + codex backend (openai provider)" + ) + assert "gpt" in resolved_model.lower(), ( + f"Resolved model '{resolved_model}' for codex/worker should contain 'gpt'" + ) + + def test_scout_role_resolves_to_gpt_model_for_codex(self): + """Scout role (typical_class=haiku) should resolve to a gpt-* model for codex.""" + from agent_notes.registries.model_registry import load_model_registry + from agent_notes.registries.role_registry import load_role_registry + + model_registry = load_model_registry() + role_registry = load_role_registry() + registry = load_registry() + codex = registry.get("codex") + + role = role_registry.get("scout") + resolved_model = None + for model in reversed(model_registry.all()): + if model.model_class != role.typical_class: + continue + result = model.resolve_for_providers(list(codex.accepted_providers)) + if result is not None: + _provider, alias_str = result + resolved_model = alias_str + break + + assert resolved_model is not None, ( + "No model resolved for scout role + codex backend (openai provider)" + ) + assert "gpt" in resolved_model.lower(), ( + f"Resolved model '{resolved_model}' for codex/scout should contain 'gpt'" + ) + + def test_claude_backend_does_not_resolve_gpt_models(self): + """Claude backend (anthropic provider) must not select gpt-* models.""" + from agent_notes.registries.model_registry import load_model_registry + from agent_notes.registries.role_registry import load_role_registry + + model_registry = load_model_registry() + role_registry = load_role_registry() + registry = load_registry() + + try: + claude = registry.get("claude") + except KeyError: + pytest.skip("claude backend not in registry") + + role = role_registry.get("worker") + for model in model_registry.all(): + if model.model_class != role.typical_class: + continue + result = model.resolve_for_providers(list(claude.accepted_providers)) + if result is not None: + _provider, alias_str = result + assert "gpt" not in alias_str.lower(), ( + f"Claude backend resolved to '{alias_str}' — gpt models must not be selected" + ) diff --git a/tests/unit/services/test_credentials.py b/tests/unit/services/test_credentials.py index 30d73a9..43900d2 100644 --- a/tests/unit/services/test_credentials.py +++ b/tests/unit/services/test_credentials.py @@ -85,3 +85,77 @@ def test_load_warns_on_loose_permissions(tmp_creds): credentials.load() # On normal filesystems chmod will succeed silently. Assert no crash. assert credentials.load()["providers"]["openrouter"]["api_key"] == "x" + + +# --------------------------------------------------------------------------- +# tomli_w integration tests +# --------------------------------------------------------------------------- + +def test_roundtrip_multiple_providers(tmp_creds): + """Write multiple providers then read back; dict must be identical.""" + credentials.set_value("openrouter", "api_key", "fake-key-123") + credentials.set_value("anthropic", "api_key", "fake-key-456") + data = credentials.load() + assert data["providers"]["openrouter"]["api_key"] == "fake-key-123" + assert data["providers"]["anthropic"]["api_key"] == "fake-key-456" + assert data["providers"]["openrouter"]["enabled"] is True + assert data["providers"]["anthropic"]["enabled"] is True + + +def test_roundtrip_special_chars_in_value(tmp_creds): + """Values with double-quotes, backslashes, newlines, and unicode survive round-trip.""" + tricky = 'back\\slash "quoted"\nnewline éclair' + credentials.set_value("openrouter", "api_key", tricky) + assert credentials.get("openrouter") == tricky + + +def test_dump_toml_filters_none_values(tmp_creds): + """_dump_toml must silently drop None values (TOML has no null type). + + Contract: a provider block with a None field produces valid TOML that + omits the None key entirely. The None is NOT written as a Python repr. + """ + data = { + "providers": { + "openrouter": { + "api_key": "fake-key-123", + "base_url": None, + "enabled": True, + } + } + } + toml_str = credentials._dump_toml(data) + assert "None" not in toml_str + assert "base_url" not in toml_str + # Verify the TOML is parseable + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + parsed = tomllib.loads(toml_str) + assert "base_url" not in parsed["providers"]["openrouter"] + assert parsed["providers"]["openrouter"]["enabled"] is True + + +def test_empty_providers_produces_parseable_toml(tmp_creds): + """An empty providers dict must not crash and must produce valid TOML.""" + data: dict = {"providers": {}} + toml_str = credentials._dump_toml(data) + try: + import tomllib + except ImportError: + import tomli as tomllib # type: ignore[no-redef] + parsed = tomllib.loads(toml_str) + assert parsed == {"providers": {}} + + +def test_write_to_unwritable_directory_raises(tmp_path, monkeypatch): + """If the temp directory is unwritable, _write must propagate the OSError.""" + path = tmp_path / "credentials.toml" + monkeypatch.setattr(credentials, "CONFIG_PATH", path) + tmp_path.chmod(0o444) + try: + with pytest.raises(OSError): + credentials.set_value("openrouter", "api_key", "fake-key-123") + finally: + tmp_path.chmod(0o755) # restore so pytest can clean up diff --git a/tests/unit/services/test_installer_codex.py b/tests/unit/services/test_installer_codex.py new file mode 100644 index 0000000..0bdc8b4 --- /dev/null +++ b/tests/unit/services/test_installer_codex.py @@ -0,0 +1,460 @@ +"""Tests for Codex CLI-specific installer logic.""" +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + +from agent_notes.services.installer import ( + _agent_glob, + _hooks_filename, + _install_session_hook, + _uninstall_session_hook, + _plan_session_hook, + plan_install, + InstallAction, +) +from agent_notes.services.settings_writer import has_hook, install_hook +from agent_notes.registries.cli_registry import load_registry +from agent_notes.domain.cli_backend import CLIBackend + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_codex_backend(tmp_path: Path) -> CLIBackend: + """Build a codex CLIBackend pointing to tmp_path.""" + global_home = tmp_path / "codex_global" + global_home.mkdir(parents=True, exist_ok=True) + return CLIBackend( + name="codex", + label="Codex CLI", + global_home=global_home, + local_dir=str(tmp_path / ".codex"), + layout={ + "agents": "agents/", + "skills": "skills/", + "config": "AGENTS.md", + "hooks": "hooks.json", + "agent_extension": "toml", + }, + features={ + "agents": True, + "skills": True, + "rules": False, + "commands": False, + "memory": False, + "frontmatter": "codex", + "session_hook": True, + "stop_hook": False, + "allow_entries": False, + }, + global_template="global-codex.md", + accepted_providers=("openai",), + ) + + +@pytest.fixture +def codex_backend(tmp_path): + return _make_codex_backend(tmp_path) + + +@pytest.fixture(autouse=True) +def patch_session_deps(tmp_path): + """Patch session-context and state-store dependencies used by hook installers.""" + with patch("agent_notes.services.session_context.write_context"), \ + patch("agent_notes.registries.skill_registry.default_skill_registry") as mock_reg, \ + patch("agent_notes.services.state_store.load_state") as mock_state, \ + patch("agent_notes.config.get_version", return_value="0.0.0-test"), \ + patch("agent_notes.config.memory_dir_for_backend", return_value=None), \ + patch("agent_notes.services.installer.load_state") as mock_state2: + mock_reg.return_value.all.return_value = [] + mock_state.return_value = None + mock_state2.return_value = None + yield + + +# --------------------------------------------------------------------------- +# _agent_glob — layout-driven extension +# --------------------------------------------------------------------------- + +class TestAgentGlob: + def test_toml_backend_returns_toml_glob(self, codex_backend): + """Backend with layout.agent_extension=toml returns *.toml.""" + assert _agent_glob(codex_backend) == "*.toml" + + def test_md_backend_returns_md_glob(self, tmp_path): + """Backend with layout.agent_extension=md returns *.md.""" + backend = CLIBackend( + name="other", + label="Other", + global_home=tmp_path, + local_dir=str(tmp_path), + layout={"agent_extension": "md"}, + features={}, + global_template=None, + ) + assert _agent_glob(backend) == "*.md" + + def test_missing_agent_extension_defaults_to_md(self, tmp_path): + """Backend without layout.agent_extension defaults to *.md.""" + backend = CLIBackend( + name="other", + label="Other", + global_home=tmp_path, + local_dir=str(tmp_path), + layout={}, + features={}, + global_template=None, + ) + assert _agent_glob(backend) == "*.md" + + def test_codex_backend_returns_toml_glob(self, codex_backend): + assert _agent_glob(codex_backend) == "*.toml" + + def test_non_codex_backend_returns_md_glob(self): + registry = load_registry() + try: + claude = registry.get("claude") + except KeyError: + pytest.skip("claude backend not in registry") + assert _agent_glob(claude) == "*.md" + + +# --------------------------------------------------------------------------- +# _hooks_filename +# --------------------------------------------------------------------------- + +class TestHooksFilename: + def test_codex_uses_hooks_json(self, codex_backend): + assert _hooks_filename(codex_backend) == "hooks.json" + + def test_backend_without_hooks_key_falls_back_to_settings_json(self, tmp_path): + backend = CLIBackend( + name="other", + label="Other", + global_home=tmp_path, + local_dir=str(tmp_path), + layout={"settings": "settings.json"}, + features={}, + global_template=None, + ) + assert _hooks_filename(backend) == "settings.json" + + def test_backend_with_no_hooks_or_settings_falls_back(self, tmp_path): + backend = CLIBackend( + name="other", + label="Other", + global_home=tmp_path, + local_dir=str(tmp_path), + layout={}, + features={}, + global_template=None, + ) + assert _hooks_filename(backend) == "settings.json" + + +# --------------------------------------------------------------------------- +# _install_session_hook (unified — codex path: no stop_hook, no allow_entries) +# --------------------------------------------------------------------------- + +class TestInstallCodexSessionHook: + def test_creates_hooks_json(self, codex_backend, tmp_path): + hooks_path = codex_backend.global_home / "hooks.json" + assert not hooks_path.exists() + + _install_session_hook(codex_backend, "global") + + assert hooks_path.exists() + + def test_hooks_json_has_session_start_entry(self, codex_backend, tmp_path): + _install_session_hook(codex_backend, "global") + + hooks_path = codex_backend.global_home / "hooks.json" + data = json.loads(hooks_path.read_text()) + assert "SessionStart" in data.get("hooks", {}), ( + "hooks.json must contain a 'SessionStart' event key" + ) + + def test_session_start_command_cats_context_file(self, codex_backend): + _install_session_hook(codex_backend, "global") + + hooks_path = codex_backend.global_home / "hooks.json" + data = json.loads(hooks_path.read_text()) + session_hooks = data["hooks"]["SessionStart"] + commands = [ + h["command"] + for entry in session_hooks + for h in entry.get("hooks", []) + ] + assert any("agent-notes-context.md" in cmd for cmd in commands), ( + f"No command cats agent-notes-context.md. Commands: {commands}" + ) + + def test_install_is_idempotent(self, codex_backend): + _install_session_hook(codex_backend, "global") + _install_session_hook(codex_backend, "global") + + hooks_path = codex_backend.global_home / "hooks.json" + data = json.loads(hooks_path.read_text()) + # Count how many SessionStart entries mention agent-notes-context.md + session_hooks = data["hooks"].get("SessionStart", []) + matching = [ + h + for entry in session_hooks + for h in entry.get("hooks", []) + if "agent-notes-context.md" in h.get("command", "") + ] + assert len(matching) == 1, ( + f"Expected exactly 1 SessionStart hook after idempotent install, got {len(matching)}" + ) + + def test_codex_does_not_install_stop_hook(self, codex_backend): + """Codex has stop_hook=false — Stop hook must NOT be installed.""" + from agent_notes.constants import Hooks + _install_session_hook(codex_backend, "global") + hooks_path = codex_backend.global_home / "hooks.json" + assert not has_hook(hooks_path, "Stop", Hooks.COST_REPORT), ( + "Codex backend must not install the Stop/cost-report hook" + ) + + def test_codex_does_not_install_allow_entries(self, codex_backend, tmp_path): + """Codex has allow_entries=false — permissions block must NOT appear.""" + _install_session_hook(codex_backend, "global") + hooks_path = codex_backend.global_home / "hooks.json" + data = json.loads(hooks_path.read_text()) + # permissions block is at top-level or under 'permissions' key in settings.json + # For codex using hooks.json, there should be no permissions key at all + assert "permissions" not in data, ( + "Codex backend must not write a permissions block" + ) + + +# --------------------------------------------------------------------------- +# _uninstall_session_hook (unified — codex path) +# --------------------------------------------------------------------------- + +class TestUninstallCodexSessionHook: + def test_removes_session_start_hook(self, codex_backend): + _install_session_hook(codex_backend, "global") + hooks_path = codex_backend.global_home / "hooks.json" + + # Verify it's installed first + data = json.loads(hooks_path.read_text()) + assert "SessionStart" in data.get("hooks", {}) + + _uninstall_session_hook(codex_backend, "global") + + data_after = json.loads(hooks_path.read_text()) + session_hooks = data_after.get("hooks", {}).get("SessionStart", []) + remaining = [ + h + for entry in session_hooks + for h in entry.get("hooks", []) + if "agent-notes-context.md" in h.get("command", "") + ] + assert remaining == [], ( + f"SessionStart hook not removed after uninstall. Remaining: {remaining}" + ) + + def test_uninstall_missing_hooks_json_is_noop(self, codex_backend): + """Uninstall when hooks.json doesn't exist should not raise.""" + hooks_path = codex_backend.global_home / "hooks.json" + assert not hooks_path.exists() + # Should not raise + _uninstall_session_hook(codex_backend, "global") + + +# --------------------------------------------------------------------------- +# _plan_session_hook (unified — codex path) +# --------------------------------------------------------------------------- + +class TestPlanCodexSessionHook: + def test_install_action_when_hooks_json_absent(self, codex_backend): + actions = _plan_session_hook(codex_backend, "global") + assert len(actions) == 1 + assert actions[0].action == "install" + + def test_modify_action_when_hook_absent_from_existing_file(self, codex_backend): + hooks_path = codex_backend.global_home / "hooks.json" + hooks_path.write_text(json.dumps({"hooks": {}})) + + actions = _plan_session_hook(codex_backend, "global") + assert len(actions) == 1 + assert actions[0].action in ("install", "modify") + + def test_skip_action_when_hook_already_present(self, codex_backend): + # Actually install it, then plan again + _install_session_hook(codex_backend, "global") + + actions = _plan_session_hook(codex_backend, "global") + assert len(actions) == 1 + assert actions[0].action == "skip" + + +# --------------------------------------------------------------------------- +# Unified hook loop — feature flag driven +# --------------------------------------------------------------------------- + +class TestSessionHookFeatureFlags: + def test_backend_with_session_hook_false_gets_no_hook(self, tmp_path): + """Backend with session_hook=false must not have hooks installed via the loop.""" + from agent_notes.registries.cli_registry import CLIRegistry + backend = CLIBackend( + name="nohook", + label="No Hook Backend", + global_home=tmp_path / "nohook_global", + local_dir=str(tmp_path / ".nohook"), + layout={"hooks": "hooks.json", "agent_extension": "md"}, + features={"session_hook": False, "stop_hook": False, "allow_entries": False}, + global_template=None, + ) + (tmp_path / "nohook_global").mkdir(parents=True, exist_ok=True) + registry = CLIRegistry([backend]) + # with_feature("session_hook") must return empty for this backend + assert registry.with_feature("session_hook") == [] + + def test_backend_without_memory_feature_does_not_emit_memory_entries(self, tmp_path): + """A backend with stop_hook=false and allow_entries=false emits no memory/allow entries.""" + from agent_notes.constants import Hooks + codex = _make_codex_backend(tmp_path) + _install_session_hook(codex, "global", memory_backend="obsidian") + hooks_path = codex.global_home / "hooks.json" + # No Stop hook + assert not has_hook(hooks_path, "Stop", Hooks.COST_REPORT) + # No memory bridge + assert not has_hook(hooks_path, "SessionStart", Hooks.MEMORY_BRIDGE) + + def test_registry_missing_backend_does_not_crash_plan(self, tmp_path): + """A registry with no session_hook backends must not crash plan_install.""" + from agent_notes.registries.cli_registry import CLIRegistry + backend = CLIBackend( + name="nohook", + label="No Hook", + global_home=tmp_path / "g", + local_dir=str(tmp_path / ".nohook"), + layout={"agent_extension": "md"}, + features={"session_hook": False}, + global_template=None, + ) + (tmp_path / "g").mkdir(parents=True, exist_ok=True) + registry = CLIRegistry([backend]) + # Must not raise + actions = plan_install(scope="global", registry=registry) + # No hook actions expected + hook_actions = [a for a in actions if "hooks.json" in str(a.dst) or "settings.json" in str(a.dst)] + assert hook_actions == [] + + def test_registry_missing_claude_does_not_crash_install(self, tmp_path): + """install_all with a registry containing only codex must not crash.""" + from agent_notes.services.installer import install_all + from agent_notes.registries.cli_registry import CLIRegistry + codex = _make_codex_backend(tmp_path) + registry = CLIRegistry([codex]) + # Should not raise + with patch("agent_notes.services.installer._install_universal_skills"): + install_all(scope="global", copy_mode=True, registry=registry) + + def test_registry_missing_codex_does_not_crash_install(self, tmp_path): + """install_all with a registry containing only claude must not crash.""" + from agent_notes.services.installer import install_all + from agent_notes.registries.cli_registry import CLIRegistry + claude_home = tmp_path / "claude_global" + claude_home.mkdir(parents=True, exist_ok=True) + claude = CLIBackend( + name="claude", + label="Claude Code", + global_home=claude_home, + local_dir=str(tmp_path / ".claude"), + layout={"agents": "agents/", "settings": "settings.json", "agent_extension": "md"}, + features={ + "agents": True, "skills": False, "rules": False, "commands": False, + "memory": False, "session_hook": True, "stop_hook": True, "allow_entries": True, + }, + global_template=None, + ) + registry = CLIRegistry([claude]) + # Should not raise + with patch("agent_notes.services.installer._install_universal_skills"): + install_all(scope="global", copy_mode=True, registry=registry) + + +# --------------------------------------------------------------------------- +# plan_install with codex selected +# --------------------------------------------------------------------------- + +def _make_registry_with_codex(tmp_path: Path): + """Return a CLIRegistry whose codex backend has global_home pointing to tmp_path. + + global_home_override no longer redirects non-claude backends (fix #3), so tests + that need to avoid touching real ~/.codex must inject a pre-redirected backend + via a custom registry instead. + """ + from agent_notes.registries.cli_registry import CLIRegistry + real_registry = load_registry() + try: + real_codex = real_registry.get("codex") + except KeyError: + return None, None + + fake_global = tmp_path / "fake_codex_global" + fake_global.mkdir(parents=True, exist_ok=True) + redirected = real_codex.with_global_home(fake_global) + # Rebuild a registry with only the redirected codex backend (others unused here) + registry = CLIRegistry([redirected]) + return registry, fake_global + + +class TestPlanInstallCodex: + def test_plan_install_codex_includes_hooks_json_action(self, tmp_path): + registry, fake_global = _make_registry_with_codex(tmp_path) + if registry is None: + pytest.skip("codex backend not in registry") + + with patch("agent_notes.services.settings_writer.has_hook", return_value=False): + actions = plan_install( + scope="global", + registry=registry, + selected_clis={"codex"}, + ) + + hooks_actions = [a for a in actions if "hooks.json" in str(a.dst)] + assert hooks_actions, ( + "Expected at least one action targeting hooks.json for codex install" + ) + + def test_plan_install_codex_includes_toml_agent_actions(self, tmp_path, built_dist): + """plan_install with codex selected should include .toml agent files.""" + registry, fake_global = _make_registry_with_codex(tmp_path) + if registry is None: + pytest.skip("codex backend not in registry") + + with patch("agent_notes.services.settings_writer.has_hook", return_value=False): + actions = plan_install( + scope="global", + registry=registry, + selected_clis={"codex"}, + ) + + toml_actions = [a for a in actions if str(a.src).endswith(".toml")] + assert toml_actions, ( + "Expected at least one .toml file action in codex install plan" + ) + + def test_plan_install_codex_includes_agents_md(self, tmp_path, built_dist): + """plan_install with codex selected should include AGENTS.md config.""" + registry, fake_global = _make_registry_with_codex(tmp_path) + if registry is None: + pytest.skip("codex backend not in registry") + + with patch("agent_notes.services.settings_writer.has_hook", return_value=False): + actions = plan_install( + scope="global", + registry=registry, + selected_clis={"codex"}, + ) + + config_actions = [a for a in actions if "AGENTS.md" in str(a.dst)] + assert config_actions, ( + "Expected at least one action targeting AGENTS.md for codex install" + ) diff --git a/tests/unit/templates/__init__.py b/tests/unit/templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/templates/test_codex_frontmatter.py b/tests/unit/templates/test_codex_frontmatter.py new file mode 100644 index 0000000..4401acc --- /dev/null +++ b/tests/unit/templates/test_codex_frontmatter.py @@ -0,0 +1,383 @@ +"""Unit tests for agent_notes.data.templates.frontmatter.codex.""" +import sys +import textwrap +import pytest + +if sys.version_info >= (3, 11): + import tomllib +else: + import tomli as tomllib + +from agent_notes.data.templates.frontmatter.codex import ( + emit_file, + post_process, + _sandbox_mode, + _strip_sections, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_ctx( + name: str = "coder", + description: str = "A coder agent.", + effort: str = "medium", + role: str = "worker", + claude_tools: str = "", + opencode_permission: dict = None, + model_str: str = "gpt-5.4", +) -> tuple[dict, str]: + """Return (ctx, body) pair suitable for emit_file / post_process.""" + agent_config = { + "description": description, + "effort": effort, + "role": role, + "claude": {"tools": claude_tools}, + } + if opencode_permission is not None: + agent_config["opencode"] = {"permission": opencode_permission} + + ctx = { + "agent_name": name, + "agent_config": agent_config, + "model_str": model_str, + } + body = "You are a coder.\n\n## Process\n\nDo work." + return ctx, body + + +# --------------------------------------------------------------------------- +# emit_file: return type and filename +# --------------------------------------------------------------------------- + +class TestEmitFileReturnType: + def test_returns_two_element_tuple(self): + ctx, body = _make_ctx() + result = emit_file(ctx, body) + assert isinstance(result, tuple) + assert len(result) == 2 + + def test_filename_is_agent_name_dot_toml(self): + ctx, body = _make_ctx(name="explorer") + filename, _ = emit_file(ctx, body) + assert filename == "explorer.toml" + + def test_content_parses_as_toml(self): + ctx, body = _make_ctx() + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert isinstance(doc, dict) + + def test_content_has_name(self): + ctx, body = _make_ctx(name="reviewer") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["name"] == "reviewer" + + def test_content_has_description(self): + ctx, body = _make_ctx(description="Reviews code.") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["description"] == "Reviews code." + + def test_content_has_developer_instructions_matching_body(self): + ctx, body = _make_ctx() + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["developer_instructions"] == body + + def test_model_present_when_model_str_given(self): + ctx, body = _make_ctx(model_str="gpt-5.4") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model"] == "gpt-5.4" + + def test_model_absent_when_model_str_empty(self): + ctx, body = _make_ctx(model_str="") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert "model" not in doc + + def test_model_absent_when_model_str_none(self): + ctx, body = _make_ctx() + ctx["model_str"] = None + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert "model" not in doc + + +# --------------------------------------------------------------------------- +# emit_file: sandbox_mode derivation +# --------------------------------------------------------------------------- + +class TestEmitFileSandboxMode: + def test_write_tool_produces_workspace_write(self): + ctx, body = _make_ctx(claude_tools="Read, Write, Bash") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["sandbox_mode"] == "workspace-write" + + def test_edit_tool_produces_workspace_write(self): + ctx, body = _make_ctx(claude_tools="Read, Edit, Bash") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["sandbox_mode"] == "workspace-write" + + def test_read_only_tools_produce_read_only(self): + ctx, body = _make_ctx(claude_tools="Read, Grep, Glob") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["sandbox_mode"] == "read-only" + + def test_empty_tools_produce_read_only(self): + ctx, body = _make_ctx(claude_tools="") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["sandbox_mode"] == "read-only" + + def test_opencode_permission_edit_allow_produces_workspace_write(self): + ctx, body = _make_ctx(claude_tools="Read", opencode_permission={"edit": "allow"}) + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["sandbox_mode"] == "workspace-write" + + +# --------------------------------------------------------------------------- +# emit_file: model_reasoning_effort mapping +# --------------------------------------------------------------------------- + +class TestEmitFileEffortMapping: + def test_low_effort_maps_to_low(self): + ctx, body = _make_ctx(effort="low") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model_reasoning_effort"] == "low" + + def test_medium_effort_maps_to_medium(self): + ctx, body = _make_ctx(effort="medium") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model_reasoning_effort"] == "medium" + + def test_high_effort_maps_to_high(self): + ctx, body = _make_ctx(effort="high") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model_reasoning_effort"] == "high" + + def test_unknown_effort_defaults_to_medium(self): + ctx, body = _make_ctx(effort="super-ultra") + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model_reasoning_effort"] == "medium" + + def test_absent_effort_defaults_to_medium(self): + ctx, body = _make_ctx() + # Remove effort from agent_config + del ctx["agent_config"]["effort"] + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["model_reasoning_effort"] == "medium" + + +# --------------------------------------------------------------------------- +# emit_file: TOML escaping / round-trip +# --------------------------------------------------------------------------- + +class TestEmitFileTOMLEscaping: + def test_body_with_double_quotes_round_trips(self): + ctx, _ = _make_ctx() + body = 'Use "double quotes" freely in your output.' + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["developer_instructions"] == body + + def test_body_with_backslashes_round_trips(self): + ctx, _ = _make_ctx() + body = "Check path C:\\\\Users\\\\foo and also \\n newline." + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["developer_instructions"] == body + + def test_body_with_multiline_content_round_trips(self): + ctx, _ = _make_ctx() + body = "Line one.\nLine two.\nLine three." + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["developer_instructions"] == body + + def test_body_with_toml_special_chars_round_trips(self): + ctx, _ = _make_ctx() + body = "Keys look like: name = \"value\" and [section]" + _, content = emit_file(ctx, body) + doc = tomllib.loads(content) + assert doc["developer_instructions"] == body + + +# --------------------------------------------------------------------------- +# _sandbox_mode helper +# --------------------------------------------------------------------------- + +class TestSandboxModeHelper: + def test_claude_tools_string_with_write(self): + cfg = {"claude": {"tools": "Read, Write, Bash"}} + assert _sandbox_mode(cfg) == "workspace-write" + + def test_claude_tools_string_with_edit(self): + cfg = {"claude": {"tools": "Read, Edit"}} + assert _sandbox_mode(cfg) == "workspace-write" + + def test_claude_tools_list_with_write(self): + cfg = {"claude": {"tools": ["Read", "Write", "Bash"]}} + assert _sandbox_mode(cfg) == "workspace-write" + + def test_claude_tools_list_read_only(self): + cfg = {"claude": {"tools": ["Read", "Grep"]}} + assert _sandbox_mode(cfg) == "read-only" + + def test_no_claude_config_returns_read_only(self): + cfg = {} + assert _sandbox_mode(cfg) == "read-only" + + def test_opencode_edit_allow_returns_workspace_write(self): + cfg = {"opencode": {"permission": {"edit": "allow"}}} + assert _sandbox_mode(cfg) == "workspace-write" + + def test_opencode_edit_deny_returns_read_only(self): + cfg = {"opencode": {"permission": {"edit": "deny"}}} + assert _sandbox_mode(cfg) == "read-only" + + def test_claude_tools_none_returns_read_only(self): + cfg = {"claude": {"tools": None}} + assert _sandbox_mode(cfg) == "read-only" + + +# --------------------------------------------------------------------------- +# post_process: section stripping +# --------------------------------------------------------------------------- + +class TestPostProcess: + def _ctx(self): + ctx, _ = _make_ctx() + return ctx + + def test_strips_memory_section(self): + body = textwrap.dedent("""\ + ## Process + + Do work. + + ## Memory + + Save memories using the CLI. + + ## Reporting + + Report back.""") + result = post_process(body, self._ctx()) + assert "## Memory" not in result + assert "Save memories" not in result + + def test_strips_cost_reporting_section(self): + body = textwrap.dedent("""\ + ## Process + + Do work. + + ## Cost reporting + + Use agent-notes cost-report. + + ## Reporting + + Report back.""") + result = post_process(body, self._ctx()) + assert "## Cost reporting" not in result + assert "cost-report" not in result + + def test_preserves_sections_after_stripped_one(self): + body = textwrap.dedent("""\ + ## Process + + Do work. + + ## Memory + + Save memories. + + ## Reporting + + Report back.""") + result = post_process(body, self._ctx()) + assert "## Reporting" in result + assert "Report back." in result + + def test_preserves_sections_before_stripped_one(self): + body = textwrap.dedent("""\ + ## Process + + Do work. + + ## Memory + + Save memories.""") + result = post_process(body, self._ctx()) + assert "## Process" in result + assert "Do work." in result + + def test_strips_memory_with_subsection_heading(self): + """## Memory* should strip the section even if heading is e.g. '## Memory (read-before-work)'.""" + body = textwrap.dedent("""\ + ## Process + + Do work. + + ## Memory (read-before-work, write-on-discovery) + + Read the index. + + ## Reporting + + Report back.""") + result = post_process(body, self._ctx()) + assert "Memory" not in result + assert "Read the index." not in result + + def test_empty_body_returns_empty(self): + result = post_process("", self._ctx()) + assert result == "" + + def test_body_without_stripped_sections_unchanged_content(self): + body = "## Process\n\nDo work.\n\n## Reporting\n\nReport back." + result = post_process(body, self._ctx()) + assert "## Process" in result + assert "## Reporting" in result + + +# --------------------------------------------------------------------------- +# _strip_sections helper +# --------------------------------------------------------------------------- + +class TestStripSections: + def test_strips_exact_prefix_match(self): + content = "## Memory\n\nsome text\n\n## Other\n\nother text" + result = _strip_sections(content, ("## Memory",)) + assert "## Memory" not in result + assert "some text" not in result + assert "## Other" in result + + def test_does_not_strip_section_with_different_prefix(self): + content = "## Memory\n\nfoo\n\n## Memorize\n\nbar" + # "## Memorize" doesn't start with "## Memory" if exact word boundary matters, + # but the impl checks startswith — so "## Memorize".startswith("## Memory") is True. + # This test documents the current behavior (it WILL be stripped). + result = _strip_sections(content, ("## Memory",)) + # Both "## Memory" and "## Memorize" are stripped — confirm "## Memory" gone + assert "## Memory\n" not in result + + def test_trailing_blank_lines_removed(self): + content = "## Keep\n\nkeep text\n\n## Memory\n\nmemory text\n\n\n" + result = _strip_sections(content, ("## Memory",)) + assert not result.endswith("\n\n") diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..902a65c --- /dev/null +++ b/uv.lock @@ -0,0 +1,237 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "agent-notes" +source = { editable = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomli-w" }, +] + +[package.optional-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0" }, + { name = "pyyaml", specifier = ">=6.0" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, + { name = "tomli-w", specifier = ">=1.0.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" }, + { url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" }, + { url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" }, + { url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" }, + { url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" }, + { url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "tomli-w" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +]