diff --git a/src/skills/bundled/__init__.py b/src/skills/bundled/__init__.py index 74b3f1b60..3525b2f7f 100644 --- a/src/skills/bundled/__init__.py +++ b/src/skills/bundled/__init__.py @@ -20,6 +20,7 @@ from .loop import register_loop_skill from .simplify import register_simplify_skill from .stuck import register_stuck_skill +from .update_config import register_update_config_skill from .verify_content import register_verify_content_skill logger = logging.getLogger(__name__) @@ -50,6 +51,7 @@ def init_bundled_skills() -> None: register_debug_skill() register_loop_skill() register_batch_skill() + register_update_config_skill() register_stuck_skill() register_verify_content_skill() _INITIALIZED = True @@ -71,6 +73,7 @@ def reset_bundled_skills_init_flag() -> None: "register_debug_skill", "register_loop_skill", "register_batch_skill", + "register_update_config_skill", "register_stuck_skill", "register_verify_content_skill", ] diff --git a/src/skills/bundled/update_config.py b/src/skills/bundled/update_config.py new file mode 100644 index 000000000..eef56a0e2 --- /dev/null +++ b/src/skills/bundled/update_config.py @@ -0,0 +1,148 @@ +"""Bundled ``/update-config`` skill — ADAPTED port of ``bundled/updateConfig.ts``. + +updateConfig.ts is a settings-editing skill. It is NOT ported verbatim: its +whole job is telling the user which file + shape to write, and the port's +settings topology differs from TS. Per the SKILLS-2 scope review + an +empirical file-topology spike, the prompt is HAND-AUTHORED (like TS, which +deliberately hand-wrote examples rather than auto-generating schema docs) and +grounded in the port's REAL on-disk loaders: + +* permissions + env + hooks → ``.clawcodex/settings.json`` (user + ``~/.clawcodex/settings.json``, project ``/.clawcodex/settings.json``, + local ``.clawcodex/settings.local.json``) — NOT ``.claude/`` (the real + Claude Code harness owns ``/.claude/settings.json``). +* the model/provider "settings" block → ``~/.clawcodex/config.json``. +* MCP → ``.mcp.json`` + the approval flow (not a settings.json key). + +The TS ``generateSettingsSchema()`` introspector is intentionally dropped (it +was aimed at the wrong artifact); the ``[hooks-only]`` mode is deferred (the +port's ``/init`` does not invoke it). +""" + +from __future__ import annotations + +from ..bundled_skills import BundledSkillDefinition, register_bundled_skill + +UPDATE_CONFIG_PROMPT = """# Update Config: Edit clawcodex Configuration + +Modify clawcodex configuration by editing its settings files. Read the target file first, apply a minimal JSON edit, and keep it valid JSON. + +## Where settings live + +clawcodex reads **two different files** — pick by what you are configuring. + +### 1. Harness settings — `.clawcodex/settings.json` + +Permissions, environment variables, and hooks. Three scopes, later overrides earlier: + +| File | Scope | Git | +|------|-------|-----| +| `~/.clawcodex/settings.json` | Global (all projects) | N/A | +| `/.clawcodex/settings.json` | Project | Commit | +| `/.clawcodex/settings.local.json` | Personal, this project | Gitignore | + +**Do NOT write to `/.claude/settings.json`** — clawcodex deliberately does not own that path; the ambient Claude Code harness does. Use `.clawcodex/`. + +### 2. Runtime config — `~/.clawcodex/config.json` + +Model/provider selection and related runtime knobs live under the `"settings"` key of the global config file. Prefer the `/model`, `/advisor`, and `/config` commands over hand-editing this file. + +## Permissions (`.clawcodex/settings.json`) + +```json +{ + "permissions": { + "allow": ["Bash(npm:*)", "Read", "Edit(src/**)"], + "deny": ["Bash(rm -rf:*)"], + "ask": ["Bash(git push:*)"] + }, + "additionalWorkingDirectories": ["/extra/dir"] +} +``` + +- `allow` / `deny` / `ask` are arrays of **rule strings** — the enforced permission set (read at startup). +- Rule syntax: `Tool` alone (e.g. `Read`) matches all uses of that tool; `Tool(specifier)` scopes it. For `Bash`, `Bash(cmd:*)` is a prefix match (`Bash(git:*)` matches `git status`, `git commit`, …); `Bash(npm run test)` is exact. For file tools, `Edit(src/**)` / `Write(*.env)` match by path glob. +- `additionalWorkingDirectories` (a **top-level** list of path strings) grants access to paths outside the workspace root — this is the key the port reads at startup (NOT a `permissions.additionalDirectories` key). +- The permission MODE is `default`, `plan`, `acceptEdits`, `bypassPermissions`, or `dontAsk`, but set it via the `--permission-mode` flag or the `/mode` command — a `defaultMode` key is not yet read back at startup, so writing it to settings.json has no effect today. + +## Environment variables (`.clawcodex/settings.json`) + +```json +{ + "env": { + "DEBUG": "true", + "MY_API_KEY": "value" + } +} +``` + +Top-level `env`; applied to the session's environment. + +## Hooks (`.clawcodex/settings.json`) + +Hooks run commands in response to lifecycle EVENTS. If the user wants something to happen automatically in response to an event, they need a hook — memory/preferences cannot trigger automated actions. + +```json +{ + "hooks": { + "PostToolUse": [ + { + "matcher": "Edit", + "hooks": [ + { "type": "command", "command": "jq -r '.tool_input.file_path // empty' | xargs -r eslint --fix 2>/dev/null || true" } + ] + } + ] + } +} +``` + +- Top-level `hooks` → `{ EventName: [ matcher-group, … ] }`. +- Each matcher-group is `{ "matcher": , "hooks": [ , … ] }`. Omit/blank `matcher` to match every invocation of the event. +- Common events: `PreToolUse`, `PostToolUse`, `PostToolUseFailure`, `Stop`, `StopFailure`, `SubagentStop`, `SessionStart`, `Notification`. +- **A `command` hook receives the event payload as JSON on STDIN** — `{ "session_id", "tool_name", "tool_input", "tool_response", … }` — so extract fields with `jq` reading stdin (the edited path is `.tool_input.file_path`). The port does NOT set a `$CLAUDE_FILE_PATHS` env var; it does export `CLAUDE_HOOK_EVENT`, `CLAUDE_PROJECT_DIR`, `CLAUDE_ENV_FILE`, `CLAUDE_PLUGIN_ROOT`, `CLAUDE_CONFIG_DIR`. +- Hook `type` is one of: `command` (run a shell command), `agent` (run a subagent — key `agentInstructions`), `http` (POST to a `url`), `prompt` (inject a prompt — key `promptText`). A hook may also carry `if` (a conditional command gate), `once` (run at most once), and `shell` (the shell to use). +- Example (agent hook): `{ "type": "agent", "matcher": "Edit", "agentInstructions": "Verify tests still pass" }`. Example (prompt hook): `{ "type": "prompt", "promptText": "Was that change safe?" }`. + +## MCP servers + +MCP servers are configured in a project-root `.mcp.json` file (not a settings.json key), and clawcodex prompts for approval before enabling a project's MCP servers on first use. To pre-approve all of a project's servers, set `"enableAllProjectMcpServers": true` in `.clawcodex/settings.json`. + +## How to apply an edit + +1. **Read** the target file first (create it as `{}` if absent). +2. Merge the minimal change into the existing JSON — do not clobber unrelated keys. +3. Write valid JSON (no comments, no trailing commas). +4. Tell the user which file you edited and whether a restart or `/config` reload is needed for it to take effect. +""" + + +def _get_prompt_for_command(args: str) -> str: + prompt = UPDATE_CONFIG_PROMPT + focus = args.strip() + if focus: + prompt += f"\n\n## User Request\n\n{focus}" + return prompt + + +def register_update_config_skill() -> None: + register_bundled_skill( + BundledSkillDefinition( + name="update-config", + description=( + "Modify clawcodex configuration by editing its settings files " + "(permissions, env, hooks in .clawcodex/settings.json)." + ), + when_to_use=( + "Use when the user wants to change clawcodex configuration — " + "permissions, environment variables, hooks, or model/provider " + "settings." + ), + argument_hint="", + user_invocable=True, + # Mirror TS's allowedTools:['Read'] — auto-approve the read-before- + # write step (SKILLS-2 N1). + allowed_tools=["Read"], + get_prompt_for_command=_get_prompt_for_command, + ) + ) diff --git a/tests/skills/test_update_config_skill.py b/tests/skills/test_update_config_skill.py new file mode 100644 index 000000000..06a279109 --- /dev/null +++ b/tests/skills/test_update_config_skill.py @@ -0,0 +1,161 @@ +"""SKILLS-2 — the ``/update-config`` bundled skill (ADAPTED port). + +updateConfig.ts is a settings-editing skill; ported ADAPTED (hand-authored, +grounded in the port's real on-disk loaders) rather than verbatim, because a +verbatim port would teach the model to write settings the port cannot parse. +These pin the port-correct topology + shapes and the absence of TS-only / +internal keys. +""" +from __future__ import annotations + +import json + +import pytest + +from src.skills.bundled import init_bundled_skills +from src.skills.bundled.update_config import ( + UPDATE_CONFIG_PROMPT, + _get_prompt_for_command, +) +from src.skills.bundled_skills import ( + clear_bundled_skills, + get_bundled_skill_by_name, +) + + +@pytest.fixture() +def _fresh_registry(): + clear_bundled_skills() + init_bundled_skills() + yield + clear_bundled_skills() + + +class TestRegistration: + def test_registered(self, _fresh_registry): + u = get_bundled_skill_by_name("update-config") + assert u is not None + assert u.user_invocable is True + assert u.allowed_tools == ["Read"] # N1: read-before-write auto-approve + + def test_args_appended_as_focus(self): + out = _get_prompt_for_command("allow npm without prompting") + assert "## User Request\n\nallow npm without prompting" in out + assert _get_prompt_for_command("") == UPDATE_CONFIG_PROMPT + + +class TestPortCorrectTopology: + def test_edit_target_is_clawcodex_not_claude(self): + p = UPDATE_CONFIG_PROMPT + assert ".clawcodex/settings.json" in p + # the ONLY mention of .claude is the explicit "do NOT write here" warning + assert "Do NOT write to" in p + assert "`/.claude/settings.json`" in p + # ~/.claude is never taught as an edit target + assert "~/.claude/settings.json" not in p + + def test_three_scopes(self): + p = UPDATE_CONFIG_PROMPT + assert "~/.clawcodex/settings.json" in p + assert "/.clawcodex/settings.json" in p + assert "/.clawcodex/settings.local.json" in p + + def test_config_block_file(self): + assert "~/.clawcodex/config.json" in UPDATE_CONFIG_PROMPT + + +class TestPortCorrectShapes: + def test_five_permission_modes(self): + p = UPDATE_CONFIG_PROMPT + for mode in ("default", "plan", "acceptEdits", "bypassPermissions", "dontAsk"): + assert mode in p, mode + + def test_permissions_allow_deny_ask_strings(self): + p = UPDATE_CONFIG_PROMPT + assert '"allow"' in p and '"deny"' in p and '"ask"' in p + assert "Bash(npm:*)" in p # a real, parseable rule string + # the CORRECT working-dirs key (top-level); the TS shape only appears in + # the explicit "NOT this key" caveat, never as a taught JSON key. + assert "additionalWorkingDirectories" in p + assert '"additionalDirectories"' not in p # never as a JSON key + + def test_default_mode_caveated_as_write_only(self): + # critic MA3: defaultMode is not read back at startup — must be caveated, + # not taught as a functional settings.json key. + p = UPDATE_CONFIG_PROMPT + assert "--permission-mode" in p and "/mode" in p + assert "not yet read back at startup" in p + + def test_permission_example_rules_parse_with_semantics(self): + # critic N1: assert the SEMANTICS (the parser never returns None, so the + # old `is not None` check was vacuous). + from src.permissions.loader import settings_to_rules + + rules = settings_to_rules( + {"allow": ["Bash(npm:*)", "Read", "Edit(src/**)"], + "deny": ["Bash(rm -rf:*)"], "ask": ["Bash(git push:*)"]}, + source="user_settings", + ) + assert len(rules) == 5 + by_content = {(r.rule_value.tool_name, r.rule_value.rule_content) for r in rules} + assert ("Bash", "npm:*") in by_content + assert ("Read", None) in by_content # tool-only rule + assert ("Bash", "rm -rf:*") in by_content + + def test_env_and_hooks(self): + p = UPDATE_CONFIG_PROMPT + assert '"env"' in p + assert '"hooks"' in p and "matcher" in p and "PostToolUse" in p + # hook types + for t in ("command", "agent", "http", "prompt"): + assert t in p + + def test_hook_examples_use_port_runtime_contract(self): + # critic MA1/MA2: the EXAMPLE command uses the stdin/jq contract, not + # the TS $CLAUDE_FILE_PATHS env var (which the port never sets — it may + # still be NAMED in the explanatory "don't rely on this" caveat). + p = UPDATE_CONFIG_PROMPT + assert '"command": "jq -r' in p # example uses stdin/jq, not an env var + assert "$CLAUDE_FILE_PATHS" not in p.split("```")[3] # not inside the hooks JSON block + assert "on STDIN" in p # the real stdin-JSON contract documented + assert "agentInstructions" in p and "promptText" in p # port's real keys + # the agent hook example must not use the un-parsed "prompt" key + assert '"type": "agent", "prompt"' not in p + + def test_mcp_points_to_mcp_json_not_settings_key(self): + p = UPDATE_CONFIG_PROMPT + assert ".mcp.json" in p + assert "enableAllProjectMcpServers" in p + # NOT the TS settings.json MCP object + assert '"mcpServers"' not in p + + +class TestNoWrongKeys: + def test_absent_keys_not_taught(self): + p = UPDATE_CONFIG_PROMPT + for bad in ( + "cleanupPeriodDays", + "respectGitignore", + "spinnerTipsEnabled", + "alwaysThinkingEnabled", + "syntaxHighlightingDisabled", + ): + assert bad not in p, bad + + def test_internal_settingsschema_fields_not_taught_as_knobs(self): + p = UPDATE_CONFIG_PROMPT + for internal in ( + "advisor_model", + "auto_mode_classifier", + "memory_relevance_prefetch", + ): + assert internal not in p, internal + + def test_valid_json_examples(self): + # every fenced ```json block must be parseable JSON + import re + + blocks = re.findall(r"```json\n(.*?)\n```", UPDATE_CONFIG_PROMPT, re.DOTALL) + assert len(blocks) >= 3 + for b in blocks: + json.loads(b) # raises on malformed