SLOPSHOPPER

agent-skills

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

newguardcommandprompttool
★ 1v0.1.0AGPL-3.0updated 2026-10-15usemoxen/moxen/mods/agent-skills
A shopper browsing a rack in a slop shop
README

moxen

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.

Requirements

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).

Install

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.

Terminal UI

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.

KeysAction
enterSend (composer) / confirm (close menu)
shift+enter, ctrl+jNewline in the composer
escUnfocus composer / close diff / cancel menus
ctrl+cClear prompt → close menu → quit (empty prompt jumps straight to the menu)
ctrl+tToggle tasks panel
ctrl+pCommand palette
ctrl+o, alt+eEdit the draft in $VISUAL / $EDITOR
ctrl+v, cmd+vPaste an image from the clipboard
iFocus the composer
s, [, ]Sidebar mode / cycle project
arrows, pgup/pgdnScroll 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.

CLI

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.

Security

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).

License

AGPL-3.0-only. See LICENSE. The tree-sitter grammars under src/core/syntax/ are attributed in THIRD_PARTY_LICENSES.md.

Source 1 files
hooks/register.ts 204 lines
1import 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