mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-02 08:17:42 +08:00
Compare commits
1 Commits
bb/main-ve
...
bb/pencil-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6db63d3542 |
179
optional-skills/creative/pencil/SKILL.md
Normal file
179
optional-skills/creative/pencil/SKILL.md
Normal 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.
|
||||
74
optional-skills/creative/pencil/references/mcp-tools.md
Normal file
74
optional-skills/creative/pencil/references/mcp-tools.md
Normal 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.
|
||||
97
optional-skills/creative/pencil/scripts/pencil_doctor.py
Normal file
97
optional-skills/creative/pencil/scripts/pencil_doctor.py
Normal 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())
|
||||
177
optional-skills/creative/pencil/scripts/pencil_repl.py
Normal file
177
optional-skills/creative/pencil/scripts/pencil_repl.py
Normal 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())
|
||||
188
tests/skills/test_pencil_skill.py
Normal file
188
tests/skills/test_pencil_skill.py
Normal 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
|
||||
Reference in New Issue
Block a user