diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 98252a4..d5402e7 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -31,6 +31,12 @@ "source": "./plugins/swarm", "description": "Local mixture-of-agents code review for Claude Code. Fans a diff across Claude lenses plus the codex and grok CLIs (grok-4.5), merges by mechanism with cross-family consensus, verifies solo findings and all design suggestions, and presents one ranked report. Optional --fix / --loop applies the findings you agreed with; --pr reviews a GitHub PR diff and posts the result. Skills: /swarm:review, /swarm:agents.", "version": "0.5.0" + }, + { + "name": "settings", + "source": "./plugins/settings", + "description": "Plugin settings system for Claude Code marketplaces. Per-plugin TOML config resolved over schema defaults: list, show, get, set, validate. Each plugin owns its config file, defaults, and validation schema.", + "version": "0.1.0" } ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index ca7a096..47ee482 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -231,3 +231,8 @@ entries are grouped per plugin, newest first. ### 0.1.0 — 2026-07-03 - Initial swarm plugin: local mixture-of-agents review adapter (P1). + +## settings + +### 0.1.0 — 2026-07-16 +- Initial settings plugin (phase 1: config surface). Per-plugin TOML config resolved over schema defaults, with `list` / `show [--overrides]` / `get` / `set` / `unset` / `validate` / `defaults` via `scripts/settings.py` (Python 3.11+ stdlib) and the `/settings` skill. Each plugin owns a `schema/settings.schema.json` (types, enums, defaults, config filename); work-system, knowledge-system, and pr-flow ship schemas whose defaults match current behavior. Includes a `[related_projects]` sibling-project address book (path-existence warnings, not errors). Consumer wiring lands in a follow-up. diff --git a/CLAUDE.md b/CLAUDE.md index ef4f9c8..8eca47c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -22,6 +22,7 @@ This is a **Claude Code plugin marketplace** (monorepo) containing plugins that - **work-system** (v1.8.x) — Task and worktree workflow. Skills: `/define`, `/kickoff`, `/adopt`, `/continue`, `/status`, `/close`, `/list`, `/statusline` - **pr-flow** (v1.3.x) — PR review feedback loop. Skills: `/open`, `/cycle`, `/check`, `/fix`, `/rebase`, `/merge` - **swarm** (v0.5.x) — Local mixture-of-agents code review (external `codex`/`grok` CLIs — grok-4.5 — plus Claude lenses: 11 in 4 clusters). P2: `/swarm:review` pipeline (scope→fan-out→merge→verify); P5: `--fix`/`--loop` apply the findings you agreed with. Skills: `/swarm:review`, `/swarm:agents` +- **settings** (v0.1.x) — Per-plugin TOML config resolved over schema defaults; each plugin owns its `schema/settings.schema.json`. Skill: `/settings` (list/show/get/set/validate). Phase 1: config surface only. ## Plugin Anatomy diff --git a/README.md b/README.md index cef2387..025ddc8 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,14 @@ Local mixture-of-agents code review. Fans out one review across Claude lens suba [Documentation →](plugins/swarm/) +### Settings + +Plugin settings system: per-plugin TOML config resolved over schema defaults. Each plugin owns its config file (`.work-system.toml`, `.knowledge-system.toml`, `.pr-flow.toml`), defaults, and validation schema; users override only what they need. `list`, `show`, `get`, `set`, `validate` via one script and skill. Includes a `[related_projects]` sibling-project address book for cross-project orchestration. *(Phase 1: config surface only — consumer wiring lands next.)* + +**Commands:** `/settings` *(subcommands: `list`, `show`, `get`, `set`, `unset`, `validate`, `defaults`)* + +[Documentation →](plugins/settings/) + ## Installation ### 1. Add the marketplace @@ -51,6 +59,7 @@ Local mixture-of-agents code review. Fans out one review across Claude lens suba /plugin install work-system /plugin install pr-flow /plugin install swarm +/plugin install settings ``` ### 3. Reload plugins diff --git a/plugins/knowledge-system/schema/settings.schema.json b/plugins/knowledge-system/schema/settings.schema.json new file mode 100644 index 0000000..3e71f1e --- /dev/null +++ b/plugins/knowledge-system/schema/settings.schema.json @@ -0,0 +1,57 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "knowledge-system settings", + "x-plugin": "knowledge-system", + "x-config-file": ".knowledge-system.toml", + "type": "object", + "properties": { + "paths": { + "type": "object", + "description": "Where knowledge-system stores rules, knowledge, and the index. Defaults match today's Claude-specific layout.", + "properties": { + "knowledge_dir": { + "type": "string", + "default": ".claude/knowledge", + "description": "Directory holding knowledge entries." + }, + "rules_dir": { + "type": "string", + "default": ".claude/rules", + "description": "Directory holding auto-loaded rule files." + }, + "index_file": { + "type": "string", + "default": "_index.md", + "description": "Index filename inside knowledge_dir." + }, + "claude_md": { + "type": "string", + "default": "CLAUDE.md", + "description": "Project instructions file the index is injected into." + } + } + }, + "behavior": { + "type": "object", + "properties": { + "auto_prime_via_claude_md": { + "type": "boolean", + "default": true, + "description": "Inject the knowledge index into claude_md so it auto-loads each session." + } + } + }, + "mode": { + "type": "object", + "description": "How knowledge storage binds to the host tool.", + "properties": { + "knowledge_mode": { + "type": "string", + "enum": ["claude", "neutral", "symlinked"], + "default": "claude", + "description": "claude = .claude/* paths; neutral = tool-agnostic paths; symlinked = neutral store with .claude/* symlinks. Storage only in this phase." + } + } + } + } +} diff --git a/plugins/pr-flow/schema/settings.schema.json b/plugins/pr-flow/schema/settings.schema.json new file mode 100644 index 0000000..68b0360 --- /dev/null +++ b/plugins/pr-flow/schema/settings.schema.json @@ -0,0 +1,45 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "pr-flow settings", + "x-plugin": "pr-flow", + "x-config-file": ".pr-flow.toml", + "type": "object", + "properties": { + "checks": { + "type": "object", + "description": "Readiness checks run before opening a PR. All on by default (current behavior).", + "properties": { + "knowledge_check_enabled": { + "type": "boolean", + "default": true, + "description": "Prompt to curate knowledge from the diff before opening a PR." + }, + "readme_check_enabled": { + "type": "boolean", + "default": true, + "description": "Verify READMEs are updated for added/renamed/removed features." + }, + "changelog_check_enabled": { + "type": "boolean", + "default": true, + "description": "Verify CHANGELOG is updated." + }, + "version_bump_check_enabled": { + "type": "boolean", + "default": true, + "description": "Verify plugin versions are bumped and in sync." + } + } + }, + "review": { + "type": "object", + "properties": { + "review_bot": { + "type": "string", + "default": "@claude", + "description": "Mention that triggers the automated PR review." + } + } + } + } +} diff --git a/plugins/settings/.claude-plugin/plugin.json b/plugins/settings/.claude-plugin/plugin.json new file mode 100644 index 0000000..59a5da8 --- /dev/null +++ b/plugins/settings/.claude-plugin/plugin.json @@ -0,0 +1,11 @@ +{ + "name": "settings", + "description": "Plugin settings system for Claude Code marketplaces. Per-plugin TOML config resolved over schema defaults: list, show, get, set, validate. Each plugin owns its config file, defaults, and validation schema.", + "version": "0.1.0", + "author": { + "name": "gering" + }, + "repository": "https://github.com/gering/claude-plugins", + "license": "MIT", + "keywords": ["settings", "config", "toml", "schema", "plugins"] +} diff --git a/plugins/settings/README.md b/plugins/settings/README.md new file mode 100644 index 0000000..9d4df42 --- /dev/null +++ b/plugins/settings/README.md @@ -0,0 +1,158 @@ +# settings + +A plugin settings system for the `gering-plugins` marketplace. Each plugin +declares a small schema with defaults; users override only what they need in a +plugin-local TOML file; the resolver merges the two. One skill (`/settings`) and +one script (`scripts/settings.py`, Python 3.11+ stdlib only) expose the whole +surface. + +## Why + +The plugins hardcode conventions — `tasks/`, `.claude/worktrees/`, `task/` +branches, `.claude/knowledge/`, `CLAUDE.md`. This makes those conventions +**explicit, readable, overrideable, and validatable** without duplicating +defaults into every repo. It also prepares a future neutral / Codex layout: the +schema carries a migration path (`[compat]`, `mode.knowledge_mode`) as storage +today, behavior later. + +## Ownership model + +Each plugin owns its settings, the settings plugin owns only the infrastructure: + +| Owned by each plugin | Owned by `settings` | +|----------------------|---------------------| +| `schema/settings.schema.json` (types, enums, defaults, config filename) | discovery, resolve, validate, read/write CLI | +| the semantic meaning of its keys | the merge + validation engine | + +A plugin adds settings by dropping a `schema/settings.schema.json` into its own +directory. No change to the settings plugin is needed. + +## Config files + +One TOML file per plugin at the **repo root**, holding only overrides: + +``` +.work-system.toml +.knowledge-system.toml +.pr-flow.toml +``` + +A missing file is fully valid — the plugin resolves to its schema defaults. + +## Commands + +```sh +python3 plugins/settings/scripts/settings.py +``` + +| Command | Does | +|---------|------| +| `list` | plugins + config filenames, whether an override file exists | +| `show [plugin]` | effective config: defaults merged with overrides | +| `show [plugin] --overrides` | only the user overrides (raw) | +| `defaults [plugin]` | schema defaults only | +| `get .
.` | one resolved value | +| `set .
. ` | write one override (type-coerced, enum-checked) | +| `unset .
.` | remove one override, pruning emptied sections | +| `validate [plugin]` | check TOML syntax + schema semantics; non-zero exit on errors | + +Add `--json` to any read command for machine output. Discovery of schemas and +config location can be overridden with `SETTINGS_PLUGINS_DIR` (os.pathsep- +separated roots to scan for `*/schema/settings.schema.json`) and +`SETTINGS_PROJECT_ROOT` (where config files live). + +## Schema format + +A `settings.schema.json` is a **subset of JSON Schema** plus two custom keys. +The subset the validator understands: `type` (`string` / `integer` / `number` / +`boolean` / `array` / `object`), `properties`, `items`, `enum`, `required`, and +`default`. Defaults live here — never in the config TOML. + +```jsonc +{ + "x-plugin": "work-system", // plugin name (defaults to the dir name) + "x-config-file": ".work-system.toml", // override file at the repo root + "type": "object", + "properties": { + "paths": { + "type": "object", + "properties": { + "tasks_dir": { "type": "string", "default": "tasks" } + } + }, + "mode": { + "type": "object", + "properties": { + "knowledge_mode": { + "type": "string", + "enum": ["claude", "neutral", "symlinked"], + "default": "claude" + } + } + } + } +} +``` + +### Dynamic sections (`related_projects`) + +A section annotated `"x-semantic": "related-projects"` is a free-form map of +sibling projects this repo coordinates with — the address book cross-project +orchestration consumes. Entries take either form: + +```toml +[related_projects] +backend = "/abs/path/to/backend" # shorthand: name = path + +[related_projects.frontend] # table: adds role/tags +path = "/abs/path/to/frontend" +role = "ui" +tags = ["web", "spa"] +``` + +Validation checks the shape (a `path` string is required in the table form) and +**warns** — never errors — when a path doesn't exist locally, since machines +differ. `normalize_related_projects()` resolves both forms to +`{name: {path, role?, tags?}}` for consumers. Storage/resolve only for now; no +plugin acts on it yet. + +## How plugins should consume resolved settings (contract) + +Consumer wiring lands in a follow-up. When a plugin adopts settings, it should: + +1. **Read once, resolved.** Call + `settings.py get .
. --json` (or `show --json` + for the whole effective config) and use the value. Never read the TOML file + directly — that skips defaults and validation. +2. **Fall back to the default, always.** The resolver guarantees a value even + with no config file. A consumer must not require the file to exist. +3. **Don't hardcode the old constant beside the lookup.** Replace + `tasks/` with the resolved `paths.tasks_dir`; the schema default (`"tasks"`) + preserves today's behavior. +4. **Change behavior via defaults, not code.** To migrate a convention (e.g. to + a neutral worktrees dir), flip the schema default and the `[compat]` toggle + in one deliberate step — consumers keep reading the same key. + +## Defaults reflect current behavior + +Per the task's guiding rule, defaults are **what the plugins assume today**, not +a speculative redesign — e.g. `worktrees_dir` defaults to `.claude/worktrees` +(current) with the neutral `.worktrees` target gated behind +`[compat].prefer_neutral_worktrees` (off). Migrations change defaults +deliberately, later. + +## Limitations (phase 1) + +- `set` / `unset` rewrite the override file from parsed values — **TOML comments + and formatting are not preserved**. Files are small, so this stays readable. +- Schema discovery targets the **monorepo `plugins/` layout**. For an installed + marketplace (versioned cache dirs), point `SETTINGS_PLUGINS_DIR` at the plugin + roots. A discovery story for installed marketplaces is a follow-up. +- No config-version field yet — added only when versioned migrations exist. + +## Tests + +`scripts/test_settings.py` (run by `scripts/check-structure.py` in CI) covers +default extraction, resolve merge, validation (type / enum / unknown key / +related-project paths), TOML round-trip, value coercion, and the `set`/`get`/ +`unset` CLI cycle against a throwaway schema and project root. diff --git a/plugins/settings/scripts/settings.py b/plugins/settings/scripts/settings.py new file mode 100644 index 0000000..97a48fa --- /dev/null +++ b/plugins/settings/scripts/settings.py @@ -0,0 +1,751 @@ +#!/usr/bin/env python3 +"""Plugin settings system: load, resolve, and validate per-plugin TOML config. + +Each plugin owns a `schema/settings.schema.json` file (a JSON-Schema subset that +also carries defaults). This tool discovers those schemas, reads the optional +user override file per plugin (e.g. `.work-system.toml` at the project root), +merges the two, and validates the result. + +Design invariants (see plugins/settings/README.md): + - Defaults live in the schema, never duplicated into config TOML files. + - Only user overrides are written; a missing config file is fully valid. + - `show --resolved` is the merged view consumers should read. + - Validation covers syntax (TOML) and semantics (types, enums, unknown keys, + related-project paths). + +Runtime deps: Python 3.11+ stdlib only (`tomllib`). No third-party packages, no +build step — matching the rest of this build-less marketplace repo. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tomllib +from pathlib import Path + +# --------------------------------------------------------------------------- # +# Discovery +# --------------------------------------------------------------------------- # + + +def find_project_root() -> Path: + """Directory that holds the user config TOML files. + + `SETTINGS_PROJECT_ROOT` overrides everything; otherwise the git toplevel of + the cwd; otherwise the cwd. Config files (`.work-system.toml`, …) live here. + """ + env = os.environ.get("SETTINGS_PROJECT_ROOT") + if env: + return Path(env).resolve() + try: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if out: + return Path(out).resolve() + except (subprocess.CalledProcessError, FileNotFoundError): + pass + return Path.cwd().resolve() + + +def find_plugins_dirs() -> list[Path]: + """Directories to scan for `*/schema/settings.schema.json`. + + `SETTINGS_PLUGINS_DIR` (os.pathsep-separated) overrides discovery. Otherwise + we walk up from this script and from the cwd looking for a `plugins/` dir — + which is how this monorepo lays plugins out during development. Cross-plugin + discovery for an *installed* marketplace (versioned cache dirs) is a phase-2 + concern; set SETTINGS_PLUGINS_DIR there. + """ + env = os.environ.get("SETTINGS_PLUGINS_DIR") + if env: + return [Path(p).resolve() for p in env.split(os.pathsep) if p] + + found: list[Path] = [] + for start in (Path(__file__).resolve(), Path.cwd().resolve()): + for parent in [start, *start.parents]: + candidate = parent / "plugins" + if candidate.is_dir(): + resolved = candidate.resolve() + if resolved not in found: + found.append(resolved) + break + return found + + +def load_schemas() -> dict[str, dict]: + """Map plugin name -> parsed schema, discovered across all plugins dirs. + + Plugin name comes from the schema's `x-plugin`, falling back to the owning + `plugins//` directory. First win on duplicate names (dev dir before + any installed copy). + """ + schemas: dict[str, dict] = {} + for plugins_dir in find_plugins_dirs(): + for schema_path in sorted(plugins_dir.glob("*/schema/settings.schema.json")): + try: + schema = json.loads(schema_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError) as exc: + raise SettingsError(f"{schema_path}: invalid schema JSON — {exc}") + plugin = schema.get("x-plugin") or schema_path.parent.parent.name + schema.setdefault("_source", str(schema_path)) + schemas.setdefault(plugin, schema) + return schemas + + +class SettingsError(Exception): + """A user-facing error (bad plugin name, unreadable config, …).""" + + +# --------------------------------------------------------------------------- # +# Schema helpers +# --------------------------------------------------------------------------- # + + +def config_filename(schema: dict) -> str: + name = schema.get("x-config-file") + if not name: + raise SettingsError(f"schema {schema.get('x-plugin')} lacks x-config-file") + # x-config-file comes from a (plugin-authored) schema but is joined onto the + # project root for read/write/unlink — a plain filename only, never a path + # that could traverse out (`../…`), be absolute, or expand `~`. Reject + # anything but a single basename so a malformed/hostile schema cannot touch + # files outside the project root. + if ( + name in (".", "..") + or name != os.path.basename(name) + or os.path.isabs(name) + or name.startswith("~") + ): + raise SettingsError( + f"schema {schema.get('x-plugin')}: x-config-file must be a plain " + f"filename, got {name!r}" + ) + return name + + +def extract_defaults(node: dict) -> object: + """Collect the default value tree from a schema node. + + An object node recurses into `properties`; a leaf contributes its `default` + if present. Object nodes with an explicit `default` (e.g. related_projects + `{}`) use it directly. + """ + if node.get("type") == "object" and "properties" in node: + result: dict[str, object] = {} + for key, sub in node["properties"].items(): + value = extract_defaults(sub) + if value is not _NO_DEFAULT: + result[key] = value + return result + return node.get("default", _NO_DEFAULT) + + +_NO_DEFAULT = object() + + +def schema_defaults(schema: dict) -> dict: + out = extract_defaults(schema) + return out if isinstance(out, dict) else {} + + +# --------------------------------------------------------------------------- # +# Resolve +# --------------------------------------------------------------------------- # + + +def deep_merge(base: dict, over: dict) -> dict: + """Recursively merge `over` onto a copy of `base`; scalars/arrays replace.""" + result = dict(base) + for key, value in over.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = deep_merge(result[key], value) + else: + result[key] = value + return result + + +def resolve(schema: dict, user: dict) -> dict: + return deep_merge(schema_defaults(schema), user) + + +# --------------------------------------------------------------------------- # +# User config IO +# --------------------------------------------------------------------------- # + + +def load_user_config(schema: dict, project_root: Path) -> dict: + """Parse the plugin's override TOML, or {} when absent.""" + path = project_root / config_filename(schema) + if not path.exists(): + return {} + try: + with path.open("rb") as fh: + return tomllib.load(fh) + except (tomllib.TOMLDecodeError, OSError) as exc: + raise SettingsError(f"{path}: cannot read config — {exc}") + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # + +_TYPE_LABELS = { + "string": "string", + "integer": "integer", + "number": "number", + "boolean": "boolean", + "array": "array", + "object": "table", +} + + +def _matches_type(value: object, typ: str) -> bool: + if typ == "string": + return isinstance(value, str) + if typ == "boolean": + return isinstance(value, bool) + if typ == "integer": + return isinstance(value, int) and not isinstance(value, bool) + if typ == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + if typ == "array": + return isinstance(value, list) + if typ == "object": + return isinstance(value, dict) + return True + + +def validate(schema: dict, user: dict, project_root: Path) -> list[tuple[str, str]]: + """Return (level, message) findings for a plugin's user config. + + level is "error" (invalid config) or "warn" (suspect but tolerated, e.g. an + unknown key or a related-project path that doesn't exist locally). + """ + findings: list[tuple[str, str]] = [] + _validate_node(schema, user, "", findings, project_root) + return findings + + +def _validate_node(node: dict, value: object, path: str, findings, project_root): + semantic = node.get("x-semantic") + if semantic == "related-projects": + _validate_related_projects(value, path, findings, project_root) + return + + typ = node.get("type") + if typ and not _matches_type(value, typ): + findings.append( + ("error", f"{path or ''}: expected {_TYPE_LABELS.get(typ, typ)}, " + f"got {type(value).__name__}") + ) + return + + if "enum" in node and value not in node["enum"]: + allowed = ", ".join(json.dumps(v) for v in node["enum"]) + findings.append(("error", f"{path}: {value!r} not in allowed values [{allowed}]")) + + if typ == "object" and isinstance(value, dict): + props = node.get("properties", {}) + for key in node.get("required", []): + if key not in value: + findings.append(("error", f"{path or ''}: missing required key '{key}'")) + for key, sub in value.items(): + child = f"{path}.{key}" if path else key + if key in props: + _validate_node(props[key], sub, child, findings, project_root) + else: + findings.append(("warn", f"{child}: unknown key (not in schema)")) + + if typ == "array" and isinstance(value, list) and "items" in node: + for i, item in enumerate(value): + _validate_node(node["items"], item, f"{path}[{i}]", findings, project_root) + + +def _validate_related_projects(value, path, findings, project_root): + if not isinstance(value, dict): + findings.append(("error", f"{path}: expected a table of project entries")) + return + for name, entry in value.items(): + child = f"{path}.{name}" + if isinstance(entry, str): + proj_path = entry + elif isinstance(entry, dict): + if "path" not in entry: + findings.append(("error", f"{child}: table entry missing 'path'")) + continue + proj_path = entry["path"] + if not isinstance(proj_path, str): + findings.append(("error", f"{child}.path: expected string")) + continue + if "role" in entry and not isinstance(entry["role"], str): + findings.append(("error", f"{child}.role: expected string")) + if "tags" in entry and not ( + isinstance(entry["tags"], list) + and all(isinstance(t, str) for t in entry["tags"]) + ): + findings.append(("error", f"{child}.tags: expected array of strings")) + for extra in set(entry) - {"path", "role", "tags"}: + findings.append(("warn", f"{child}.{extra}: unknown key (not in schema)")) + else: + findings.append(("error", f"{child}: expected a path string or table")) + continue + candidate = Path(proj_path) + if not candidate.is_absolute(): + candidate = project_root / candidate + if not candidate.exists(): + findings.append(("warn", f"{child}: path does not exist locally ({proj_path})")) + + +def normalize_related_projects(value: dict) -> dict: + """Resolve string/table entries to a uniform {name: {path, role?, tags?}}.""" + out: dict[str, dict] = {} + for name, entry in value.items(): + if isinstance(entry, str): + out[name] = {"path": entry} + elif isinstance(entry, dict): + out[name] = dict(entry) + return out + + +# --------------------------------------------------------------------------- # +# TOML serialization (write path) +# --------------------------------------------------------------------------- # + + +_TOML_SHORT_ESCAPES = { + "\\": "\\\\", + '"': '\\"', + "\n": "\\n", + "\r": "\\r", + "\t": "\\t", + "\b": "\\b", + "\f": "\\f", +} + + +def _toml_basic_string(s: str) -> str: + """A TOML basic string with control chars escaped, so it always round-trips. + + Plain `"…"` quoting that left a raw newline/tab inside produced a file tomllib + then refused to parse. Escape the short forms and \\uXXXX everything else in + the C0 range (+ DEL), which TOML basic strings forbid unescaped. + """ + out = [] + for ch in s: + if ch in _TOML_SHORT_ESCAPES: + out.append(_TOML_SHORT_ESCAPES[ch]) + elif ord(ch) < 0x20 or ord(ch) == 0x7F: + out.append(f"\\u{ord(ch):04X}") + else: + out.append(ch) + return '"' + "".join(out) + '"' + + +def _is_bare_key(k: str) -> bool: + """TOML bare-key charset: ASCII letters, digits, `_`, `-`.""" + return bool(k) and all( + ("a" <= c <= "z") or ("A" <= c <= "Z") or ("0" <= c <= "9") or c in "_-" + for c in k + ) + + +def _toml_key(k: str) -> str: + """A key/table-name segment: bare where legal, else a quoted string.""" + return k if _is_bare_key(k) else _toml_basic_string(k) + + +def _toml_scalar(value: object) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, str): + return _toml_basic_string(value) + if isinstance(value, list): + return "[" + ", ".join(_toml_scalar(v) for v in value) + "]" + raise SettingsError(f"cannot serialize value of type {type(value).__name__}") + + +def dump_toml(data: dict) -> str: + """Serialize a two-level config dict to TOML. + + Handles section -> {key: scalar/array} plus one level of sub-tables (used by + related_projects table entries). Comments and original formatting are NOT + preserved — `set`/`unset` rewrite the override file from parsed values. This + is an accepted phase-1 limitation; override files are small and defaults live + in the schema, so a regenerated file stays readable. + """ + lines: list[str] = [] + for section, body in data.items(): + if not isinstance(body, dict): + raise SettingsError(f"top-level key '{section}' must be a table") + scalars = {k: v for k, v in body.items() if not isinstance(v, dict)} + tables = {k: v for k, v in body.items() if isinstance(v, dict)} + if scalars or not tables: + if lines: + lines.append("") + lines.append(f"[{_toml_key(section)}]") + for key, value in scalars.items(): + lines.append(f"{_toml_key(key)} = {_toml_scalar(value)}") + for name, sub in tables.items(): + if lines: + lines.append("") + lines.append(f"[{_toml_key(section)}.{_toml_key(name)}]") + for key, value in sub.items(): + lines.append(f"{_toml_key(key)} = {_toml_scalar(value)}") + return "\n".join(lines) + "\n" if lines else "" + + +def write_user_config(schema: dict, project_root: Path, data: dict) -> Path: + path = project_root / config_filename(schema) + text = dump_toml(data) + if text: + path.write_text(text, encoding="utf-8") + elif path.exists(): + path.unlink() # emptied config → remove the file entirely + return path + + +# --------------------------------------------------------------------------- # +# Path navigation for get/set/unset +# --------------------------------------------------------------------------- # + + +def split_dotted(path: str) -> list[str]: + parts = [p for p in path.split(".") if p] + if not parts: + raise SettingsError("empty setting path") + return parts + + +def dig(data: dict, keys: list[str]): + cur: object = data + for key in keys: + if not isinstance(cur, dict) or key not in cur: + return _NO_DEFAULT + cur = cur[key] + return cur + + +def resolve_set_target(schema: dict, keys: list[str]) -> tuple[str, object]: + """Classify a dotted set-path against the schema. Returns (kind, detail): + + - ("leaf", spec) settable scalar/array leaf → coerce + enum-check + - ("section", node) path stops at an object/section → reject (#5) + - ("scalar-prefix", i) key[i] descends past a scalar leaf → reject (#8) + - ("dynamic", None) under a dynamic-map (related_projects) → free-form + - ("unknown", None) key not in schema → written with a warning + """ + node = schema + for i, key in enumerate(keys): + if node.get("x-semantic") == "related-projects": + return ("dynamic", None) # entries are free-form project names + if node.get("type") == "object" or "properties" in node: + props = node.get("properties") + if not isinstance(props, dict) or key not in props: + return ("unknown", None) + node = props[key] + else: + return ("scalar-prefix", i) # node is a scalar leaf, but keys remain + if node.get("type") == "object" or "properties" in node: + return ("section", node) + return ("leaf", node) + + +def coerce_value(raw: str, spec: dict | None) -> object: + """Coerce a CLI string to the schema type; enum-checked by the caller.""" + typ = spec.get("type") if spec else None + if typ == "boolean": + low = raw.strip().lower() + if low in ("true", "1", "yes", "on"): + return True + if low in ("false", "0", "no", "off"): + return False + raise SettingsError(f"cannot read {raw!r} as boolean") + if typ == "integer": + try: + return int(raw) + except ValueError: + raise SettingsError(f"cannot read {raw!r} as integer") + if typ == "number": + try: + return float(raw) + except ValueError: + raise SettingsError(f"cannot read {raw!r} as number") + if typ == "array": + raw = raw.strip() + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return parsed + except json.JSONDecodeError: + pass + return [p.strip() for p in raw.split(",") if p.strip()] + return raw + + +# --------------------------------------------------------------------------- # +# Command implementations +# --------------------------------------------------------------------------- # + + +def _pick_schema(schemas: dict[str, dict], plugin: str) -> dict: + if plugin not in schemas: + known = ", ".join(sorted(schemas)) or "(none discovered)" + raise SettingsError(f"unknown plugin '{plugin}'. Known: {known}") + return schemas[plugin] + + +def cmd_list(args, schemas, project_root): + rows = [] + for plugin in sorted(schemas): + schema = schemas[plugin] + cfg = config_filename(schema) + exists = (project_root / cfg).exists() + rows.append({"plugin": plugin, "config_file": cfg, "override_present": exists}) + if args.json: + print(json.dumps(rows, indent=2)) + else: + width = max((len(r["plugin"]) for r in rows), default=6) + for r in rows: + mark = "override" if r["override_present"] else "defaults" + print(f"{r['plugin']:<{width}} {r['config_file']:<24} [{mark}]") + + +def cmd_show(args, schemas, project_root): + plugins = [args.plugin] if args.plugin else sorted(schemas) + result = {} + for plugin in plugins: + schema = _pick_schema(schemas, plugin) + user = load_user_config(schema, project_root) + result[plugin] = user if args.overrides else resolve(schema, user) + if args.json: + print(json.dumps(result if args.plugin is None else result[args.plugin], indent=2)) + return + for plugin in plugins: + header = f"# {plugin}" + (" (overrides)" if args.overrides else " (resolved)") + print(header) + body = dump_toml(result[plugin]) + print(body if body.strip() else "# (no settings)") + if plugin != plugins[-1]: + print() + + +def cmd_defaults(args, schemas, project_root): + plugins = [args.plugin] if args.plugin else sorted(schemas) + result = {p: schema_defaults(_pick_schema(schemas, p)) for p in plugins} + if args.json: + print(json.dumps(result if args.plugin is None else result[args.plugin], indent=2)) + return + for plugin in plugins: + print(f"# {plugin} (defaults)") + print(dump_toml(result[plugin]) or "# (no defaults)") + if plugin != plugins[-1]: + print() + + +def cmd_get(args, schemas, project_root): + keys = split_dotted(args.path) + plugin, rest = keys[0], keys[1:] + schema = _pick_schema(schemas, plugin) + resolved = resolve(schema, load_user_config(schema, project_root)) + value = dig(resolved, rest) + if value is _NO_DEFAULT: + raise SettingsError(f"no setting at '{args.path}'") + if args.json: + print(json.dumps(value)) + elif isinstance(value, (dict, list)): + print(json.dumps(value)) + elif isinstance(value, bool): + print("true" if value else "false") + else: + print(value) + + +def cmd_set(args, schemas, project_root): + keys = split_dotted(args.path) + plugin, rest = keys[0], keys[1:] + if not rest: + raise SettingsError("set path must include a section and key, e.g. work-system.paths.tasks_dir") + schema = _pick_schema(schemas, plugin) + kind, detail = resolve_set_target(schema, rest) + if kind == "section": + raise SettingsError( + f"{args.path} is a section, not a value — specify a section and key, " + f"e.g. {args.path}." + ) + if kind == "scalar-prefix": + prefix = ".".join([plugin] + rest[: detail + 1]) + raise SettingsError(f"{prefix} is a value, not a section — cannot set a key beneath it") + + spec = detail if kind == "leaf" else None + if kind == "dynamic": + value = args.value # free-form entry (e.g. a related_projects path) — no coercion + else: + value = coerce_value(args.value, spec) + if spec and "enum" in spec and value not in spec["enum"]: + allowed = ", ".join(json.dumps(v) for v in spec["enum"]) + raise SettingsError(f"{args.path}: {value!r} not in allowed values [{allowed}]") + + user = load_user_config(schema, project_root) + cur = user + for key in rest[:-1]: + nxt = cur.get(key) + if not isinstance(nxt, dict): + nxt = {} + cur[key] = nxt + cur = nxt + cur[rest[-1]] = value + + # Gate the write on schema validity: a coerced array with wrong element types + # (#4) or a dynamic related_projects entry given a mistyped field (#6) must + # not silently write an invalid config. Only errors block; warnings (unknown + # key, non-existent related-project path) are tolerated as before. + errors = [msg for lvl, msg in validate(schema, user, project_root) if lvl == "error"] + if errors: + raise SettingsError( + f"{args.path}: refusing to write an invalid config — " + + "; ".join(errors) + ) + + path = write_user_config(schema, project_root, user) + if kind == "unknown": + print(f"set {args.path} = {json.dumps(value)} (warning: not in schema) -> {path}") + else: + print(f"set {args.path} = {json.dumps(value)} -> {path}") + + +def cmd_unset(args, schemas, project_root): + keys = split_dotted(args.path) + plugin, rest = keys[0], keys[1:] + schema = _pick_schema(schemas, plugin) + user = load_user_config(schema, project_root) + cur = user + trail = [cur] + for key in rest[:-1]: + if not isinstance(cur.get(key), dict): + raise SettingsError(f"no override at '{args.path}'") + cur = cur[key] + trail.append(cur) + if not rest or rest[-1] not in cur: + raise SettingsError(f"no override at '{args.path}'") + del cur[rest[-1]] + # prune now-empty parent tables so the file stays minimal + for key, parent in zip(reversed(rest[:-1]), reversed(trail[:-1])): + if isinstance(parent.get(key), dict) and not parent[key]: + del parent[key] + path = write_user_config(schema, project_root, user) + print(f"unset {args.path} -> {path}") + + +def cmd_validate(args, schemas, project_root): + plugins = [args.plugin] if args.plugin else sorted(schemas) + all_findings: dict[str, list[tuple[str, str]]] = {} + errors = 0 + warns = 0 + for plugin in plugins: + schema = _pick_schema(schemas, plugin) + try: + user = load_user_config(schema, project_root) + except SettingsError as exc: + all_findings[plugin] = [("error", str(exc))] + errors += 1 + continue + findings = validate(schema, user, project_root) + all_findings[plugin] = findings + errors += sum(1 for lvl, _ in findings if lvl == "error") + warns += sum(1 for lvl, _ in findings if lvl == "warn") + + if args.json: + print(json.dumps( + {p: [{"level": l, "message": m} for l, m in f] for p, f in all_findings.items()}, + indent=2, + )) + else: + for plugin in plugins: + findings = all_findings[plugin] + if not findings: + print(f"{plugin}: OK") + continue + print(f"{plugin}:") + for lvl, msg in findings: + print(f" {lvl.upper():5} {msg}") + print() + print(f"{errors} error(s), {warns} warning(s)") + return 1 if errors else 0 + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="settings", description="Plugin settings system") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("list", help="list plugins and their config files") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_list) + + p = sub.add_parser("show", help="show effective config (defaults + overrides)") + p.add_argument("plugin", nargs="?", help="restrict to one plugin") + p.add_argument("--overrides", action="store_true", help="show only user overrides, not the merged result") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_show) + + p = sub.add_parser("defaults", help="show schema defaults") + p.add_argument("plugin", nargs="?") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_defaults) + + p = sub.add_parser("get", help="read a resolved value: .
.") + p.add_argument("path") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_get) + + p = sub.add_parser("set", help="write an override: .
. ") + p.add_argument("path") + p.add_argument("value") + p.set_defaults(func=cmd_set) + + p = sub.add_parser("unset", help="remove an override: .
.") + p.add_argument("path") + p.set_defaults(func=cmd_unset) + + p = sub.add_parser("validate", help="validate config syntax + semantics") + p.add_argument("plugin", nargs="?") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_validate) + + return parser + + +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + schemas = load_schemas() + if not schemas: + print("no plugin schemas found (set SETTINGS_PLUGINS_DIR?)", file=sys.stderr) + return 1 + project_root = find_project_root() + rc = args.func(args, schemas, project_root) + return rc if isinstance(rc, int) else 0 + except SettingsError as exc: + print(f"error: {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/settings/scripts/test_settings.py b/plugins/settings/scripts/test_settings.py new file mode 100644 index 0000000..815c3b9 --- /dev/null +++ b/plugins/settings/scripts/test_settings.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Self-contained tests for settings.py. Run by check-structure.py in CI. + +Uses a throwaway plugins dir + project root via SETTINGS_PLUGINS_DIR / +SETTINGS_PROJECT_ROOT so nothing touches the real repo config. +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import settings # noqa: E402 + +SCHEMA = { + "x-plugin": "demo", + "x-config-file": ".demo.toml", + "type": "object", + "properties": { + "paths": { + "type": "object", + "properties": { + "tasks_dir": {"type": "string", "default": "tasks"}, + "worktrees_dir": {"type": "string", "default": ".claude/worktrees"}, + }, + }, + "branches": { + "type": "object", + "properties": { + "task_prefix": {"type": "string", "default": "task/"}, + "backup_prefixes": { + "type": "array", + "items": {"type": "string"}, + "default": ["backup/", "old/"], + }, + }, + }, + "behavior": { + "type": "object", + "properties": { + "copy_task_to_worktree": {"type": "boolean", "default": True}, + "retries": {"type": "integer", "default": 3}, + }, + }, + "mode": { + "type": "object", + "properties": { + "knowledge_mode": { + "type": "string", + "enum": ["claude", "neutral", "symlinked"], + "default": "claude", + }, + }, + }, + "related_projects": { + "type": "object", + "x-semantic": "related-projects", + "default": {}, + }, + }, +} + + +def make_env(tmp: Path) -> tuple[Path, Path]: + """Create a plugins dir with the demo schema + a project root; wire env.""" + plugins = tmp / "plugins" + schema_dir = plugins / "demo" / "schema" + schema_dir.mkdir(parents=True) + (schema_dir / "settings.schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8") + project = tmp / "project" + project.mkdir() + os.environ["SETTINGS_PLUGINS_DIR"] = str(plugins) + os.environ["SETTINGS_PROJECT_ROOT"] = str(project) + return plugins, project + + +def run(argv: list[str]) -> int: + return settings.main(argv) + + +def test_defaults_and_resolve(): + schema = SCHEMA + defaults = settings.schema_defaults(schema) + assert defaults["paths"]["tasks_dir"] == "tasks" + assert defaults["branches"]["backup_prefixes"] == ["backup/", "old/"] + assert defaults["behavior"]["retries"] == 3 + assert defaults["related_projects"] == {} + # user override replaces just the touched leaf; siblings survive + merged = settings.resolve(schema, {"paths": {"tasks_dir": "todo"}}) + assert merged["paths"]["tasks_dir"] == "todo" + assert merged["paths"]["worktrees_dir"] == ".claude/worktrees" + print("ok: defaults + resolve") + + +def test_validation(project: Path): + # type error, enum error, unknown key + bad = { + "behavior": {"retries": "three"}, + "mode": {"knowledge_mode": "bogus"}, + "paths": {"nope": "x"}, + } + findings = settings.validate(SCHEMA, bad, project) + levels = [lvl for lvl, _ in findings] + msgs = " | ".join(m for _, m in findings) + assert "error" in levels, msgs + assert "expected integer" in msgs, msgs + assert "not in allowed values" in msgs, msgs + assert any(lvl == "warn" and "unknown key" in m for lvl, m in findings), msgs + # a clean config validates with no findings + assert settings.validate(SCHEMA, {"paths": {"tasks_dir": "t"}}, project) == [] + print("ok: validation (type/enum/unknown)") + + +def test_related_projects(project: Path): + existing = project / "sibling" + existing.mkdir() + cfg = { + "related_projects": { + "backend": str(existing), # string form, exists + "frontend": {"path": str(project / "missing"), "role": "ui", "tags": ["web"]}, + "broken": {"role": "x"}, # table missing 'path' + } + } + findings = settings.validate(SCHEMA, cfg, project) + msgs = " | ".join(f"{l}:{m}" for l, m in findings) + assert any(l == "warn" and "does not exist" in m and "frontend" in m for l, m in findings), msgs + assert any(l == "error" and "missing 'path'" in m for l, m in findings), msgs + assert not any("backend" in m for _, m in findings), msgs # existing path: silent + norm = settings.normalize_related_projects(cfg["related_projects"]) + assert norm["backend"] == {"path": str(existing)} + assert norm["frontend"]["role"] == "ui" + print("ok: related_projects semantics") + + +def test_toml_roundtrip(): + data = { + "paths": {"tasks_dir": "tasks"}, + "branches": {"backup_prefixes": ["a/", "b/"]}, + "behavior": {"copy_task_to_worktree": False, "retries": 5}, + "related_projects": {"api": {"path": "/x", "tags": ["p"]}}, + } + import tomllib + + reparsed = tomllib.loads(settings.dump_toml(data)) + assert reparsed == data, reparsed + assert settings.dump_toml({}) == "" + + # control chars in a string value must escape so the file reparses (#1) + tricky = {"paths": {"tasks_dir": "a\nb\tc\\d\"e"}} + assert tomllib.loads(settings.dump_toml(tricky)) == tricky + # non-bare table names / keys must be quoted, not emitted raw (#7) + special = {"related_projects": {"web api": {"path": "/x"}, "a.b": {"path": "/y"}}} + assert tomllib.loads(settings.dump_toml(special)) == special + print("ok: toml dump round-trips (control chars + non-bare keys)") + + +def test_config_filename_sandbox(): + # x-config-file must be a plain basename — traversal / absolute / ~ rejected (#3) + for bad in ["../escape.toml", "/tmp/x.toml", "a/b.toml", "~/x.toml", "..", "."]: + try: + settings.config_filename({"x-plugin": "demo", "x-config-file": bad}) + assert False, f"expected rejection for {bad!r}" + except settings.SettingsError: + pass + assert settings.config_filename({"x-config-file": ".demo.toml"}) == ".demo.toml" + print("ok: config filename sandbox") + + +def test_set_guards(project: Path): + cfg_path = project / ".demo.toml" + if cfg_path.exists(): + cfg_path.unlink() + # #5: setting a section (object node) is rejected, nothing written + assert run(["set", "demo.paths", "foo"]) == 1 + assert not cfg_path.exists() + # #8: descending past a scalar leaf is rejected + assert run(["set", "demo.paths.tasks_dir.extra", "x"]) == 1 + assert not cfg_path.exists() + # #4: an array with wrong element types is refused before writing + assert run(["set", "demo.branches.backup_prefixes", "[1,2,3]"]) == 1 + assert not cfg_path.exists() + # valid array still works + assert run(["set", "demo.branches.backup_prefixes", '["x/","y/"]']) == 0 + import tomllib + + with cfg_path.open("rb") as fh: + assert tomllib.load(fh)["branches"]["backup_prefixes"] == ["x/", "y/"] + # #6 guard: a mistyped dynamic related_projects field can't silently corrupt + assert run(["set", "demo.related_projects.frontend.tags", '["web"]']) == 1 + # string-shorthand related_projects entry still works (path warns, not errors) + assert run(["set", "demo.related_projects.backend", "/tmp"]) == 0 + with cfg_path.open("rb") as fh: + assert tomllib.load(fh)["related_projects"]["backend"] == "/tmp" + cfg_path.unlink() # leave a clean slate for the next test + print("ok: set guards (#4 #5 #6 #8)") + + +def test_coercion(): + assert settings.coerce_value("false", {"type": "boolean"}) is False + assert settings.coerce_value("on", {"type": "boolean"}) is True + assert settings.coerce_value("7", {"type": "integer"}) == 7 + assert settings.coerce_value("a, b ,c", {"type": "array"}) == ["a", "b", "c"] + assert settings.coerce_value('["x","y"]', {"type": "array"}) == ["x", "y"] + assert settings.coerce_value("plain", {"type": "string"}) == "plain" + try: + settings.coerce_value("nope", {"type": "integer"}) + assert False, "expected SettingsError" + except settings.SettingsError: + pass + print("ok: coercion") + + +def test_cli_set_get_unset(project: Path): + cfg_path = project / ".demo.toml" + assert run(["set", "demo.paths.tasks_dir", "todo"]) == 0 + assert cfg_path.exists() + import tomllib + + with cfg_path.open("rb") as fh: + assert tomllib.load(fh)["paths"]["tasks_dir"] == "todo" + # get returns the resolved value (override wins) + assert run(["get", "demo.paths.tasks_dir"]) == 0 + # get a default (not overridden) still resolves + assert run(["get", "demo.branches.task_prefix"]) == 0 + # enum-safe set rejects an invalid value and does not write it + assert run(["set", "demo.mode.knowledge_mode", "bogus"]) == 1 + assert run(["set", "demo.mode.knowledge_mode", "neutral"]) == 0 + # boolean coercion through the CLI + assert run(["set", "demo.behavior.copy_task_to_worktree", "false"]) == 0 + with cfg_path.open("rb") as fh: + loaded = tomllib.load(fh) + assert loaded["behavior"]["copy_task_to_worktree"] is False + assert loaded["mode"]["knowledge_mode"] == "neutral" + # unset prunes the emptied section + assert run(["unset", "demo.behavior.copy_task_to_worktree"]) == 0 + with cfg_path.open("rb") as fh: + assert "behavior" not in tomllib.load(fh) + # unset a missing key errors + assert run(["unset", "demo.paths.missing"]) == 1 + print("ok: cli set/get/unset") + + +def test_cli_list_show_validate(): + assert run(["list"]) == 0 + assert run(["show", "demo"]) == 0 # resolved by default + assert run(["show", "demo", "--overrides"]) == 0 + assert run(["show", "--json"]) == 0 + assert run(["defaults", "demo", "--json"]) == 0 + assert run(["validate"]) == 0 # current config is valid + assert run(["get", "demo.does.not.exist"]) == 1 + assert run(["show", "no-such-plugin"]) == 1 + print("ok: cli list/show/validate") + + +def main() -> int: + with tempfile.TemporaryDirectory() as td: + tmp = Path(td) + _, project = make_env(tmp) + test_defaults_and_resolve() + test_validation(project) + test_related_projects(project) + test_toml_roundtrip() + test_config_filename_sandbox() + test_coercion() + test_set_guards(project) + test_cli_set_get_unset(project) + test_cli_list_show_validate() + print("\nall settings tests passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/settings/skills/settings/SKILL.md b/plugins/settings/skills/settings/SKILL.md new file mode 100644 index 0000000..bcf4e27 --- /dev/null +++ b/plugins/settings/skills/settings/SKILL.md @@ -0,0 +1,66 @@ +--- +name: settings +description: | + Read/write per-plugin TOML settings resolved over schema defaults. + Trigger: "show settings", "settings for work-system", "set a setting", "validate config". +user_invocable: true +--- + +# Plugin Settings + +> List, read, write, and validate per-plugin settings. Each plugin owns a config +> file (`.work-system.toml`, `.knowledge-system.toml`, `.pr-flow.toml`), its +> defaults, and its validation schema. Only user overrides are stored; defaults +> live in the schema and are merged in on read. + +## Arguments + +`$ARGUMENTS` is a settings subcommand. Map the user's intent to one of: + +| Intent | Command | +|--------|---------| +| what's configurable | `list` | +| see the effective config | `show ` | +| see only a plugin's overrides | `show --overrides` | +| see built-in defaults | `defaults ` | +| read one value | `get .
.` | +| change one value | `set .
. ` | +| remove an override | `unset .
.` | +| check config health | `validate` | + +`` is optional for `show`/`defaults`/`validate` (omit = all plugins). + +## Instructions + +1. **Run the script** — all logic lives in it; don't reimplement it in prose: + ```sh + python3 "${CLAUDE_PLUGIN_ROOT}/scripts/settings.py" [args...] + ``` + Add `--json` to any read command (`list`, `show`, `defaults`, `get`, + `validate`) when you need to parse the output; the default is human-readable. + +2. **Pick the subcommand** from the table above based on what the user asked. + - For a read ("what's the tasks dir?"), prefer `get .
.` + for one value, or `show ` for the whole effective config (add + `--overrides` to see only what the user changed). + - For a write, use `set`. The script coerces the value to the schema type and + rejects out-of-enum values, so pass the raw string (`set + knowledge-system.mode.knowledge_mode neutral`). It writes only the override + into the plugin's TOML file at the repo root. + +3. **Report results plainly.** For `validate`, surface errors vs warnings as the + script prints them (exit code is non-zero when there are errors). A missing + config file is not an error — every plugin resolves to its defaults. + +## Notes + +- **Discovery:** the script finds `plugins/*/schema/settings.schema.json` by + walking up to a `plugins/` dir, and config files at the git repo root. Override + with `SETTINGS_PLUGINS_DIR` / `SETTINGS_PROJECT_ROOT` if running outside the + monorepo layout. +- **`set`/`unset` rewrite the override file** from parsed values — TOML comments + and original formatting are not preserved. Override files are small (defaults + come from the schema), so this stays readable. +- **Consumers** (teaching `work-system` / `knowledge-system` to read resolved + settings) are a follow-up; this skill only manages the config surface. +- See `plugins/settings/README.md` for the schema format and design rationale. diff --git a/plugins/work-system/schema/settings.schema.json b/plugins/work-system/schema/settings.schema.json new file mode 100644 index 0000000..90e2a26 --- /dev/null +++ b/plugins/work-system/schema/settings.schema.json @@ -0,0 +1,90 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "work-system settings", + "x-plugin": "work-system", + "x-config-file": ".work-system.toml", + "type": "object", + "properties": { + "paths": { + "type": "object", + "description": "Where work-system stores tasks, worktrees, and Claude config.", + "properties": { + "tasks_dir": { + "type": "string", + "default": "tasks", + "description": "Directory holding task markdown files, relative to the repo root." + }, + "worktrees_dir": { + "type": "string", + "default": ".claude/worktrees", + "description": "Directory that holds task worktrees. Default matches current behavior; the neutral `.worktrees` target is opt-in via [compat].prefer_neutral_worktrees." + }, + "claude_settings_file": { + "type": "string", + "default": ".claude/settings.json", + "description": "Claude Code settings file copied into new worktrees when auto_copy_claude_settings is on." + } + } + }, + "branches": { + "type": "object", + "description": "Branch naming conventions.", + "properties": { + "task_prefix": { + "type": "string", + "default": "task/", + "description": "Prefix for task branches created by /kickoff (e.g. task/)." + }, + "backup_prefixes": { + "type": "array", + "items": { "type": "string" }, + "default": ["backup/", "archive/", "old/"], + "description": "Branch prefixes treated as non-task backups when resolving task names." + } + } + }, + "behavior": { + "type": "object", + "description": "Operational toggles.", + "properties": { + "copy_task_to_worktree": { + "type": "boolean", + "default": true, + "description": "Copy the task file into the worktree on /kickoff instead of symlinking." + }, + "task_filename": { + "type": "string", + "default": "TASK.md", + "description": "Filename the task file takes inside a worktree." + }, + "auto_copy_claude_settings": { + "type": "boolean", + "default": true, + "description": "Copy claude_settings_file into new worktrees on /kickoff." + } + } + }, + "compat": { + "type": "object", + "description": "Migration toggles between Claude-specific and neutral conventions. Storage only in this phase; no plugin consumes them yet.", + "properties": { + "prefer_neutral_worktrees": { + "type": "boolean", + "default": false, + "description": "When true, prefer a neutral `.worktrees` dir over `.claude/worktrees`. Off by default to match current behavior." + }, + "allow_legacy_claude_worktrees": { + "type": "boolean", + "default": true, + "description": "Keep recognizing an existing `.claude/worktrees` dir during a migration." + } + } + }, + "related_projects": { + "type": "object", + "x-semantic": "related-projects", + "default": {}, + "description": "Address book of sibling projects this repo coordinates with. Each entry is `name = \"/abs/path\"`, or a table `name = { path = \"/abs/path\", role = \"backend\", tags = [\"api\"] }`. A missing path is a warning, not an error (machines differ). Storage/resolve only — no behavior yet." + } + } +}