@o/cli 0.16.19 → 0.16.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,193 @@
1
+ import { describe, expect, it } from 'bun:test'
2
+ import {
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ rmSync,
9
+ symlinkSync,
10
+ writeFileSync,
11
+ } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join, resolve } from 'node:path'
14
+
15
+ import { transformSkillForHarness } from './skills'
16
+
17
+ describe('transformSkillForHarness', () => {
18
+ it('maps runner tiers to Claude models for claude harness', () => {
19
+ const inputSm = `---
20
+ name: my-skill
21
+ model: sm
22
+ description: A helpful skill
23
+ ---
24
+
25
+ # My skill
26
+ `
27
+ const outputSm = transformSkillForHarness(inputSm, 'claude')
28
+ expect(outputSm).toContain('model: haiku')
29
+ expect(outputSm).not.toContain('model: sm')
30
+
31
+ const inputMd = `---
32
+ name: my-skill
33
+ model: md
34
+ description: A helpful skill
35
+ ---
36
+ `
37
+ expect(transformSkillForHarness(inputMd, 'claude')).toContain('model: sonnet')
38
+
39
+ const inputLg = `---
40
+ name: my-skill
41
+ model: lg
42
+ description: A helpful skill
43
+ ---
44
+ `
45
+ expect(transformSkillForHarness(inputLg, 'claude')).toContain('model: opus')
46
+
47
+ const inputXl = `---
48
+ name: my-skill
49
+ model: xl
50
+ description: A helpful skill
51
+ ---
52
+ `
53
+ expect(transformSkillForHarness(inputXl, 'claude')).toContain('model: fable')
54
+ })
55
+
56
+ it('leaves models untouched for agents harness', () => {
57
+ const input = `---
58
+ name: my-skill
59
+ model: sm
60
+ description: A helpful skill
61
+ ---
62
+ `
63
+ expect(transformSkillForHarness(input, 'agents')).toBe(input)
64
+ })
65
+
66
+ it('leaves already-specific Claude models untouched', () => {
67
+ const input = `---
68
+ name: my-skill
69
+ model: haiku
70
+ description: A helpful skill
71
+ ---
72
+ `
73
+ expect(transformSkillForHarness(input, 'claude')).toBe(input)
74
+ })
75
+
76
+ it('leaves files without model untouched', () => {
77
+ const input = `---
78
+ name: my-skill
79
+ description: A helpful skill
80
+ ---
81
+
82
+ # Body
83
+ `
84
+ expect(transformSkillForHarness(input, 'claude')).toBe(input)
85
+ })
86
+ })
87
+
88
+ describe('skills generation ownership', () => {
89
+ it('leaves linked skill directories intact during generation, clean and stale pruning', () => {
90
+ const fixture = mkdtempSync(join(tmpdir(), 'orez-skill-ownership-'))
91
+ const cli = resolve(import.meta.dir, '../cli.ts')
92
+ const original =
93
+ '---\nname: upgrade\ndescription: shared owned skill\n---\nkeep this source\n'
94
+ try {
95
+ const shared = join(fixture, '.team-machine/skills/upgrade')
96
+ mkdirSync(shared, { recursive: true })
97
+ writeFileSync(join(shared, 'SKILL.md'), original)
98
+ mkdirSync(join(shared, 'agents'))
99
+ const docs = join(fixture, 'skills')
100
+ mkdirSync(docs)
101
+ for (const harness of ['.claude', '.agents']) {
102
+ const root = join(fixture, harness, 'skills')
103
+ mkdirSync(root, { recursive: true })
104
+ symlinkSync(shared, join(root, 'upgrade'), 'dir')
105
+ symlinkSync(shared, join(root, 'takeout-linked'), 'dir')
106
+ const stale = join(root, 'takeout-stale')
107
+ mkdirSync(stale)
108
+ writeFileSync(join(stale, 'SKILL.md'), 'old generated skill')
109
+ }
110
+ const run = (clean: boolean) => {
111
+ const result = Bun.spawnSync({
112
+ cmd: [
113
+ process.execPath,
114
+ cli,
115
+ 'skills',
116
+ 'generate',
117
+ ...(clean ? ['--clean'] : []),
118
+ ],
119
+ cwd: fixture,
120
+ stdout: 'pipe',
121
+ stderr: 'pipe',
122
+ })
123
+ expect(result.exitCode).toBe(0)
124
+ expect(result.stderr.toString()).toBe('')
125
+ expect(readFileSync(join(shared, 'SKILL.md'), 'utf8')).toBe(original)
126
+ for (const harness of ['.claude', '.agents']) {
127
+ const root = join(fixture, harness, 'skills')
128
+ expect(lstatSync(join(root, 'upgrade')).isSymbolicLink()).toBe(true)
129
+ expect(lstatSync(join(root, 'takeout-linked')).isSymbolicLink()).toBe(true)
130
+ expect(existsSync(join(root, 'takeout-stale'))).toBe(false)
131
+ }
132
+ }
133
+ run(false)
134
+ writeFileSync(
135
+ join(docs, 'upgrade.md'),
136
+ '---\nname: upgrade\ndescription: a generated name collision\nmodel: md\n---\nreplacement\n'
137
+ )
138
+ writeFileSync(
139
+ join(docs, 'current.md'),
140
+ '---\nname: current\ndescription: actual generated output\nmodel: md\n---\ncurrent source\n'
141
+ )
142
+ run(true)
143
+ expect(
144
+ readFileSync(join(fixture, '.claude/skills/current/SKILL.md'), 'utf8')
145
+ ).toContain('model: sonnet')
146
+ expect(
147
+ readFileSync(join(fixture, '.agents/skills/current/SKILL.md'), 'utf8')
148
+ ).toContain('model: md')
149
+ } finally {
150
+ rmSync(fixture, { recursive: true, force: true })
151
+ }
152
+ })
153
+
154
+ it('leaves a linked harness root intact while generating for independent harnesses', () => {
155
+ const fixture = mkdtempSync(join(tmpdir(), 'orez-skill-root-'))
156
+ try {
157
+ const shared = join(fixture, 'shared-skills')
158
+ mkdirSync(join(shared, 'manual'), { recursive: true })
159
+ writeFileSync(join(shared, 'manual/SKILL.md'), 'retain root-owned source')
160
+ mkdirSync(join(fixture, '.claude'))
161
+ symlinkSync(shared, join(fixture, '.claude/skills'), 'dir')
162
+ mkdirSync(join(fixture, 'skills'))
163
+ writeFileSync(
164
+ join(fixture, 'skills/current.md'),
165
+ '---\nname: current\ndescription: generated skill\n---\ncurrent source\n'
166
+ )
167
+ const result = Bun.spawnSync({
168
+ cmd: [
169
+ process.execPath,
170
+ resolve(import.meta.dir, '../cli.ts'),
171
+ 'skills',
172
+ 'generate',
173
+ '--clean',
174
+ ],
175
+ cwd: fixture,
176
+ stdout: 'pipe',
177
+ stderr: 'pipe',
178
+ })
179
+ expect(result.exitCode).toBe(0)
180
+ expect(result.stderr.toString()).toBe('')
181
+ expect(readFileSync(join(shared, 'manual/SKILL.md'), 'utf8')).toBe(
182
+ 'retain root-owned source'
183
+ )
184
+ expect(existsSync(join(shared, 'current'))).toBe(false)
185
+ expect(lstatSync(join(fixture, '.claude/skills')).isSymbolicLink()).toBe(true)
186
+ expect(
187
+ readFileSync(join(fixture, '.agents/skills/current/SKILL.md'), 'utf8')
188
+ ).toContain('current source')
189
+ } finally {
190
+ rmSync(fixture, { recursive: true, force: true })
191
+ }
192
+ })
193
+ })
@@ -0,0 +1,361 @@
1
+ /**
2
+ * skills command group - manage repository agent skills
3
+ */
4
+
5
+ import {
6
+ existsSync,
7
+ lstatSync,
8
+ mkdirSync,
9
+ readdirSync,
10
+ readFileSync,
11
+ readlinkSync,
12
+ rmSync,
13
+ rmdirSync,
14
+ symlinkSync,
15
+ unlinkSync,
16
+ writeFileSync,
17
+ } from 'node:fs'
18
+ import { join, relative, resolve } from 'node:path'
19
+
20
+ import { defineCommand } from 'citty'
21
+ import pc from 'picocolors'
22
+
23
+ // --- doc skills generation ---
24
+
25
+ const SKILL_PREFIX = 'takeout-'
26
+
27
+ export type SkillHarness = 'claude' | 'agents'
28
+
29
+ export interface SkillTargetDir {
30
+ path: string
31
+ harness: SkillHarness
32
+ }
33
+
34
+ export const TIER_TO_CLAUDE_MODEL: Record<string, string> = {
35
+ sm: 'haiku',
36
+ md: 'sonnet',
37
+ lg: 'opus',
38
+ xl: 'fable',
39
+ }
40
+
41
+ export function transformSkillForHarness(content: string, harness: SkillHarness): string {
42
+ if (harness !== 'claude') return content
43
+ if (!content.startsWith('---')) return content
44
+ const endIndex = content.indexOf('---', 3)
45
+ if (endIndex === -1) return content
46
+ const frontmatter = content.slice(0, endIndex)
47
+ const transformed = frontmatter.replace(
48
+ /^(\s*model:\s*)(sm|md|lg|xl)\s*$/m,
49
+ (match, prefix, tier) => {
50
+ const mapped = TIER_TO_CLAUDE_MODEL[tier]
51
+ return mapped ? `${prefix}${mapped}` : match
52
+ }
53
+ )
54
+ return transformed + content.slice(endIndex)
55
+ }
56
+
57
+ function getSkillsDirs(cwd: string): SkillTargetDir[] {
58
+ return [
59
+ { path: join(cwd, '.claude', 'skills'), harness: 'claude' },
60
+ { path: join(cwd, '.agents', 'skills'), harness: 'agents' },
61
+ ].filter(
62
+ (target) => !lstatSync(target.path, { throwIfNoEntry: false })?.isSymbolicLink()
63
+ ) as SkillTargetDir[]
64
+ }
65
+
66
+ function hasSkillFrontmatter(content: string): boolean {
67
+ if (!content.startsWith('---')) return false
68
+ const endIndex = content.indexOf('---', 3)
69
+ if (endIndex === -1) return false
70
+ const frontmatter = content.slice(3, endIndex)
71
+ return frontmatter.includes('name:') && frontmatter.includes('description:')
72
+ }
73
+
74
+ function isDevOnly(content: string): boolean {
75
+ if (!content.startsWith('---')) return false
76
+ const endIndex = content.indexOf('---', 3)
77
+ if (endIndex === -1) return false
78
+ const frontmatter = content.slice(3, endIndex)
79
+ return /\bdev:\s*true\b/.test(frontmatter)
80
+ }
81
+
82
+ // prefer a project's ./skills dir if it exists, else fall back to ./docs. lets
83
+ // consumers cleanly separate agent skills (frontmatter md) from plain docs.
84
+ function resolveLocalDocsDir(cwd: string): string {
85
+ const skillsDir = join(cwd, 'skills')
86
+ if (existsSync(skillsDir)) return skillsDir
87
+ return join(cwd, 'docs')
88
+ }
89
+
90
+ function collectAllDocs(cwd: string): Array<{ name: string; path: string }> {
91
+ const docs: Array<{ name: string; path: string }> = []
92
+
93
+ const localDocsDir = resolveLocalDocsDir(cwd)
94
+ if (existsSync(localDocsDir)) {
95
+ const files = readdirSync(localDocsDir).filter((f) => f.endsWith('.md'))
96
+ for (const file of files) {
97
+ const name = file.replace(/\.md$/, '')
98
+ docs.push({ name, path: join(localDocsDir, file) })
99
+ }
100
+ }
101
+
102
+ return docs
103
+ }
104
+
105
+ async function generateDocSkills(
106
+ cwd: string,
107
+ clean: boolean
108
+ ): Promise<{
109
+ symlinked: number
110
+ generated: number
111
+ unchanged: number
112
+ removed: number
113
+ skipped: number
114
+ }> {
115
+ const skillsDirs = getSkillsDirs(cwd)
116
+ const docs = collectAllDocs(cwd)
117
+ const localDocsDir = resolveLocalDocsDir(cwd)
118
+ const expectedSkillNames = new Set<string>()
119
+
120
+ if (docs.length === 0) {
121
+ console.info(pc.yellow('no documentation files found'))
122
+ } else {
123
+ console.info(pc.dim(`found ${docs.length} documentation files`))
124
+ }
125
+
126
+ for (const target of skillsDirs) {
127
+ const skillsDir = target.path
128
+ if (clean && existsSync(skillsDir)) {
129
+ const existing = readdirSync(skillsDir)
130
+ for (const dir of existing) {
131
+ if (
132
+ dir.startsWith(SKILL_PREFIX) &&
133
+ !lstatSync(join(skillsDir, dir)).isSymbolicLink()
134
+ ) {
135
+ rmSync(join(skillsDir, dir), { recursive: true })
136
+ }
137
+ }
138
+ }
139
+
140
+ if (!existsSync(skillsDir)) {
141
+ mkdirSync(skillsDir, { recursive: true })
142
+ }
143
+ }
144
+
145
+ let symlinked = 0
146
+ let generated = 0
147
+ let unchanged = 0
148
+ let removed = 0
149
+ let skipped = 0
150
+ const isDev = !!process.env.IS_TAMAGUI_DEV
151
+
152
+ for (const doc of docs) {
153
+ const content = readFileSync(doc.path, 'utf-8')
154
+ if (isDevOnly(content) && !isDev) continue
155
+
156
+ const hasFrontmatter = hasSkillFrontmatter(content)
157
+
158
+ if (hasFrontmatter) {
159
+ const nameMatch = content.match(/^---\s*\nname:\s*([^\n]+)/m)
160
+ if (!nameMatch) continue
161
+
162
+ const skillName = nameMatch[1]!.trim()
163
+ expectedSkillNames.add(skillName)
164
+
165
+ for (const target of skillsDirs) {
166
+ const skillsDir = target.path
167
+ const skillDir = join(skillsDir, skillName)
168
+ const skillFile = join(skillDir, 'SKILL.md')
169
+
170
+ // linked directories belong to another source, including dangling links.
171
+ if (lstatSync(skillDir, { throwIfNoEntry: false })?.isSymbolicLink()) {
172
+ skipped++
173
+ continue
174
+ }
175
+
176
+ if (!existsSync(skillDir)) {
177
+ mkdirSync(skillDir, { recursive: true })
178
+ }
179
+
180
+ const relativePath = relative(skillDir, doc.path)
181
+ const targetContent = transformSkillForHarness(content, target.harness)
182
+ const isSymlinkTarget = targetContent === content
183
+
184
+ let shouldCreate = true
185
+ try {
186
+ const stat = lstatSync(skillFile)
187
+ if (isSymlinkTarget && stat.isSymbolicLink() && existsSync(skillFile)) {
188
+ const existingContent = readFileSync(skillFile, 'utf-8')
189
+ if (existingContent === content) {
190
+ unchanged++
191
+ shouldCreate = false
192
+ }
193
+ } else if (
194
+ !isSymlinkTarget &&
195
+ !stat.isSymbolicLink() &&
196
+ existsSync(skillFile)
197
+ ) {
198
+ const existingContent = readFileSync(skillFile, 'utf-8')
199
+ if (existingContent === targetContent) {
200
+ unchanged++
201
+ shouldCreate = false
202
+ }
203
+ }
204
+ if (shouldCreate) {
205
+ unlinkSync(skillFile)
206
+ }
207
+ } catch {
208
+ // nothing exists
209
+ }
210
+
211
+ if (!shouldCreate) continue
212
+
213
+ if (isSymlinkTarget) {
214
+ symlinkSync(relativePath, skillFile)
215
+ symlinked++
216
+
217
+ console.info(
218
+ ` ${pc.green('⟷')} ${skillName} ${pc.dim(`(${relative(cwd, skillsDir)})`)}`
219
+ )
220
+ } else {
221
+ writeFileSync(skillFile, targetContent, 'utf-8')
222
+ generated++
223
+
224
+ console.info(
225
+ ` ${pc.cyan('✎')} ${skillName} ${pc.dim(`(${relative(cwd, skillsDir)}: model mapped)`)}`
226
+ )
227
+ }
228
+ }
229
+ } else {
230
+ if (!hasFrontmatter) {
231
+ skipped++
232
+ console.info(
233
+ ` ${pc.yellow('!')} skipped ${pc.dim(doc.name)} ${pc.dim('(missing skill frontmatter)')}`
234
+ )
235
+ continue
236
+ }
237
+ }
238
+ }
239
+
240
+ for (const target of skillsDirs) {
241
+ const skillsDir = target.path
242
+ for (const dir of readdirSync(skillsDir)) {
243
+ if (expectedSkillNames.has(dir)) continue
244
+
245
+ const skillDir = join(skillsDir, dir)
246
+ const skillFile = join(skillDir, 'SKILL.md')
247
+
248
+ if (lstatSync(skillDir).isSymbolicLink()) continue
249
+
250
+ if (dir.startsWith(SKILL_PREFIX)) {
251
+ rmSync(skillDir, { recursive: true, force: true })
252
+ removed++
253
+ console.info(
254
+ ` ${pc.red('✕')} ${dir} ${pc.dim('(removed stale generated skill)')}`
255
+ )
256
+ continue
257
+ }
258
+
259
+ let shouldUnlink = false
260
+
261
+ try {
262
+ const stat = lstatSync(skillFile)
263
+ if (stat.isSymbolicLink()) {
264
+ const linkTarget = readlinkSync(skillFile)
265
+ const resolvedTarget = resolve(skillDir, linkTarget)
266
+ shouldUnlink = resolvedTarget.startsWith(`${localDocsDir}/`)
267
+ } else if (stat.isFile()) {
268
+ shouldUnlink = true
269
+ }
270
+ } catch {
271
+ // ignore unrelated skill directories
272
+ }
273
+
274
+ if (!shouldUnlink) continue
275
+
276
+ unlinkSync(skillFile)
277
+ if (readdirSync(skillDir).length === 0) {
278
+ rmdirSync(skillDir)
279
+ }
280
+ removed++
281
+ console.info(` ${pc.red('✕')} ${dir} ${pc.dim('(removed stale skill)')}`)
282
+ }
283
+ }
284
+
285
+ return { symlinked, generated, unchanged, removed, skipped }
286
+ }
287
+
288
+ // --- commands ---
289
+
290
+ const generateCommand = defineCommand({
291
+ meta: {
292
+ name: 'generate',
293
+ description: 'Generate Claude Code and Codex skills from documentation',
294
+ },
295
+ args: {
296
+ clean: {
297
+ type: 'boolean',
298
+ description: 'Remove existing takeout-* skills before generating',
299
+ default: false,
300
+ },
301
+ 'skip-internal-docs': {
302
+ type: 'boolean',
303
+ description: 'Skip generating skills from internal documentation files',
304
+ default: false,
305
+ },
306
+ },
307
+ async run({ args }) {
308
+ const cwd = process.cwd()
309
+ const skillsDirs = getSkillsDirs(cwd)
310
+
311
+ console.info()
312
+ console.info(pc.bold(pc.cyan('Generate all skills')))
313
+ console.info()
314
+
315
+ let symlinked = 0
316
+ let generated = 0
317
+ let unchanged = 0
318
+ let removed = 0
319
+ let skipped = 0
320
+
321
+ // 1. doc skills (unless skipped)
322
+ if (!args['skip-internal-docs']) {
323
+ const docStats = await generateDocSkills(cwd, args.clean)
324
+ symlinked = docStats.symlinked
325
+ generated = docStats.generated
326
+ unchanged = docStats.unchanged
327
+ removed = docStats.removed
328
+ skipped = docStats.skipped
329
+ console.info()
330
+ }
331
+
332
+ // summary
333
+ console.info()
334
+ console.info(pc.bold('summary:'))
335
+ if (symlinked > 0) console.info(` ${pc.green(`${symlinked} symlinked`)}`)
336
+ if (generated > 0)
337
+ console.info(
338
+ ` ${pc.cyan(`${generated} generated`)} ${pc.dim('(model tier translated)')}`
339
+ )
340
+ if (skipped > 0)
341
+ console.info(
342
+ ` ${pc.yellow(`${skipped} skipped`)} ${pc.dim('(linked directories or missing skill frontmatter)')}`
343
+ )
344
+ if (unchanged > 0) console.info(` ${pc.dim(`${unchanged} unchanged`)}`)
345
+ if (removed > 0) console.info(` ${pc.red(`${removed} removed`)}`)
346
+ for (const target of skillsDirs) {
347
+ console.info(pc.dim(` skills in ${target.path}`))
348
+ }
349
+ console.info()
350
+ },
351
+ })
352
+
353
+ export const skillsCommand = defineCommand({
354
+ meta: {
355
+ name: 'skills',
356
+ description: 'Manage repository agent skills',
357
+ },
358
+ subCommands: {
359
+ generate: generateCommand,
360
+ },
361
+ })