[FEATURE] Skill groups: a `group` frontmatter field to collapse N related skills into one system-prompt entry
Problem
A single CLI can ship many skills. For example, one Lark CLI I use ships ~26 skills (lark-doc, lark-calendar, lark-im, lark-sheets, ...). With that pack plus a few other CLIs, it's easy to pass 50+ installed skills.
Each skill's name + description sits in the system prompt on every turn, even when the conversation never touches those tools. At a rough ~50–100 tokens per entry, 50 skills cost on the order of a few thousand tokens of fixed overhead (rough estimate). And from my reading of the source it appears the listing has a fixed budget: the constants are SKILL_BUDGET_CONTEXT_PERCENT = 0.01 and DEFAULT_CHAR_BUDGET = 8000 in src/tools/SkillTool/prompt.ts. Note this budget is measured in characters, not tokens — getCharBudget() computes contextWindowTokens * CHARS_PER_TOKEN (4) * 0.01, i.e. roughly 1% of the context window, falling back to 8000 chars. As skill count grows, descriptions get truncated to fit that budget — which can hurt trigger-match precision.
I'd love a way for a CLI to expose a whole family of skills as one listing entry.
How it works today (brief)
Based on reading the publicly-restored source (as of the version I inspected), skills appear to load progressively: (1) name + description go into the system-prompt listing; (2) on trigger, the full SKILL.md body is injected; (3) references/ are read on demand. Existing mitigations don't seem to cover this case:
- Conditional skills (
paths) genuinely remove a skill from the listing, but only work for skills tied to file paths. API-oriented skills (Lark, etc.) have no path association. (More precisely: from my reading, unactivated conditional skills are withheld fromgetSkillDirCommands()and held in a separate map; once a path matches,activateConditionalSkillsForPathsmoves them into the dynamic-skills set and they rejoin the listing.) user-invocable: falseonly setsisHidden(which hides from slash completion). From my reading,getSkillToolCommands()doesn't filter onisHidden, so the skill still appears in the listing.- Description trimming is palliative — N skills still cost N entries.
Proposal: a group frontmatter field
- Skills with a
groupare filtered out of the system-prompt listing. - A group-entry skill (no
group) appears as usual; itsSKILL.mdcarries a routing table of the sub-skills. - When the entry skill triggers, the model picks the right sub-skill and invokes it via the Skill tool. The sub-skill's body is then injected the normal way, landing at the same level as any directly-triggered skill.
Net effect: 26 Lark skills cost one listing entry; the other 25 stay installed but surface on demand through the entry skill.
# lark/SKILL.md (entry skill — appears in the listing)
---
name: lark
description: Lark/Feishu suite. Route to the matching sub-skill below.
---
# Routing table
# - docs / wiki -> invoke skill `lark-doc`
# - calendar -> invoke skill `lark-calendar`
# - chat / IM -> invoke skill `lark-im`
# lark-doc/SKILL.md (sub-skill — hidden from the listing)
---
name: lark-doc
description: Read and edit Lark docs.
group: lark
---
Why it should be backward-compatible & low-risk
Skills without a group would behave exactly as today; the load/trigger/execute path stays untouched. From my reading, the change is a small one spread across a handful of files:
<details>
<summary>Likely touch points (source-derived, may be off)</summary>
src/types/command.ts: addgroup?: stringtoCommand.src/utils/frontmatterParser.ts: parsegroup.src/skills/loadSkillsDir.ts: this is more than "carrygrouponto theCommand." From my reading,parseSkillFrontmatterFields()returns a fully typed object andcreateSkillCommand()takes a fully destructured/typed parameter list — so the new field has to be threaded through both (added to the return type ofparseSkillFrontmatterFieldsand tocreateSkillCommand's param list), not just attached ad hoc.src/commands.tsgetSkillToolCommands()(~563): addif (cmd.group) return false;.src/commands.tsgetSlashCommandToolSkills()(~586): this is a separate filter feeding slash-completion and skill counts. It would still surfacegroupsub-skills unless it's filtered too.src/tools/SkillTool/SkillTool.tsSAFE_SKILL_PROPERTIES(~875): this is a hard allowlist gating permission auto-allow. From my reading, a newgroupproperty that isn't added here would failskillHasOnlySafeProperties()and flip everygroupsub-skill from auto-allow to requiring a permission prompt — which would contradict the "no behavior change" goal. Sogroupneeds to be added to this set.
Key constraint: filter only at the listing level (getSkillToolCommands() / getSlashCommandToolSkills()), not at getSkillDirCommands(). From my reading, findCommand(commandName, commands) resolves over the array returned by getCommands(), so a skill absent from getCommands() is unresolvable — meaning sub-skills must remain in getCommands() or the Skill-tool invocation can't resolve them. This is roughly the opposite of unactivated conditional skills, which are withheld from the dir-level list until a path activates them. The listing in attachments.ts calls getSkillToolCommands() (and applies the incremental sentSkillNames delta), so sub-skills would be excluded there with no extra work.
</details>
On trust level / how the model weighs the injected body
This is my own inference, not a property I see the code asserting. From my reading, when a sub-skill is invoked through the Skill tool, its SKILL.md body is injected as a meta user message (the code path constructs it with isMeta: true), distinct from a Bash tool_result. My expectation is that a model tends to weight a user message differently from a tool_result (the latter being external data that's reasonable to discount for prompt-injection reasons). For SOPs with hard rules ("MUST query the index first"), I'd expect that difference to matter — but I'm describing model behavior, not a label set by the framework.
Alternatives considered
A CLI can already expose a skills read/embed command and have the entry skill Bash the sub-skill's SOP into context (some packs do this). A framework-side group seems preferable because:
- How the body arrives: an SOP fetched via
Basharrives as atool_result, whereas a group-injected SOP arrives as a (meta) user message — see the caveat above; this is my inference about model weighting, not a code-asserted guarantee. - Coupling/cost: the CLI-read approach requires every CLI to implement list/read commands and spawns a
Bashprocess (plus a permission prompt) per fetch.groupwould work for any skill, with no backing CLI, as one in-framework Skill-tool call.
Happy to attempt a PR if this seems worth pursuing. Thanks for considering — and apologies if I've misread any of the internals.
---
This is based on reading the publicly-restored/minified source as of the version I inspected; these aren't documented public APIs and may change. The maintainers are the source of truth.