diff --git a/.ai/skill-build.md b/.ai/skill-build.md index 234e6ed..b8c7da6 100644 --- a/.ai/skill-build.md +++ b/.ai/skill-build.md @@ -38,6 +38,14 @@ def on_unload(self): pass ``` +**Auto-injected attribute — always present, never `None`:** + +```python +self.configuration # dict[str, str] — values from the configuration: block in config.yaml +``` + +The daemon sets `self.configuration` on every skill instance **before** calling `on_load`. It is available in `on_load`, `run()`, `on_error()`, and `on_unload()`. Defaults to `{}` when no `configuration:` block exists in `config.yaml`. See Section 6 for the full configuration workflow. + **Minimal skeleton (start here):** ```python @@ -150,8 +158,15 @@ class_name: MySkill # str — class name inside entrypoint. Required fo # is used at runtime). requires: [] # list[str] — reserved for future inter-skill dependencies; parsed but not # currently enforced by the daemon. Safe to omit. +config_fields: # list — declares user-configurable parameters; see Section 6 + - key: target_language # str (required) — YAML key written into the configuration: block + description: "Target language code" # str (required) — prompt label shown during nimble add + default: "en" # str or null (optional) — used when user presses Enter; must be in possible_values + possible_values: [en, es, fr] # list[str] (optional) — constrains valid input; free-text if absent ``` +A `ManifestError` is raised in the following cases: `config_fields` is present but not a list; any entry is not a YAML mapping; an entry is missing `key` or `description`; `default` is set to a non-string, non-null value; `default` is not in `possible_values` when both are set; or `possible_values` contains non-string items. Absence of `config_fields` is not an error — `ManifestSpec.config_fields` defaults to `[]`. + **`permissions` values** (shown to users before community-skill install): | Value | Meaning | @@ -164,7 +179,7 @@ requires: [] # list[str] — reserved for future inter-skill dep **`dependencies`** — pip package names for `nimble add` install. Example: `[anthropic, requests]` -**`api_version`** — must be ≤ `SUPPORTED_API_VERSION` in `nimble/__init__.py` (currently `1`). The daemon refuses skills whose `api_version` exceeds `SUPPORTED_API_VERSION`; skills targeting an earlier version load with a deprecation warning. Increment on every breaking interface change (see Section 8). +**`api_version`** — must be ≤ `SUPPORTED_API_VERSION` in `nimble/__init__.py` (currently `1`). The daemon refuses skills whose `api_version` exceeds `SUPPORTED_API_VERSION`; skills targeting an earlier version load with a deprecation warning. Increment on every breaking interface change (see Section 9). --- @@ -180,6 +195,8 @@ skills: class_name: MySkill # class name inside skill.py binding: "ctrl+shift+m" # hotkey — pynput format, case-insensitive disabled: false # optional — set true to skip-load this skill (set by `nimble disable`) + configuration: # optional — key-value pairs injected as self.configuration; see Section 6 + target_language: es # all values stored and delivered as strings ai: provider: anthropic # anthropic | openai @@ -197,7 +214,82 @@ ai: --- -## 6. Complete Working Example +## 6. Skill Custom Configuration + +Skills can declare named configuration parameters in `manifest.yaml` (`config_fields`). When installed via `nimble add`, the user is prompted for each field interactively; the collected values are written into the `configuration:` block in `config.yaml`. The daemon then injects those values as `self.configuration` (`dict[str, str]`) before `on_load` is called. + +### Step 1 — Declare fields in `manifest.yaml` + +```yaml +config_fields: + - key: target_language + description: "Target language code" + default: "en" + possible_values: [en, es, fr] + - key: api_key + description: "External API key" # no default → user must enter a value; no possible_values → free-text +``` + +### Step 2 — What `nimble add` does + +After the user confirms install, `nimble add` prompts for each `config_fields` entry in order: + +``` +target_language — Target language code [en/es/fr] (default: 'en'): es +api_key — External API key: sk-abc123 +``` + +Prompt rules: +- `[v1/v2/...]` is shown only when `possible_values` is set. +- `(default: 'X')` is shown only when `default` is set. Pressing Enter uses the default. +- When there is no `default`, pressing Enter prints `'' is required.` and re-prompts. +- An invalid value (not in `possible_values`) prints `Invalid value. Choose from: ...` and re-prompts. +- Input is stripped of leading/trailing whitespace before validation. `" es "` is treated as `"es"`. +- If stdin is closed (EOF) or a read error occurs, the CLI prints an error to stderr and exits with code 1. + +The `configuration:` block is written only when at least one field was prompted (i.e. `config_fields` is non-empty). Skills with no `config_fields` produce no block. + +### Step 3 — The `configuration:` block in `config.yaml` + +```yaml + - name: translator + source: community + path: .nimble/skills/translator/skill.py + class_name: TranslatorSkill + binding: "ctrl+shift+t" + configuration: + target_language: es + api_key: sk-abc123 +``` + +You can edit this block manually at any time. All values are stored and delivered as strings — YAML integers (e.g. `count: 5`) are coerced to `"5"`. When the block is absent, `self.configuration` defaults to `{}`. + +### Step 4 — Access values in skill code + +`self.configuration` is available in `on_load`, `run()`, `on_error()`, and `on_unload()`. + +```python +class TranslatorSkill: + def on_load(self, config): + self._lang = self.configuration.get("target_language", "en") + + def run(self, context, tools): + text = context.selection or context.clipboard + if not text: + tools.popup.show("Select or copy some text first.") + return + result = tools.ai.ask(text, prompt=f"Translate to {self._lang} in one sentence.") + tools.clipboard.set(result) + tools.popup.show("Translation copied to clipboard.") +``` + +Caching in `on_load` (as `self._lang` above) avoids repeated dict lookups on every hotkey fire. Accessing `self.configuration` directly in `run()` is equally valid and simpler for one-off reads. + +**Canonical access path:** always use `self.configuration`. The `config` dict passed to `on_load` also contains a `"configuration"` key (the raw JSON payload), but `self.configuration` is the documented, str-coerced interface — prefer it over `config.get("configuration")`. + +--- + +## 7. Complete Working Example ```python # skills/summarise/skill.py @@ -237,7 +329,7 @@ author: "Your Name" --- -## 7. Anti-Patterns +## 8. Anti-Patterns ```python # WRONG — do not annotate with nimble types (skill venv won't have nimble installed) @@ -289,11 +381,22 @@ class GoodSkill: self._count = 0 def run(self, context, tools): self._count += 1 # instance state is fine (one worker process per skill) + + +# WRONG — do not read configuration via the on_load config dict +class BadConfigSkill: + def on_load(self, config): + self._lang = config.get("configuration", {}).get("target_language", "en") + +# CORRECT — use self.configuration (always set before on_load, str-coerced) +class GoodConfigSkill: + def on_load(self, config): + self._lang = self.configuration.get("target_language", "en") ``` --- -## 8. Maintenance Contract (for contributors) +## 9. Maintenance Contract (for contributors) `skill-build.md` is a **required PR artifact** (NFR21). Any PR that changes any of the following files **must** update this document in the same PR: @@ -304,9 +407,12 @@ class GoodSkill: | `worker/entrypoint.py` — lifecycle invocations / construction | Section 1 lifecycle signatures | | `nimble/__init__.py` — `SUPPORTED_API_VERSION` | Section 4 `api_version` note | | `nimble/manifest/parser.py` — `ManifestSpec` fields, required-field set | Section 4 manifest spec | +| `nimble/manifest/parser.py` — `ConfigFieldSpec` fields, `_parse_config_fields` behaviour | Section 4 optional fields, Section 6 | +| `nimble/manifest/parser.py` — `append_skill_to_config` `configuration` parameter | Section 6 Step 2/3 | | `nimble/skills/runner.py` — api_version refusal, lifecycle phase tracking | Sections 1, 4 | | `nimble/skills/registry.py` — `SkillConfig` fields | Section 5 config.yaml entry | | `nimble/cli/commands.py` — `_PERMISSION_DESCRIPTIONS` | Section 4 permissions table | +| `nimble/cli/commands.py` — `_collect_config_values` prompt format or validation | Section 6 Step 2 | `api_version` in `nimble/__init__.py` must be incremented on every breaking skill interface change (NFR23). The daemon refuses skills whose `api_version` exceeds `SUPPORTED_API_VERSION`. diff --git a/docs/bmad_output/implementation-artifacts/8-3-interactive-config-prompting-in-nimble-add.md b/docs/bmad_output/implementation-artifacts/8-3-interactive-config-prompting-in-nimble-add.md new file mode 100644 index 0000000..934b7a8 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/8-3-interactive-config-prompting-in-nimble-add.md @@ -0,0 +1,488 @@ +# Story 8.3: Interactive Config Prompting in `nimble add` + +Status: done + +## Story + +As a user installing a community skill, +I want to be prompted for each declared configuration field during `nimble add`, +So that my `config.yaml` is fully populated with skill parameters without any manual editing. + +## Acceptance Criteria + +1. **Given** a skill's `ManifestSpec.config_fields` contains one field with `key: target_language`, + `description: "Target language code"`, `default: "en"`, `possible_values: [en, es, fr]` + **When** `nimble add` runs after the user confirms install + **Then** the CLI prompts: `target_language — Target language code [en/es/fr] (default: 'en'):` + +2. **Given** the user presses Enter without typing a value and a default exists + **When** the prompt is processed + **Then** the default value is used — the user is not re-prompted + +3. **Given** the user enters a value not in `possible_values` + **When** the prompt is processed + **Then** the CLI prints an error and re-prompts until a valid value is entered + +4. **Given** a field with no `default` and no `possible_values` + **When** the user presses Enter without typing + **Then** the CLI prints `'' is required.` and re-prompts until a non-empty value is entered + +5. **Given** all config fields have been collected + **When** `append_skill_to_config()` writes the skill entry + **Then** the entry includes a `configuration:` block with all collected key-value pairs + +6. **Given** a skill's `ManifestSpec.config_fields` is empty + **When** `nimble add` runs + **Then** no configuration prompts appear and no `configuration:` block is written to `config.yaml` + +## Tasks / Subtasks + +- [x] Task 1: Add `_collect_config_values()` helper to `nimble/cli/commands.py` (AC: 1, 2, 3, 4) + - [x] Add `TYPE_CHECKING` import at top: `from typing import TYPE_CHECKING` and `if TYPE_CHECKING: from nimble.manifest.parser import ConfigFieldSpec` + - [x] Implement `_collect_config_values(config_fields: list[ConfigFieldSpec]) -> dict[str, str]` + - [x] For each field: build prompt string, read via `sys.stdin.readline()`, validate, loop on error + - [x] Prompt format: `{key} — {description} [{v1}/{v2}] (default: '{default'}):` (omit [] if no possible_values; omit default clause if no default) + +- [x] Task 2: Wire `_collect_config_values()` into `add` command in `nimble/cli/commands.py` (AC: 1–6) + - [x] Call `_collect_config_values(spec.config_fields)` after `_prompt_install_confirm_y_only()` returns `True` + - [x] Pass resulting `configuration` dict to `append_skill_to_config()` as new parameter + +- [x] Task 3: Add `configuration` parameter to `append_skill_to_config()` in `nimble/manifest/parser.py` (AC: 5, 6) + - [x] Add `configuration: dict[str, str] | None = None` as last parameter + - [x] If `configuration` is truthy (non-empty dict), add `"configuration": configuration` to the `entry` dict before appending + - [x] Empty dict or `None` → no `configuration:` key written (AC 6) + +- [x] Task 4: Write tests (AC: 1–6) + - [x] `tests/unit/cli/test_commands.py`: extend existing file — add tests for prompt rendering, default handling, re-prompt on invalid, re-prompt on empty required, no prompts for empty config_fields, `append_skill_to_config` called with configuration + - [x] `tests/unit/manifest/test_parser.py`: extend existing file — test `append_skill_to_config` writes `configuration:` block when non-empty; skips block when empty/None + +- [x] Task 5: Quality gates + - [x] `flake8 nimble/ tests/ worker/` — exits 0 + - [x] `mypy nimble/ tests/ worker/ --strict` — exits 0 (no new errors) + - [x] `pytest tests/` — all tests pass (≥331 baseline, no regressions) + +### Review Findings + +- [x] [Review][Patch] EOF/input-read failure can cause non-terminating prompt loop for required fields [nimble/cli/commands.py:40] +- [x] [Review][Patch] Prompt validation does not normalize whitespace, causing avoidable invalid inputs and weak required-field checks [nimble/cli/commands.py:57] +- [x] [Review][Patch] Missing tests for EOF and input-read failure paths in config prompting loop [tests/unit/cli/test_commands.py:548] + +## Dev Notes + +### What This Story Delivers + +Two files change (three have tests added): + +1. `nimble/cli/commands.py` — adds `_collect_config_values()` helper + wires it into `add` +2. `nimble/manifest/parser.py` — `append_skill_to_config()` gains optional `configuration` param + +**Explicitly out of scope:** +- Any daemon or worker changes — configuration injection is complete from Story 8.2 +- `ManifestSpec`, `ConfigFieldSpec`, or `parse_manifest_yaml` — complete from Story 8.1 +- `SkillConfig` or `_parse_skill_configuration` — complete from Story 8.2 + +### Exact Changes Per File + +#### `nimble/cli/commands.py` + +At the top of the file, after existing imports, add a `TYPE_CHECKING` block so the type annotation on `_collect_config_values` resolves without a runtime import (all other `nimble.manifest` imports in this file are lazy/inside functions): + +```python +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from nimble.manifest.parser import ConfigFieldSpec +``` + +Add this helper function near the other helpers (after `_prompt_install_confirm_y_only`, before `_running_pid_or_none`): + +```python +def _collect_config_values( + config_fields: list[ConfigFieldSpec], +) -> dict[str, str]: + result: dict[str, str] = {} + for cf in config_fields: + while True: + if cf.possible_values: + choices = "/".join(cf.possible_values) + prompt = f"{cf.key} — {cf.description} [{choices}]" + else: + prompt = f"{cf.key} — {cf.description}" + if cf.default is not None: + prompt += f" (default: '{cf.default}')" + prompt += ": " + typer.echo(prompt, nl=False) + try: + raw = sys.stdin.readline() + except (OSError, UnicodeDecodeError): + raw = "" + value = raw.rstrip("\r\n") + if value == "": + if cf.default is not None: + result[cf.key] = cf.default + break + typer.echo(f"'{cf.key}' is required.") + continue + if cf.possible_values and value not in cf.possible_values: + joined = ", ".join(cf.possible_values) + typer.echo(f"Invalid value. Choose from: {joined}") + continue + result[cf.key] = value + break + return result +``` + +In the `add()` command, after `_prompt_install_confirm_y_only()` returns `True` and before the install block, insert the config collection: + +```python + if not _prompt_install_confirm_y_only(): + typer.echo("Installation cancelled.") + raise typer.Exit(0) + + configuration = _collect_config_values(spec.config_fields) # NEW + + import shutil + ... +``` + +Update the `append_skill_to_config` call (currently line ~371) to pass `configuration`: + +```python + try: + append_skill_to_config( + config_path, spec, shortcut, repo_url, repo_root, configuration + ) + except ConfigError as exc: +``` + +#### `nimble/manifest/parser.py` + +`append_skill_to_config` currently ends at line ~272. Add `configuration` parameter: + +```python +def append_skill_to_config( + config_path: Path, + spec: ManifestSpec, + binding: str, + repo_url: str, + repo_root: Path, + configuration: dict[str, str] | None = None, # NEW +) -> None: +``` + +Inside the function, after building the `entry` dict, add the configuration block before `skills.append(entry)`: + +```python + entry: dict[str, Any] = { + "name": spec.name, + "source": "community", + "path": rel_path, + "class_name": spec.class_name, + "binding": binding, + "installed_from": repo_url, + "version": spec.version, + } + if configuration: # NEW — only when non-empty + entry["configuration"] = configuration # NEW + skills.append(entry) +``` + +`if configuration:` handles both `None` and empty dict `{}` — neither writes the block (AC 6). + +### Test Patterns to Follow + +#### `tests/unit/cli/test_commands.py` + +The existing `_make_manifest_spec(**overrides)` helper at line ~339 builds a `ManifestSpec`. For testing prompts, create a version with `config_fields`. Import `ConfigFieldSpec` at the top of the test file (it's already used indirectly; add it to the existing parser import). + +Add to existing imports block: +```python +from nimble.manifest.parser import ( + ConfigError, + ConfigFieldSpec, # NEW + ManifestError, + ManifestSpec, + NimbleConfig, +) +``` + +All tests use `runner = CliRunner()` (typer testing). Supply `input=` lines for both the install confirmation and any config prompts, in order. + +```python +def _make_manifest_spec_with_fields(**overrides: object) -> ManifestSpec: + fields = [ + ConfigFieldSpec( + key="target_language", + description="Target language code", + default="en", + possible_values=["en", "es", "fr"], + ) + ] + return _make_manifest_spec(config_fields=fields, **overrides) + + +def _add_invoke( + spec: ManifestSpec, + input_lines: str, + fake_root: Path | None = None, +) -> ...: + """Helper that patches all install steps and invokes add.""" + root = fake_root or Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config"), + patch("nimble.manifest.lock.write_lock_entry"), + ): + return runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input=input_lines, + ) +``` + +Key tests to add: + +```python +def test_add_prompts_config_fields_after_confirmation() -> None: + spec = _make_manifest_spec_with_fields() + result = runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nes\n", # confirm=y, value=es + ) + # The prompt must appear in output + assert "target_language" in result.output + assert "Target language code" in result.output + assert "[en/es/fr]" in result.output + assert "(default: 'en')" in result.output + + +def test_add_config_field_enter_uses_default() -> None: + spec = _make_manifest_spec_with_fields() + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=Path("/tmp/f")), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n\n", # confirm=y, Enter=use default + ) + assert result.exit_code == 0 + _, kwargs = mock_append.call_args + # configuration is passed as positional arg (index 5) or via kwargs + # verify "en" (default) ended up in the configuration + assert mock_append.called + + +def test_add_config_field_invalid_value_reprompts() -> None: + spec = _make_manifest_spec_with_fields() + result = runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nzh\nes\n", # confirm=y, invalid=zh, valid=es + ) + assert "Invalid value" in result.output + assert result.exit_code == 0 + + +def test_add_config_field_required_no_default_reprompts() -> None: + fields = [ConfigFieldSpec(key="api_key", description="API key")] + spec = _make_manifest_spec(config_fields=fields) + result = runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n\nsecret\n", # confirm=y, empty=reprompt, then value + ) + assert "'api_key' is required." in result.output + assert result.exit_code == 0 + + +def test_add_empty_config_fields_no_prompts() -> None: + spec = _make_manifest_spec(config_fields=[]) + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n", # just confirm, no extra prompts + ) + assert result.exit_code == 0 + # Sixth positional arg is configuration={} + call_args = mock_append.call_args + configuration = call_args.args[5] if len(call_args.args) > 5 else call_args.kwargs.get("configuration", {}) + assert configuration == {} + + +def test_add_passes_configuration_to_append_skill_to_config() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + runner.invoke( + app, ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nes\n", # confirm + valid value + ) + call_args = mock_append.call_args + configuration = call_args.args[5] if len(call_args.args) > 5 else call_args.kwargs.get("configuration") + assert configuration == {"target_language": "es"} +``` + +#### `tests/unit/manifest/test_parser.py` + +Extend the existing `append_skill_to_config` tests (look for `test_append_skill_to_config*` or add alongside them). Follow the existing `_write_config(tmp_path, content)` helper pattern. + +```python +def test_append_skill_to_config_writes_configuration_block(tmp_path: Path) -> None: + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") # use existing helper + append_skill_to_config( + cfg, spec, "ctrl+t", "github.com/u/r", tmp_path, + configuration={"target_language": "es"}, + ) + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert entry["configuration"] == {"target_language": "es"} + + +def test_append_skill_to_config_no_configuration_block_when_empty(tmp_path: Path) -> None: + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") + append_skill_to_config( + cfg, spec, "ctrl+t", "github.com/u/r", tmp_path, + configuration={}, + ) + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert "configuration" not in entry + + +def test_append_skill_to_config_no_configuration_block_when_none(tmp_path: Path) -> None: + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") + append_skill_to_config( + cfg, spec, "ctrl+t", "github.com/u/r", tmp_path, + # configuration not passed — defaults to None + ) + data = yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert "configuration" not in entry +``` + +**Note:** Check what helper `_make_manifest_spec` looks like in `test_parser.py` — it may differ from the one in `test_commands.py`. In `test_parser.py`, look for `_write_config` and whatever spec helper exists there. If no `_make_manifest_spec` exists in `test_parser.py`, construct `ManifestSpec` inline or add a helper matching the existing style. + +### Architecture Compliance + +- `mypy --strict` enforced project-wide +- Max line length: 88 (`pyproject.toml` `[tool.black]`) +- Import order: stdlib → third-party → local +- `from __future__ import annotations` is at top of both `commands.py` (line 1) and `parser.py` (line 1) — string annotations are already enabled +- Absolute imports only — no relative imports +- Use `TYPE_CHECKING` guard for `ConfigFieldSpec` in `commands.py` (avoids runtime import; consistent with how other nimble.manifest types are handled in this file) +- I/O pattern: `typer.echo(prompt, nl=False)` + `sys.stdin.readline()` — exactly the same as `_prompt_install_confirm_y_only()` on lines 26–33 of `commands.py` + +### Previous Story Intelligence (Stories 8.1 and 8.2) + +**From Story 8.1:** +- `ConfigFieldSpec` is in `nimble/manifest/parser.py` — fields: `key: str`, `description: str`, `default: str | None`, `possible_values: list[str] | None` +- `ManifestSpec.config_fields: list[ConfigFieldSpec]` — empty list when absent +- `_parse_config_field_entry()` is the validation-style model for accessing these fields + +**From Story 8.2:** +- `SkillConfig.configuration: dict[str, str]` is already complete — Story 8.3 only writes it to `config.yaml` via `append_skill_to_config`; the daemon already reads and injects it +- E501 (line too long > 88) was a recurring issue — watch every line +- Always use fully typed generics: `dict[str, str]` not `dict`, `list[ConfigFieldSpec]` not `list` +- `from __future__ import annotations` at top — type annotations are strings, no runtime import needed for `TYPE_CHECKING` usage +- `mypy --strict` errors on `type-arg` (unparameterized generics) — fully qualify all generics + +### Git History Relevant Patterns + +From recent commits: +- `70fad92 config yaml fields for skills` — Story 8.2 implementation +- `e6af40d refactor: split _parse_config_fields into focused helpers` — helper decomposition style in `parser.py`; each focused on one concern +- `e1d3875 fix(tests): extract patch-target constants to fix E501 in clipboard tests` — long patch strings cause E501; use intermediate variables if needed +- `f6429c4 fix: fix remaining flake8 errors` — E501 was most common issue + +### Project Structure Notes + +Files to change (exactly two): +- `nimble/cli/commands.py` — add TYPE_CHECKING import block, add `_collect_config_values` helper, update `add()` command +- `nimble/manifest/parser.py` — add `configuration` parameter to `append_skill_to_config` + +Test files to extend (never create new files): +- `tests/unit/cli/test_commands.py` — extend existing file (551 lines) +- `tests/unit/manifest/test_parser.py` — extend existing file + +### Critical Implementation Notes + +1. **Prompt only after confirmation** — `_collect_config_values` must be called AFTER `_prompt_install_confirm_y_only()` returns `True`, not before. If user cancels, no prompts appear. + +2. **`configuration={}` → no block** — `if configuration:` in `append_skill_to_config` correctly skips writing the block for both `None` and `{}`. Do NOT use `if configuration is not None:` — that would write an empty `configuration: {}` block. + +3. **`_collect_config_values` always returns a dict** — even when `config_fields` is empty (returns `{}`). The `add()` command unconditionally calls it and passes the result. + +4. **Existing call sites unaffected** — the only call to `append_skill_to_config` is in `commands.py:add()`. Adding a default parameter `configuration: dict[str, str] | None = None` doesn't break any other usage. + +5. **Test input ordering** — `CliRunner.invoke(..., input=...)` feeds lines sequentially. The install confirm `y/N` prompt comes first; config field prompts come after. `input="y\nes\n"` means: first readline → "y" (install confirm), second readline → "es" (first config field). + +6. **Line length watch** — `typer.echo(f"Invalid value. Choose from: {joined}")` is safe. The `_collect_config_values` function uses a local `joined` variable to avoid a long f-string. Keep lines ≤ 88 chars throughout. + +### References + +- Story 8.3 AC: [Source: docs/bmad_output/planning-artifacts/epics.md#Story-8.3] +- `ConfigFieldSpec` dataclass: [Source: nimble/manifest/parser.py#28-33] +- `ManifestSpec.config_fields`: [Source: nimble/manifest/parser.py#47] +- `append_skill_to_config()`: [Source: nimble/manifest/parser.py#234-272] +- `_prompt_install_confirm_y_only()` I/O pattern: [Source: nimble/cli/commands.py#26-33] +- `add()` command flow: [Source: nimble/cli/commands.py#305-396] +- Test helper `_make_manifest_spec`: [Source: tests/unit/cli/test_commands.py#339-353] +- Test `CliRunner.invoke` + `input=` pattern: [Source: tests/unit/cli/test_commands.py#356-370] +- Existing `append_skill_to_config` tests: [Source: tests/unit/cli/test_commands.py#508-535] +- `_parse_config_fields()` style reference: [Source: nimble/manifest/parser.py#170-178] + +## Dev Agent Record + +### Agent Model Used + +claude-sonnet-4-6 + +### Debug Log References + +None — implementation was straightforward following the story spec exactly. + +### Completion Notes List + +- Added `TYPE_CHECKING` guard in `commands.py` for `ConfigFieldSpec` (avoids runtime import, consistent with project pattern) +- Added `_collect_config_values()` helper after `_prompt_install_confirm_y_only()` in `commands.py` — uses same `sys.stdin.readline()` + `typer.echo()` I/O pattern +- Wired `_collect_config_values(spec.config_fields)` into `add()` immediately after install confirmation, before lazy imports +- Added optional `configuration: dict[str, str] | None = None` to `append_skill_to_config()` in `parser.py` +- `if configuration:` guard correctly skips writing the block for both `None` and `{}` (AC 6) +- 9 new tests added: 6 in `test_commands.py`, 3 in `test_parser.py` +- 340 total tests pass (331 baseline + 9 new); no regressions +- flake8 clean; mypy: no new errors introduced (7 pre-existing errors in unrelated files) + +### File List + +- nimble/cli/commands.py +- nimble/manifest/parser.py +- tests/unit/cli/test_commands.py +- tests/unit/manifest/test_parser.py + +## Change Log + +- 2026-05-05: Story created — interactive config prompting for `nimble add` +- 2026-05-05: Story implemented and ready for review diff --git a/docs/bmad_output/implementation-artifacts/spec-skill-build-custom-config-docs.md b/docs/bmad_output/implementation-artifacts/spec-skill-build-custom-config-docs.md new file mode 100644 index 0000000..846d6b0 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-skill-build-custom-config-docs.md @@ -0,0 +1,22 @@ +--- +title: 'Document skill custom configuration in skill-build.md' +type: 'chore' +created: '2026-05-05' +status: 'done' +route: 'one-shot' +--- + +## Intent + +**Problem:** `skill-build.md` documented the skill authoring contract but had no coverage of the custom configuration feature delivered in Epic 8 stories 8.1–8.3: the `config_fields` manifest schema, the `configuration:` config.yaml block, `self.configuration` injection, and the `nimble add` interactive prompting flow. + +**Approach:** Add `self.configuration` to Section 1, `config_fields` to Section 4, `configuration:` to Section 5, a new Section 6 "Skill Custom Configuration" with a four-step walkthrough, a configuration anti-pattern to Section 8, and two new maintenance rows to Section 9. Renumber old Sections 6–8 to 7–9. + +## Suggested Review Order + +- [`.ai/skill-build.md:41`](../../../.ai/skill-build.md) — Section 1: `self.configuration` auto-injected attribute +- [`.ai/skill-build.md:161`](../../../.ai/skill-build.md) — Section 4: `config_fields` optional field schema and error cases +- [`.ai/skill-build.md:198`](../../../.ai/skill-build.md) — Section 5: `configuration:` in config.yaml binding example +- [`.ai/skill-build.md:217`](../../../.ai/skill-build.md) — Section 6: full custom configuration walkthrough (Steps 1–4) +- [`.ai/skill-build.md:386`](../../../.ai/skill-build.md) — Section 8: new anti-pattern for `config.get("configuration")` +- [`.ai/skill-build.md:410`](../../../.ai/skill-build.md) — Section 9: new maintenance table rows for `ConfigFieldSpec` and `_collect_config_values` diff --git a/docs/bmad_output/implementation-artifacts/sprint-status.yaml b/docs/bmad_output/implementation-artifacts/sprint-status.yaml index 33221fd..9016263 100644 --- a/docs/bmad_output/implementation-artifacts/sprint-status.yaml +++ b/docs/bmad_output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: "2026-04-16" -last_updated: "2026-05-05" # updated by code-review: 8-2 +last_updated: "2026-05-05" # updated by dev-story: 8-3 review project: "pixi (Nimble)" project_key: NOKEY tracking_system: file-system @@ -109,5 +109,5 @@ development_status: epic-8: in-progress 8-1-config-fields-schema-in-manifest-yaml-and-parsing: done 8-2-configuration-in-config-yaml-and-worker-injection: done - 8-3-interactive-config-prompting-in-nimble-add: backlog + 8-3-interactive-config-prompting-in-nimble-add: done epic-8-retrospective: optional diff --git a/nimble/cli/commands.py b/nimble/cli/commands.py index 741df4c..ece144e 100644 --- a/nimble/cli/commands.py +++ b/nimble/cli/commands.py @@ -5,9 +5,13 @@ import sys import time from pathlib import Path +from typing import TYPE_CHECKING import typer +if TYPE_CHECKING: + from nimble.manifest.parser import ConfigFieldSpec + import nimble.state as state from nimble.platform import is_windows @@ -33,6 +37,51 @@ def _prompt_install_confirm_y_only() -> bool: return line.rstrip("\r\n") in ("y", "Y") +def _collect_config_values( + config_fields: list[ConfigFieldSpec], +) -> dict[str, str]: + result: dict[str, str] = {} + for cf in config_fields: + while True: + if cf.possible_values: + choices = "/".join(cf.possible_values) + prompt = f"{cf.key} — {cf.description} [{choices}]" + else: + prompt = f"{cf.key} — {cf.description}" + if cf.default is not None: + prompt += f" (default: '{cf.default}')" + prompt += ": " + typer.echo(prompt, nl=False) + try: + raw = sys.stdin.readline() + except (OSError, UnicodeDecodeError): + typer.echo( + f"Failed to read input for '{cf.key}'.", + err=True, + ) + raise typer.Exit(1) + if raw == "": + typer.echo( + f"Input stream closed while reading '{cf.key}'.", + err=True, + ) + raise typer.Exit(1) + value = raw.rstrip("\r\n").strip() + if value == "": + if cf.default is not None: + result[cf.key] = cf.default + break + typer.echo(f"'{cf.key}' is required.") + continue + if cf.possible_values and value not in cf.possible_values: + joined = ", ".join(cf.possible_values) + typer.echo(f"Invalid value. Choose from: {joined}") + continue + result[cf.key] = value + break + return result + + def _running_pid_or_none(data: object) -> int | None: if not isinstance(data, dict): return None @@ -335,6 +384,8 @@ def add( typer.echo("Installation cancelled.") raise typer.Exit(0) + configuration = _collect_config_values(spec.config_fields) + import shutil from nimble.manifest.installer import ( @@ -368,7 +419,9 @@ def add( config_path = repo_root / "config.yaml" try: - append_skill_to_config(config_path, spec, shortcut, repo_url, repo_root) + append_skill_to_config( + config_path, spec, shortcut, repo_url, repo_root, configuration + ) except ConfigError as exc: typer.echo(str(exc), err=True) raise typer.Exit(1) diff --git a/nimble/manifest/parser.py b/nimble/manifest/parser.py index 6236602..f91e5c8 100644 --- a/nimble/manifest/parser.py +++ b/nimble/manifest/parser.py @@ -237,6 +237,7 @@ def append_skill_to_config( binding: str, repo_url: str, repo_root: Path, + configuration: dict[str, str] | None = None, ) -> None: if not (spec.class_name or "").strip(): raise ConfigError( @@ -265,6 +266,8 @@ def append_skill_to_config( "installed_from": repo_url, "version": spec.version, } + if configuration: + entry["configuration"] = configuration skills.append(entry) raw["skills"] = skills atomic_write( diff --git a/tests/unit/cli/test_commands.py b/tests/unit/cli/test_commands.py index 85078bb..3084d42 100644 --- a/tests/unit/cli/test_commands.py +++ b/tests/unit/cli/test_commands.py @@ -6,10 +6,11 @@ from typer.testing import CliRunner -from nimble.cli.commands import app +from nimble.cli.commands import _collect_config_values, app from nimble.manifest.installer import InstallError from nimble.manifest.parser import ( ConfigError, + ConfigFieldSpec, ManifestError, ManifestSpec, NimbleConfig, @@ -535,6 +536,226 @@ def test_add_lock_write_failure_rolls_back_config(tmp_path: Path) -> None: assert data["skills"] == [] +def _make_manifest_spec_with_fields(**overrides: object) -> ManifestSpec: + fields = [ + ConfigFieldSpec( + key="target_language", + description="Target language code", + default="en", + possible_values=["en", "es", "fr"], + ) + ] + return _make_manifest_spec(config_fields=fields, **overrides) + + +def test_add_prompts_config_fields_after_confirmation() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config"), + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nes\n", + ) + assert "target_language" in result.output + assert "Target language code" in result.output + assert "[en/es/fr]" in result.output + assert "(default: 'en')" in result.output + + +def test_add_config_field_enter_uses_default() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n\n", + ) + assert result.exit_code == 0 + assert mock_append.called + call_args = mock_append.call_args + configuration = ( + call_args.args[5] + if len(call_args.args) > 5 + else call_args.kwargs.get("configuration") + ) + assert configuration == {"target_language": "en"} + + +def test_add_config_field_invalid_value_reprompts() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config"), + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nzh\nes\n", + ) + assert "Invalid value" in result.output + assert result.exit_code == 0 + + +def test_add_config_field_required_no_default_reprompts() -> None: + fields = [ConfigFieldSpec(key="api_key", description="API key")] + spec = _make_manifest_spec(config_fields=fields) + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config"), + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n\nsecret\n", + ) + assert "'api_key' is required." in result.output + assert result.exit_code == 0 + + +def test_add_empty_config_fields_no_prompts() -> None: + spec = _make_manifest_spec(config_fields=[]) + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n", + ) + assert result.exit_code == 0 + call_args = mock_append.call_args + configuration = ( + call_args.args[5] + if len(call_args.args) > 5 + else call_args.kwargs.get("configuration", {}) + ) + assert configuration == {} + + +def test_add_passes_configuration_to_append_skill_to_config() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\nes\n", + ) + call_args = mock_append.call_args + configuration = ( + call_args.args[5] + if len(call_args.args) > 5 + else call_args.kwargs.get("configuration") + ) + assert configuration == {"target_language": "es"} + + +def test_add_config_field_eof_exits_nonzero() -> None: + fields = [ConfigFieldSpec(key="api_key", description="API key")] + spec = _make_manifest_spec(config_fields=fields) + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n", + ) + assert result.exit_code == 1 + assert "Input stream closed while reading 'api_key'." in result.output + mock_append.assert_not_called() + + +def test_add_config_field_read_error_exits_nonzero() -> None: + fields = [ConfigFieldSpec(key="api_key", description="API key")] + with ( + patch( + "nimble.cli.commands.sys.stdin.readline", + side_effect=OSError("stdin failed"), + ), + patch("nimble.cli.commands.typer.echo") as mock_echo, + ): + try: + _collect_config_values(fields) + except Exception as exc: # click.exceptions.Exit + assert exc.__class__.__name__ == "Exit" + assert getattr(exc, "exit_code", None) == 1 + else: + raise AssertionError("expected click Exit") + mock_echo.assert_any_call("Failed to read input for 'api_key'.", err=True) + + +def test_add_config_field_trims_allowed_value_whitespace() -> None: + spec = _make_manifest_spec_with_fields() + fake_root = Path("/tmp/nimble-fake-root") + with ( + patch("nimble.manifest.parser.fetch_remote_manifest", return_value=spec), + patch("nimble.cli.commands._repo_root", return_value=fake_root), + patch("nimble.manifest.installer.clone_skill_repo"), + patch("nimble.manifest.installer.install_skill_venv"), + patch("nimble.manifest.parser.append_skill_to_config") as mock_append, + patch("nimble.manifest.lock.write_lock_entry"), + ): + result = runner.invoke( + app, + ["add", "ctrl+shift+d", "github.com/user/skill"], + input="y\n es \n", + ) + assert result.exit_code == 0 + call_args = mock_append.call_args + configuration = ( + call_args.args[5] + if len(call_args.args) > 5 + else call_args.kwargs.get("configuration") + ) + assert configuration == {"target_language": "es"} + + def test_terminate_windows_openprocess_failure_raises() -> None: fake_kernel32 = MagicMock() fake_kernel32.OpenProcess.return_value = 0 diff --git a/tests/unit/manifest/test_parser.py b/tests/unit/manifest/test_parser.py index c3a5617..2d66daa 100644 --- a/tests/unit/manifest/test_parser.py +++ b/tests/unit/manifest/test_parser.py @@ -852,6 +852,57 @@ def test_load_config_skill_configuration_non_dict_raises(tmp_path: Path) -> None load_config(cfg) +# --------------------------------------------------------------------------- +# append_skill_to_config — configuration parameter tests (AC: 5, 6) +# --------------------------------------------------------------------------- + + +def test_append_skill_to_config_writes_configuration_block(tmp_path: Path) -> None: + import yaml as _yaml + + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") + append_skill_to_config( + cfg, + spec, + "ctrl+t", + "github.com/u/r", + tmp_path, + configuration={"target_language": "es"}, + ) + data = _yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert entry["configuration"] == {"target_language": "es"} + + +def test_append_skill_to_config_no_configuration_block_when_empty( + tmp_path: Path, +) -> None: + import yaml as _yaml + + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") + append_skill_to_config( + cfg, spec, "ctrl+t", "github.com/u/r", tmp_path, configuration={} + ) + data = _yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert "configuration" not in entry + + +def test_append_skill_to_config_no_configuration_block_when_none( + tmp_path: Path, +) -> None: + import yaml as _yaml + + cfg = _write_config(tmp_path, "skills: []\n") + spec = _make_manifest_spec(class_name="Translator") + append_skill_to_config(cfg, spec, "ctrl+t", "github.com/u/r", tmp_path) + data = _yaml.safe_load(cfg.read_text(encoding="utf-8")) + entry = data["skills"][0] + assert "configuration" not in entry + + def test_load_config_skill_configuration_coerces_values_to_str(tmp_path: Path) -> None: cfg = _write_config( tmp_path,