From fb873fafef96709cb8737659cadd142ae52aa8ff Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Mon, 4 May 2026 14:42:29 +0200 Subject: [PATCH 1/4] fix: community skill workers fail to load on nimble start Workers for community skills were launched with the community venv's isolated Python, which (a) resolved to ~/.nimble/ instead of the project's .nimble/ dir and (b) lacked pyyaml and other nimble deps imported at entrypoint module level. All workers now use sys.executable. Community skill venv path is passed as NIMBLE_VENV_PATH; entrypoint.py prepends its site-packages to sys.path before nimble imports, with platform-aware glob covering both Linux/macOS (lib/python*/site-packages) and Windows (Lib/site-packages). Co-Authored-By: Claude Sonnet 4.6 --- .../implementation-artifacts/deferred-work.md | 7 +- ...spec-fix-community-skill-worker-loading.md | 116 ++++++++++++++++++ nimble/skills/runner.py | 17 +-- tests/unit/skills/test_runner.py | 17 ++- worker/entrypoint.py | 12 ++ 5 files changed, 151 insertions(+), 18 deletions(-) create mode 100644 docs/bmad_output/implementation-artifacts/spec-fix-community-skill-worker-loading.md diff --git a/docs/bmad_output/implementation-artifacts/deferred-work.md b/docs/bmad_output/implementation-artifacts/deferred-work.md index f5db8a4..219c44c 100644 --- a/docs/bmad_output/implementation-artifacts/deferred-work.md +++ b/docs/bmad_output/implementation-artifacts/deferred-work.md @@ -1,8 +1,13 @@ # Deferred Work +## Deferred from: spec-fix-community-skill-worker-loading (2026-05-04) + +- `worker/entrypoint.py` — glob-based venv injection may match multiple `python3.x` dirs on unusual shared venv layouts; import resolution order between them is non-deterministic. Extremely unlikely in practice; address in a venv-hardening pass if reported. +- `nimble/skills/runner.py` — community skill isolation now relies solely on `sys.path` injection rather than a venv-isolated Python binary; if `NIMBLE_VENV_PATH` is stripped or the entrypoint is bypassed, skill deps fall back to host Python silently. Acceptable trade-off for now; revisit if skill sandboxing becomes a security requirement. + ## Deferred from: code review of 7-1-skill-build-md-ai-authoring-contract.md (2026-05-02) -- `nimble/skills/installer.py` vs `nimble/skills/runner.py`: Community-skill venv path mismatch reported by Edge Case Hunter — installer writes to `repo_root/.nimble/skills//.venv` while runner reads from `Path.home()/.nimble/skills//.venv`. If confirmed, community skills install to one venv and run from another; pre-existing across Epic 6. Verify and address in a follow-up story (out of scope for doc-only Story 7.1). +- ~~`nimble/skills/installer.py` vs `nimble/skills/runner.py`: Community-skill venv path mismatch — **resolved by spec-fix-community-skill-worker-loading (2026-05-04)**~~ - `nimble/skills/runner.py`: `api_version` refusal logic only enforces version check when `type(skill_api_version) is int`; non-int values (e.g. floats truncated by parser, or other coercions) bypass the check. Pre-existing in runner; harden in a parser/runner reliability pass. - `worker/entrypoint.py:198-201`: `on_error` exceptions are silently swallowed (`except Exception: logger.warning(...)`). Story 7.1 doc patch will reflect current behaviour (re-raise has no effect). If we want true re-raise / exception-replacement semantics, the runner needs a small redesign — capture for a future epic. diff --git a/docs/bmad_output/implementation-artifacts/spec-fix-community-skill-worker-loading.md b/docs/bmad_output/implementation-artifacts/spec-fix-community-skill-worker-loading.md new file mode 100644 index 0000000..bb72dfe --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-fix-community-skill-worker-loading.md @@ -0,0 +1,116 @@ +--- +title: 'Fix community skill worker loading' +type: 'bugfix' +created: '2026-05-04' +status: 'done' +context: [] +baseline_commit: 'b45a2afc5fc070307cb770e0dbb94e5486cea572' +--- + + + +## Intent + +**Problem:** Community skills always show `[FAILED]` in `nimble status` because `spawn_workers` launches their worker subprocess with the community venv's isolated Python, which (a) lives at the wrong path (`~/.nimble/` instead of `{repo_root}/.nimble/`) causing `FileNotFoundError`, and (b) even with the correct path, lacks `pyyaml` and other nimble deps that `entrypoint.py` imports at module level — so the worker exits before sending its handshake. + +**Approach:** Always launch worker subprocesses with the main nimble Python (`sys.executable`), which already has all nimble deps. Pass the community skill's venv path as a new `NIMBLE_VENV_PATH` env var so `entrypoint.py` can prepend its `site-packages` to `sys.path` before nimble is imported, making skill-specific deps available. + +## Boundaries & Constraints + +**Always:** +- All workers (local and community) use `sys.executable` — the same Python that runs the daemon +- Community venv `site-packages` are injected via `sys.path` in `entrypoint.py`, before any nimble imports +- Existing IPC protocol (handshake, stdin/stdout JSON) unchanged +- All 308 existing tests must continue to pass + +**Ask First:** +- If community venv's `site-packages` glob finds zero matches (venv missing or broken) — surface the error to the user rather than silently continuing + +**Never:** +- Install nimble's own dependencies into community skill venvs +- Change the venv creation location in `installer.py` +- Modify the daemon start/stop flow + +## I/O & Edge-Case Matrix + +| Scenario | Input / State | Expected Output / Behavior | Error Handling | +|----------|--------------|---------------------------|----------------| +| Community skill, venv present | `source=community`, `.nimble/skills//.venv/` exists | Worker starts, handshake succeeds, status=`loaded` | — | +| Local skill | `source=local` | Worker uses `sys.executable`, no venv injection | — | +| Community venv missing | `NIMBLE_VENV_PATH` set but directory absent | `sys.path` unchanged (glob returns nothing), worker may fail on skill's own deps | Existing failed-handshake handling marks skill `failed`, logs error | +| `NIMBLE_VENV_PATH` empty/unset | Local skill or env var missing | No site-packages injection, proceeds normally | — | + + + +## Code Map + +- `nimble/skills/runner.py` — `_get_python_executable` (currently returns community venv Python — must change to always return `sys.executable`); `spawn_workers` env dict (add `NIMBLE_VENV_PATH`) +- `worker/entrypoint.py` — module-level sys.path setup (lines 6–12); site-packages injection must go here, before line 35 (`from nimble.manifest.parser import AiConfig`) +- `tests/unit/skills/test_runner.py` — `test_spawn_workers_community_uses_venv_python` asserts the old venv-path behavior; must be replaced with assertions for `sys.executable` + `NIMBLE_VENV_PATH` + +## Tasks & Acceptance + +**Execution:** +- [x] `nimble/skills/runner.py` -- Replace `_get_python_executable` body: remove venv-path logic, always return `sys.executable`. Add `_get_community_venv_path(config, repo_root) -> str` that returns `str(repo_root / ".nimble" / "skills" / config.name / ".venv")` for community skills and `""` otherwise. In `spawn_workers` env dict, add `"NIMBLE_VENV_PATH": _get_community_venv_path(config, self._repo_root)`. +- [x] `worker/entrypoint.py` -- After the `_env_root` sys.path block (line 12) and before logging setup, add: read `NIMBLE_VENV_PATH`; if non-empty, glob `{venv_path}/lib/python*/site-packages` and `sys.path.insert(1, ...)` each match not already present. +- [x] `tests/unit/skills/test_runner.py` -- Replace `test_spawn_workers_community_uses_venv_python`: assert `cmd[0] == sys.executable` and that `kwargs["env"]["NIMBLE_VENV_PATH"]` contains `.nimble` and the skill name. + +**Acceptance Criteria:** +- Given `nimble add` has installed a community skill, when `nimble start` is run, then `nimble status` shows the skill as `loaded` (not `failed`) +- Given a local skill, when daemon starts, then its worker still uses `sys.executable` (no change to local skill behavior) +- Given community venv is missing, when daemon starts, then skill is marked `failed` gracefully without crashing the daemon +- Given all tests run, then `python -m pytest tests/ -x -q` exits 0 with 308 passed + +## Design Notes + +The `entrypoint.py` already inserts `repo_root` into `sys.path` at lines 6–8 so nimble source is importable. The community venv's site-packages must be inserted **after** that (position 1, not 0) so nimble's own modules take priority over any conflicting packages the skill might bundle. + +Example injection (after line 12) — globs both Linux/macOS and Windows layouts: + +```python +_venv_path = os.environ.get("NIMBLE_VENV_PATH") +if _venv_path: + import glob as _glob + for _pat in [ + str(Path(_venv_path) / "lib" / "python*" / "site-packages"), # Linux/macOS + str(Path(_venv_path) / "Lib" / "site-packages"), # Windows + ]: + for _sp in _glob.glob(_pat): + if _sp not in sys.path: + sys.path.insert(1, _sp) +``` + +## Spec Change Log + +**Iteration 1 loopback — bad_spec: Windows venv path** +- Triggering finding: glob pattern `lib/python*/site-packages` (Linux-only) was missing `Lib/site-packages` (Windows). Old `_get_python_executable` branched on `is_windows()` explicitly; new implementation dropped that awareness. +- What was amended: Design Notes glob example updated to iterate both platform patterns. `is_windows` import removal added as a patch task. +- Known-bad state avoided: Community skills silently fail to inject deps on Windows because glob returns zero matches. +- KEEP: `sys.path.insert(1, sp)` position; `if _sp not in sys.path:` guard; `_get_community_venv_path` returning `""` for non-community skills. + +## Verification + +**Commands:** +- `python -m pytest tests/ -x -q` -- expected: 308 passed +- `nimble restart && nimble status` -- expected: translate shows `loaded` +- `.nimble/skills/translate/.venv/bin/python -c "import googletrans"` -- expected: no error (confirms skill dep still reachable via injected path) + +## Suggested Review Order + +**Core fix — Python executable and venv path routing** + +- Entry point: `_get_python_executable` simplified + `_get_community_venv_path` added; this is why workers now load. + [`runner.py:53`](../../../nimble/skills/runner.py#L53) + +- Env dict: `NIMBLE_VENV_PATH` wired to the community venv — the channel from runner to entrypoint. + [`runner.py:150`](../../../nimble/skills/runner.py#L150) + +**Community venv injection — entrypoint sys.path setup** + +- Platform-aware glob injects venv site-packages before any nimble imports; Linux + Windows both covered. + [`entrypoint.py:14`](../../../worker/entrypoint.py#L14) + +**Tests** + +- Updated assertion: confirms `sys.executable` used and `NIMBLE_VENV_PATH` carries the right path. + [`test_runner.py:66`](../../../tests/unit/skills/test_runner.py#L66) diff --git a/nimble/skills/runner.py b/nimble/skills/runner.py index 49ed7e5..3de1d37 100644 --- a/nimble/skills/runner.py +++ b/nimble/skills/runner.py @@ -15,7 +15,6 @@ from nimble import SUPPORTED_API_VERSION from nimble.logging_setup import LOG_PATH from nimble.manifest.parser import AiConfig, read_skill_manifest -from nimble.platform import is_windows from nimble.skills.registry import SkillConfig, SkillRegistry, SkillWorker logger = logging.getLogger(__name__) @@ -52,12 +51,13 @@ class DispatchResult: def _get_python_executable(config: SkillConfig) -> str: - if config.source == "local": - return sys.executable - base = Path.home() / ".nimble" / "skills" / config.name / ".venv" - if is_windows(): - return str(base / "Scripts" / "python.exe") - return str(base / "bin" / "python") + return sys.executable + + +def _get_community_venv_path(config: SkillConfig, repo_root: Path) -> str: + if config.source != "community": + return "" + return str(repo_root / ".nimble" / "skills" / config.name / ".venv") class SkillRunner: @@ -147,6 +147,9 @@ def spawn_workers(self, configs: list[SkillConfig]) -> None: "NIMBLE_LOG_PATH": str(LOG_PATH), "NIMBLE_DEBUG": "1" if self._debug else "0", "NIMBLE_SKILL_CONFIG": skill_config_json, + "NIMBLE_VENV_PATH": _get_community_venv_path( + config, self._repo_root + ), }, ) handshake_line = _readline_with_timeout( diff --git a/tests/unit/skills/test_runner.py b/tests/unit/skills/test_runner.py index 1e41271..a669d1e 100644 --- a/tests/unit/skills/test_runner.py +++ b/tests/unit/skills/test_runner.py @@ -63,7 +63,7 @@ def test_spawn_workers_local_uses_sys_executable() -> None: assert cmd[0] == sys.executable -def test_spawn_workers_community_uses_venv_python() -> None: +def test_spawn_workers_community_uses_sys_executable_and_sets_venv_path() -> None: config = _make_config(name="my-skill", source="community") registry = SkillRegistry() runner = _make_runner(registry=registry) @@ -73,16 +73,13 @@ def test_spawn_workers_community_uses_venv_python() -> None: with patch("subprocess.Popen", return_value=fake_proc) as mock_popen: runner.spawn_workers([config]) - args, _ = mock_popen.call_args + args, kwargs = mock_popen.call_args cmd = args[0] - if sys.platform == "win32": - assert cmd[0].endswith("python.exe") - assert "Scripts" in cmd[0] - else: - assert cmd[0].endswith("python") - assert "bin" in cmd[0] - assert ".nimble" in cmd[0] - assert "my-skill" in cmd[0] + assert cmd[0] == sys.executable + env = kwargs.get("env", {}) + venv_path = env.get("NIMBLE_VENV_PATH", "") + assert ".nimble" in venv_path + assert "my-skill" in venv_path def test_spawn_workers_registers_worker() -> None: diff --git a/worker/entrypoint.py b/worker/entrypoint.py index 09fe122..0120bc5 100644 --- a/worker/entrypoint.py +++ b/worker/entrypoint.py @@ -11,6 +11,18 @@ if _env_root and _env_root not in sys.path: sys.path.append(_env_root) +_venv_path = os.environ.get("NIMBLE_VENV_PATH") +if _venv_path: + import glob as _glob + + for _pat in [ + str(Path(_venv_path) / "lib" / "python*" / "site-packages"), # Linux/macOS + str(Path(_venv_path) / "Lib" / "site-packages"), # Windows + ]: + for _sp in _glob.glob(_pat): + if _sp not in sys.path: + sys.path.insert(1, _sp) + _log_path = os.environ.get("NIMBLE_LOG_PATH") if _log_path: _handler = logging.FileHandler(_log_path) From 3ab373192ea2c1f1521a5e680f3d5b4964fe3899 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Mon, 4 May 2026 14:55:09 +0200 Subject: [PATCH 2/4] skill build --- .../how-to-build-custom-nimble-skill.md | 623 ++++++++++++++++++ 1 file changed, 623 insertions(+) create mode 100644 docs/bmad_output/how-to-build-custom-nimble-skill.md diff --git a/docs/bmad_output/how-to-build-custom-nimble-skill.md b/docs/bmad_output/how-to-build-custom-nimble-skill.md new file mode 100644 index 0000000..7a83fb7 --- /dev/null +++ b/docs/bmad_output/how-to-build-custom-nimble-skill.md @@ -0,0 +1,623 @@ +# How to Build a Custom Nimble Skill and Publish It to GitHub + +**Audience:** Developers comfortable with Git and CLI tools +**Goal:** Build a reusable, shareable Nimble skill from scratch, publish it to its own GitHub repository, and let others install it with a single command. + +--- + +## Table of Contents + +1. [What Is a Nimble Skill?](#1-what-is-a-nimble-skill) +2. [Prerequisites](#2-prerequisites) +3. [Skill Anatomy](#3-skill-anatomy) +4. [Worked Example: TranslateSkill](#4-worked-example-translateskill) + - [4.1 Plan the Skill](#41-plan-the-skill) + - [4.2 Write the Skill Class](#42-write-the-skill-class) + - [4.3 Write the Manifest](#43-write-the-manifest) + - [4.4 Register the Skill in config.yaml](#44-register-the-skill-in-configyaml) + - [4.5 Test the Skill](#45-test-the-skill) +5. [GitHub Repository Structure](#5-github-repository-structure) +6. [Publishing to GitHub](#6-publishing-to-github) +7. [Community Installation](#7-community-installation) +8. [Troubleshooting](#8-troubleshooting) + +--- + +## 1. What Is a Nimble Skill? + +Nimble is a cross-platform Python hotkey daemon. When you press a hotkey, it: + +1. **Captures context** — the selected text, clipboard content, active app, and mouse position at that exact moment. +2. **Dispatches** the context to a skill running in an isolated subprocess. +3. **The skill acts** — using the captured context and built-in tools (AI, clipboard, popups, TTS, dialogs). + +A **skill** is a Python class with a `run()` method. That's it. No framework, no decorators, no imports from Nimble required. + +```mermaid +sequenceDiagram + participant User + participant Daemon + participant Worker as Skill Subprocess + participant Tools + + User->>Daemon: Press hotkey (e.g. ctrl+shift+t) + Daemon->>Daemon: Capture context (selection, clipboard, app, mouse) + Daemon->>Worker: Send JSON {context} + Worker->>Worker: Deserialise → Context object + Worker->>Worker: Call skill.run(context, tools) + Worker->>Tools: tools.ai.ask() / clipboard.set() / popup.show() + Tools-->>Worker: Result + Worker-->>Daemon: Send JSON {status: ok} +``` + +--- + +## 2. Prerequisites + +| Requirement | Version | Purpose | +|---|---|---| +| Python | 3.11+ | Skill runtime | +| pip | any | Installing skill dependencies | +| Git | 2.x+ | Version control | +| GitHub account | — | Publishing the skill | +| GitHub CLI (`gh`) | 2.x+ | Creating releases from terminal | +| Nimble daemon running | — | Testing the skill locally | + +Install the GitHub CLI: + +```bash +# macOS +brew install gh + +# Ubuntu / Debian +sudo apt install gh + +# Windows (winget) +winget install GitHub.cli +``` + +--- + +## 3. Skill Anatomy + +Every Nimble skill consists of two files in a dedicated folder: + +``` +skills/ +└── my_skill/ + ├── skill.py ← the Python class + └── manifest.yaml ← metadata, permissions, dependencies +``` + +### 3.1 The Skill Class + +```python +class MySkill: + + def on_load(self, config): # optional — called once at daemon start + pass + + def run(self, context, tools): # required — called on every hotkey fire + pass + + def on_error(self, exc): # optional — called when run() raises + pass + + def on_unload(self): # optional — called on graceful shutdown + pass +``` + +> **Critical rule:** Do NOT annotate `context` or `tools` with `nimble.*` types. The skill subprocess may not have Nimble installed. Use bare `object` or omit annotations entirely. + +#### The `context` object + +Four fields — always strings or lists, never `None`: + +| Field | Type | Contains | +|---|---|---| +| `context.selection` | `str` | Text selected in the active window | +| `context.clipboard` | `str` | Current clipboard content | +| `context.active_app` | `str` | Active application name (e.g. `"Google Chrome"`) | +| `context.mouse_position` | `list[int]` | `[x, y]` screen coordinates | + +#### The `tools` object + +Five built-in tools: + +| Tool | Method | What it does | +|---|---|---| +| `tools.ai` | `.ask(text, prompt=None) → str` | Queries the configured LLM | +| `tools.popup` | `.show(text) → None` | System desktop notification | +| `tools.clipboard` | `.get() → str` | Reads clipboard | +| `tools.clipboard` | `.set(text) → None` | Writes to clipboard | +| `tools.tts` | `.speak(text) → None` | Text-to-speech | +| `tools.input` | `.ask(prompt) → str \| None` | Text input dialog | +| `tools.input` | `.select(prompt, choices) → str \| None` | Selection dialog | + +### 3.2 The Manifest + +`manifest.yaml` declares the skill's identity and requirements: + +```yaml +name: my_skill # unique, snake_case, no slashes +version: "1.0.0" # semver +api_version: 1 # must be ≤ 1 (current SUPPORTED_API_VERSION) +description: "…" # shown during community install +entrypoint: skill.py # Python file relative to this manifest +class_name: MySkill # class name inside entrypoint +permissions: [] # subset of: ai, clipboard, tts, input, popup +dependencies: [] # pip packages — installed into isolated venv +author: "Your Name" +``` + +### 3.3 The config.yaml Entry + +To bind your skill to a hotkey, add an entry to the project's root `config.yaml`: + +```yaml +skills: + - name: my_skill + source: local + path: skills/my_skill/skill.py + class_name: MySkill + binding: "ctrl+shift+m" +``` + +```mermaid +flowchart LR + CF[config.yaml\nbinding entry] --> D[Daemon\nreads on start] + D --> M[manifest.yaml\nvalidated] + M --> W[Worker subprocess\nspawned] + W --> OL[on_load called\nonce] + OL --> RL[Ready — waiting\nfor hotkey] + RL -->|hotkey fires| RN[run called\nwith context + tools] +``` + +--- + +## 4. Worked Example: TranslateSkill + +**TranslateSkill** translates the selected or clipboard text into a language the user chooses at runtime, using the `googletrans` Python library, and copies the result back to the clipboard. + +### 4.1 Plan the Skill + +| Question | Answer | +|---|---| +| What triggers it? | `ctrl+shift+t` (configurable) | +| What does it read? | `context.selection` if non-empty, else `context.clipboard` | +| What does it ask? | A language code (e.g. `fr`, `de`, `ja`) via input dialog | +| What does it produce? | Translated text written to clipboard + popup confirmation | +| What permissions? | `clipboard`, `popup`, `input` | +| What dependencies? | `googletrans==4.0.0rc1` | + +### 4.2 Write the Skill Class + +Create `skills/translate/skill.py`: + +```python +from googletrans import Translator + + +class TranslateSkill: + + def on_load(self, config): + self._translator = Translator() + self._default_lang = config.get("target_lang", "en") + + def run(self, context, tools): + text = context.selection or context.clipboard + if not text.strip(): + tools.popup.show("Nothing to translate — select or copy text first.") + return + + target = tools.input.ask( + f"Translate to (e.g. fr, de, ja) [{self._default_lang}]:" + ) + if target is None: # user dismissed the dialog + return + if not target.strip(): + target = self._default_lang + + try: + result = self._translator.translate(text, dest=target.strip()) + tools.clipboard.set(result.text) + tools.popup.show(f"Translated to {result.dest} — copied to clipboard.") + except Exception as exc: + tools.popup.show(f"Translation failed: {exc}") + + def on_error(self, exc): + pass # run() already handles exceptions with popup; nothing extra needed +``` + +**How it works, step by step:** + +1. `on_load()` — instantiates the `Translator` once at daemon start. Reading `config.get("target_lang", "en")` lets users set a personal default in their `config.yaml` without touching skill code. +2. `run()` — checks for text, asks the user for a target language, calls `googletrans`, writes to clipboard, notifies via popup. +3. Graceful guards — returns early if there is nothing to translate or the user cancels the dialog. + +> **Why `googletrans==4.0.0rc1`?** The `4.0.0rc1` release candidate is the stable async-compatible branch. The older `3.x` series has known SSL and JSON parse errors with current Google endpoints. + +### 4.3 Write the Manifest + +Create `skills/translate/manifest.yaml`: + +```yaml +name: translate +version: "1.0.0" +api_version: 1 +description: > + Translates selected or clipboard text to any language using Google Translate. + Prompts for a target language code at runtime. +entrypoint: skill.py +class_name: TranslateSkill +permissions: + - clipboard + - popup + - input +dependencies: + - googletrans==4.0.0rc1 +author: "Your Name" +``` + +### 4.4 Register the Skill in config.yaml + +Add to the root `config.yaml`: + +```yaml +skills: + - name: translate + source: local + path: skills/translate/skill.py + class_name: TranslateSkill + binding: "ctrl+shift+t" +``` + +To pass a personal default language, add a `config` block (optional): + +```yaml + - name: translate + source: local + path: skills/translate/skill.py + class_name: TranslateSkill + binding: "ctrl+shift+t" + config: + target_lang: "fr" +``` + +The daemon passes the `config` dict to `on_load(config)` on startup. + +### 4.5 Test the Skill + +Create `tests/unit/skills/test_translate.py`: + +```python +import importlib.util +import pathlib +from unittest.mock import MagicMock + +import pytest + + +def _load_skill(): + path = pathlib.Path("skills/translate/skill.py") + spec = importlib.util.spec_from_file_location("translate_skill", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module.TranslateSkill + + +class TestTranslateSkill: + + def setup_method(self): + self.skill = _load_skill()() + self.skill.on_load({"target_lang": "fr"}) + + def _ctx(self, selection="", clipboard=""): + ctx = MagicMock() + ctx.selection = selection + ctx.clipboard = clipboard + return ctx + + def test_translates_selection_to_chosen_language(self, mocker): + mock_result = MagicMock() + mock_result.text = "Bonjour le monde" + mock_result.dest = "fr" + mocker.patch.object( + self.skill._translator, "translate", return_value=mock_result + ) + tools = MagicMock() + tools.input.ask.return_value = "fr" + + self.skill.run(self._ctx(selection="Hello world"), tools) + + tools.clipboard.set.assert_called_once_with("Bonjour le monde") + tools.popup.show.assert_called_once_with( + "Translated to fr — copied to clipboard." + ) + + def test_falls_back_to_clipboard_when_no_selection(self, mocker): + mock_result = MagicMock(text="Hallo Welt", dest="de") + mocker.patch.object( + self.skill._translator, "translate", return_value=mock_result + ) + tools = MagicMock() + tools.input.ask.return_value = "de" + + self.skill.run(self._ctx(clipboard="Hello world"), tools) + + tools.clipboard.set.assert_called_once_with("Hallo Welt") + + def test_uses_default_lang_when_input_empty(self, mocker): + mock_result = MagicMock(text="Monde", dest="fr") + mocker.patch.object( + self.skill._translator, "translate", return_value=mock_result + ) + tools = MagicMock() + tools.input.ask.return_value = "" # user pressed Enter with no input + + self.skill.run(self._ctx(selection="World"), tools) + + self.skill._translator.translate.assert_called_with("World", dest="fr") + + def test_returns_early_when_nothing_to_translate(self): + tools = MagicMock() + self.skill.run(self._ctx(selection="", clipboard=""), tools) + tools.popup.show.assert_called_once_with( + "Nothing to translate — select or copy text first." + ) + + def test_returns_early_when_dialog_dismissed(self, mocker): + mocker.patch.object(self.skill._translator, "translate") + tools = MagicMock() + tools.input.ask.return_value = None # user dismissed + + self.skill.run(self._ctx(selection="Hello"), tools) + + self.skill._translator.translate.assert_not_called() + + def test_shows_popup_on_translation_error(self, mocker): + mocker.patch.object( + self.skill._translator, "translate", side_effect=RuntimeError("API down") + ) + tools = MagicMock() + tools.input.ask.return_value = "fr" + + self.skill.run(self._ctx(selection="Hello"), tools) + + tools.popup.show.assert_called_once() + assert "Translation failed" in tools.popup.show.call_args[0][0] +``` + +Run the tests: + +```bash +pytest tests/unit/skills/test_translate.py -v +``` + +--- + +## 5. GitHub Repository Structure + +Community skills live in their **own dedicated repository** so they can be versioned and installed independently. The repository root is what `nimble add` fetches — keep it clean. + +### Recommended layout + +``` +translate-skill/ ← repository root (= skill root) +├── skill.py ← skill entrypoint (required) +├── manifest.yaml ← skill manifest (required) +├── README.md ← installation + usage guide (required) +├── LICENSE ← MIT recommended +├── CHANGELOG.md ← version history +└── tests/ + └── test_translate.py ← unit tests (optional but strongly recommended) +``` + +> **Why no subdirectory?** `nimble add` installs the repository root directly into `~/.nimble/skills//`. Putting `skill.py` and `manifest.yaml` at the root avoids path indirection. + +### Naming conventions + +| Item | Convention | Example | +|---|---|---| +| Repository name | `nimble--skill` | `nimble-translate-skill` | +| `name` in manifest.yaml | `snake_case` | `translate` | +| Skill class name | `PascalCase` + `Skill` suffix | `TranslateSkill` | +| Hotkey (user's choice) | `ctrl+shift+` | `ctrl+shift+t` | +| Version | semver | `1.0.0` | + +--- + +## 6. Publishing to GitHub + +### Step-by-step + +```bash +# 1. Create the repository directory +mkdir nimble-translate-skill && cd nimble-translate-skill + +# 2. Copy your skill files to the repo root +cp /path/to/skills/translate/skill.py . +cp /path/to/skills/translate/manifest.yaml . +mkdir tests +cp /path/to/tests/unit/skills/test_translate.py tests/ + +# 3. Initialise git +git init +git branch -M main + +# 4. Write README.md (see template below) + +# 5. Add a LICENSE +# Visit https://choosealicense.com — MIT is standard for community skills + +# 6. First commit +git add . +git commit -m "feat: initial release of nimble-translate-skill v1.0.0" + +# 7. Create the remote repo via GitHub CLI +gh repo create nimble-translate-skill --public --source=. --remote=origin --push + +# 8. Tag the release +git tag v1.0.0 +git push origin v1.0.0 +gh release create v1.0.0 --title "v1.0.0" --notes "Initial release — Google Translate integration" +``` + +### README template + +````markdown +# nimble-translate-skill + +A [Nimble](https://github.com/your-org/pixi) skill that translates selected +or clipboard text to any language using Google Translate. + +## Requirements + +- Nimble daemon installed and running +- Python 3.11+ +- Internet connection (calls Google Translate API) + +## Installation + +```bash +nimble add ctrl+shift+t https://github.com//nimble-translate-skill +``` + +This command: +- Clones the skill to `~/.nimble/skills/translate/` +- Creates an isolated virtualenv and installs `googletrans` +- Adds the hotkey binding to your Nimble config + +## Usage + +1. Select some text or copy it to the clipboard. +2. Press `ctrl+shift+t`. +3. Type a language code (e.g. `fr`, `de`, `ja`) and press Enter. +4. The translated text is copied to your clipboard. + +### Supported language codes (examples) + +| Language | Code | +|---|---| +| French | fr | +| German | de | +| Spanish | es | +| Japanese | ja | +| Chinese (Simplified) | zh-cn | +| Arabic | ar | + +Full list: [Google Translate language codes](https://cloud.google.com/translate/docs/languages) + +## Configuration + +To set a default target language, edit your `config.yaml` after install: + +```yaml +skills: + - name: translate + binding: "ctrl+shift+t" + config: + target_lang: "fr" # ← your default language +``` + +## License + +MIT +```` + +--- + +## 7. Community Installation + +Anyone who wants to use your skill runs a single command: + +```bash +nimble add ctrl+shift+t https://github.com//nimble-translate-skill +``` + +The `nimble add` command: + +```mermaid +flowchart TD + A["nimble add <binding> <repo-url>"] --> B[Clone repo to\n~/.nimble/skills/translate/] + B --> C[Read manifest.yaml\nvalidate api_version] + C --> D[Show permissions\nto user for approval] + D -->|approved| E[Create isolated venv\n~/.nimble/skills/translate/.venv/] + E --> F[pip install\ngoogletrans==4.0.0rc1] + F --> G[Write binding to\nconfig.yaml] + G --> H[Restart daemon\nor nimble reload] + H --> I[Skill ready\nctrl+shift+t] + D -->|rejected| X[Abort — nothing installed] +``` + +### Manual installation (no CLI) + +If the `nimble add` command is unavailable: + +```bash +# 1. Clone the skill to the community skills directory +git clone https://github.com//nimble-translate-skill \ + ~/.nimble/skills/translate + +# 2. Create an isolated virtualenv and install dependencies +python3 -m venv ~/.nimble/skills/translate/.venv +~/.nimble/skills/translate/.venv/bin/pip install googletrans==4.0.0rc1 + +# 3. Add the binding to config.yaml +# (edit manually — see section 3.3 for the config.yaml entry format, +# set source: community and path: ~/.nimble/skills/translate/skill.py) + +# 4. Reload the daemon +nimble reload # or restart it +``` + +### Pinning to a specific version + +```bash +# Install a specific release tag +nimble add ctrl+shift+t https://github.com//nimble-translate-skill@v1.0.0 +``` + +--- + +## 8. Troubleshooting + +### Skill does not load at daemon start + +| Symptom | Likely cause | Fix | +|---|---|---| +| No popup, hotkey silent | `class_name` mismatch | Verify `class_name` in `config.yaml` matches the Python class name exactly | +| "api_version too high" in logs | Nimble daemon is older than the skill | Update Nimble, or set `api_version: 1` in manifest.yaml | +| "path not found" on startup | Wrong `path` in config.yaml | Use a path relative to the repository root; check it with `ls ` | +| `on_load` raises | Dependency not installed | Run `pip install googletrans==4.0.0rc1` in the correct environment | + +### googletrans errors at runtime + +| Error | Cause | Fix | +|---|---|---| +| `AttributeError: 'NoneType'` | Rate-limited by Google | Add `import time; time.sleep(1)` between calls | +| `JSONDecodeError` | Outdated googletrans | Reinstall: `pip install --force-reinstall googletrans==4.0.0rc1` | +| `httpx.ConnectError` | No internet / proxy | Check connectivity; set `HTTP_PROXY` env var if behind a proxy | +| Dialog never appears | `input` tool unavailable | Confirm your desktop environment supports GTK/Qt dialogs | + +### Checking daemon logs + +```bash +# Tail daemon output +nimble logs + +# Run daemon in foreground (verbose) for debugging +nimble start --foreground --verbose +``` + +### Verifying the skill loaded correctly + +```bash +nimble list +``` + +Shows all registered skills with their status (`ok`, `disabled`, `error`). If `translate` is listed as `error`, the skill's `on_load()` raised — check logs for the exception. + +--- + +*Built with Paige — the BMAD Technical Documentation Specialist.* From 7c6a518195907b0aabceac6f296bebcf01a4e2f1 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Mon, 4 May 2026 15:00:27 +0200 Subject: [PATCH 3/4] fix: pass absolute skill module path to worker subprocess config.path is relative (e.g. skills/hello_world/skill.py); passing it bare to the entrypoint caused spec_from_file_location to resolve it against subprocess CWD instead of repo_root, breaking skill loading when nimble is not invoked from the project directory. Co-Authored-By: Claude Sonnet 4.6 --- .../implementation-artifacts/deferred-work.md | 4 ++++ .../spec-fix-skill-module-path-absolute.md | 20 +++++++++++++++++++ nimble/skills/runner.py | 2 +- 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 docs/bmad_output/implementation-artifacts/spec-fix-skill-module-path-absolute.md diff --git a/docs/bmad_output/implementation-artifacts/deferred-work.md b/docs/bmad_output/implementation-artifacts/deferred-work.md index 219c44c..8f817d6 100644 --- a/docs/bmad_output/implementation-artifacts/deferred-work.md +++ b/docs/bmad_output/implementation-artifacts/deferred-work.md @@ -1,5 +1,9 @@ # Deferred Work +## Deferred from: spec-fix-skill-module-path-absolute (2026-05-04) + +- `nimble/skills/runner.py:137` + `nimble/skills/loader.py` (`validate_skill_paths`): both use `repo_root / config.path` without guarding against absolute paths or `..` traversal in `config.path`. Config is currently trusted, but worth hardening in a validation pass (e.g. assert path is relative and stays within repo_root after `.resolve()`). + ## Deferred from: spec-fix-community-skill-worker-loading (2026-05-04) - `worker/entrypoint.py` — glob-based venv injection may match multiple `python3.x` dirs on unusual shared venv layouts; import resolution order between them is non-deterministic. Extremely unlikely in practice; address in a venv-hardening pass if reported. diff --git a/docs/bmad_output/implementation-artifacts/spec-fix-skill-module-path-absolute.md b/docs/bmad_output/implementation-artifacts/spec-fix-skill-module-path-absolute.md new file mode 100644 index 0000000..f8847b5 --- /dev/null +++ b/docs/bmad_output/implementation-artifacts/spec-fix-skill-module-path-absolute.md @@ -0,0 +1,20 @@ +--- +title: 'Fix skill module path passed to worker as absolute' +type: 'bugfix' +created: '2026-05-04' +status: 'done' +route: 'one-shot' +--- + +# Fix skill module path passed to worker as absolute + +## Intent + +**Problem:** `runner.py` passes `config.path` (a relative path from `config.yaml`, e.g. `skills/hello_world/skill.py`) as a positional CLI argument to the worker entrypoint. `spec_from_file_location` in the entrypoint resolves it against the subprocess's CWD — not `repo_root` — so skills fail to load whenever `nimble` is not invoked from the repo directory. + +**Approach:** Prepend `self._repo_root` in the `Popen` call: `str(self._repo_root / config.path)`. One-character change, consistent with how the entrypoint path is already resolved on the adjacent line. + +## Suggested Review Order + +- The single changed line: relative → absolute skill path in Popen args. + [`runner.py:137`](../../../nimble/skills/runner.py#L137) diff --git a/nimble/skills/runner.py b/nimble/skills/runner.py index 3de1d37..3f39b34 100644 --- a/nimble/skills/runner.py +++ b/nimble/skills/runner.py @@ -134,7 +134,7 @@ def spawn_workers(self, configs: list[SkillConfig]) -> None: [ python_executable, str(self._repo_root / "worker" / "entrypoint.py"), - config.path, + str(self._repo_root / config.path), config.class_name, ], stdin=subprocess.PIPE, From a7a9f057e8e1c91d4cc6890ba526cb2e916b3f56 Mon Sep 17 00:00:00 2001 From: Bernard van der Esch Date: Mon, 4 May 2026 15:02:32 +0200 Subject: [PATCH 4/4] proper gitignore --- .gitignore | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 10485e1..d774bf5 100644 --- a/.gitignore +++ b/.gitignore @@ -16,5 +16,4 @@ build/ .venv/ *.egg -skills/* -!skills/hello_world \ No newline at end of file +.nimble \ No newline at end of file