← All posts
October 10, 2026·13 min read

Refactoring an Agent Skill With Subcommands

Field notes on the router skill pattern, where one Claude Code skill takes a subcommand argument and loads only the instructions for that mode.

toolingclaude codeopencodeai
Refactoring an Agent Skill With Subcommands

When I set up career-ops for my job search, one thing kept bugging me. Everything runs through a single skill, but I can type /career-ops pdf, /career-ops scan, /career-ops interview-prep and about thirty other variations, and each one behaves like its own command. Skills don't have subcommands as far as I knew, so how was one SKILL.md doing all of that?

I'd also just run into a case where I wanted the same thing. My Obsidian vault has two skills that import health data, and they were slowly turning into copies of each other. So I had Claude walk me through how career-ops works as a lesson, and then I applied it to my own skills.

The problem: two skills, one set of rules

My vault (I've been building it into a sort of AI-assisted bullet journal) has monthly health notes at source/health/YYYY/MM-health.md. Two skills fill them in:

  • import-oura pulls sleep, naps, steps, active calories and workouts from the Oura API through a Python script
  • import-macrofactor reads a monthly .xlsx export from MacroFactor (calories, macros, weight)

They write different sections of the same note, but almost everything else is shared. Both are "bookkeeping only" (never interpret the numbers, never comment on whether a number is good or bad). Both preview first and then run again with --write. Both create a missing note with the same three headings. And both Python scripts had this comment near the top:

# Keep in sync with SKELETON in import-oura/oura.py.

A "keep in sync" comment is a promise that you'll eventually forget to keep things in sync. I was also thinking about adding more sources, which meant more copies of all the shared rules.

How career-ops does it

The first thing I learned is that there's no official name for this. career-ops calls its no-argument menu "discovery", but that's just one mode. The lesson I worked from called the whole thing a router skill, and I'll stick with that. It has three parts.

1. An argument comes in

Claude Code skills can declare named arguments in their frontmatter. career-ops declares one called mode:

---
name: career-ops
description: >-
AI job search command center -- evaluate offers, generate CVs, scan portals,
track applications. ...
arguments: mode
argument-hint: '[scan | discover | deep | pdf | text | latex | ...]'
---

When I type /career-ops pdf, Claude Code substitutes pdf for $mode in the skill body. argument-hint is what shows up in autocomplete, so I can see the list of modes while typing.

2. A routing table

The body of the skill is mostly a table that maps $mode to a mode name:

## Mode Routing
Determine the mode from `$mode`:
| Input | Mode |
| ------------------------------- | -------------------------------- |
| (empty / no args) | `discovery` -- Show command menu |
| JD text or URL (no sub-command) | **`auto-pipeline`** |
| `pdf` | `pdf` |
| `scan` | `scan` |
| `interview-prep` | `interview-prep` |

Empty input shows the menu. Anything that looks like a job post runs the full pipeline. Everything else is looked up by name.

3. Load the mode on demand

This is the part that actually matters. Once a mode is picked, the router says to read modes/_shared.md plus modes/{mode}.md and follow them. career-ops has more than forty mode files, and on any given run Claude reads two of them. The rest are never loaded into context.

Anthropic's skill authoring best practices call this progressive disclosure:

  1. A skill's name and description are always loaded, so Claude knows the skill exists
  2. SKILL.md is loaded when the skill runs
  3. Any other file is only read when SKILL.md points at it

The guide's own example is a BigQuery skill with separate reference/finance.md and reference/sales.md files, so a sales question never pulls in the finance docs. For big workflows it says to "push them into separate files and tell Claude to read the appropriate file based on the task at hand." career-ops is that advice applied to subcommands.

The same guide adds two rules that shaped my refactor: keep references one level deep (router to mode file, never mode file to another file), and keep SKILL.md under 500 lines.

Would it work outside Claude Code?

Before building anything, I wanted to know how much of this ties me to Claude Code. I've been trying out OpenCode, and my vault is supposed to outlive whatever AI tool I happen to be using, so I don't want its skills to depend on one vendor's extras. I haven't made my skill fully OpenCode compatible (no wrapper command, no testing there yet), but I did the research so I'd know what it would take.

Skills follow an open format, the Agent Skills spec, and that spec only defines a handful of frontmatter fields: name, description, license, compatibility, metadata and allowed-tools. Everything else is something a specific harness adds on top.

FeatureClaude CodeOpenCode
Reads .claude/skills/
Yes
Yes
Reads .agents/skills/
No
Yes
Reads extra files on demand
Yes
Yes
arguments, $name, argument-hint
Yes
Not documented
${CLAUDE_SKILL_DIR}
Yes
No
Typed command with arguments
The skill itself
A wrapper in .opencode/commands/

The good news is that the part that matters most is portable. Loading files on demand works everywhere, because it isn't a feature at all. It's just the model reading a file that the skill tells it to read.

Arguments are the gap. OpenCode's skills docs don't mention them, and unknown frontmatter fields are ignored, so you can't count on $mode being filled in. This explains two odd things I'd noticed in the career-ops repo, which supports several agent CLIs:

  • The real SKILL.md lives in .agents/skills/ and is symlinked into .claude/skills/, because Claude Code doesn't read .agents/
  • It ships .opencode/commands/career-ops.md, a tiny wrapper command

OpenCode commands do support $ARGUMENTS (and $1, $2), so the wrapper puts the arguments into the prompt and then tells the model to load the skill:

---
description: career-ops command center - evaluate offers, scan portals, track applications
---
# career-ops
$ARGUMENTS
Load the career-ops skill:
skill({ name: "career-ops" })

So the lock-in risk is small, and it comes down to a few habits:

  • Route on the argument when there is one, and fall back to the request when there isn't. Then the skill still works when a harness doesn't fill in the placeholder, or when I just say "pull my sleep data" instead of typing a command.
  • Keep script paths relative to the project root instead of using ${CLAUDE_SKILL_DIR}, since only Claude Code fills that in.
  • Keep one real SKILL.md. OpenCode already reads .claude/skills/ (opencode debug skill lists every skill in my vault), so I don't even need career-ops' symlinks. If I switch, the only new file is a wrapper command.

Merge or keep separate?

None of the docs give a rule for when to merge skills, so I had to weigh it myself:

Merge into oneKeep two
Shared rules live in one place instead of two
Each description triggers on its own keywords
A shared health_note.py replaces the "keep in sync" comment
Callers can name the skill directly
A new source is one file plus one table row
Smaller files to read when one source breaks

The description concern turned out to be minor. One description has to cover both sleep and calories now, but mine came out at about 470 characters and the spec allows 1,024. Merging won because every source follows the same rules. My rule of thumb now: merge when the subcommands share rules, code or output; split when they'd need very different descriptions or share nothing. And treat a "keep in sync" comment as a sign that things should be merged.

My implementation: import-data

The lesson's worked example called the merged skill import-health, since both sources write health notes. I went with import-data instead. Down the line I may want to import things that have nothing to do with health, like bank statements, and I didn't want to rename the skill (and every caller) again when that happens.

Here's the layout. I used git mv for the scripts so their history follows them:

.claude/skills/import-data/
├── SKILL.md router + rules for every source
├── sources/
│ ├── oura.md credentials, picking days, columns, API notes
│ └── macrofactor.md picking the export, columns
└── scripts/
├── health_note.py note skeleton + table helpers (was duplicated)
├── oura.py still the only owner of the OAuth tokens
└── macrofactor.py

The scripts/ folder is executed, never read into context, so it doesn't cost anything. The duplicated note skeleton moved into health_note.py, and both scripts import it with a plain sibling import (from health_note import SKELETON), which works under both python3 and uv run. The whole router is 41 lines, and the two source files are 46 and 31.

Here's the router. There's no source-specific detail in it at all, just the table, the fallback rules and the rules every source shares:

---
name: import-data
description: Import data recorded by apps and devices into source/ notes, one source at a time. Sources - oura (sleep, naps, steps, active calories, workouts, via the Oura API) and macrofactor (calories, macros, weight, from a monthly .xlsx export), both into the monthly health notes (source/health/YYYY/MM-health.md). Use when Kelvin asks to import, pull or sync Oura, sleep, steps, workouts, MacroFactor, nutrition, calories, macros or weight, or says he dropped a new export.
arguments: source
argument-hint: '[oura [from] [to] | macrofactor [file]]'
---
# import-data
Copies what an app or device recorded into `source/`. This is bookkeeping: never interpret the numbers, never tick habits, never write Kelvin's own content, never say whether a number is good or bad.
## 1. Pick the source
Source: `$source` (full arguments: `$ARGUMENTS`)
| Source | Writes | Read next |
| ------------- | ----------------------------------------------------------------------- | ------------------------ |
| `oura` | `source/health/YYYY/MM-health.md`: `## Sleep & Activity`, `## Workouts` | `sources/oura.md` |
| `macrofactor` | `source/health/YYYY/MM-health.md`: `## Nutrition & Weight` | `sources/macrofactor.md` |
- **Source is in the table**: read that file (in this skill's folder) and follow it. Pass the rest of the arguments (dates, a file) on to its script.
- **Source is blank or starts with `$`** (OpenCode doesn't fill it in): take it from Kelvin's request.
- **Still unclear** (or he asks for several at once): show this table and ask which one. Run sources one at a time, each with its own preview.
## 2. Rules for every source
- Run from the vault root. Preview first, then `--write`.
- Use the source's script. Never copy numbers by hand. If it stops with an error, tell Kelvin instead of fixing the note.
- A source writes only the sections it owns in the table above, and never another source's section. Any note can hold sections from several sources.
- Report in chat:
1. Notes created or updated, and the date range.
2. Cells that changed on a re-run (old to new).
3. Gaps the script lists. Say them plainly, without nagging, and only from 2026-10 on (see "Journal history" in `AGENTS.md`).
4. Anything extra the source file asks for.
- Don't commit. Wiki figures update on the next ingest.
## Adding a source
1. A script in `scripts/` that owns its sections and never touches any others. For a health note, import `health_note.py` and add the section to its `SKELETON` so new notes get every heading.
2. `sources/<name>.md`: where the input comes from, how to pick it, the script commands, the columns, and any extra report items. Don't repeat the rules above.
3. One row in the table above, the source's keywords in `description` and `argument-hint`, and the section owner in `AGENTS.md` (Source conventions and the Skills table).

A few details I want to call out:

  • The "Writes" column names the note, not just the section. With import-health every source wrote the same note, so a section name was enough. A bank statement source would write somewhere else entirely, so each row says exactly which file and which sections it owns. That column does double duty: it routes, and it documents who is allowed to write what.
  • Pass arguments on in words. Substitution only happens in SKILL.md, not in the files it points to. So sources/oura.md can't use $ARGUMENTS; the router has to say "pass the rest of the arguments on to its script."
  • The "starts with $" line is my one bit of portability insurance. If a harness doesn't substitute $source, the model sees the literal placeholder and knows to read the request instead. It costs one line, so I kept it even though I'm not using OpenCode for this yet.
  • The unclear case copies career-ops. Running /import-data with nothing after it shows the table and asks, the same way /career-ops shows its menu. I also added "one at a time" so a request like "import everything" doesn't turn into two writes behind a single preview.
  • "Adding a source" is a checklist. It's the part of the file I'll read next time, probably months from now, so it lists every place a new source has to be registered, including my AGENTS.md.

Each old SKILL.md body, minus the shared rules, became its sources/*.md file. Then I fixed the callers (my weekly new-week skill runs the Oura import, and my AGENTS.md has a skills table) and made sure nothing still pointed at the old names:

grep -rn -e import-oura -e import-macrofactor --exclude-dir=.git .

Takeaways

The individual pieces are all documented. Claude Code documents arguments and $name, Anthropic's best practices guide covers splitting a skill into domain files, and the Agent Skills spec says what's portable. What I couldn't find was anyone putting them together as "subcommands for a skill", and especially not the question of what survives a switch to another tool.

If you want to try it:

  1. Declare one argument in the frontmatter and list the modes in argument-hint
  2. Make the body a routing table that points at one file per mode
  3. Keep the shared rules in SKILL.md and everything mode-specific in the mode files, one level deep
  4. Route on the argument, fall back to the request, and show the table when it's still unclear
  5. Name the skill for where it's going, not just what it does today

If I do add bank statements someday, the router means that should be one script, one source file and one row in a table, and none of the shared rules will need to be copied again.