Skip to content

Latest commit

 

History

History
458 lines (371 loc) · 17.5 KB

skills.md

File metadata and controls

458 lines (371 loc) · 17.5 KB
title Skills
description The unified extension model: commands, subagents, tools, and event subscriptions in one ergonomic primitive
sidebar_order 1

Skills

A skill is the unit of extension in Nanocoder — the umbrella that unifies commands, subagents, and tools, plus the event subscriptions that fire them. If you're adding any kind of custom behaviour to Nanocoder, this is the page to start with; the individual command / subagent / tool pages drill into the per-member details.

Skills come in two ergonomic forms - a single .md file in .nanocoder/commands|agents|tools/ (the single-file form) or a directory under .nanocoder/skills/ containing a skill.yaml and any subset of commands/, agents/, tools/ subdirs (the bundle form).

Both forms produce the same runtime data model, register into the same registries, and surface through the same /skills slash command.

Why two forms

Most extensions are a single piece: a command, a subagent, or a tool. For those, the single-file form keeps the cost at exactly one file - exactly how .nanocoder/commands/, .nanocoder/agents/, and .nanocoder/tools/ already work today.

A bundle skill is for multi-piece features. A "PR reviewer" skill wants its subagent (the reviewer), a tool (gh_pr_diff), and a command (/review) to ship and version together. The bundle form gives that a home with one manifest and one shareable artifact.

Single-file form

Drop one .md into the right flat dir. Frontmatter declares the member; the file basename is the skill name (a namespaced command such as commands/refactor/dry.md is the skill refactor:dry). A command, an agent and a tool may share a name - they show up as separate entries in /skills.

<!-- .nanocoder/agents/docs-agent.md -->
---
name: docs-agent
description: Watch docs and refresh outputs when source changes.
subscribe:
  - kind: file.changed
    paths: ["docs/**"]
    eventKinds: [add, change]
---

You watch the docs directory for changes...

The subscribe: block is new. When the per-project daemon is running, it wakes this subagent whenever a file under docs/** changes.

Bundle form

A directory under .nanocoder/skills/<name>/ with skill.yaml:

.nanocoder/skills/k8s/
  skill.yaml
  commands/k8s.md
  agents/k8s-agent.md
  tools/
    k8s_pods.md
    k8s_logs.md
# skill.yaml
name: k8s                        # required, kebab-case: ^[a-z][a-z0-9-]*$
description: Kubernetes operational helpers.   # required
version: 0.2.0                   # optional
author: you@example.com          # optional
tags: [kubernetes, ops]          # optional, informational (not used for matching)

subscribe:
  - kind: file.changed
    target: agent:k8s-agent
    paths: ["k8s/**/*.yaml"]

tools_visibility:
  default: scoped

tools_visibility: scoped (or global) is shorthand for the default: mapping above.

Inside a bundle, members are aware of each other:

  • A bundle's subagent automatically gets its sibling tools in its effective tool list, even when its frontmatter has a tools: allowlist. You do not list k8s_pods and k8s_logs in agents/k8s-agent.md's tools: field - they are siblings.
  • Scoped tools (tools_visibility.default: scoped, the default for bundles) are hidden from the global tool list - only the bundle's own subagent sees them.
  • Set tools_visibility.default: global to expose tools alongside built-ins, MCP tools, and the existing .nanocoder/tools/*.md flat-form tools.

How many of each member kind?

  • commands/ — any number. Each file auto-namespaces under the bundle name, so commands/status.md in bundle k8s invokes as /k8s:status. Shortcut: commands/<bundleName>.md (e.g. commands/k8s.md) keeps the bare bundle name (/k8s).
  • agents/ — exactly one (or zero). The agent is the bundle's brain; if you need a second one, that's a second skill. Extra .md files in agents/ are ignored and reported as a load error.
  • tools/ — any number. Tools are named by their frontmatter name: (snake_case), independent of the bundle name.

A multi-verb bundle is natural:

.nanocoder/skills/git/
  skill.yaml                # name: git
  commands/
    status.md               # invokes as /git:status
    commit.md               # invokes as /git:commit
    push.md                 # invokes as /git:push
  agents/git-agent.md       # one shared agent
  tools/
    git_log.md
    git_diff.md

Event subscriptions

Subscriptions can declare on member frontmatter (target is implicit self) or on the bundle manifest (target is explicit kind:name):

# bundle manifest, multiple targets in one place
subscribe:
  - kind: file.changed
    target: agent:docs-agent
    paths: ["docs/**"]

  - kind: schedule.cron
    target: command:weekly-report
    cron: "0 9 * * MON"

Inside a bundle, member frontmatter must not set target: itself - a member's subscription always targets that member. Put explicit targets in skill.yaml.

Target kinds:

  • agent: - the daemon runs the subagent with a prompt describing the event and its payload.
  • command: - the daemon renders the command (with no arguments, so parameter defaults apply) and runs it as an unattended agent run, with the event payload appended. In a bundle manifest, name the member as it appears in commands/ (command:status, not command:k8s:status).
  • tool: - rejected. A triggered tool call has nothing to fill in its arguments, so the subscription reports an error at load time. Point the subscription at an agent or command that calls the tool instead.
  • skill: - parsed for forward compatibility, but registering it today raises a clear "not supported yet" error.

Event kinds:

  • file.changed - filters: paths (glob list; omit it to match every file) and eventKinds (any of add, change, unlink; omit it to match all three). Globs support *, **, ? and {a,b}; negation (!) is not supported. Changes under .git/, node_modules/ and .nanocoder/ are never reported.
  • schedule.cron - filter: cron, a standard 5-field expression (or 6-field with seconds). An invalid expression is rejected when the file loads instead of reaching the daemon.
  • ci.job.failed - filter: branches, glob-matched against the failing run's branch; omitted matches every branch. Only ever fires when the daemon's CI watch is active - see CI Watch Configuration.

A malformed subscribe: block on a single-file command, agent or tool is logged and dropped: the member still loads, just without triggers. In skill.yaml it fails the bundle's load, with the error shown by /skills and in the daemon log.

confirm: true

Default: triggered runs execute in headless mode (autonomous, no foreground prompts). Set confirm: true on a subscription to make the run execute in plan mode instead - the subagent proposes changes without applying them.

subscribe:
  - kind: file.changed
    target: agent:docs-agent
    paths: ["docs/**"]
    confirm: true

The daemon

Events only fire while a process is running to host them. The interactive TUI does NOT start file watchers or cron tickers — that's the daemon's job.

nanocoder daemon start      # spawn the per-project daemon detached
nanocoder daemon stop       # SIGTERM the daemon, wait for lockfile cleanup
nanocoder daemon status     # report running / not running, PID, uptime
nanocoder daemon logs       # tail .nanocoder/daemon.log (last 64KB)
nanocoder daemon install    # install per-user auto-start
nanocoder daemon uninstall  # remove the auto-start unit

daemon start refuses to start in a directory you haven't trusted, because triggered runs execute tools without confirmation. Trust the directory by running nanocoder interactively there once, pass --trust-directory to bypass the check for that one start (not saved), or set NANOCODER_TRUST_DIRECTORY=1 to trust it and save that for future runs.

daemon install is not gated: it only writes the auto-start unit, and the gate applies when the daemon actually boots. Installing in an untrusted directory therefore succeeds and says so — install prints a warning naming both remedies — and every autostart boot after it is refused until you trust the directory. That refusal comes from daemon start itself, before the daemon child that owns daemon.log is spawned, so it goes to the daemon start command's own output — under an autostart service, wherever the service manager sends it — not to daemon.log.

The daemon writes a JSON lockfile at .nanocoder/daemon.json (PID, socket path, start time) and an append-only log at .nanocoder/daemon.log. Stale lockfiles (PID no longer alive) are reaped automatically on the next daemon start or daemon status.

Auto-start ships for macOS (LaunchAgent under ~/Library/LaunchAgents/), Linux (systemd user unit under ~/.config/systemd/user/), and Windows (Task Scheduler task registered via schtasks /Create /XML with an ONLOGON trigger; the XML manifest is stored under %LOCALAPPDATA%\nanocoder\tasks\). All three are namespaced by a short hash of the project path so multiple projects each get their own daemon.

The daemon's IPC surface uses an AF_UNIX socket at .nanocoder/daemon.sock on macOS/Linux and a named pipe at \\.\pipe\nanocoder-daemon-<hash> on Windows. Unix socket paths are capped by sockaddr_un.sun_path (104 bytes on macOS, 108 on Linux), and libuv silently truncates anything longer, so for deeply nested projects the socket moves to <tmpdir>/nanocoder-daemon-<hash>.sock instead. The bound path is recorded in the lockfile, so clients always read it back rather than recomputing it. nanocoder daemon stop prefers an IPC shutdown request (clean drain of the event loop) and falls back to SIGTERM only if the daemon is unreachable, so stops are graceful on Windows too where SIGTERM would otherwise be force-kill.

Internally, the daemon runs every triggered agent or command in headless mode (no foreground prompts, no ask_user, no agent, and only tools that never need approval). The confirm: true opt-in below switches a specific subscription to plan mode instead.

Triggered runs are subagent runs, so the maxRepeatedToolCalls cap applies: a triggered skill whose model gets stuck repeating the same tool call stops with an error instead of burning tokens unattended (see Loop Protection).

Inspecting and creating skills

/skills                 list every loaded skill
/skills show k8s        details for one skill (members, subscriptions, source)
/skills create k8s      scaffold a new bundle at .nanocoder/skills/k8s/
/skills check k8s       validate a bundle without loading it
/skills promote k8s     copy a project skill up to the global level
/skills demote k8s      copy a global skill down into this project

/skills create only scaffolds bundles. For single-file skills, use the existing creators:

/commands create my-cmd     scaffold .nanocoder/commands/my-cmd.md
/agents create my-agent     scaffold .nanocoder/agents/my-agent.md
/tools create my-tool       scaffold .nanocoder/tools/my-tool.md

Each of those drops a stub file in the right flat dir and chains into an AI-assisted design conversation so the model can help fill in the frontmatter and body.

Installing a skill from a repository

A bundle is one shareable artifact, so it can be installed straight from a git repository:

nanocoder skills add owner/repo                        owner/repo shorthand
nanocoder skills add https://example.com/skills.git    any git URL
nanocoder skills add ./local-checkout                  a local directory, git repo or not
nanocoder skills add pr-reviewer                       resolve the name through the index

Flags:

--ref <ref>       branch, tag, or commit to clone
--subdir <path>   where the bundle sits inside the repo
--global          install into the config dir instead of this project
--force           replace an existing skill of the same name
--yes             skip the trust prompt (for scripts and CI)
--index <url>     use a different skills.json for name resolution

Without --subdir, the manifest is looked for at the repo root, then at skills/<name>/, then across a one-directory-per-bundle layout. A repo with several bundles and no --subdir lists the candidates rather than guessing.

The trust prompt

Installing a skill means running its code: a bundle tool is a shell script, approval: never skips confirmation entirely, and a subscribe: block makes the daemon fire it unattended. Nothing is written into the project until you have seen what that means, so the prompt names every tool with its approval policy and every subscription with its trigger:

Skill "pr-reviewer" v0.1.0 by nano-collective
  Reviews pull requests and posts findings.

  from: https://github.com/Nano-Collective/nanocoder-skills
  into: /repo/.nanocoder/skills/pr-reviewer

  Commands: /pr-reviewer:review
  Agent:    reviewer
  Tools (visible only to this skill's agent):
    - gh_pr_diff · shell script · approval: never (runs WITHOUT asking) · read-only
  Event subscriptions (the daemon fires these unattended):
    - file.changed → agent:reviewer · paths src/**

Installing a skill means running its code. Only install skills you trust.
Install this skill? [y/N]

Before you ever see that prompt the bundle has been shallow-cloned into a temp dir, had its .git stripped, been rejected if it contains a symlink (a bundle is markdown and YAML; a symlink is only ever an escape attempt), and been validated by the same linter /skills check runs. Declining leaves the project untouched. Accepting lands the bundle through the same copy the promote / demote commands use, so it refuses to overwrite an existing skill unless you pass --force.

The index

Bare names resolve through a plain skills.json file - a list, hosted in a git repo, not a registry service:

{
  "skills": [
    {
      "name": "pr-reviewer",
      "description": "Reviews pull requests and posts findings.",
      "repo": "https://github.com/Nano-Collective/nanocoder-skills",
      "subdir": "skills/pr-reviewer"
    }
  ]
}

Point NANOCODER_SKILLS_INDEX (or --index) at another URL, or at a local file, to use a different list - a team can check one into its own repo. If the index promises one name and the cloned manifest declares another, the install is refused.

The default index above isn't published yet, so a bare name won't resolve until it is; install by owner/repo, a git URL, or a local path in the meantime, or point NANOCODER_SKILLS_INDEX at your own list.

Sharing skills across repos

Skills load from three levels, highest priority first:

  • project - .nanocoder/skills/ (and the flat commands|agents|tools/ dirs) in the working directory. Scoped to this repo, committed with it.
  • personal / global - the same layout under your platform config dir (~/.config/nanocoder/ on Linux, ~/Library/Preferences/nanocoder/ on macOS, or $NANOCODER_CONFIG_DIR). Available in every repo on the machine.
  • built-in - shipped with nanocoder. Today that is only the explore subagent; no built-in bundles ship yet.

A project skill shadows a personal one of the same name, which shadows a built-in. To reuse a skill you wrote in one repo everywhere, move it up a level; to vendor a global or built-in skill into a repo so teammates get it, move it down:

/skills promote pr-review          copy project -> global, keep both
/skills promote pr-review --move    move project -> global, remove the original
/skills demote pr-review           copy global -> project, keep both
/skills demote pr-review --move     move global -> project, remove the original

Notes:

  • Both default to copy, leaving the original in place. After a copy you have the skill at both levels and the higher-priority one wins on the next load. Pass --move to remove the source instead.
  • The copy refuses to overwrite an existing skill at the destination. Re-run with --force to replace it.
  • promote only works on a project skill; demote works on a global or built-in skill (demoting a built-in vendors a copy you can then edit).
  • Both write to disk only. Restart nanocoder for the new copy to load.

Migration from schedules.json

The legacy scheduler (the ScheduleRunner that read .nanocoder/schedules.json) has been removed. Cron-driven runs now happen exclusively through skill subscriptions executed by the daemon.

Move each entry into the targeted command's frontmatter, or into a bundle manifest. When the cron fires, the daemon renders the command and runs it as an unattended agent run in headless mode:

Before:

[{"cron": "0 9 * * MON", "command": "/weekly-report"}]

After (frontmatter form):

<!-- .nanocoder/commands/weekly-report.md -->
---
description: Monday morning summary.
subscribe:
  - kind: schedule.cron
    cron: "0 9 * * MON"
---

Summarize last week's commits...

After (manifest form, inside a bundle):

subscribe:
  - kind: schedule.cron
    target: command:weekly-report
    cron: "0 9 * * MON"

If .nanocoder/schedules.json is present when Nanocoder boots, you see a loud warning pointing at this migration guide. The file itself is no longer read - only its presence triggers the warning.

After migrating, run nanocoder daemon start (or nanocoder daemon install for auto-start across reboots) to make the cron subscriptions actually fire.