Compare commits

...

1 Commits

Author SHA1 Message Date
Brooklyn Nicholson
6db63d3542 feat(skills): add optional Pencil (pencil.dev) design skill
Optional skill under optional-skills/creative/pencil — inactive until
`hermes skills install official/creative/pencil`. Drives Pencil `.pen` files
via the `pencil` CLI through terminal (lazy npm dep, no core footprint).

Mode A: pipe batch_design/batch_get/get_screenshot into `pencil interactive`
over stdin (pencil_repl.py). Mode B: `pencil --out … --prompt …`.

Not an MCP catalog entry: public @pencil.dev/cli has no mcp-server command;
Pencil's MCP server is app-embedded. CLI+skill is the lower-footprint path.

Ships pencil_repl.py, pencil_doctor.py, references/mcp-tools.md, tests.
2026-06-24 22:14:55 -05:00
5 changed files with 715 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
---
name: pencil
description: "Create, edit, and export .pen design files via the CLI."
version: 1.0.0
author: Hermes Agent
license: MIT
platforms: [linux, macos, windows]
metadata:
hermes:
category: creative
tags: [Pencil, Design, UI, Figma, Design-to-Code, Canvas, pencil.dev]
related_skills: [excalidraw, claude-design, popular-web-designs]
prerequisites:
commands: [pencil, node]
---
# Pencil Skill
Optional skill — **not active until installed**:
```bash
hermes skills install official/creative/pencil
```
After install, scripts live under `~/.hermes/skills/creative/pencil/`. In prose
below, `{SKILL_DIR}` means that directory (or the repo path
`optional-skills/creative/pencil/` before install).
Drive [Pencil](https://pencil.dev) — an IDE-first design canvas that stores
designs as version-controlled `.pen` files — from Hermes through its CLI. You
can call Pencil's design tools directly (deterministic, no extra API key) or
hand Pencil a natural-language prompt and let its built-in agent generate a
design. This skill does **not** add a dependency to Hermes: the `pencil` binary
is a lazy runtime dependency you install only if you use this skill.
> Why the CLI and not an MCP server: Pencil's MCP server is embedded in its
> closed-source desktop app / IDE extension and auto-injected into specific
> hosts (Claude Code, Cursor, …). It exposes no public stdio command for a
> generic MCP host, so a true MCP wiring would require a bridge. The `pencil`
> CLI gives the same tool surface with zero bridge — Pencil's own CLI help even
> says *"Agents should use this mode to design with Pencil."*
## When to Use
- The user wants to create or edit a `.pen` design (screens, components,
design systems) that lives in their repo.
- The user wants a design rendered to an image (PNG/JPEG/WEBP/PDF).
- The user wants to read an existing `.pen` file's structure, variables, or
components to keep design and code in sync.
## When NOT to Use
- Quick throwaway diagrams (arch/flow/sequence) → use the `excalidraw` skill.
- Pure HTML/CSS mockups with no design file → use `popular-web-designs` /
`claude-design`.
- The user is editing a Figma `.fig` file → that's a different product
(OpenPencil); this skill targets `pencil.dev` `.pen` files.
## Prerequisites
- **Node.js** and the Pencil CLI: `npm install -g @pencil.dev/cli`. Docs say
Node 18+, but newer CLI builds need **Node 22+** — upgrade if you hit
`ERR_REQUIRE_ESM`. No sudo? `npm install --prefix ~/.local`.
- **Auth is required for BOTH modes.** `pencil login` (stores
`~/.pencil/session-cli.json`) or set `PENCIL_CLI_KEY`. Pencil has no offline
mode — even headless `pencil interactive` refuses to start unauthenticated.
Verify with `pencil status`.
- **Agent mode (Mode B)** additionally needs an agent key, e.g.
`ANTHROPIC_API_KEY` (or `PENCIL_AGENT_API_KEY`).
- Run the preflight check first via the `terminal` tool:
`python {SKILL_DIR}/scripts/pencil_doctor.py`
## How to Run
Two modes. Prefer **Mode A** when Hermes is doing the thinking; use **Mode B**
to delegate generation to Pencil's own agent.
### Mode A — Direct tool control (recommended, no agent key)
Hermes decides the design and calls Pencil's MCP tools through the
`pencil interactive` REPL, driven non-interactively by `scripts/pencil_repl.py`
via the `terminal` tool. Always read the live schema first:
```bash
python {SKILL_DIR}/scripts/pencil_repl.py --out design.pen \
--cmd 'get_editor_state({ include_schema: true })'
```
Then issue tool calls (`batch_get`, `batch_design`, `get_screenshot`, …). The
wrapper appends `save()` (headless) and `exit()` automatically:
```bash
python {SKILL_DIR}/scripts/pencil_repl.py --out design.pen \
--cmd 'batch_design({ operations: "hero=I(document,{type:\"frame\",name:\"Hero\",x:0,y:0,width:1440,height:900,fill:\"#0A0A0A\"})" })' \
--cmd 'get_screenshot({ nodeId: "hero" })'
```
Connect to a running Pencil desktop app instead (changes apply live):
```bash
python {SKILL_DIR}/scripts/pencil_repl.py --app desktop --in design.pen \
--cmd 'batch_get({ patterns: [{ reusable: true }] })'
```
See `references/mcp-tools.md` for the tool list and the `batch_design` DSL.
### Mode B — Prompt-driven generation (delegates to Pencil's agent)
```bash
# New design from a prompt
pencil --out landing.pen --prompt "Create a SaaS landing page with hero, features, pricing" --agent claude
# Modify an existing design
pencil --in landing.pen --out landing-v2.pen --prompt "Add a dark footer with social links"
# Attach reference images (repeatable)
pencil --out ui.pen --prompt "Match this style" -f ./ref.png
# Export to an image
pencil --in landing.pen --export landing.png --export-scale 2 --export-type png
```
## Quick Reference
| Goal | Command |
| --- | --- |
| Preflight check | `python .../scripts/pencil_doctor.py` |
| Read document schema + DSL | REPL: `get_editor_state({ include_schema: true })` |
| List design-system components | REPL: `batch_get({ patterns: [{ reusable: true }] })` |
| Mutate the design | REPL: `batch_design({ operations: "..." })` |
| Screenshot a node | REPL: `get_screenshot({ nodeId: "..." })` |
| Generate from a prompt | `pencil --out x.pen --prompt "..." --agent claude` |
| Export an image | `pencil --in x.pen --export x.png --export-type png` |
| List models | `pencil --list-models` |
## Procedure
1. **Preflight.** Run `pencil_doctor.py`. If `pencil` is missing, tell the user
to `npm install -g @pencil.dev/cli`; if unauthenticated, `pencil login`.
2. **Pick the file.** New design → choose an output path ending in `.pen`.
Editing → pass the existing file as `--in`.
3. **Inspect first (Mode A).** Call `get_editor_state({ include_schema: true })`
and, when editing, `batch_get` to learn the current node tree and the exact
`batch_design` DSL for this Pencil version.
4. **Make changes.** Issue `batch_design` operations (Mode A) or a prompt
(Mode B). Keep `batch_get` `readDepth` ≤ 3 to avoid flooding context.
5. **Verify** (see below), then **commit** the `.pen` file with the related
code change so design and implementation move together in Git.
## Pitfalls
- **Auth is mandatory.** There is no offline path; both modes refuse to run
without a `pencil login` session or `PENCIL_CLI_KEY`.
- **`batch_design` binding names are per-call.** A name you bind (e.g.
`hero=I(...)`) only exists within that single `batch_design` call. To touch
the node in a later call, reference it by its real node id (from the call's
output or `batch_get`) — do **not** reuse the binding name across calls.
Combine related inserts/edits into one `batch_design` where possible.
- **Pencil must be running for `--app` mode.** App mode connects over a local
socket to the desktop app / extension. If it isn't running, use headless
mode (`--out`, no `--app`).
- **Don't guess the `batch_design` DSL.** Operation letters/args can change
between versions — read them from `get_editor_state`, not from memory.
- **Multi-line stdin: use `--cmds-file`, not inline escaping.** `batch_design`
operations are strings of JS-object literals; piping them through a shell
`printf`/`--cmd '...'` with escaped quotes is fragile. For anything
non-trivial, write the REPL calls to a file and pass `--cmds-file`.
- **Mode B costs tokens and needs an agent key.** It runs Pencil's own LLM
agent. For precise, free, deterministic edits prefer Mode A.
- **`.pen` is the source of truth, in your repo.** Treat it like code: review
the diff, commit it alongside the implementation.
## Verification
- After a mutation, run `get_screenshot({ nodeId })`, save the PNG, and read it
back with Hermes's `vision_analyze` / `read_file` to confirm the result.
- Re-run `batch_get` / `snapshot_layout` to confirm the node tree and bounds.
- Confirm the `.pen` file was written (headless `save()` succeeded) before
committing.

View File

@@ -0,0 +1,74 @@
# Pencil MCP tools reference
Pencil exposes the same tool surface through `pencil interactive` (the REPL
this skill drives) that its IDE/desktop MCP server exposes to other AI hosts.
Call them as `tool_name({ ...args })`. The file path is injected automatically
in headless mode — never pass it yourself.
> **Authoritative source:** always run
> `get_editor_state({ include_schema: true })` first. It returns the live
> document schema **and** the full `batch_design` operation DSL for the running
> Pencil version. Treat that schema as source of truth; this file is a primer
> so you know which tools exist and roughly how `batch_design` reads.
> **Auth required:** the shell will not start without a `pencil login` session
> or `PENCIL_CLI_KEY` — there is no offline mode, even headless.
## Read / inspect
| Tool | Purpose |
| --- | --- |
| `get_editor_state({ include_schema: true })` | Document metadata + structure + the batch_design DSL schema. Call this first. |
| `batch_get({ patterns?, nodeIds?, parentId?, readDepth?, searchDepth?, resolveVariables? })` | Search and read nodes. No args → top-level children. `patterns: [{ reusable: true }]` → list design-system components. Keep `readDepth` ≤ 3 to avoid huge output. |
| `get_variables()` | Read design tokens / theme values (for syncing with CSS). |
| `snapshot_layout()` | Document structure with computed bounds (find overlaps / positioning issues). |
## Mutate
| Tool | Purpose |
| --- | --- |
| `batch_design({ operations })` | The workhorse: insert / update / replace / move / copy / delete nodes, set variables, and generate images. `operations` is a compact string DSL (see below). |
`batch_design` operation DSL (confirmed forms — verify the rest via
`get_editor_state`):
- **Insert:** `name=I(parent,{ ...props })` — inserts a node under `parent`
(use `document` for top level) and binds the new node id to `name`. Example:
`hero=I(document,{type:"frame",name:"Hero",x:0,y:0,width:1440,height:900,fill:"#0A0A0A"})`
**Binding names are ephemeral — they only exist within this one
`batch_design` call.** In a later call, reference the node by its real id
(from the call output or `batch_get`), not by the binding name.
- **Generate image into a node:**
- `G(nodeId,"ai",prompt)` — AI-generated image from a text prompt.
- `G(nodeId,"stock",keywords)` — stock photo (Unsplash).
- **Set variables / themes:** written via a `SetVariables` operation inside
`batch_design` (some CLI versions also expose a top-level `set_variables`
tool for two-way CSS-token sync) — confirm the exact shape via
`get_editor_state`.
Other operations referenced by Pencil's docs — copy, update, replace, move,
delete — follow the same `OP(...)` shape; read their exact letters/args from
the `get_editor_state` schema rather than guessing.
## Visual / export
| Tool | Purpose |
| --- | --- |
| `get_screenshot({ nodeId })` | Render a node to a PNG (verify visual output). Save it, then read it back with Hermes's `vision_analyze`/`read_file` to check the result. |
| `export_nodes({ ... })` | Export nodes to PNG / JPEG / WEBP / PDF. |
## Style / guidelines
| Tool | Purpose |
| --- | --- |
| `get_guidelines()` | List available guides/styles for working with `.pen` files. |
| `get_guidelines({ category: "guide", name: "Landing Page" })` | Load a specific guide before generating that kind of layout. |
## Session lifecycle (REPL)
- `save()` — write the document to the `--out` path (headless mode only; app
mode applies changes live).
- `exit()` — leave the shell.
The `pencil_repl.py` wrapper appends `save()` (headless) and `exit()`
automatically, so you only supply the tool-call lines.

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Preflight for the Pencil CLI (this skill's lazy runtime dependency).
Checks `node` + `pencil` presence/version and `pencil status` auth, with
actionable hints. Exit 0 iff `pencil` is installed.
python pencil_doctor.py [--json]
"""
from __future__ import annotations
import argparse
import json
import shutil
import subprocess
def _probe(argv, runner):
"""Run a short command, swallowing missing-binary/timeout into None."""
try:
return runner(argv, text=True, capture_output=True, timeout=30)
except (FileNotFoundError, subprocess.TimeoutExpired):
return None
def _out(proc):
return (proc.stdout or "").strip() if proc else None
def _major(version):
head = (version or "").lstrip("v").split(".", 1)[0]
return int(head) if head.isdigit() else None
def check(*, which=shutil.which, runner=subprocess.run) -> dict:
"""Structured environment status. ``which``/``runner`` are injectable."""
node, pencil = which("node"), which("pencil")
node_ver = _out(_probe([node, "--version"], runner)) if node else None
pencil_ver = _out(_probe([pencil, "version"], runner)) if pencil else None
auth = _probe([pencil, "status"], runner) if pencil else None
return {
"node": {
"present": bool(node),
"version": node_ver,
"ok": (_major(node_ver) or 0) >= 18,
},
"pencil": {"present": bool(pencil), "version": pencil_ver},
"auth": {"checked": auth is not None, "ok": bool(auth and auth.returncode == 0)},
}
def _summary(s: dict) -> str:
n, p, a = s["node"], s["pencil"], s["auth"]
lines = []
if not n["present"]:
lines.append("✗ node: not found — install Node.js (https://nodejs.org)")
elif not n["ok"]:
lines.append(
f"⚠ node: {n['version']} — Pencil needs Node 18+ "
"(newer builds need 22+; upgrade if you hit ERR_REQUIRE_ESM)"
)
else:
lines.append(f"✓ node: {n['version']}")
if not p["present"]:
lines.append("✗ pencil: not found — `npm install -g @pencil.dev/cli`")
else:
lines.append(f"✓ pencil: {p['version'] or 'installed'}")
if p["present"]:
if not a["checked"]:
lines.append("⚠ auth: could not run `pencil status`")
elif a["ok"]:
lines.append("✓ auth: authenticated")
else:
lines.append(
"✗ auth: not authenticated — `pencil login` (or set "
"PENCIL_CLI_KEY). REQUIRED for both modes: no offline mode, "
"so even headless `pencil interactive` refuses to start."
)
return "\n".join(lines)
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="Check the Pencil CLI environment.")
ap.add_argument("--json", action="store_true", help="Emit JSON")
args = ap.parse_args(argv)
status = check()
print(json.dumps(status, indent=2) if args.json else _summary(status))
return 0 if status["pencil"]["present"] else 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Drive Pencil's interactive MCP-tool shell non-interactively.
Pencil (pencil.dev) ships ``pencil interactive`` — a REPL that calls its MCP
design tools directly on a ``.pen`` file (``batch_design``, ``batch_get``,
``get_screenshot``, ``snapshot_layout``, ``get_editor_state``,
``get_variables``, ``export_nodes`` …). This wrapper feeds a fixed sequence of
tool-call lines to that REPL over stdin, appends ``save()``/``exit()`` in
headless mode, and returns the captured output. It lets an agent perform
deterministic design operations WITHOUT an LLM/agent API key and WITHOUT an MCP
bridge — the agent supplies the intent, Pencil executes the tool calls.
The ``pencil`` binary is a *lazy* runtime dependency: install it only if you
use this skill, with ``npm install -g @pencil.dev/cli``. Auth is required even
headless — run ``pencil_doctor.py`` first.
Examples
--------
Headless edit of a new/existing file::
python pencil_repl.py --out design.pen \\
--cmd 'get_editor_state({ include_schema: true })' \\
--cmd 'batch_design({ operations: "hero=I(document,{type:\\"frame\\",width:1440,height:900,fill:\\"#0A0A0A\\"})" })'
Drive a running desktop app (changes apply live, no save() needed)::
python pencil_repl.py --app desktop --in design.pen \\
--cmd 'batch_get({ patterns: [{ reusable: true }] })'
Read the command list from a file (one REPL call per line)::
python pencil_repl.py --out design.pen --cmds-file ops.txt
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from typing import List, Optional, Sequence
PENCIL_INSTALL_HINT = (
"`pencil` not found on PATH. Install the Pencil CLI:\n"
" npm install -g @pencil.dev/cli\n"
"then authenticate with `pencil login` (or set PENCIL_CLI_KEY)."
)
def build_pencil_argv(
*,
pencil_bin: str = "pencil",
out: Optional[str] = None,
in_path: Optional[str] = None,
app: Optional[str] = None,
) -> List[str]:
"""Assemble the ``pencil interactive`` argv.
App mode connects to a running Pencil instance; headless mode requires an
output path. Raises ``ValueError`` when neither ``app`` nor ``out`` is set,
matching the CLI's own requirement.
"""
if not app and not out:
raise ValueError("headless mode requires --out (or use --app <name>)")
argv: List[str] = [pencil_bin, "interactive"]
if app:
argv += ["--app", app]
if in_path:
argv += ["--in", in_path]
if out:
argv += ["--out", out]
return argv
def build_stdin(commands: Sequence[str], *, save: bool = True) -> str:
"""Build the REPL stdin payload: the tool-call lines, an optional
``save()`` (headless only), then ``exit()`` so the shell terminates."""
lines: List[str] = [c.strip() for c in commands if c.strip()]
if save:
lines.append("save()")
lines.append("exit()")
return "\n".join(lines) + "\n"
def run_repl(
commands: Sequence[str],
*,
pencil_bin: str = "pencil",
out: Optional[str] = None,
in_path: Optional[str] = None,
app: Optional[str] = None,
save: bool = True,
timeout: int = 180,
_run=subprocess.run,
_which=shutil.which,
) -> subprocess.CompletedProcess:
"""Run the interactive shell with the given REPL command lines piped in.
``_run`` / ``_which`` are injectable for testing. In app mode ``save`` is
forced off because changes apply live.
"""
if _which(pencil_bin) is None:
raise FileNotFoundError(PENCIL_INSTALL_HINT)
argv = build_pencil_argv(
pencil_bin=pencil_bin, out=out, in_path=in_path, app=app
)
stdin_payload = build_stdin(commands, save=save and not app)
return _run(
argv,
input=stdin_payload,
text=True,
capture_output=True,
timeout=timeout,
)
def main(argv: Optional[List[str]] = None) -> int:
p = argparse.ArgumentParser(
description="Drive `pencil interactive` non-interactively over stdin.",
)
p.add_argument("--out", "-o", help="Output .pen file (required in headless mode)")
p.add_argument("--in", "-i", dest="in_path", help="Input .pen file (optional)")
p.add_argument("--app", "-a", help="Connect to a running Pencil app, e.g. 'desktop'")
p.add_argument(
"--cmd",
action="append",
default=[],
help="A REPL tool-call line, e.g. 'batch_get()'. Repeatable.",
)
p.add_argument("--cmds-file", help="File with one REPL call per line")
p.add_argument(
"--no-save",
action="store_true",
help="Do not append save() (headless mode only)",
)
p.add_argument("--pencil-bin", default="pencil", help="Path to the pencil binary")
p.add_argument("--timeout", type=int, default=180, help="Seconds before abort")
args = p.parse_args(argv)
commands: List[str] = list(args.cmd)
if args.cmds_file:
with open(args.cmds_file, "r", encoding="utf-8") as f:
commands += f.read().splitlines()
if not commands:
print("No REPL commands given (use --cmd or --cmds-file).", file=sys.stderr)
return 2
try:
proc = run_repl(
commands,
pencil_bin=args.pencil_bin,
out=args.out,
in_path=args.in_path,
app=args.app,
save=not args.no_save,
timeout=args.timeout,
)
except FileNotFoundError as exc:
print(str(exc), file=sys.stderr)
return 127
except ValueError as exc:
print(f"error: {exc}", file=sys.stderr)
return 2
except subprocess.TimeoutExpired:
print(f"pencil interactive timed out after {args.timeout}s", file=sys.stderr)
return 124
if proc.stdout:
sys.stdout.write(proc.stdout)
if proc.stderr:
sys.stderr.write(proc.stderr)
return proc.returncode
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,188 @@
"""Tests for the creative/pencil skill.
Covers the SKILL.md authoring contract and the two helper scripts
(pencil_repl.py argv/stdin assembly + missing-binary handling, and
pencil_doctor.py status reporting). No live network or real `pencil` calls —
subprocess and PATH lookups are mocked.
"""
from __future__ import annotations
import importlib.util
import re
from pathlib import Path
import pytest
SKILL_DIR = (
Path(__file__).resolve().parents[2]
/ "optional-skills"
/ "creative"
/ "pencil"
)
def _load(module_name: str, filename: str):
path = SKILL_DIR / "scripts" / filename
spec = importlib.util.spec_from_file_location(module_name, path)
assert spec and spec.loader
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
@pytest.fixture
def repl():
return _load("pencil_repl", "pencil_repl.py")
@pytest.fixture
def doctor():
return _load("pencil_doctor", "pencil_doctor.py")
# --------------------------------------------------------------------------
# SKILL.md authoring contract
# --------------------------------------------------------------------------
class TestSkillContract:
def test_skill_md_exists(self):
assert (SKILL_DIR / "SKILL.md").is_file()
def test_description_under_60_chars(self):
text = (SKILL_DIR / "SKILL.md").read_text()
m = re.search(r'^description:\s*"?(.*?)"?\s*$', text, re.MULTILINE)
assert m, "description frontmatter missing"
desc = m.group(1)
assert len(desc) <= 60, f"{len(desc)} chars: {desc!r}"
assert desc.endswith("."), "description should end with a period"
def test_scripts_and_reference_present(self):
assert (SKILL_DIR / "scripts" / "pencil_repl.py").is_file()
assert (SKILL_DIR / "scripts" / "pencil_doctor.py").is_file()
assert (SKILL_DIR / "references" / "mcp-tools.md").is_file()
# --------------------------------------------------------------------------
# pencil_repl.py
# --------------------------------------------------------------------------
class TestReplArgv:
def test_headless_requires_out(self, repl):
with pytest.raises(ValueError):
repl.build_pencil_argv()
def test_headless_argv(self, repl):
argv = repl.build_pencil_argv(out="design.pen", in_path="base.pen")
assert argv == ["pencil", "interactive", "--in", "base.pen", "--out", "design.pen"]
def test_app_argv_no_out_needed(self, repl):
argv = repl.build_pencil_argv(app="desktop")
assert argv == ["pencil", "interactive", "--app", "desktop"]
def test_custom_binary(self, repl):
argv = repl.build_pencil_argv(out="x.pen", pencil_bin="/opt/pencil")
assert argv[0] == "/opt/pencil"
class TestReplStdin:
def test_appends_save_and_exit(self, repl):
payload = repl.build_stdin(["batch_get()"], save=True)
assert payload == "batch_get()\nsave()\nexit()\n"
def test_no_save(self, repl):
payload = repl.build_stdin(["batch_get()"], save=False)
assert payload == "batch_get()\nexit()\n"
def test_blank_lines_dropped(self, repl):
payload = repl.build_stdin([" ", "a()", ""], save=False)
assert payload == "a()\nexit()\n"
class TestReplRun:
def test_missing_binary_raises_with_hint(self, repl):
with pytest.raises(FileNotFoundError) as ei:
repl.run_repl(["batch_get()"], out="x.pen", _which=lambda _b: None)
assert "npm install -g @pencil.dev/cli" in str(ei.value)
def test_app_mode_forces_no_save(self, repl):
captured = {}
def fake_run(argv, **kw):
captured["argv"] = argv
captured["input"] = kw.get("input")
class _P:
returncode = 0
stdout = ""
stderr = ""
return _P()
repl.run_repl(
["batch_get()"],
app="desktop",
save=True, # requested, but app mode must override to no-save
_run=fake_run,
_which=lambda _b: "/usr/bin/pencil",
)
# save() must NOT be present in app mode
assert "save()" not in captured["input"]
assert captured["input"].endswith("exit()\n")
# --------------------------------------------------------------------------
# pencil_doctor.py
# --------------------------------------------------------------------------
class TestDoctor:
def _runner(self, mapping):
"""Build a fake subprocess.run keyed by the first argv token tail."""
class _P:
def __init__(self, rc, out=""):
self.returncode = rc
self.stdout = out
self.stderr = ""
def run(argv, **kw):
key = argv[1] if len(argv) > 1 else argv[0]
rc, out = mapping.get(key, (1, ""))
return _P(rc, out)
return run
def test_all_present_and_authed(self, doctor):
which = {"node": "/n/node", "pencil": "/n/pencil"}.get
runner = self._runner(
{"--version": (0, "v20.10.0"), "version": (0, "0.2.7"), "status": (0, "ok")}
)
st = doctor.check(which=which, runner=runner)
assert st["node"]["ok"] is True
assert st["pencil"]["present"] is True
assert st["auth"]["ok"] is True
def test_pencil_missing(self, doctor):
which = {"node": "/n/node"}.get
runner = self._runner({"--version": (0, "v20.0.0")})
st = doctor.check(which=which, runner=runner)
assert st["pencil"]["present"] is False
assert doctor.main is not None
def test_old_node_not_ok(self, doctor):
which = {"node": "/n/node", "pencil": "/n/pencil"}.get
runner = self._runner(
{"--version": (0, "v16.0.0"), "version": (0, "0.2.7"), "status": (1, "no")}
)
st = doctor.check(which=which, runner=runner)
assert st["node"]["ok"] is False
assert st["auth"]["ok"] is False
def test_major_parse(self, doctor):
assert doctor._major("v20.10.0") == 20
assert doctor._major("18.0.0") == 18
assert doctor._major(None) is None
assert doctor._major("weird") is None