@otto-assistant/otto 0.1.0 → 0.1.2
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 +142 -0
- package/dist/cli.js +406 -12
- package/dist/cli.js.map +1 -1
- package/dist/config.test.js +9 -9
- package/dist/config.test.js.map +1 -1
- package/dist/detect.test.js +4 -3
- package/dist/detect.test.js.map +1 -1
- package/dist/docker.d.ts +7 -0
- package/dist/docker.d.ts.map +1 -0
- package/dist/docker.js +17 -0
- package/dist/docker.js.map +1 -0
- package/dist/docker.test.d.ts +2 -0
- package/dist/docker.test.d.ts.map +1 -0
- package/dist/docker.test.js +12 -0
- package/dist/docker.test.js.map +1 -0
- package/dist/health.d.ts +4 -0
- package/dist/health.d.ts.map +1 -1
- package/dist/health.js +40 -1
- package/dist/health.js.map +1 -1
- package/dist/health.test.js +21 -2
- package/dist/health.test.js.map +1 -1
- package/dist/index.d.ts +11 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/installer.test.js +2 -2
- package/dist/installer.test.js.map +1 -1
- package/dist/lifecycle.d.ts +6 -0
- package/dist/lifecycle.d.ts.map +1 -1
- package/dist/lifecycle.js +26 -11
- package/dist/lifecycle.js.map +1 -1
- package/dist/lifecycle.test.js +5 -4
- package/dist/lifecycle.test.js.map +1 -1
- package/dist/manifest.js +4 -4
- package/dist/manifest.js.map +1 -1
- package/dist/skills-baseline.d.ts +7 -0
- package/dist/skills-baseline.d.ts.map +1 -0
- package/dist/skills-baseline.js +9 -0
- package/dist/skills-baseline.js.map +1 -0
- package/dist/skills.d.ts +110 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +429 -0
- package/dist/skills.js.map +1 -0
- package/dist/skills.test.d.ts +2 -0
- package/dist/skills.test.d.ts.map +1 -0
- package/dist/skills.test.js +416 -0
- package/dist/skills.test.js.map +1 -0
- package/dist/tenant.d.ts +13 -0
- package/dist/tenant.d.ts.map +1 -0
- package/dist/tenant.js +105 -0
- package/dist/tenant.js.map +1 -0
- package/dist/tenant.test.d.ts +2 -0
- package/dist/tenant.test.d.ts.map +1 -0
- package/dist/tenant.test.js +37 -0
- package/dist/tenant.test.js.map +1 -0
- package/package.json +15 -5
- package/src/cli.ts +457 -12
- package/src/config.test.ts +9 -9
- package/src/detect.test.ts +4 -3
- package/src/docker.test.ts +12 -0
- package/src/docker.ts +23 -0
- package/src/health.test.ts +23 -1
- package/src/health.ts +45 -1
- package/src/index.ts +37 -3
- package/src/installer.test.ts +2 -2
- package/src/lifecycle.test.ts +6 -5
- package/src/lifecycle.ts +29 -10
- package/src/manifest.ts +4 -4
- package/src/skills-baseline.ts +14 -0
- package/src/skills.test.ts +503 -0
- package/src/skills.ts +512 -0
- package/src/tenant.test.ts +49 -0
- package/src/tenant.ts +120 -0
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
|
|
2
|
+
import fs from "node:fs"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
import os from "node:os"
|
|
5
|
+
import { execFileSync } from "node:child_process"
|
|
6
|
+
import {
|
|
7
|
+
SKILLS_INDEX_PATH,
|
|
8
|
+
OPENCODE_SKILLS_DIR,
|
|
9
|
+
DEFAULT_SKILL_REPOS,
|
|
10
|
+
parseSkillMd,
|
|
11
|
+
listInstalledSkills,
|
|
12
|
+
removeSkill,
|
|
13
|
+
loadSkillsIndex,
|
|
14
|
+
saveSkillsIndex,
|
|
15
|
+
isIndexStale,
|
|
16
|
+
searchSkills,
|
|
17
|
+
getAllIndexedSkills,
|
|
18
|
+
getConfiguredRepos,
|
|
19
|
+
installSkillFromIndex,
|
|
20
|
+
installSkillsBaseline,
|
|
21
|
+
fetchRepoSkillsIndex,
|
|
22
|
+
type SkillIndexEntry,
|
|
23
|
+
type SkillsIndex,
|
|
24
|
+
} from "./skills.js"
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
function makeTmp(prefix = "otto-skills-test-"): string {
|
|
31
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix))
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function writeSkillMd(dir: string, content: string): void {
|
|
35
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
36
|
+
fs.writeFileSync(path.join(dir, "SKILL.md"), content, "utf-8")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const VALID_SKILL_MD = `---
|
|
40
|
+
name: my-skill
|
|
41
|
+
description: A test skill
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
# My Skill
|
|
45
|
+
|
|
46
|
+
Body text here.
|
|
47
|
+
`
|
|
48
|
+
|
|
49
|
+
const VALID_SKILL_MD_WITH_META = `---
|
|
50
|
+
name: my-skill
|
|
51
|
+
description: A test skill with metadata
|
|
52
|
+
metadata:
|
|
53
|
+
author: otto
|
|
54
|
+
version: "1.0"
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
# My Skill
|
|
58
|
+
|
|
59
|
+
Body text here.
|
|
60
|
+
`
|
|
61
|
+
|
|
62
|
+
const NO_FRONTMATTER_MD = `# No frontmatter
|
|
63
|
+
|
|
64
|
+
Just a regular markdown file.
|
|
65
|
+
`
|
|
66
|
+
|
|
67
|
+
const MISSING_NAME_MD = `---
|
|
68
|
+
description: Has description but no name
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
# No name
|
|
72
|
+
`
|
|
73
|
+
|
|
74
|
+
const MISSING_DESC_MD = `---
|
|
75
|
+
name: has-name
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
# No description
|
|
79
|
+
`
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Constants
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
describe("skills constants", () => {
|
|
86
|
+
it("DEFAULT_SKILL_REPOS includes key repos", () => {
|
|
87
|
+
expect(DEFAULT_SKILL_REPOS).toContain("otto-assistant/skills")
|
|
88
|
+
expect(DEFAULT_SKILL_REPOS).toContain("anthropics/skills")
|
|
89
|
+
expect(DEFAULT_SKILL_REPOS).toContain("vercel-labs/agent-skills")
|
|
90
|
+
expect(DEFAULT_SKILL_REPOS.length).toBeGreaterThanOrEqual(3)
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("SKILLS_INDEX_PATH returns ~/.cache/otto/skills-index.json", () => {
|
|
94
|
+
const home = process.env.HOME || process.env.USERPROFILE || "/root"
|
|
95
|
+
expect(SKILLS_INDEX_PATH()).toContain(".cache/otto/skills-index.json")
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it("OPENCODE_SKILLS_DIR returns ~/.config/opencode/skills", () => {
|
|
99
|
+
expect(OPENCODE_SKILLS_DIR()).toContain(".config/opencode/skills")
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Parser
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
describe("parseSkillMd", () => {
|
|
108
|
+
it("parses valid SKILL.md with required fields only", () => {
|
|
109
|
+
const result = parseSkillMd(VALID_SKILL_MD)
|
|
110
|
+
expect(result).not.toBeNull()
|
|
111
|
+
expect(result!.name).toBe("my-skill")
|
|
112
|
+
expect(result!.description).toBe("A test skill")
|
|
113
|
+
expect(result!.metadata).toBeUndefined()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it("parses valid SKILL.md with optional metadata", () => {
|
|
117
|
+
const result = parseSkillMd(VALID_SKILL_MD_WITH_META)
|
|
118
|
+
expect(result).not.toBeNull()
|
|
119
|
+
expect(result!.name).toBe("my-skill")
|
|
120
|
+
expect(result!.description).toBe("A test skill with metadata")
|
|
121
|
+
expect(result!.metadata).toEqual({ author: "otto", version: "1.0" })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it("returns null for missing frontmatter", () => {
|
|
125
|
+
expect(parseSkillMd(NO_FRONTMATTER_MD)).toBeNull()
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it("returns null for missing name", () => {
|
|
129
|
+
expect(parseSkillMd(MISSING_NAME_MD)).toBeNull()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it("returns null for missing description", () => {
|
|
133
|
+
expect(parseSkillMd(MISSING_DESC_MD)).toBeNull()
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("returns null for empty string", () => {
|
|
137
|
+
expect(parseSkillMd("")).toBeNull()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Local skill discovery
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
describe("listInstalledSkills", () => {
|
|
146
|
+
let tmpBase: string
|
|
147
|
+
|
|
148
|
+
beforeEach(() => {
|
|
149
|
+
tmpBase = makeTmp()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
afterEach(() => {
|
|
153
|
+
fs.rmSync(tmpBase, { recursive: true, force: true })
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it("lists skills from given base dir", () => {
|
|
157
|
+
writeSkillMd(path.join(tmpBase, "installed-skill"), VALID_SKILL_MD)
|
|
158
|
+
const skills = listInstalledSkills(tmpBase)
|
|
159
|
+
expect(skills).toHaveLength(1)
|
|
160
|
+
expect(skills[0].name).toBe("my-skill")
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it("returns empty array for non-existent dir", () => {
|
|
164
|
+
const skills = listInstalledSkills(path.join(tmpBase, "nonexistent"))
|
|
165
|
+
expect(skills).toEqual([])
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Remove skill
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
describe("removeSkill", () => {
|
|
174
|
+
let tmpTarget: string
|
|
175
|
+
|
|
176
|
+
beforeEach(() => {
|
|
177
|
+
tmpTarget = makeTmp()
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
afterEach(() => {
|
|
181
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true })
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it("deletes installed skill", () => {
|
|
185
|
+
writeSkillMd(path.join(tmpTarget, "remove-me"), VALID_SKILL_MD)
|
|
186
|
+
expect(fs.existsSync(path.join(tmpTarget, "remove-me", "SKILL.md"))).toBe(true)
|
|
187
|
+
|
|
188
|
+
const result = removeSkill("remove-me", tmpTarget)
|
|
189
|
+
expect(result).toBe(true)
|
|
190
|
+
expect(fs.existsSync(path.join(tmpTarget, "remove-me"))).toBe(false)
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it("returns false if not installed", () => {
|
|
194
|
+
const result = removeSkill("not-there", tmpTarget)
|
|
195
|
+
expect(result).toBe(false)
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------------------
|
|
200
|
+
// Skills Index — load, save, stale check
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
describe("Skills Index", () => {
|
|
204
|
+
let tmpDir: string
|
|
205
|
+
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
tmpDir = makeTmp()
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
fs.rmSync(tmpDir, { recursive: true, force: true })
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it("loadSkillsIndex returns empty index when file missing", () => {
|
|
215
|
+
const indexPath = path.join(tmpDir, "skills-index.json")
|
|
216
|
+
const idx = loadSkillsIndex(indexPath)
|
|
217
|
+
expect(idx.version).toBe(1)
|
|
218
|
+
expect(idx.repos).toEqual({})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it("saveSkillsIndex + loadSkillsIndex roundtrip", () => {
|
|
222
|
+
const indexPath = path.join(tmpDir, "skills-index.json")
|
|
223
|
+
const idx: SkillsIndex = {
|
|
224
|
+
version: 1,
|
|
225
|
+
updated: new Date().toISOString(),
|
|
226
|
+
repos: {
|
|
227
|
+
"anthropics/skills": {
|
|
228
|
+
fetched: new Date().toISOString(),
|
|
229
|
+
skills: [
|
|
230
|
+
{ name: "pdf", description: "Create PDF files", source: "anthropics/skills", path: "skills/pdf" },
|
|
231
|
+
],
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
saveSkillsIndex(idx, indexPath)
|
|
236
|
+
const loaded = loadSkillsIndex(indexPath)
|
|
237
|
+
expect(loaded.repos["anthropics/skills"].skills).toHaveLength(1)
|
|
238
|
+
expect(loaded.repos["anthropics/skills"].skills[0].name).toBe("pdf")
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("isIndexStale returns true for old timestamps", () => {
|
|
242
|
+
const oldDate = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString()
|
|
243
|
+
expect(isIndexStale(oldDate, 24)).toBe(true)
|
|
244
|
+
expect(isIndexStale(new Date().toISOString(), 24)).toBe(false)
|
|
245
|
+
expect(isIndexStale("", 24)).toBe(true)
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
// ---------------------------------------------------------------------------
|
|
250
|
+
// Search
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
|
|
253
|
+
describe("searchSkills", () => {
|
|
254
|
+
let tmpIndex: string
|
|
255
|
+
|
|
256
|
+
beforeEach(() => {
|
|
257
|
+
tmpIndex = path.join(makeTmp(), "skills-index.json")
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
fs.rmSync(path.dirname(tmpIndex), { recursive: true, force: true })
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
function makeTestIndex(): SkillsIndex {
|
|
265
|
+
return {
|
|
266
|
+
version: 1,
|
|
267
|
+
updated: new Date().toISOString(),
|
|
268
|
+
repos: {
|
|
269
|
+
"anthropics/skills": {
|
|
270
|
+
fetched: new Date().toISOString(),
|
|
271
|
+
skills: [
|
|
272
|
+
{ name: "frontend-design", description: "Design web frontends", source: "anthropics/skills", path: "skills/frontend-design" },
|
|
273
|
+
{ name: "pdf", description: "Create PDF documents", source: "anthropics/skills", path: "skills/pdf" },
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
"vercel-labs/agent-skills": {
|
|
277
|
+
fetched: new Date().toISOString(),
|
|
278
|
+
skills: [
|
|
279
|
+
{ name: "react-best-practices", description: "React and Next.js performance patterns", source: "vercel-labs/agent-skills", path: "skills/react-best-practices" },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
it("finds skills by name substring", () => {
|
|
287
|
+
saveSkillsIndex(makeTestIndex(), tmpIndex)
|
|
288
|
+
const results = searchSkills("react", tmpIndex)
|
|
289
|
+
expect(results).toHaveLength(1)
|
|
290
|
+
expect(results[0].name).toBe("react-best-practices")
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it("finds skills by description substring", () => {
|
|
294
|
+
saveSkillsIndex(makeTestIndex(), tmpIndex)
|
|
295
|
+
const results = searchSkills("PDF", tmpIndex)
|
|
296
|
+
expect(results).toHaveLength(1)
|
|
297
|
+
expect(results[0].name).toBe("pdf")
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it("returns empty array for no matches", () => {
|
|
301
|
+
saveSkillsIndex(makeTestIndex(), tmpIndex)
|
|
302
|
+
const results = searchSkills("nonexistent-xyz", tmpIndex)
|
|
303
|
+
expect(results).toHaveLength(0)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it("finds skills across multiple repos", () => {
|
|
307
|
+
saveSkillsIndex(makeTestIndex(), tmpIndex)
|
|
308
|
+
const results = searchSkills("design", tmpIndex)
|
|
309
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
310
|
+
})
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
// getAllIndexedSkills
|
|
315
|
+
// ---------------------------------------------------------------------------
|
|
316
|
+
|
|
317
|
+
describe("getAllIndexedSkills", () => {
|
|
318
|
+
let tmpIndex: string
|
|
319
|
+
|
|
320
|
+
beforeEach(() => {
|
|
321
|
+
tmpIndex = path.join(makeTmp(), "skills-index.json")
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
afterEach(() => {
|
|
325
|
+
fs.rmSync(path.dirname(tmpIndex), { recursive: true, force: true })
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it("returns all skills from all repos", () => {
|
|
329
|
+
const idx: SkillsIndex = {
|
|
330
|
+
version: 1,
|
|
331
|
+
updated: new Date().toISOString(),
|
|
332
|
+
repos: {
|
|
333
|
+
"anthropics/skills": {
|
|
334
|
+
fetched: new Date().toISOString(),
|
|
335
|
+
skills: [
|
|
336
|
+
{ name: "pdf", description: "Create PDFs", source: "anthropics/skills", path: "skills/pdf" },
|
|
337
|
+
],
|
|
338
|
+
},
|
|
339
|
+
"vercel-labs/agent-skills": {
|
|
340
|
+
fetched: new Date().toISOString(),
|
|
341
|
+
skills: [
|
|
342
|
+
{ name: "react-best", description: "React patterns", source: "vercel-labs/agent-skills", path: "skills/react-best" },
|
|
343
|
+
],
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
}
|
|
347
|
+
saveSkillsIndex(idx, tmpIndex)
|
|
348
|
+
const all = getAllIndexedSkills(tmpIndex)
|
|
349
|
+
expect(all).toHaveLength(2)
|
|
350
|
+
})
|
|
351
|
+
})
|
|
352
|
+
|
|
353
|
+
// ---------------------------------------------------------------------------
|
|
354
|
+
// installSkillFromIndex (unit tests — no network)
|
|
355
|
+
// ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
describe("installSkillFromIndex", () => {
|
|
358
|
+
let tmpTarget: string
|
|
359
|
+
let tmpIndex: string
|
|
360
|
+
|
|
361
|
+
beforeEach(() => {
|
|
362
|
+
tmpTarget = makeTmp()
|
|
363
|
+
tmpIndex = path.join(makeTmp(), "skills-index.json")
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
afterEach(() => {
|
|
367
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true })
|
|
368
|
+
fs.rmSync(path.dirname(tmpIndex), { recursive: true, force: true })
|
|
369
|
+
})
|
|
370
|
+
|
|
371
|
+
it("returns false if skill not found in index", () => {
|
|
372
|
+
const idx: SkillsIndex = { version: 1, updated: "", repos: {} }
|
|
373
|
+
saveSkillsIndex(idx, tmpIndex)
|
|
374
|
+
|
|
375
|
+
const result = installSkillFromIndex("nonexistent", tmpTarget, tmpIndex)
|
|
376
|
+
expect(result).toBe(false)
|
|
377
|
+
})
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
// installSkillsBaseline
|
|
382
|
+
// ---------------------------------------------------------------------------
|
|
383
|
+
|
|
384
|
+
describe("installSkillsBaseline", () => {
|
|
385
|
+
let tmpTarget: string
|
|
386
|
+
let tmpIndex: string
|
|
387
|
+
|
|
388
|
+
beforeEach(() => {
|
|
389
|
+
tmpTarget = makeTmp()
|
|
390
|
+
tmpIndex = path.join(makeTmp(), "skills-index.json")
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
afterEach(() => {
|
|
394
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true })
|
|
395
|
+
fs.rmSync(path.dirname(tmpIndex), { recursive: true, force: true })
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
it("reports installed, already present, and failed skills", () => {
|
|
399
|
+
// preinstall one skill
|
|
400
|
+
writeSkillMd(
|
|
401
|
+
path.join(tmpTarget, "existing-skill"),
|
|
402
|
+
`---\nname: existing-skill\ndescription: Existing\n---\n`,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
const idx: SkillsIndex = {
|
|
406
|
+
version: 1,
|
|
407
|
+
updated: new Date().toISOString(),
|
|
408
|
+
repos: {
|
|
409
|
+
"otto-assistant/skills": {
|
|
410
|
+
fetched: new Date().toISOString(),
|
|
411
|
+
skills: [
|
|
412
|
+
{
|
|
413
|
+
name: "otto-subagent-threads",
|
|
414
|
+
description: "Enforce Discord threads",
|
|
415
|
+
source: "otto-assistant/skills",
|
|
416
|
+
path: "skills/otto-subagent-threads",
|
|
417
|
+
},
|
|
418
|
+
],
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
}
|
|
422
|
+
saveSkillsIndex(idx, tmpIndex)
|
|
423
|
+
|
|
424
|
+
const result = installSkillsBaseline([
|
|
425
|
+
"existing-skill",
|
|
426
|
+
"otto-subagent-threads",
|
|
427
|
+
"missing-skill",
|
|
428
|
+
], tmpTarget, tmpIndex)
|
|
429
|
+
|
|
430
|
+
expect(result.alreadyPresent).toContain("existing-skill")
|
|
431
|
+
if (hasGhAuth()) {
|
|
432
|
+
expect(result.installed).toContain("otto-subagent-threads")
|
|
433
|
+
} else {
|
|
434
|
+
expect(result.failed).toContain("otto-subagent-threads")
|
|
435
|
+
}
|
|
436
|
+
expect(result.failed).toContain("missing-skill")
|
|
437
|
+
}, 20_000)
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Integration tests (require gh auth)
|
|
442
|
+
// ---------------------------------------------------------------------------
|
|
443
|
+
|
|
444
|
+
function hasGhAuth(): boolean {
|
|
445
|
+
try {
|
|
446
|
+
execFileSync("gh", ["auth", "status"], { encoding: "utf-8", stdio: "pipe" })
|
|
447
|
+
return true
|
|
448
|
+
} catch {
|
|
449
|
+
return false
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
describe("GitHub API integration", () => {
|
|
454
|
+
it("fetchRepoSkillsIndex indexes skills from otto-assistant/skills", () => {
|
|
455
|
+
if (!hasGhAuth()) return
|
|
456
|
+
const entries = fetchRepoSkillsIndex("otto-assistant/skills")
|
|
457
|
+
expect(entries.length).toBeGreaterThanOrEqual(3)
|
|
458
|
+
for (const entry of entries) {
|
|
459
|
+
expect(entry.name).toBeTruthy()
|
|
460
|
+
expect(entry.description).toBeTruthy()
|
|
461
|
+
expect(entry.source).toBe("otto-assistant/skills")
|
|
462
|
+
}
|
|
463
|
+
}, 15_000)
|
|
464
|
+
|
|
465
|
+
it("fetchRepoSkillsIndex indexes skills from anthropics/skills", () => {
|
|
466
|
+
if (!hasGhAuth()) return
|
|
467
|
+
const entries = fetchRepoSkillsIndex("anthropics/skills")
|
|
468
|
+
expect(entries.length).toBeGreaterThanOrEqual(5)
|
|
469
|
+
for (const entry of entries) {
|
|
470
|
+
expect(entry.name).toBeTruthy()
|
|
471
|
+
expect(entry.description).toBeTruthy()
|
|
472
|
+
}
|
|
473
|
+
}, 30_000)
|
|
474
|
+
|
|
475
|
+
it("installSkillFromIndex installs a real skill", () => {
|
|
476
|
+
if (!hasGhAuth()) return
|
|
477
|
+
const tmpTarget = makeTmp()
|
|
478
|
+
const tmpIdx = path.join(makeTmp(), "skills-index.json")
|
|
479
|
+
try {
|
|
480
|
+
const idx: SkillsIndex = {
|
|
481
|
+
version: 1,
|
|
482
|
+
updated: new Date().toISOString(),
|
|
483
|
+
repos: {
|
|
484
|
+
"otto-assistant/skills": {
|
|
485
|
+
fetched: new Date().toISOString(),
|
|
486
|
+
skills: [
|
|
487
|
+
{ name: "otto-subagent-threads", description: "Enforce Discord threads", source: "otto-assistant/skills", path: "skills/otto-subagent-threads" },
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
}
|
|
492
|
+
saveSkillsIndex(idx, tmpIdx)
|
|
493
|
+
|
|
494
|
+
const result = installSkillFromIndex("otto-subagent-threads", tmpTarget, tmpIdx)
|
|
495
|
+
expect(result).toBe(true)
|
|
496
|
+
const skillMd = fs.readFileSync(path.join(tmpTarget, "otto-subagent-threads", "SKILL.md"), "utf-8")
|
|
497
|
+
expect(skillMd).toContain("name: otto-subagent-threads")
|
|
498
|
+
} finally {
|
|
499
|
+
fs.rmSync(tmpTarget, { recursive: true, force: true })
|
|
500
|
+
fs.rmSync(path.dirname(tmpIdx), { recursive: true, force: true })
|
|
501
|
+
}
|
|
502
|
+
}, 15_000)
|
|
503
|
+
})
|