Serves skills from .agents/skills (project and home) and Moxen's bundled skills to Claude Code, without copying them into .claude/skills.

moxen drives provider CLIs directly from your terminal: a full interactive TUI for working with threads, plus a scriptable CLI for handovers, thread inspection, messaging, and automation. Threads, projects, and orchestration live in a local store (~/.moxen), and turns run against claude or opencode serve through their own logins.
Bun (it manages packages and runs everything) and at least one provider CLI: claude (claude auth login) or opencode v2 or newer (opencode auth login, including openai for ChatGPT and xai for SuperGrok, or provider API keys).
git clone https://github.com/arrrnmp/moxen.git
cd moxen
bun install
Build the bundle once, and again after pulling changes (it takes a second or two):
bun run build
Then point a moxen command at dist/, from any working directory, with nothing to put on PATH.
macOS/Linux (bash/zsh) — add to your shell profile (~/.bashrc, ~/.zshrc, ...):
moxen() { bun "/absolute/path/to/moxen/dist/cli/index.js" "$@"; }
Windows (PowerShell) — add to your $PROFILE:
function moxen { bun "C:\absolute\path\to\moxen\dist\cli\index.js" @args }
Reload the shell, then verify:
moxen --json doctor
Every example below assumes moxen resolves that way. This package is not published (private: true), so install it by cloning.
moxen tui
Opens the interactive client: sidebar with your threads, the live transcript with per-turn diffs, and a composer with model/effort pickers, image attachments, and an external-editor shortcut. Clicking a user prompt opens message actions (copy, revert); clicking a diff-turn header jumps the transcript to that turn; the command palette covers copy/thread/jump actions.
| Keys | Action |
|---|---|
enter | Send (composer) / confirm (close menu) |
shift+enter, ctrl+j | Newline in the composer |
esc | Unfocus composer / close diff / cancel menus |
ctrl+c | Clear prompt → close menu → quit (empty prompt jumps straight to the menu) |
ctrl+t | Toggle tasks panel |
ctrl+p | Command palette |
ctrl+o, alt+e | Edit the draft in $VISUAL / $EDITOR |
ctrl+v, cmd+v | Paste an image from the clipboard |
i | Focus the composer |
s, [, ] | Sidebar mode / cycle project |
arrows, pgup/pgdn | Scroll the focused pane |
The UI needs at least a 180×47 terminal (btop-style): below that it shows a resize notice instead of a broken layout, and resumes live when you grow it back. Resizes are followed over SSH too.
The same binary scripts everything the TUI does, with stable --json envelopes for automation:
moxen handover --prompt "Continue the implementation from this handover."
moxen threads list --status active --cwd .
moxen threads send --thread <thread-id> --prompt "Run the morning check."
Full reference: docs/cli.md. How it runs under the hood: docs/overview.md, docs/threads.md, docs/providers.md.
There are no bearer tokens anywhere in this stack: provider CLIs own their own logins, and this CLI never reads their secrets — it only observes credential presence for status display. Threads live as local JSONL under ~/.moxen (override with MOXEN_STORE_ROOT).
AGPL-3.0-only. See LICENSE. The tree-sitter grammars under src/core/syntax/ are attributed in THIRD_PARTY_LICENSES.md.
hooks/register.ts 204 lines1import type { Register } from 'claude-code'
2
3/**
4 * Skills that live outside `.claude/skills`, served to Claude Code.
5 *
6 * `.agents/skills` is the folder Codex, OpenCode and Grok read; Claude Code
7 * reads only `.claude/skills`. Instead of copying or linking every skill into
8 * `.claude/skills` (and keeping the copies current), this mod finds them where
9 * they are and offers them two ways:
10 *
11 * - to Claude, as one tool whose description lists the skills and whose call
12 * returns a skill's instructions (what the built-in Skill tool does);
13 * - to the person, as a `/<name>` command that runs the skill.
14 *
15 * Looked up, first match wins by name: `<project>/.agents/skills`,
16 * `~/.agents/skills`, then the folder named by `MOXEN_SKILLS_DIR` (Moxen's own
17 * bundled skills, which it sets for the sessions it starts). A skill that
18 * `.claude/skills` already provides is left to Claude Code.
19 */
20
21type Skill = {
22 name: string
23 description: string
24 argumentHint: string | null
25 dir: string
26 body: string
27 /** `disable-model-invocation: true`: only the person runs it. */
28 userOnly: boolean
29 /** `user-invocable: false`: only Claude runs it. */
30 modelOnly: boolean
31}
32
33const TOOL = 'skill'
34const TOOL_FULL_NAME = 'mcp__agent-skills__skill'
35/** Keeps the tool description, which rides in every request, to a size worth sending. */
36const LISTING_BUDGET = 8_000
37const DESCRIPTION_CAP = 250
38
39/** Splits `---` frontmatter from the body and reads its flat `key: value` lines (and `>`/`|` blocks). */
40export function parseSkill(text: string): { meta: Record<string, string>; body: string } {
41 const lines = text.replace(/\r\n/g, '\n').split('\n')
42 if (lines[0]?.trim() !== '---') return { meta: {}, body: text.trim() }
43 const end = lines.indexOf('---', 1)
44 if (end === -1) return { meta: {}, body: text.trim() }
45 const meta: Record<string, string> = {}
46 for (let index = 1; index < end; index += 1) {
47 const match = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[index] ?? '')
48 if (!match) continue
49 const key = match[1]!
50 let value = match[2]!.trim()
51 if (/^[>|][+-]?$/.test(value)) {
52 const parts: string[] = []
53 while (index + 1 < end && /^\s+\S/.test(lines[index + 1] ?? '')) {
54 index += 1
55 parts.push((lines[index] ?? '').trim())
56 }
57 value = parts.join(' ')
58 }
59 if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1)
60 meta[key] = value
61 }
62 return { meta, body: lines.slice(end + 1).join('\n').trim() }
63}
64
65/** The text a skill run hands over: `$ARGUMENTS` filled in, or the arguments appended when the skill has no slot. */
66export function expand(skill: Skill, args: string): string {
67 const given = args.trim()
68 let text = skill.body.replace(/\$ARGUMENTS\b/g, given)
69 if (given !== '' && !skill.body.includes('$ARGUMENTS')) text += `\n\nARGUMENTS: ${given}`
70 return `Base directory for this skill: ${skill.dir}\n\n${text}`
71}
72
73export function listing(skills: readonly Skill[]): string {
74 const lines: string[] = []
75 let used = 0
76 for (const skill of skills) {
77 if (skill.userOnly) continue
78 const description = skill.description.length > DESCRIPTION_CAP ? `${skill.description.slice(0, DESCRIPTION_CAP - 1)}…` : skill.description
79 const line = `- ${skill.name}: ${description}`
80 if (used + line.length > LISTING_BUDGET) {
81 lines.push(`- …and ${skills.filter((entry) => !entry.userOnly).length - lines.length} more (call the tool with a name to load one)`)
82 break
83 }
84 lines.push(line)
85 used += line.length + 1
86 }
87 return lines.join('\n')
88}
89
90export const register: Register = (on) => {
91 let skills: Skill[] = []
92
93 on('session.start', async ($, e, next) => {
94 const home = (await $.env.get('HOME')) ?? (await $.env.get('USERPROFILE')) ?? ''
95 const bundled = (await $.env.get('MOXEN_SKILLS_DIR')) ?? ''
96 const roots = [`${e.cwd}/.agents/skills`, home === '' ? '' : `${home}/.agents/skills`, bundled].filter((root) => root !== '')
97 const claudeRoots = [`${e.cwd}/.claude/skills`, home === '' ? '' : `${home}/.claude/skills`].filter((root) => root !== '')
98
99 const found = new Map<string, Skill>()
100 for (const root of roots) {
101 let entries: Awaited<ReturnType<typeof $.fs.list>> = []
102 try {
103 entries = await $.fs.list(root)
104 } catch {
105 continue
106 }
107 for (const entry of entries) {
108 // A symlink reads as `other` (users link skills in); anything that is not a plain file may hold one.
109 if (entry.kind === 'file' || found.has(entry.name)) continue
110 const dir = `${root}/${entry.name}`
111 const file = `${dir}/SKILL.md`
112 if (!(await $.fs.exists(file))) continue
113 let provided = false
114 for (const claudeRoot of claudeRoots) provided ||= await $.fs.exists(`${claudeRoot}/${entry.name}/SKILL.md`)
115 if (provided) continue
116 try {
117 const { meta, body } = parseSkill(await $.fs.read(file))
118 const name = meta.name && /^[\w-]{1,64}$/.test(meta.name) ? meta.name : entry.name
119 if (found.has(name)) continue
120 found.set(name, {
121 name,
122 description: meta.description ?? `The ${name} skill`,
123 argumentHint: meta['argument-hint'] ?? null,
124 dir,
125 body,
126 userOnly: meta['disable-model-invocation'] === 'true',
127 modelOnly: meta['user-invocable'] === 'false',
128 })
129 } catch {
130 // An unreadable skill is skipped, not fatal.
131 }
132 }
133 }
134 skills = [...found.values()].sort((left, right) => left.name.localeCompare(right.name))
135
136 if (skills.some((skill) => !skill.userOnly)) {
137 await $.tool.register({
138 name: TOOL,
139 description:
140 'Load a skill from .agents/skills: instructions for a kind of task. When a task matches one below, call this first with its name, ' +
141 'then follow what it returns. Skills listed here are not in the built-in Skill tool.\n\nAvailable skills:\n' +
142 listing(skills),
143 inputSchema: {
144 type: 'object',
145 properties: {
146 skill: { type: 'string', description: 'The skill name, as listed.' },
147 args: { type: 'string', description: 'Arguments for the skill, when it takes any.' },
148 },
149 required: ['skill'],
150 },
151 })
152 }
153 // Registered last: a name a built-in command holds throws, and that must not cost the rest.
154 for (const skill of skills) {
155 if (skill.modelOnly) continue
156 try {
157 await $.command.register({
158 name: skill.name,
159 description: skill.description.length > 120 ? `${skill.description.slice(0, 119)}…` : skill.description,
160 ...(skill.argumentHint === null ? {} : { argumentHint: skill.argumentHint }),
161 })
162 } catch {
163 // Taken by a built-in or another mod: that one wins.
164 }
165 }
166 return next(e)
167 })
168
169 on('tool.call', { tool: TOOL_FULL_NAME }, async ($, e) => {
170 const input = e as unknown as { skill?: unknown; args?: unknown }
171 const wanted = typeof input.skill === 'string' ? input.skill.trim().replace(/^\//, '') : ''
172 const skill = skills.find((entry) => entry.name === wanted)
173 if (!skill) {
174 const names = skills.filter((entry) => !entry.userOnly).map((entry) => entry.name).join(', ')
175 return { result: `No skill named "${wanted}". Available: ${names}` }
176 }
177 if (skill.userOnly) return { result: `The ${skill.name} skill can only be run by the user, with /${skill.name}.` }
178 return { result: expand(skill, typeof input.args === 'string' ? input.args : '') }
179 }).catch(async (_$, e, next) => next(e))
180
181 on('command.run', async ($, e, next) => {
182 const skill = skills.find((entry) => entry.name === e.command && !entry.modelOnly)
183 if (!skill) return next(e)
184 // The skill is the person's request: it starts a turn, as the built-in /skill commands do.
185 await $.prompt.submit({ text: expand(skill, e.args), asUser: true })
186 return {}
187 }).catch(async (_$, e, next) => next(e))
188
189 on('prompt.compose', async ($, e, next) => {
190 const result = await next(e)
191 if (!skills.some((skill) => !skill.userOnly)) return result
192 return {
193 sections: [
194 ...result.sections,
195 {
196 id: 'agent-skills:guide',
197 scope: 'session' as const,
198 text: `Skills in .agents/skills are loaded with the ${TOOL_FULL_NAME} tool, not the Skill tool. When the user's request matches one, load it first and follow it.`,
199 },
200 ],
201 }
202 })
203}
204