Skip to content

Commit 0c9f797

Browse files
committed
feat: add unit tests and README docs for author command
- 12 unit tests covering detectMonorepoPackages and patchPackageJsonFiles - Tests: workspace detection, pnpm-workspace.yaml, private pkg filtering, quoted entries, repo URL parsing, files array patching - README: new "For Maintainers" section documenting skilld author workflow, consumer setup with skilld prepare, and command options - Export detectMonorepoPackages and patchPackageJsonFiles for testability
1 parent 8fd064b commit 0c9f797

3 files changed

Lines changed: 355 additions & 3 deletions

File tree

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,52 @@ Share via `skilld add owner/repo` - consumers get fully functional skills with n
238238
| `--from` | | | Collect releases/issues/discussions from this date (YYYY-MM-DD, eject only) |
239239
| `--debug` | | `false` | Save raw LLM output to logs/ for each section |
240240

241+
## For Maintainers
242+
243+
Ship skills with your npm package so consumers get them automatically. No LLM needed on their end.
244+
245+
### Generate a skill
246+
247+
From your package root (or monorepo root):
248+
249+
```bash
250+
npx skilld author
251+
```
252+
253+
In a monorepo, skilld auto-detects workspaces and prompts which packages to generate for. Docs are resolved from: package `docs/`, monorepo `docs/content/`, `llms.txt`, or `README.md`.
254+
255+
This creates a `skills/<your-package>/` directory with a `SKILL.md` and ejected reference files. It also adds `"skills"` to your `package.json` `files` array.
256+
257+
### How consumers get it
258+
259+
Once published, consumers run:
260+
261+
```bash
262+
npx skilld prepare
263+
```
264+
265+
Or add it to their `package.json` so it runs on every install:
266+
267+
```json
268+
{
269+
"scripts": {
270+
"prepare": "skilld prepare"
271+
}
272+
}
273+
```
274+
275+
`skilld prepare` auto-detects shipped skills in `node_modules` and symlinks them into the agent's skill directory. Compatible with [skills-npm](https://github.com/antfu/skills-npm).
276+
277+
### Options
278+
279+
| Flag | Alias | Default | Description |
280+
|:----------|:-----:|:-------:|:------------|
281+
| `--model` | `-m` | | LLM model for enhancement |
282+
| `--out` | `-o` | | Output directory (single package only) |
283+
| `--force` | `-f` | `false` | Clear cache and regenerate |
284+
| `--yes` | `-y` | `false` | Skip prompts, use defaults |
285+
| `--debug` | | `false` | Save raw LLM output to logs/ |
286+
241287
## The Landscape
242288

243289
Several approaches exist for steering agent knowledge. Each fills a different niche:

src/commands/author.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,15 +44,15 @@ const QUOTE_SUFFIX_RE = /['"]$/
4444

4545
// ── Monorepo detection ──
4646

47-
interface MonorepoPackage {
47+
export interface MonorepoPackage {
4848
name: string
4949
version: string
5050
description?: string
5151
repoUrl?: string
5252
dir: string
5353
}
5454

55-
function detectMonorepoPackages(cwd: string): MonorepoPackage[] | null {
55+
export function detectMonorepoPackages(cwd: string): MonorepoPackage[] | null {
5656
const pkgPath = join(cwd, 'package.json')
5757
if (!existsSync(pkgPath))
5858
return null
@@ -308,7 +308,7 @@ async function fetchRemoteSupplements(opts: {
308308

309309
// ── package.json patching ──
310310

311-
function patchPackageJsonFiles(packageDir: string): void {
311+
export function patchPackageJsonFiles(packageDir: string): void {
312312
const pkgPath = join(packageDir, 'package.json')
313313
if (!existsSync(pkgPath))
314314
return

test/unit/author.test.ts

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
vi.mock('node:fs', async () => {
4+
const actual = await vi.importActual<typeof import('node:fs')>('node:fs')
5+
return {
6+
...actual,
7+
existsSync: vi.fn(),
8+
readdirSync: vi.fn(),
9+
readFileSync: vi.fn(),
10+
writeFileSync: vi.fn(),
11+
mkdirSync: vi.fn(),
12+
rmSync: vi.fn(),
13+
}
14+
})
15+
16+
vi.mock('@clack/prompts', () => ({
17+
log: { success: vi.fn(), warn: vi.fn(), error: vi.fn() },
18+
}))
19+
20+
describe('author', () => {
21+
beforeEach(() => {
22+
vi.resetAllMocks()
23+
})
24+
25+
describe('detectMonorepoPackages', () => {
26+
it('returns null when no package.json exists', async () => {
27+
const { existsSync } = await import('node:fs')
28+
vi.mocked(existsSync).mockReturnValue(false)
29+
30+
const { detectMonorepoPackages } = await import('../../src/commands/author')
31+
expect(detectMonorepoPackages('/project')).toBeNull()
32+
})
33+
34+
it('returns null for non-private packages', async () => {
35+
const { existsSync, readFileSync } = await import('node:fs')
36+
vi.mocked(existsSync).mockReturnValue(true)
37+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'my-pkg', version: '1.0.0' }))
38+
39+
const { detectMonorepoPackages } = await import('../../src/commands/author')
40+
expect(detectMonorepoPackages('/project')).toBeNull()
41+
})
42+
43+
it('detects npm workspaces array', async () => {
44+
const { existsSync, readFileSync, readdirSync } = await import('node:fs')
45+
46+
vi.mocked(existsSync).mockImplementation((p: any) => {
47+
const s = String(p)
48+
if (s === '/project/package.json')
49+
return true
50+
if (s === '/project/packages')
51+
return true
52+
if (s === '/project/packages/foo/package.json')
53+
return true
54+
return false
55+
})
56+
57+
vi.mocked(readFileSync).mockImplementation((p: any) => {
58+
const s = String(p)
59+
if (s === '/project/package.json')
60+
return JSON.stringify({ private: true, workspaces: ['packages/*'] })
61+
if (s === '/project/packages/foo/package.json')
62+
return JSON.stringify({ name: '@scope/foo', version: '2.0.0', description: 'Foo package' })
63+
return ''
64+
})
65+
66+
vi.mocked(readdirSync).mockReturnValue([
67+
{ name: 'foo', isDirectory: () => true, isFile: () => false } as any,
68+
])
69+
70+
const { detectMonorepoPackages } = await import('../../src/commands/author')
71+
const result = detectMonorepoPackages('/project')
72+
73+
expect(result).toHaveLength(1)
74+
expect(result![0]).toMatchObject({
75+
name: '@scope/foo',
76+
version: '2.0.0',
77+
description: 'Foo package',
78+
})
79+
})
80+
81+
it('detects pnpm-workspace.yaml', async () => {
82+
const { existsSync, readFileSync, readdirSync } = await import('node:fs')
83+
84+
vi.mocked(existsSync).mockImplementation((p: any) => {
85+
const s = String(p)
86+
if (s === '/project/package.json')
87+
return true
88+
if (s === '/project/pnpm-workspace.yaml')
89+
return true
90+
if (s === '/project/packages')
91+
return true
92+
if (s === '/project/packages/bar/package.json')
93+
return true
94+
return false
95+
})
96+
97+
vi.mocked(readFileSync).mockImplementation((p: any) => {
98+
const s = String(p)
99+
if (s === '/project/package.json')
100+
return JSON.stringify({ private: true })
101+
if (s === '/project/pnpm-workspace.yaml')
102+
return 'packages:\n - packages/*\n'
103+
if (s === '/project/packages/bar/package.json')
104+
return JSON.stringify({ name: 'bar', version: '1.0.0' })
105+
return ''
106+
})
107+
108+
vi.mocked(readdirSync).mockReturnValue([
109+
{ name: 'bar', isDirectory: () => true, isFile: () => false } as any,
110+
])
111+
112+
const { detectMonorepoPackages } = await import('../../src/commands/author')
113+
const result = detectMonorepoPackages('/project')
114+
115+
expect(result).toHaveLength(1)
116+
expect(result![0].name).toBe('bar')
117+
})
118+
119+
it('skips private child packages', async () => {
120+
const { existsSync, readFileSync, readdirSync } = await import('node:fs')
121+
122+
vi.mocked(existsSync).mockImplementation((p: any) => {
123+
const s = String(p)
124+
if (s === '/project/package.json')
125+
return true
126+
if (s === '/project/packages')
127+
return true
128+
if (s === '/project/packages/internal/package.json')
129+
return true
130+
if (s === '/project/packages/public/package.json')
131+
return true
132+
return false
133+
})
134+
135+
vi.mocked(readFileSync).mockImplementation((p: any) => {
136+
const s = String(p)
137+
if (s === '/project/package.json')
138+
return JSON.stringify({ private: true, workspaces: ['packages/*'] })
139+
if (s === '/project/packages/internal/package.json')
140+
return JSON.stringify({ name: 'internal', private: true })
141+
if (s === '/project/packages/public/package.json')
142+
return JSON.stringify({ name: 'public-pkg', version: '1.0.0' })
143+
return ''
144+
})
145+
146+
vi.mocked(readdirSync).mockReturnValue([
147+
{ name: 'internal', isDirectory: () => true, isFile: () => false } as any,
148+
{ name: 'public', isDirectory: () => true, isFile: () => false } as any,
149+
])
150+
151+
const { detectMonorepoPackages } = await import('../../src/commands/author')
152+
const result = detectMonorepoPackages('/project')
153+
154+
expect(result).toHaveLength(1)
155+
expect(result![0].name).toBe('public-pkg')
156+
})
157+
158+
it('handles pnpm-workspace.yaml with quoted entries', async () => {
159+
const { existsSync, readFileSync, readdirSync } = await import('node:fs')
160+
161+
vi.mocked(existsSync).mockImplementation((p: any) => {
162+
const s = String(p)
163+
if (s === '/project/package.json')
164+
return true
165+
if (s === '/project/pnpm-workspace.yaml')
166+
return true
167+
if (s === '/project/libs')
168+
return true
169+
if (s === '/project/libs/a/package.json')
170+
return true
171+
return false
172+
})
173+
174+
vi.mocked(readFileSync).mockImplementation((p: any) => {
175+
const s = String(p)
176+
if (s === '/project/package.json')
177+
return JSON.stringify({ private: true })
178+
if (s === '/project/pnpm-workspace.yaml')
179+
return 'packages:\n - \'libs/*\'\n'
180+
if (s === '/project/libs/a/package.json')
181+
return JSON.stringify({ name: 'lib-a', version: '0.1.0' })
182+
return ''
183+
})
184+
185+
vi.mocked(readdirSync).mockReturnValue([
186+
{ name: 'a', isDirectory: () => true, isFile: () => false } as any,
187+
])
188+
189+
const { detectMonorepoPackages } = await import('../../src/commands/author')
190+
const result = detectMonorepoPackages('/project')
191+
192+
expect(result).toHaveLength(1)
193+
expect(result![0].name).toBe('lib-a')
194+
})
195+
196+
it('resolves repository URL from object form', async () => {
197+
const { existsSync, readFileSync, readdirSync } = await import('node:fs')
198+
199+
vi.mocked(existsSync).mockImplementation((p: any) => {
200+
const s = String(p)
201+
if (s === '/project/package.json')
202+
return true
203+
if (s === '/project/packages')
204+
return true
205+
if (s === '/project/packages/x/package.json')
206+
return true
207+
return false
208+
})
209+
210+
vi.mocked(readFileSync).mockImplementation((p: any) => {
211+
const s = String(p)
212+
if (s === '/project/package.json')
213+
return JSON.stringify({ private: true, workspaces: ['packages/*'] })
214+
if (s === '/project/packages/x/package.json') {
215+
return JSON.stringify({
216+
name: 'x-pkg',
217+
version: '1.0.0',
218+
repository: { type: 'git', url: 'git+https://github.com/org/x.git' },
219+
})
220+
}
221+
return ''
222+
})
223+
224+
vi.mocked(readdirSync).mockReturnValue([
225+
{ name: 'x', isDirectory: () => true, isFile: () => false } as any,
226+
])
227+
228+
const { detectMonorepoPackages } = await import('../../src/commands/author')
229+
const result = detectMonorepoPackages('/project')
230+
231+
expect(result![0].repoUrl).toBe('https://github.com/org/x')
232+
})
233+
})
234+
235+
describe('patchPackageJsonFiles', () => {
236+
it('warns when no files array exists', async () => {
237+
const { existsSync, readFileSync } = await import('node:fs')
238+
const { log } = await import('@clack/prompts')
239+
240+
vi.mocked(existsSync).mockReturnValue(true)
241+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ name: 'test' }))
242+
243+
const { patchPackageJsonFiles } = await import('../../src/commands/author')
244+
patchPackageJsonFiles('/pkg')
245+
246+
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining('No `files` array'))
247+
})
248+
249+
it('adds skills to files array preserving formatting', async () => {
250+
const { existsSync, readFileSync, writeFileSync } = await import('node:fs')
251+
252+
vi.mocked(existsSync).mockReturnValue(true)
253+
const original = `{
254+
"name": "test",
255+
"files": [
256+
"dist"
257+
]
258+
}`
259+
vi.mocked(readFileSync).mockReturnValue(original)
260+
261+
const { patchPackageJsonFiles } = await import('../../src/commands/author')
262+
patchPackageJsonFiles('/pkg')
263+
264+
const written = vi.mocked(writeFileSync).mock.calls[0][1] as string
265+
expect(written).toContain('"skills"')
266+
expect(written).toContain('"dist"')
267+
// Should not have been reformatted by JSON.stringify (no double-space after "name")
268+
expect(JSON.parse(written).files).toContain('skills')
269+
})
270+
271+
it('skips if skills already in files array', async () => {
272+
const { existsSync, readFileSync, writeFileSync } = await import('node:fs')
273+
274+
vi.mocked(existsSync).mockReturnValue(true)
275+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ files: ['dist', 'skills'] }))
276+
277+
const { patchPackageJsonFiles } = await import('../../src/commands/author')
278+
patchPackageJsonFiles('/pkg')
279+
280+
expect(writeFileSync).not.toHaveBeenCalled()
281+
})
282+
283+
it('skips if skills/ variant already in files array', async () => {
284+
const { existsSync, readFileSync, writeFileSync } = await import('node:fs')
285+
286+
vi.mocked(existsSync).mockReturnValue(true)
287+
vi.mocked(readFileSync).mockReturnValue(JSON.stringify({ files: ['dist', 'skills/'] }))
288+
289+
const { patchPackageJsonFiles } = await import('../../src/commands/author')
290+
patchPackageJsonFiles('/pkg')
291+
292+
expect(writeFileSync).not.toHaveBeenCalled()
293+
})
294+
295+
it('does nothing when no package.json exists', async () => {
296+
const { existsSync, writeFileSync } = await import('node:fs')
297+
298+
vi.mocked(existsSync).mockReturnValue(false)
299+
300+
const { patchPackageJsonFiles } = await import('../../src/commands/author')
301+
patchPackageJsonFiles('/pkg')
302+
303+
expect(writeFileSync).not.toHaveBeenCalled()
304+
})
305+
})
306+
})

0 commit comments

Comments
 (0)