Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/skills/bundled/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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
Expand All @@ -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",
]
148 changes: 148 additions & 0 deletions src/skills/bundled/update_config.py
Original file line number Diff line number Diff line change
@@ -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 ``<cwd>/.clawcodex/settings.json``,
local ``.clawcodex/settings.local.json``) — NOT ``.claude/`` (the real
Claude Code harness owns ``<project>/.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 |
| `<project>/.clawcodex/settings.json` | Project | Commit |
| `<project>/.clawcodex/settings.local.json` | Personal, this project | Gitignore |

**Do NOT write to `<project>/.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": <tool-name or pattern>, "hooks": [ <hook>, … ] }`. 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="<what to configure>",
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,
)
)
161 changes: 161 additions & 0 deletions tests/skills/test_update_config_skill.py
Original file line number Diff line number Diff line change
@@ -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 "`<project>/.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 "<project>/.clawcodex/settings.json" in p
assert "<project>/.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