Skill Operations

A skill that ships a script can register it with flydocs run: a stable operation ID, no file path, and no second permission prompt. The manifest schema, what your script inherits, and how to check it.

A skill that ships a script can register it with flydocs run, so it is reached the same way every built-in operation is: a stable ID, no file path, and no second permission prompt. This guide is for skill authors — what you can contribute, the manifest schema, what your script inherits, and how to check the result.

What is extensible, and what is not

Surface Extensible by a skill Why
MCP tools (flydocs mcp) no fixed product surface — see below
flydocs run <operation-id> yes operations.json beside your SKILL.md

The MCP roster is not skill-declared, and that is a decision rather than a gap. Every tool costs idle context in a client that does not defer tool listings, carries a wire name that can never change once a client has cached it, and is schema-validated against a bridge operation that has to exist first. A roster assembled from whatever skills happen to be installed would make the token budget, the tool names and the enforcement guarantees all depend on a local directory listing.

The runner has none of those properties. It costs zero idle context — an agent learns IDs from flydocs run --list when it needs them — and the standing permission rule Bash(flydocs run:*) already covers whatever you add, with no settings rewrite.


Where the manifest goes

.claude/skills/
├── flydocs-workflow/
│   └── scripts/            ← the bundled dispatchers
└── your-skill/
    ├── SKILL.md
    ├── operations.json     ← this file
    └── scripts/
        └── export-tokens.py

Manifests are discovered in the .claude/skills directory that owns the workflow scripts. In a multi-repo workspace that is the workspace root's skills tree, not a child repo's. A skill without an operations.json is skipped in silence — that is the normal case.


The manifest

{
  "operations": [
    {
      "id": "design.tokens",
      "script": "scripts/export-tokens.py",
      "command": ["export"],
      "summary": "Export design tokens",
      "positionals": [{ "name": "target", "required": true }],
      "flags": [
        { "name": "format", "kind": "value", "required": false },
        { "name": "watch", "kind": "boolean" }
      ],
      "timeoutMs": 60000
    }
  ]
}

That declares flydocs run design.tokens <target> [--format <v>] [--watch].

Field Required Meaning
id yes domain.verb, lowercase; the public contract
script yes entrypoint, relative to your skill directory
summary yes one line, shown by flydocs run --list
command no subcommand chain passed before the arguments, for multi-command scripts
positionals no ordered { name, required } entries
flags no see the table below
timeoutMs no override the default wall-clock budget for an operation that runs long

Flag kinds

kind Caller types Child receives Use for
value --format json --format json a single value; a second is a usage error
boolean --watch --watch a switch
repeatable --check 1 --check 2 --check 1 --check 2 argparse action="append"
list --issues A --issues B --issues A B argparse nargs="*"

The three multi-value shapes are not interchangeable — argparse is not forgiving about them. Declare the one your script actually parses. A list flag accepts the repeated spelling from the caller and emits the collapsed one, because repeating an nargs="*" flag would leave argparse holding only the last occurrence.

Flags default to appearing after the subcommand chain. Add "position": "pre" for a flag your script declares on its top-level parser, which argparse requires to come first.


What is enforced, not trusted

A manifest is validated at load. Every problem is reported as a warning on stderr and the operation is dropped — never silently, because a skill whose operations vanished without comment is indistinguishable from one that was never installed.

  • The script must resolve inside your skill directory. An absolute path or a ../ escape is refused. A contributed manifest is not a way to execute /usr/bin/anything.
  • The script must exist at that path.
  • The ID must match domain.verb — lowercase, dot-separated.
  • The ID may not shadow a bundled operation, and may not be claimed by a skill loaded earlier. Two skills claiming the same ID is a conflict, not a merge; skills are loaded in name order.
  • --repo and --help are reserved. An operation may not declare either, so --repo means the same thing in every position of every invocation.
  • A summary is required. An operation nobody can find in --list is not reachable in practice.

What your script inherits

Once registered, your operation is spawned exactly the way a bundled one is.

  • The repo is resolved for you. The child's working directory is the repo that owns .flydocs/config.json, found from the caller's directory or from --repo <name|path>. Your script never needs a cd in front of it, and never has to find the workspace root itself.
  • Arguments are an argv array. There is no shell. Quoting, $(…), && and ; have no meaning at any point in the path, so a value containing them is a value.
  • Undeclared arguments never reach you. They stop the invocation with a usage exit code and a message naming what was accepted.
  • The environment is an allowlist, not the caller's shell: process basics, locale, proxy and CA-bundle variables, and every FLYDOCS_* variable. PYTHONPATH, PYTHONHOME, PYTHONSTARTUP and PYTHONEXECUTABLE are blocked outright — they decide which modules an interpreter imports before your script runs. The runner also sets PYTHONUNBUFFERED=1, PYTHONIOENCODING=utf-8, FLYDOCS_RUN=1 and FLYDOCS_RUN_OPERATION=<your-id>.
  • Output streams as it arrives and your exit code is propagated unchanged. A run killed by the timeout or the output cap says so on stderr and exits with its own distinct code, so a truncated run can never be mistaken for a completed one.
  • Interrupts reach you. SIGINT and SIGTERM are forwarded, not swallowed.
  • Ops logging records the operation ID, exit code and duration — never your arguments, and never your output.

Long inputs: the scratch convention

A command line is the wrong place for a multi-line body — a rewritten issue description, a filled template, a generated report. Heredocs, escaped newlines and nested quotes in an argument are how a body arrives mangled, or how a stray quote truncates it silently.

So the convention across the whole runner surface is: write the body to a file under .flydocs/scratch/, pass the path, delete the file.

mkdir -p .flydocs/scratch

cat > .flydocs/scratch/tokens.md <<'EOF'
## Palette

Multi-line content, quotes and all — this is a file, not an argument.
EOF

flydocs run design.tokens web --file .flydocs/scratch/tokens.md

rm .flydocs/scratch/tokens.md

The heredoc writes the file; the operation argument is a path, which has no quoting problem to have. --file is the catalog-wide spelling — declare it as a value flag if your operation takes a body.

.flydocs/scratch/ rather than a system temp directory because it sits beside the config the operation runs against, it is in the managed gitignore list, and when something goes wrong the exact bytes you sent are still on disk and still readable by the person you are working with. Clean up after a successful call.


MCP or the runner?

If you are wondering whether something you are building belongs in the tool roster rather than the runner catalog, the promotion criteria are written down in flydocs/knowledge/notes/tool-surface-architecture.md. The short version: an MCP tool has to be on the hot path, be an outcome rather than a CRUD verb, have a schema that is doing enforcement work, want structured arguments, fit the deferred-listing token budget, and front an existing bridge operation. Everything else is better as an operation ID.


Checking your work

flydocs run --list                    # your IDs appear, tagged [skill:your-skill]
flydocs run --dry-run design.tokens web   # resolves the entrypoint and argv, runs nothing
flydocs doctor --tools                # run.registry warns about a bad manifest

--list groups by domain and marks contributed operations with their source skill. --dry-run goes before the ID and prints the resolved command without executing it — the fastest way to confirm your command, position and flag kinds produce the argv your script expects. run.registry is the doctor check that reports a manifest the loader rejected.

There is no per-operation --help: an invocation the registry cannot accept is refused with the accepted shape printed.


If you are contributing to this repo

Three tests hold documentation and code to each other, and they will fail your change rather than let it drift:

  • src/lib/mcp/skill-docs.test.ts — every tool name used in a skill file exists in the roster and is wired, every wired tool appears in SKILL.md, and no procedure file teaches staging a body through a temp file.
  • src/lib/run/operations.coverage.test.ts — the catalog mirrors the dispatchers' own argparse definitions, and no operation claims a reserved flag.
  • src/lib/tool-surface-docs.test.ts — the published guides in docs/ agree with the shipped check IDs, tool names, operation IDs and permission rules.

Next