@otto-assistant/otto 0.1.0 → 0.1.1

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.
Files changed (72) hide show
  1. package/dist/cli.js +406 -12
  2. package/dist/cli.js.map +1 -1
  3. package/dist/config.test.js +9 -9
  4. package/dist/config.test.js.map +1 -1
  5. package/dist/detect.test.js +4 -3
  6. package/dist/detect.test.js.map +1 -1
  7. package/dist/docker.d.ts +7 -0
  8. package/dist/docker.d.ts.map +1 -0
  9. package/dist/docker.js +17 -0
  10. package/dist/docker.js.map +1 -0
  11. package/dist/docker.test.d.ts +2 -0
  12. package/dist/docker.test.d.ts.map +1 -0
  13. package/dist/docker.test.js +12 -0
  14. package/dist/docker.test.js.map +1 -0
  15. package/dist/health.d.ts +4 -0
  16. package/dist/health.d.ts.map +1 -1
  17. package/dist/health.js +40 -1
  18. package/dist/health.js.map +1 -1
  19. package/dist/health.test.js +21 -2
  20. package/dist/health.test.js.map +1 -1
  21. package/dist/index.d.ts +11 -3
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +6 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/installer.test.js +2 -2
  26. package/dist/installer.test.js.map +1 -1
  27. package/dist/lifecycle.d.ts +6 -0
  28. package/dist/lifecycle.d.ts.map +1 -1
  29. package/dist/lifecycle.js +26 -11
  30. package/dist/lifecycle.js.map +1 -1
  31. package/dist/lifecycle.test.js +5 -4
  32. package/dist/lifecycle.test.js.map +1 -1
  33. package/dist/manifest.js +4 -4
  34. package/dist/manifest.js.map +1 -1
  35. package/dist/skills-baseline.d.ts +7 -0
  36. package/dist/skills-baseline.d.ts.map +1 -0
  37. package/dist/skills-baseline.js +9 -0
  38. package/dist/skills-baseline.js.map +1 -0
  39. package/dist/skills.d.ts +110 -0
  40. package/dist/skills.d.ts.map +1 -0
  41. package/dist/skills.js +429 -0
  42. package/dist/skills.js.map +1 -0
  43. package/dist/skills.test.d.ts +2 -0
  44. package/dist/skills.test.d.ts.map +1 -0
  45. package/dist/skills.test.js +416 -0
  46. package/dist/skills.test.js.map +1 -0
  47. package/dist/tenant.d.ts +13 -0
  48. package/dist/tenant.d.ts.map +1 -0
  49. package/dist/tenant.js +105 -0
  50. package/dist/tenant.js.map +1 -0
  51. package/dist/tenant.test.d.ts +2 -0
  52. package/dist/tenant.test.d.ts.map +1 -0
  53. package/dist/tenant.test.js +37 -0
  54. package/dist/tenant.test.js.map +1 -0
  55. package/package.json +15 -5
  56. package/src/cli.ts +457 -12
  57. package/src/config.test.ts +9 -9
  58. package/src/detect.test.ts +4 -3
  59. package/src/docker.test.ts +12 -0
  60. package/src/docker.ts +23 -0
  61. package/src/health.test.ts +23 -1
  62. package/src/health.ts +45 -1
  63. package/src/index.ts +37 -3
  64. package/src/installer.test.ts +2 -2
  65. package/src/lifecycle.test.ts +6 -5
  66. package/src/lifecycle.ts +29 -10
  67. package/src/manifest.ts +4 -4
  68. package/src/skills-baseline.ts +14 -0
  69. package/src/skills.test.ts +503 -0
  70. package/src/skills.ts +512 -0
  71. package/src/tenant.test.ts +49 -0
  72. 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
+ })