@owlmeans/agent-skills 0.1.8
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/README.md +92 -0
- package/agent-meta/instructions/shadcn-versions.instructions.md +46 -0
- package/agent-meta/instructions/shadcn-web.instructions.md +94 -0
- package/agent-meta/manifest.json +37 -0
- package/agent-meta/skills/shadcn-versions/SKILL.md +126 -0
- package/agent-meta/skills/shadcn-web/SKILL.md +222 -0
- package/build/apply.d.ts +13 -0
- package/build/apply.d.ts.map +1 -0
- package/build/apply.js +28 -0
- package/build/apply.js.map +1 -0
- package/build/args.d.ts +14 -0
- package/build/args.d.ts.map +1 -0
- package/build/args.js +93 -0
- package/build/args.js.map +1 -0
- package/build/bin.d.ts +3 -0
- package/build/bin.d.ts.map +1 -0
- package/build/bin.js +21 -0
- package/build/bin.js.map +1 -0
- package/build/discover.d.ts +41 -0
- package/build/discover.d.ts.map +1 -0
- package/build/discover.js +160 -0
- package/build/discover.js.map +1 -0
- package/build/index.d.ts +11 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +6 -0
- package/build/index.js.map +1 -0
- package/build/linked.d.ts +15 -0
- package/build/linked.d.ts.map +1 -0
- package/build/linked.js +44 -0
- package/build/linked.js.map +1 -0
- package/build/plan.d.ts +15 -0
- package/build/plan.d.ts.map +1 -0
- package/build/plan.js +40 -0
- package/build/plan.js.map +1 -0
- package/build/prompt.d.ts +4 -0
- package/build/prompt.d.ts.map +1 -0
- package/build/prompt.js +21 -0
- package/build/prompt.js.map +1 -0
- package/build/run.d.ts +18 -0
- package/build/run.d.ts.map +1 -0
- package/build/run.js +124 -0
- package/build/run.js.map +1 -0
- package/package.json +37 -0
- package/src/apply.ts +38 -0
- package/src/args.ts +108 -0
- package/src/bin.ts +20 -0
- package/src/discover.ts +202 -0
- package/src/index.ts +14 -0
- package/src/linked.ts +51 -0
- package/src/plan.ts +69 -0
- package/src/prompt.ts +25 -0
- package/src/run.ts +167 -0
- package/tests/installer.test.ts +297 -0
- package/tsconfig.json +11 -0
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline/promises'
|
|
2
|
+
import { stdin, stdout } from 'node:process'
|
|
3
|
+
|
|
4
|
+
let rl: ReturnType<typeof createInterface> | null = null
|
|
5
|
+
|
|
6
|
+
const getReadline = (): ReturnType<typeof createInterface> => {
|
|
7
|
+
if (rl == null) {
|
|
8
|
+
rl = createInterface({ input: stdin, output: stdout })
|
|
9
|
+
}
|
|
10
|
+
return rl
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const closeReadline = (): void => {
|
|
14
|
+
if (rl != null) {
|
|
15
|
+
rl.close()
|
|
16
|
+
rl = null
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const confirm = async (question: string): Promise<boolean> => {
|
|
21
|
+
const answer = await getReadline().question(`${question} [y/N] `)
|
|
22
|
+
return answer.trim().toLowerCase() === 'y' || answer.trim().toLowerCase() === 'yes'
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const isTTY = (): boolean => process.stdin.isTTY === true
|
package/src/run.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import type { CliArgs } from './args.js'
|
|
3
|
+
import { discover } from './discover.js'
|
|
4
|
+
import { detectLinked } from './linked.js'
|
|
5
|
+
import { planInstall } from './plan.js'
|
|
6
|
+
import { applyInstall } from './apply.js'
|
|
7
|
+
import { confirm, closeReadline, isTTY } from './prompt.js'
|
|
8
|
+
import type { InstallAction, InstallItem } from './plan.js'
|
|
9
|
+
|
|
10
|
+
const ACTION_LABEL: Record<InstallAction, string> = {
|
|
11
|
+
'install': 'install',
|
|
12
|
+
'skip-uptodate': 'up-to-date',
|
|
13
|
+
'update': 'update',
|
|
14
|
+
'conflict': 'CONFLICT',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const padEnd = (s: string, n: number): string => s + ' '.repeat(Math.max(0, n - s.length))
|
|
18
|
+
|
|
19
|
+
const printTable = (items: InstallItem[]): void => {
|
|
20
|
+
const colWidths = {
|
|
21
|
+
kind: 11,
|
|
22
|
+
name: Math.max(...items.map(i => i.entry.name.length), 4) + 2,
|
|
23
|
+
pkg: Math.max(...items.map(i => i.entry.packageName.length), 7) + 2,
|
|
24
|
+
action: 10,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const header =
|
|
28
|
+
padEnd('kind', colWidths.kind) +
|
|
29
|
+
padEnd('name', colWidths.name) +
|
|
30
|
+
padEnd('package', colWidths.pkg) +
|
|
31
|
+
'action'
|
|
32
|
+
process.stdout.write(header + '\n')
|
|
33
|
+
process.stdout.write('-'.repeat(header.length) + '\n')
|
|
34
|
+
|
|
35
|
+
for (const item of items) {
|
|
36
|
+
const kindLabel = item.entry.kind === 'skill' ? 'skill' : 'instruction'
|
|
37
|
+
const actionLabel = ACTION_LABEL[item.action]
|
|
38
|
+
process.stdout.write(
|
|
39
|
+
padEnd(kindLabel, colWidths.kind) +
|
|
40
|
+
padEnd(item.entry.name, colWidths.name) +
|
|
41
|
+
padEnd(item.entry.packageName, colWidths.pkg) +
|
|
42
|
+
actionLabel +
|
|
43
|
+
'\n',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
process.stdout.write('\n')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RunResult =
|
|
50
|
+
| { code: 0 }
|
|
51
|
+
| { code: 2; message: string } // CLI parse
|
|
52
|
+
| { code: 3; message: string } // nothing found
|
|
53
|
+
| { code: 4; message: string } // linked refusal
|
|
54
|
+
| { code: 5; message: string } // unresolved conflicts
|
|
55
|
+
|
|
56
|
+
export const run = async (args: CliArgs): Promise<RunResult> => {
|
|
57
|
+
const targetDir = resolve(args.dir)
|
|
58
|
+
|
|
59
|
+
// 1. Linked monorepo check
|
|
60
|
+
if (!args.force) {
|
|
61
|
+
const linked = detectLinked(targetDir)
|
|
62
|
+
if (linked.linked) {
|
|
63
|
+
const pkgList = linked.evidence.slice(0, 5).join(', ')
|
|
64
|
+
const more = linked.evidence.length > 5 ? ` (+${linked.evidence.length - 5} more)` : ''
|
|
65
|
+
process.stderr.write(
|
|
66
|
+
`\nerror: linked monorepo detected — ${pkgList}${more} are symlinked.\n\n` +
|
|
67
|
+
`In this setup, agents already load guidance from the linked monorepo's\n` +
|
|
68
|
+
`root .claude/skills/ and .github/instructions/. Installing embedded copies\n` +
|
|
69
|
+
`would place stale duplicates that conflict with the canonical live files.\n\n` +
|
|
70
|
+
`Pass --force only if you understand this and want to proceed anyway.\n\n`,
|
|
71
|
+
)
|
|
72
|
+
return { code: 4, message: 'linked monorepo' }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Discover
|
|
77
|
+
const entries = discover(targetDir, {
|
|
78
|
+
extras: args.extras,
|
|
79
|
+
only: args.only.length > 0 ? args.only : undefined,
|
|
80
|
+
claudeOnly: args.claudeOnly,
|
|
81
|
+
copilotOnly: args.copilotOnly,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (entries.length === 0) {
|
|
85
|
+
process.stdout.write(
|
|
86
|
+
`No embedded @owlmeans/* agent guidance found in node_modules.\n` +
|
|
87
|
+
`Install @owlmeans/* packages first, then re-run.\n`,
|
|
88
|
+
)
|
|
89
|
+
return { code: 3, message: 'nothing found' }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// 3. Plan
|
|
93
|
+
const items = planInstall(entries, targetDir, { force: args.force })
|
|
94
|
+
|
|
95
|
+
// 4. Print table
|
|
96
|
+
process.stdout.write('\n')
|
|
97
|
+
printTable(items)
|
|
98
|
+
|
|
99
|
+
if (args.dryRun) {
|
|
100
|
+
process.stdout.write('(dry-run — no files written)\n')
|
|
101
|
+
return { code: 0 }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 5. Count actionable items
|
|
105
|
+
const toWrite = items.filter(i => i.action === 'install' || i.action === 'update')
|
|
106
|
+
const conflicts = items.filter(i => i.action === 'conflict')
|
|
107
|
+
|
|
108
|
+
if (toWrite.length === 0 && conflicts.length === 0) {
|
|
109
|
+
process.stdout.write('Everything up to date.\n')
|
|
110
|
+
closeReadline()
|
|
111
|
+
return { code: 0 }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 6. Interactive: confirm per conflict, or if not --yes confirm overall
|
|
115
|
+
const resolvedItems = [...items]
|
|
116
|
+
|
|
117
|
+
if (isTTY() && !args.yes && toWrite.length > 0) {
|
|
118
|
+
const ok = await confirm(`Install ${toWrite.length} file(s) into ${targetDir}?`)
|
|
119
|
+
if (!ok) {
|
|
120
|
+
process.stdout.write('Aborted.\n')
|
|
121
|
+
closeReadline()
|
|
122
|
+
return { code: 0 }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Per-conflict prompts in interactive mode
|
|
127
|
+
if (isTTY() && conflicts.length > 0) {
|
|
128
|
+
for (const item of resolvedItems) {
|
|
129
|
+
if (item.action !== 'conflict') continue
|
|
130
|
+
const ok = await confirm(
|
|
131
|
+
` ${item.entry.name} has local edits — overwrite ${item.targetPath}?`,
|
|
132
|
+
)
|
|
133
|
+
if (ok) {
|
|
134
|
+
// Upgrade to update
|
|
135
|
+
Object.assign(item, { action: 'update' })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
closeReadline()
|
|
141
|
+
|
|
142
|
+
const remainingConflicts = resolvedItems.filter(i => i.action === 'conflict')
|
|
143
|
+
if (remainingConflicts.length > 0) {
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
`\nSkipping ${remainingConflicts.length} file(s) with local edits:\n`,
|
|
146
|
+
)
|
|
147
|
+
for (const c of remainingConflicts) {
|
|
148
|
+
process.stdout.write(` ${c.targetPath}\n`)
|
|
149
|
+
}
|
|
150
|
+
process.stdout.write(`Pass --force to overwrite.\n\n`)
|
|
151
|
+
|
|
152
|
+
// In non-interactive --yes mode with remaining conflicts, exit 5
|
|
153
|
+
if (!isTTY() && !args.yes && !args.force) {
|
|
154
|
+
return { code: 5, message: 'unresolved conflicts' }
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 7. Apply
|
|
159
|
+
const result = applyInstall(resolvedItems)
|
|
160
|
+
|
|
161
|
+
process.stdout.write(
|
|
162
|
+
`Done: ${result.installed} installed, ${result.updated} updated, ` +
|
|
163
|
+
`${result.skipped} up-to-date, ${result.conflicts} skipped (conflicts).\n`,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
return { code: 0 }
|
|
167
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
|
|
6
|
+
import { discover } from '../src/discover.js'
|
|
7
|
+
import { detectLinked } from '../src/linked.js'
|
|
8
|
+
import { planInstall, AUTO_GENERATED_BANNER } from '../src/plan.js'
|
|
9
|
+
import { applyInstall } from '../src/apply.js'
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Fixture helpers
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
const BANNER = AUTO_GENERATED_BANNER
|
|
16
|
+
|
|
17
|
+
const makeFixtureManifest = (pkgName: string, version: string, entries: Array<{
|
|
18
|
+
kind: 'skill' | 'instruction'
|
|
19
|
+
name: string
|
|
20
|
+
category?: 'package-specific' | 'multi-package'
|
|
21
|
+
}>) => ({
|
|
22
|
+
schemaVersion: 1,
|
|
23
|
+
package: pkgName,
|
|
24
|
+
version,
|
|
25
|
+
generatedAt: '2026-06-10T00:00:00Z',
|
|
26
|
+
canonicalRepo: 'https://github.com/owlmeans/common',
|
|
27
|
+
entries: entries.map(e => ({
|
|
28
|
+
kind: e.kind,
|
|
29
|
+
name: e.name,
|
|
30
|
+
category: e.category ?? 'package-specific',
|
|
31
|
+
file: e.kind === 'skill'
|
|
32
|
+
? `skills/${e.name}/SKILL.md`
|
|
33
|
+
: `instructions/${e.name}.instructions.md`,
|
|
34
|
+
canonicalPath: e.kind === 'skill'
|
|
35
|
+
? `.claude/skills/${e.name}/SKILL.md`
|
|
36
|
+
: `.github/instructions/${e.name}.instructions.md`,
|
|
37
|
+
})),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const writeFixturePackage = (
|
|
41
|
+
nmDir: string,
|
|
42
|
+
pkgName: string,
|
|
43
|
+
version: string,
|
|
44
|
+
entries: Array<{ kind: 'skill' | 'instruction'; name: string }>,
|
|
45
|
+
): void => {
|
|
46
|
+
const pkgDir = join(nmDir, '@owlmeans', pkgName.replace('@owlmeans/', ''))
|
|
47
|
+
const agentMetaDir = join(pkgDir, 'agent-meta')
|
|
48
|
+
mkdirSync(join(agentMetaDir, 'skills'), { recursive: true })
|
|
49
|
+
mkdirSync(join(agentMetaDir, 'instructions'), { recursive: true })
|
|
50
|
+
|
|
51
|
+
const manifest = makeFixtureManifest(pkgName, version, entries)
|
|
52
|
+
writeFileSync(join(agentMetaDir, 'manifest.json'), JSON.stringify(manifest, null, 2), 'utf8')
|
|
53
|
+
|
|
54
|
+
for (const e of entries) {
|
|
55
|
+
let content = `${BANNER}\n`
|
|
56
|
+
if (e.kind === 'skill') {
|
|
57
|
+
content += `---\nname: ${e.name}\n---\n# ${e.name}\nSkill content.\n`
|
|
58
|
+
const skillDir = join(agentMetaDir, 'skills', e.name)
|
|
59
|
+
mkdirSync(skillDir, { recursive: true })
|
|
60
|
+
writeFileSync(join(skillDir, 'SKILL.md'), content, 'utf8')
|
|
61
|
+
} else {
|
|
62
|
+
content += `---\ndescription: "${e.name}"\napplyTo: "**/*.ts"\n---\n# ${e.name}\n`
|
|
63
|
+
writeFileSync(join(agentMetaDir, 'instructions', `${e.name}.instructions.md`), content, 'utf8')
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Test setup
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
let tmpDir: string
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
tmpDir = join(tmpdir(), `agent-skills-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
|
76
|
+
mkdirSync(tmpDir, { recursive: true })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
afterEach(() => {
|
|
80
|
+
rmSync(tmpDir, { recursive: true, force: true })
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// discover()
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
describe('discover()', () => {
|
|
88
|
+
it('returns empty when no node_modules/@owlmeans exists', () => {
|
|
89
|
+
const entries = discover(tmpDir, { extras: false })
|
|
90
|
+
expect(entries).toEqual([])
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('discovers entries from node_modules', () => {
|
|
94
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
95
|
+
{ kind: 'skill', name: 'context' },
|
|
96
|
+
{ kind: 'instruction', name: 'context' },
|
|
97
|
+
])
|
|
98
|
+
const entries = discover(tmpDir, { extras: false })
|
|
99
|
+
expect(entries).toHaveLength(2)
|
|
100
|
+
expect(entries.some(e => e.kind === 'skill' && e.name === 'context')).toBe(true)
|
|
101
|
+
expect(entries.some(e => e.kind === 'instruction' && e.name === 'context')).toBe(true)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('deduplicates by (kind, name), keeping highest version', () => {
|
|
105
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.5', [
|
|
106
|
+
{ kind: 'skill', name: 'context' },
|
|
107
|
+
])
|
|
108
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/auth', '0.1.7', [
|
|
109
|
+
{ kind: 'skill', name: 'context' }, // same name, higher version
|
|
110
|
+
])
|
|
111
|
+
const entries = discover(tmpDir, { extras: false })
|
|
112
|
+
expect(entries).toHaveLength(1)
|
|
113
|
+
expect(entries[0].version).toBe('0.1.7')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('--only filter restricts by package name', () => {
|
|
117
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
118
|
+
{ kind: 'skill', name: 'context' },
|
|
119
|
+
])
|
|
120
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/auth', '0.1.7', [
|
|
121
|
+
{ kind: 'skill', name: 'auth' },
|
|
122
|
+
])
|
|
123
|
+
const entries = discover(tmpDir, { extras: false, only: ['@owlmeans/context'] })
|
|
124
|
+
expect(entries).toHaveLength(1)
|
|
125
|
+
expect(entries[0].name).toBe('context')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('--claude-only filter', () => {
|
|
129
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
130
|
+
{ kind: 'skill', name: 'context' },
|
|
131
|
+
{ kind: 'instruction', name: 'context' },
|
|
132
|
+
])
|
|
133
|
+
const entries = discover(tmpDir, { extras: false, claudeOnly: true })
|
|
134
|
+
expect(entries.every(e => e.kind === 'skill')).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('--copilot-only filter', () => {
|
|
138
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
139
|
+
{ kind: 'skill', name: 'context' },
|
|
140
|
+
{ kind: 'instruction', name: 'context' },
|
|
141
|
+
])
|
|
142
|
+
const entries = discover(tmpDir, { extras: false, copilotOnly: true })
|
|
143
|
+
expect(entries.every(e => e.kind === 'instruction')).toBe(true)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
148
|
+
// detectLinked()
|
|
149
|
+
// ---------------------------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
describe('detectLinked()', () => {
|
|
152
|
+
it('returns linked=false when no node_modules', () => {
|
|
153
|
+
const result = detectLinked(tmpDir)
|
|
154
|
+
expect(result.linked).toBe(false)
|
|
155
|
+
expect(result.evidence).toEqual([])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('returns linked=false for regular (non-symlinked) packages', () => {
|
|
159
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [])
|
|
160
|
+
const result = detectLinked(tmpDir)
|
|
161
|
+
expect(result.linked).toBe(false)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('detects symlinked packages', () => {
|
|
165
|
+
const nmScope = join(tmpDir, 'node_modules', '@owlmeans')
|
|
166
|
+
mkdirSync(nmScope, { recursive: true })
|
|
167
|
+
// Create real package elsewhere and symlink it
|
|
168
|
+
const realPkg = join(tmpDir, 'real-pkg')
|
|
169
|
+
mkdirSync(realPkg)
|
|
170
|
+
symlinkSync(realPkg, join(nmScope, 'context'))
|
|
171
|
+
|
|
172
|
+
const result = detectLinked(tmpDir)
|
|
173
|
+
expect(result.linked).toBe(true)
|
|
174
|
+
expect(result.evidence).toContain('@owlmeans/context')
|
|
175
|
+
})
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// planInstall()
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
describe('planInstall()', () => {
|
|
183
|
+
it('marks missing targets as install', () => {
|
|
184
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
185
|
+
{ kind: 'skill', name: 'context' },
|
|
186
|
+
])
|
|
187
|
+
const entries = discover(tmpDir, { extras: false })
|
|
188
|
+
const items = planInstall(entries, tmpDir)
|
|
189
|
+
expect(items).toHaveLength(1)
|
|
190
|
+
expect(items[0].action).toBe('install')
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('marks identical files as skip-uptodate', () => {
|
|
194
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
195
|
+
{ kind: 'skill', name: 'context' },
|
|
196
|
+
])
|
|
197
|
+
const entries = discover(tmpDir, { extras: false })
|
|
198
|
+
// Write same content to target
|
|
199
|
+
const targetDir = join(tmpDir, '.claude', 'skills', 'context')
|
|
200
|
+
mkdirSync(targetDir, { recursive: true })
|
|
201
|
+
const source = readFileSync(entries[0].sourcePath, 'utf8')
|
|
202
|
+
writeFileSync(join(targetDir, 'SKILL.md'), source, 'utf8')
|
|
203
|
+
|
|
204
|
+
const items = planInstall(entries, tmpDir)
|
|
205
|
+
expect(items[0].action).toBe('skip-uptodate')
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('marks managed (banner) changed files as update', () => {
|
|
209
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
210
|
+
{ kind: 'skill', name: 'context' },
|
|
211
|
+
])
|
|
212
|
+
const entries = discover(tmpDir, { extras: false })
|
|
213
|
+
const targetDir = join(tmpDir, '.claude', 'skills', 'context')
|
|
214
|
+
mkdirSync(targetDir, { recursive: true })
|
|
215
|
+
// Write old managed content (has banner, but different)
|
|
216
|
+
writeFileSync(join(targetDir, 'SKILL.md'), `${BANNER}\n# Old content\n`, 'utf8')
|
|
217
|
+
|
|
218
|
+
const items = planInstall(entries, tmpDir)
|
|
219
|
+
expect(items[0].action).toBe('update')
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
it('marks locally-edited (no banner) files as conflict', () => {
|
|
223
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
224
|
+
{ kind: 'skill', name: 'context' },
|
|
225
|
+
])
|
|
226
|
+
const entries = discover(tmpDir, { extras: false })
|
|
227
|
+
const targetDir = join(tmpDir, '.claude', 'skills', 'context')
|
|
228
|
+
mkdirSync(targetDir, { recursive: true })
|
|
229
|
+
writeFileSync(join(targetDir, 'SKILL.md'), '# My custom skill\n', 'utf8')
|
|
230
|
+
|
|
231
|
+
const items = planInstall(entries, tmpDir)
|
|
232
|
+
expect(items[0].action).toBe('conflict')
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('--force upgrades conflict to update', () => {
|
|
236
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
237
|
+
{ kind: 'skill', name: 'context' },
|
|
238
|
+
])
|
|
239
|
+
const entries = discover(tmpDir, { extras: false })
|
|
240
|
+
const targetDir = join(tmpDir, '.claude', 'skills', 'context')
|
|
241
|
+
mkdirSync(targetDir, { recursive: true })
|
|
242
|
+
writeFileSync(join(targetDir, 'SKILL.md'), '# My custom skill\n', 'utf8')
|
|
243
|
+
|
|
244
|
+
const items = planInstall(entries, tmpDir, { force: true })
|
|
245
|
+
expect(items[0].action).toBe('update')
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// applyInstall() — idempotency
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe('applyInstall()', () => {
|
|
254
|
+
it('writes install items', () => {
|
|
255
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
256
|
+
{ kind: 'skill', name: 'context' },
|
|
257
|
+
{ kind: 'instruction', name: 'context' },
|
|
258
|
+
])
|
|
259
|
+
const entries = discover(tmpDir, { extras: false })
|
|
260
|
+
const items = planInstall(entries, tmpDir)
|
|
261
|
+
const result = applyInstall(items)
|
|
262
|
+
expect(result.installed).toBe(2)
|
|
263
|
+
expect(existsSync(join(tmpDir, '.claude', 'skills', 'context', 'SKILL.md'))).toBe(true)
|
|
264
|
+
expect(existsSync(join(tmpDir, '.github', 'instructions', 'context.instructions.md'))).toBe(true)
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
it('is idempotent — second run yields all skip-uptodate', () => {
|
|
268
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
269
|
+
{ kind: 'skill', name: 'context' },
|
|
270
|
+
])
|
|
271
|
+
const entries = discover(tmpDir, { extras: false })
|
|
272
|
+
// First run
|
|
273
|
+
applyInstall(planInstall(entries, tmpDir))
|
|
274
|
+
// Second run
|
|
275
|
+
const items2 = planInstall(entries, tmpDir)
|
|
276
|
+
expect(items2[0].action).toBe('skip-uptodate')
|
|
277
|
+
const result2 = applyInstall(items2)
|
|
278
|
+
expect(result2.installed).toBe(0)
|
|
279
|
+
expect(result2.skipped).toBe(1)
|
|
280
|
+
})
|
|
281
|
+
|
|
282
|
+
it('does not write conflict items', () => {
|
|
283
|
+
writeFixturePackage(join(tmpDir, 'node_modules'), '@owlmeans/context', '0.1.7', [
|
|
284
|
+
{ kind: 'skill', name: 'context' },
|
|
285
|
+
])
|
|
286
|
+
const entries = discover(tmpDir, { extras: false })
|
|
287
|
+
const targetDir = join(tmpDir, '.claude', 'skills', 'context')
|
|
288
|
+
mkdirSync(targetDir, { recursive: true })
|
|
289
|
+
const localContent = '# My custom skill\n'
|
|
290
|
+
writeFileSync(join(targetDir, 'SKILL.md'), localContent, 'utf8')
|
|
291
|
+
|
|
292
|
+
const items = planInstall(entries, tmpDir)
|
|
293
|
+
applyInstall(items)
|
|
294
|
+
// File should remain unchanged
|
|
295
|
+
expect(readFileSync(join(targetDir, 'SKILL.md'), 'utf8')).toBe(localContent)
|
|
296
|
+
})
|
|
297
|
+
})
|
package/tsconfig.json
ADDED