@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.
- package/dist/cmd.d.ts +10 -8
- package/dist/cmd.d.ts.map +1 -1
- package/dist/cmd.js.map +1 -1
- package/package.json +31 -6
- package/src/tko/cli.ts +173 -0
- package/src/tko/commands/changed.ts +313 -0
- package/src/tko/commands/docs.ts +164 -0
- package/src/tko/commands/env-setup.ts +69 -0
- package/src/tko/commands/onboard.ts +1420 -0
- package/src/tko/commands/run-all.ts +83 -0
- package/src/tko/commands/run.ts +135 -0
- package/src/tko/commands/script.ts +431 -0
- package/src/tko/commands/skills.test.ts +193 -0
- package/src/tko/commands/skills.ts +361 -0
- package/src/tko/commands/sync.ts +319 -0
- package/src/tko/constants/ascii.ts +17 -0
- package/src/tko/index.ts +1 -0
- package/src/tko/types.ts +59 -0
- package/src/tko/utils/env-categories.ts +246 -0
- package/src/tko/utils/env-setup.ts +335 -0
- package/src/tko/utils/env.ts +127 -0
- package/src/tko/utils/files.ts +302 -0
- package/src/tko/utils/parallel-runner.ts +195 -0
- package/src/tko/utils/ports.ts +77 -0
- package/src/tko/utils/prerequisites.ts +137 -0
- package/src/tko/utils/prompts.ts +203 -0
- package/src/tko/utils/script-listing.ts +194 -0
- package/src/tko/utils/script-utils.ts +103 -0
- package/src/tko/utils/sync.ts +101 -0
- package/src/tko/withExpoModulesCoreSwift6Fix.cjs +58 -0
- package/src/tko/withOpSqliteStatic.cjs +51 -0
|
@@ -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
|
+
})
|