@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
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@otto-assistant/otto",
3
- "version": "0.1.0",
4
- "description": "Otto — terminal UI distribution wrapper for opencode + kimaki + opencode-agent-memory",
3
+ "version": "0.1.1",
4
+ "description": "Otto — terminal UI distribution wrapper for opencode + kimaki + mempalace",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "otto": "./dist/cli.js"
@@ -15,8 +15,17 @@
15
15
  "default": "./dist/index.js"
16
16
  }
17
17
  },
18
- "files": ["src", "dist"],
19
- "keywords": ["otto", "opencode", "kimaki", "ai-agent", "distribution"],
18
+ "files": [
19
+ "src",
20
+ "dist"
21
+ ],
22
+ "keywords": [
23
+ "otto",
24
+ "opencode",
25
+ "kimaki",
26
+ "ai-agent",
27
+ "distribution"
28
+ ],
20
29
  "license": "MIT",
21
30
  "repository": {
22
31
  "type": "git",
@@ -25,6 +34,7 @@
25
34
  "scripts": {
26
35
  "build": "rm -rf dist *.tsbuildinfo && tsc && chmod +x dist/cli.js",
27
36
  "dev": "tsc --watch",
37
+ "build:artifacts": "./scripts/build-local-artifacts.sh",
28
38
  "prepublishOnly": "pnpm build",
29
39
  "test": "vitest run",
30
40
  "test:watch": "vitest"
@@ -34,7 +44,7 @@
34
44
  "picocolors": "^1.1.1"
35
45
  },
36
46
  "devDependencies": {
37
- "@types/node": "^22.10.0",
47
+ "@types/node": "^22.19.17",
38
48
  "typescript": "^5.7.3",
39
49
  "vitest": "^3.2.0"
40
50
  }
package/src/cli.ts CHANGED
@@ -15,9 +15,28 @@ import {
15
15
  type OpenCodeConfig,
16
16
  } from "./config.js"
17
17
  import { installMissingPackages, upgradePackage, planStableUpgrades } from "./installer.js"
18
+ import fs from "node:fs"
19
+ import path from "node:path"
18
20
  import { hasKimakiBinary, restartKimaki } from "./lifecycle.js"
19
- import { checkPackagePresence, checkConfigHealth, checkDirectoryHealth } from "./health.js"
21
+ import { checkPackagePresence, checkConfigHealth, checkDirectoryHealth, checkTenantHealth } from "./health.js"
20
22
  import { syncUpstreams } from "./sync.js"
23
+ import { runCompose } from "./docker.js"
24
+ import { ensureTenantScaffold, resolveTenantMode } from "./tenant.js"
25
+ import {
26
+ searchSkills,
27
+ getAllIndexedSkills,
28
+ listInstalledSkills,
29
+ installSkillFromIndex,
30
+ installSkillsBaseline,
31
+ removeSkill,
32
+ ensureSkillsIndex,
33
+ getConfiguredRepos,
34
+ loadSkillsIndex,
35
+ DEFAULT_SKILL_REPOS,
36
+ type SkillIndexEntry,
37
+ type SkillMeta,
38
+ } from "./skills.js"
39
+ import { GENTLEMAN_SKILLS_BASELINE } from "./skills-baseline.js"
21
40
 
22
41
  const args = process.argv.slice(2)
23
42
  const command = args[0] ?? ""
@@ -82,7 +101,23 @@ async function cmdInstall(): Promise<void> {
82
101
  console.log("Created otto-subagent-threads skill")
83
102
  }
84
103
 
85
- // 5. Restart kimaki if needed but NOT if running inside kimaki
104
+ // 5. Install Otto-core skills from skills repo (best-effort)
105
+ try {
106
+ ensureSkillsIndex()
107
+ const allSkills = getAllIndexedSkills()
108
+ const installedSkillNames = new Set(listInstalledSkills().map((s) => s.name))
109
+ const coreSkills = allSkills.filter(
110
+ (s) => s.source === "otto-assistant/skills" && !installedSkillNames.has(s.name),
111
+ )
112
+ for (const skill of coreSkills) {
113
+ const ok = installSkillFromIndex(skill.name)
114
+ if (ok) console.log(`Installed skill: ${skill.name}`)
115
+ }
116
+ } catch {
117
+ console.log("⚠ Could not fetch skills from GitHub (offline?). Skipping.")
118
+ }
119
+
120
+ // 6. Restart kimaki if needed — but NOT if running inside kimaki
86
121
  // (kimaki restart kills the current opencode session)
87
122
  const runningInsideKimaki = !!process.env.KIMAKI
88
123
  if (configChanged || installed.length > 0) {
@@ -198,6 +233,18 @@ async function cmdStatus(): Promise<void> {
198
233
  console.log(` ask before thread delete: ${configHealth.subagentThreadsAskBeforeDelete ? "yes" : "no"}`)
199
234
  console.log(` auto delete thread on complete: ${configHealth.subagentThreadsAutoDelete ? "yes" : "no"}`)
200
235
  console.log(` kimaki process: ${configHealth.kimakiRunning ? "running" : "not running"}`)
236
+
237
+ console.log("\nSkills:")
238
+ const skillsInstalled = listInstalledSkills()
239
+ console.log(` installed: ${skillsInstalled.length > 0 ? skillsInstalled.map((s) => s.name).join(", ") : "(none)"}`)
240
+ try {
241
+ const index = loadSkillsIndex()
242
+ const repoCount = Object.keys(index.repos).length
243
+ const totalIndexed = getAllIndexedSkills().length
244
+ console.log(` indexed: ${totalIndexed} skills from ${repoCount} repos`)
245
+ } catch {
246
+ console.log(" indexed: (unavailable)")
247
+ }
201
248
  }
202
249
 
203
250
  async function cmdDoctor(): Promise<void> {
@@ -223,9 +270,9 @@ async function cmdDoctor(): Promise<void> {
223
270
  hasErrors = true
224
271
  }
225
272
  if (configHealth.memoryPluginEnabled) {
226
- console.log(" ✓ opencode-agent-memory plugin enabled")
273
+ console.log(" ✓ mempalace plugin enabled")
227
274
  } else {
228
- console.log(" ✗ opencode-agent-memory plugin NOT enabled — run `otto install`")
275
+ console.log(" ✗ mempalace plugin NOT enabled — run `otto install`")
229
276
  hasErrors = true
230
277
  }
231
278
  if (configHealth.subagentPolicyInjected) {
@@ -248,9 +295,386 @@ async function cmdDoctor(): Promise<void> {
248
295
  if (d.status === "error") hasErrors = true
249
296
  }
250
297
 
298
+ console.log("\nChecking skills...")
299
+ const skillsInstalled = listInstalledSkills()
300
+ if (skillsInstalled.length > 0) {
301
+ console.log(` ✓ ${skillsInstalled.length} skill(s) installed`)
302
+ } else {
303
+ console.log(" ⚠ No skills installed — run `otto skills add --all`")
304
+ }
305
+ try {
306
+ const totalIndexed = getAllIndexedSkills().length
307
+ if (totalIndexed > 0) {
308
+ console.log(` ✓ Skills index available (${totalIndexed} skills)`)
309
+ } else {
310
+ console.log(" ⚠ Skills index empty — run `otto skills update`")
311
+ }
312
+ } catch {
313
+ console.log(" ⚠ Skills index unavailable")
314
+ }
315
+
251
316
  console.log(hasErrors ? "\n✗ Issues found. Run `otto install` to fix." : "\n✓ All checks passed!")
252
317
  }
253
318
 
319
+ // ---------------------------------------------------------------------------
320
+ // otto skills sub-commands
321
+ // ---------------------------------------------------------------------------
322
+
323
+ async function cmdSkills(subArgs: string[]): Promise<void> {
324
+ const skillCommand = subArgs[0] ?? ""
325
+
326
+ switch (skillCommand) {
327
+ case "list":
328
+ cmdSkillsList()
329
+ break
330
+ case "search": {
331
+ const query = subArgs.slice(1).join(" ")
332
+ if (!query) {
333
+ console.log("Usage: otto skills search <query>")
334
+ process.exit(1)
335
+ }
336
+ cmdSkillsSearch(query)
337
+ break
338
+ }
339
+ case "browse":
340
+ cmdSkillsBrowse()
341
+ break
342
+ case "add": {
343
+ const arg = subArgs[1]
344
+ if (!arg || arg === "--all") {
345
+ await cmdSkillsAddAll()
346
+ } else {
347
+ await cmdSkillsAddOne(arg)
348
+ }
349
+ break
350
+ }
351
+ case "remove": {
352
+ const name = subArgs[1]
353
+ if (!name) {
354
+ console.log("Usage: otto skills remove <name>")
355
+ process.exit(1)
356
+ }
357
+ cmdSkillsRemove(name)
358
+ break
359
+ }
360
+ case "update":
361
+ cmdSkillsUpdate()
362
+ break
363
+ case "repos":
364
+ cmdSkillsRepos()
365
+ break
366
+ default:
367
+ console.log(`Otto skills — discover and install agent skills from public repos
368
+
369
+ Usage:
370
+ otto skills search <query> Search skills by name/description
371
+ otto skills browse Browse all available skills
372
+ otto skills list List installed skills
373
+ otto skills add <name> Install a skill
374
+ otto skills add --all Install all skills from otto-assistant/skills
375
+ otto skills update Refresh skills index from GitHub
376
+ otto skills remove <name> Remove an installed skill
377
+ otto skills repos Show configured skill repositories
378
+ `)
379
+ break
380
+ }
381
+ }
382
+
383
+ function cmdSkillsSearch(query: string): void {
384
+ console.log(`Searching: "${query}"\n`)
385
+
386
+ const { refreshed } = ensureSkillsIndex()
387
+ if (refreshed > 0) {
388
+ console.log(`Updated index (${refreshed} repo(s) refreshed).\n`)
389
+ }
390
+
391
+ const results = searchSkills(query)
392
+ if (results.length === 0) {
393
+ console.log("No skills found.")
394
+ return
395
+ }
396
+
397
+ for (const skill of results) {
398
+ console.log(` ${skill.name} — ${skill.description}`)
399
+ console.log(` source: ${skill.source}`)
400
+ }
401
+
402
+ console.log(`\n${results.length} skill(s) found. Install with: otto skills add <name>`)
403
+ }
404
+
405
+ function cmdSkillsBrowse(): void {
406
+ console.log("Otto skills — browsing all available\n")
407
+
408
+ const { refreshed } = ensureSkillsIndex()
409
+ if (refreshed > 0) {
410
+ console.log(`Updated index (${refreshed} repo(s) refreshed).\n`)
411
+ }
412
+
413
+ const allSkills = getAllIndexedSkills()
414
+ const installed = new Set(listInstalledSkills().map((s) => s.name))
415
+
416
+ // Group by source repo
417
+ const byRepo: Record<string, SkillIndexEntry[]> = {}
418
+ for (const skill of allSkills) {
419
+ if (!byRepo[skill.source]) byRepo[skill.source] = []
420
+ byRepo[skill.source].push(skill)
421
+ }
422
+
423
+ for (const [repo, skills] of Object.entries(byRepo)) {
424
+ console.log(`${repo} (${skills.length} skills):`)
425
+ for (const skill of skills) {
426
+ const icon = installed.has(skill.name) ? "✓" : "•"
427
+ console.log(` ${icon} ${skill.name} — ${skill.description}`)
428
+ }
429
+ console.log()
430
+ }
431
+
432
+ const totalAvailable = allSkills.filter((s) => !installed.has(s.name)).length
433
+ console.log(`${allSkills.length} total, ${totalAvailable} available to install.`)
434
+ }
435
+
436
+ function cmdSkillsList(): void {
437
+ console.log("Otto skills\n")
438
+
439
+ const installed = listInstalledSkills()
440
+ if (installed.length > 0) {
441
+ console.log("Installed:")
442
+ for (const s of installed) {
443
+ console.log(` ✓ ${s.name} — ${s.description}`)
444
+ }
445
+ } else {
446
+ console.log("Installed: (none)")
447
+ }
448
+
449
+ console.log(`\nUse "otto skills browse" to see all available skills.`)
450
+ console.log(`Use "otto skills search <query>" to search.`)
451
+ }
452
+
453
+ async function cmdSkillsAddOne(name: string): Promise<void> {
454
+ console.log(`Installing skill: ${name}\n`)
455
+
456
+ ensureSkillsIndex()
457
+
458
+ const success = installSkillFromIndex(name)
459
+ if (!success) {
460
+ console.error(`Error: skill "${name}" not found. Run "otto skills search <query>" to find skills.`)
461
+ process.exit(1)
462
+ }
463
+
464
+ console.log(`Installed ${name} → ~/.config/opencode/skills/${name}/`)
465
+ console.log("Done!")
466
+ }
467
+
468
+ async function cmdSkillsAddAll(): Promise<void> {
469
+ console.log("Installing all skills from otto-assistant/skills...\n")
470
+
471
+ ensureSkillsIndex()
472
+ const allSkills = getAllIndexedSkills()
473
+ const ottoSkills = allSkills.filter((s) => s.source === "otto-assistant/skills")
474
+
475
+ if (ottoSkills.length === 0) {
476
+ console.log("No skills found in otto-assistant/skills. Check your connection.")
477
+ return
478
+ }
479
+
480
+ const installed = new Set(listInstalledSkills().map((s) => s.name))
481
+ let added = 0
482
+
483
+ for (const skill of ottoSkills) {
484
+ if (installed.has(skill.name)) {
485
+ console.log(` ✓ ${skill.name} (already installed)`)
486
+ continue
487
+ }
488
+ const success = installSkillFromIndex(skill.name)
489
+ if (success) {
490
+ console.log(` + ${skill.name}`)
491
+ added++
492
+ } else {
493
+ console.log(` ✗ ${skill.name} (failed)`)
494
+ }
495
+ }
496
+
497
+ if (added === 0) {
498
+ console.log("\nAll skills already installed.")
499
+ } else {
500
+ console.log(`\nInstalled ${added} skill(s).`)
501
+ }
502
+ console.log("Done!")
503
+ }
504
+
505
+ function cmdSkillsUpdate(): void {
506
+ console.log("Refreshing skills index from GitHub...\n")
507
+
508
+ const { refreshed, total } = ensureSkillsIndex(0) // force refresh all
509
+
510
+ if (refreshed === 0 && total === 0) {
511
+ console.log("No repos configured.")
512
+ return
513
+ }
514
+
515
+ console.log(`Refreshed ${refreshed}/${total} repo(s).`)
516
+
517
+ const allSkills = getAllIndexedSkills()
518
+ console.log(`Index now has ${allSkills.length} skills.`)
519
+ console.log("Done!")
520
+ }
521
+
522
+ function cmdSkillsRemove(name: string): void {
523
+ console.log(`Removing skill: ${name}\n`)
524
+
525
+ const success = removeSkill(name)
526
+ if (!success) {
527
+ console.error(`Error: skill "${name}" is not installed.`)
528
+ process.exit(1)
529
+ }
530
+
531
+ console.log(`Removed ${name}.`)
532
+ console.log("Done!")
533
+ }
534
+
535
+ function cmdSkillsRepos(): void {
536
+ console.log("Configured skill repositories:\n")
537
+
538
+ const repos = getConfiguredRepos()
539
+ for (const repo of repos) {
540
+ console.log(` ${repo}`)
541
+ }
542
+
543
+ console.log(`\n${repos.length} repo(s) configured.`)
544
+ }
545
+
546
+ async function cmdTenant(subArgs: string[]): Promise<void> {
547
+ const tenantCommand = subArgs[0] ?? ""
548
+ const tenantPathArg = subArgs[1]
549
+
550
+ if (tenantCommand === "skills") {
551
+ const action = subArgs[1] ?? ""
552
+ const pathArg = subArgs[2]
553
+ if (action !== "bootstrap" || !pathArg) {
554
+ console.log("Usage: otto tenant skills bootstrap <path>")
555
+ process.exit(1)
556
+ }
557
+
558
+ const tenantPath = path.resolve(pathArg)
559
+ const skillsDir = path.join(tenantPath, "memory", "opencode", "skills")
560
+
561
+ ensureSkillsIndex()
562
+ const report = installSkillsBaseline(GENTLEMAN_SKILLS_BASELINE, skillsDir)
563
+
564
+ console.log(`Skills baseline bootstrap: ${tenantPath}`)
565
+ console.log(` Installed: ${report.installed.length > 0 ? report.installed.join(", ") : "(none)"}`)
566
+ console.log(` Already present: ${report.alreadyPresent.length > 0 ? report.alreadyPresent.join(", ") : "(none)"}`)
567
+ if (report.failed.length > 0) {
568
+ console.log(` Failed: ${report.failed.join(", ")}`)
569
+ process.exitCode = 2
570
+ } else {
571
+ console.log(" Failed: (none)")
572
+ }
573
+ return
574
+ }
575
+
576
+ if (!tenantPathArg) {
577
+ console.log("Usage: otto tenant <init|up|down|status|logs> <path>")
578
+ process.exit(1)
579
+ }
580
+
581
+ const tenantPath = path.resolve(tenantPathArg)
582
+
583
+ switch (tenantCommand) {
584
+ case "init": {
585
+ const result = ensureTenantScaffold(tenantPath)
586
+ if (result.created.length === 0) {
587
+ console.log(`Tenant scaffold already exists: ${tenantPath}`)
588
+ } else {
589
+ console.log(`Tenant scaffold ready: ${tenantPath}`)
590
+ console.log(`Created: ${result.created.join(", ")}`)
591
+ }
592
+ console.log(`
593
+ Next steps — get your tenant running in 3 steps:
594
+
595
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
596
+ Step 1: Create a Discord Bot
597
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
598
+
599
+ 1. Open https://discord.com/developers/applications
600
+ 2. Click "New Application" → give it a name → Create
601
+ 3. Go to "Bot" tab → Click "Reset Token" → Copy the token
602
+ 4. Under "Privileged Gateway Intents" enable:
603
+ ✅ Message Content Intent
604
+ ✅ Server Members Intent (optional)
605
+ 5. Go to "OAuth2" tab → "URL Generator"
606
+ Scopes: bot
607
+ Permissions: Send Messages, Read Message History, Add Reactions
608
+ 6. Open the generated URL → add bot to your Discord server
609
+
610
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
611
+ Step 2: Configure your tenant
612
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
613
+
614
+ Edit ${tenantPath}/.env with your bot token:
615
+
616
+ KIMAKI_BOT_TOKEN=your-bot-token-here
617
+
618
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
619
+ Step 3: Start your tenant
620
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
621
+
622
+ docker compose -f ${tenantPath}/compose.yml up -d
623
+
624
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
625
+
626
+ Useful commands:
627
+ otto tenant status ${tenantPathArg} — check health
628
+ otto tenant logs ${tenantPathArg} — view logs
629
+ otto tenant down ${tenantPathArg} — stop tenant
630
+ otto tenant skills bootstrap ${tenantPathArg} — install baseline skills
631
+ `)
632
+ return
633
+ }
634
+ case "up": {
635
+ const mode = resolveTenantMode(process.env.OTTO_MODE)
636
+ if (mode === "admin") {
637
+ console.log("⚠ OTTO_MODE=admin enabled: tenant has elevated runtime profile.")
638
+ }
639
+ runCompose(tenantPath, ["up", "-d"])
640
+ return
641
+ }
642
+ case "down":
643
+ runCompose(tenantPath, ["down"])
644
+ return
645
+ case "logs": {
646
+ const follow = subArgs.includes("--follow") ? ["--follow"] : []
647
+ runCompose(tenantPath, ["logs", ...follow])
648
+ return
649
+ }
650
+ case "status": {
651
+ const health = checkTenantHealth({ tenantPath })
652
+ console.log(`Tenant status: ${tenantPath}`)
653
+ for (const item of health) {
654
+ const icon = item.status === "ok" ? "✓" : item.status === "warn" ? "⚠" : "✗"
655
+ console.log(` ${icon} ${item.name}: ${item.message}`)
656
+ }
657
+ const composeExists = fs.existsSync(path.join(tenantPath, "compose.yml"))
658
+ const skipComposePs = process.env.OTTO_SKIP_COMPOSE_PS === "1"
659
+ if (composeExists && !skipComposePs) {
660
+ try {
661
+ runCompose(tenantPath, ["ps"])
662
+ } catch {
663
+ console.log(" ⚠ docker compose ps failed")
664
+ }
665
+ }
666
+ return
667
+ }
668
+ default:
669
+ console.log("Usage: otto tenant <init|up|down|status|logs> <path> | otto tenant skills bootstrap <path>")
670
+ process.exit(1)
671
+ }
672
+ }
673
+
674
+ // ---------------------------------------------------------------------------
675
+ // Main router
676
+ // ---------------------------------------------------------------------------
677
+
254
678
  async function main(): Promise<void> {
255
679
  switch (command) {
256
680
  case "install":
@@ -268,17 +692,38 @@ async function main(): Promise<void> {
268
692
  case "sync":
269
693
  await syncUpstreams()
270
694
  break
695
+ case "skills":
696
+ await cmdSkills(args.slice(1))
697
+ break
698
+ case "tenant":
699
+ await cmdTenant(args.slice(1))
700
+ break
271
701
  default:
272
- console.log(`Otto — terminal UI distribution for opencode + kimaki + opencode-agent-memory
702
+ console.log(`Otto — terminal UI distribution for opencode + kimaki + mempalace
273
703
 
274
704
  Usage:
275
- otto install Install missing packages + configure
276
- otto upgrade Upgrade to stable (manifest-pinned) versions
277
- otto upgrade stable Upgrade to manifest-pinned versions
278
- otto upgrade latest Upgrade to npm latest versions
279
- otto status Show installed versions + config health
280
- otto doctor Validate all integration points
281
- otto sync Trigger upstream sync for all forked repos
705
+ otto tenant init <path> Create compose-first tenant scaffold
706
+ otto tenant up <path> Start tenant with docker compose
707
+ otto tenant down <path> Stop tenant with docker compose
708
+ otto tenant status <path> Show tenant preflight + compose status
709
+ otto tenant logs <path> Show tenant logs (add --follow)
710
+ otto tenant skills bootstrap <path> Install baseline skills for tenant
711
+
712
+ otto install Legacy: install missing npm packages + configure
713
+ otto upgrade Legacy: upgrade to stable (manifest-pinned) versions
714
+ otto upgrade stable Legacy: upgrade to manifest-pinned versions
715
+ otto upgrade latest Legacy: upgrade to npm latest versions
716
+ otto status Show installed versions + config health
717
+ otto doctor Validate all integration points
718
+ otto sync Trigger upstream sync for all forked repos
719
+ otto skills search <q> Search skills across public repos
720
+ otto skills browse Browse all available skills
721
+ otto skills list List installed skills
722
+ otto skills add <name> Install a skill
723
+ otto skills add --all Install all skills from otto-assistant/skills
724
+ otto skills update Refresh skills index
725
+ otto skills remove <name> Remove an installed skill
726
+ otto skills repos Show configured skill repositories
282
727
  `)
283
728
  break
284
729
  }
@@ -18,20 +18,20 @@ import os from "node:os"
18
18
  describe("config", () => {
19
19
  it("adds plugin to empty config", () => {
20
20
  const config: OpenCodeConfig = {}
21
- const result = mergePlugins(config, "opencode-agent-memory")
22
- expect(result.plugin).toEqual(["opencode-agent-memory"])
21
+ const result = mergePlugins(config, "mempalace")
22
+ expect(result.plugin).toEqual(["mempalace"])
23
23
  })
24
24
 
25
25
  it("appends plugin to existing array", () => {
26
26
  const config: OpenCodeConfig = { plugin: ["existing-plugin"] }
27
- const result = mergePlugins(config, "opencode-agent-memory")
28
- expect(result.plugin).toEqual(["existing-plugin", "opencode-agent-memory"])
27
+ const result = mergePlugins(config, "mempalace")
28
+ expect(result.plugin).toEqual(["existing-plugin", "mempalace"])
29
29
  })
30
30
 
31
31
  it("does not duplicate existing plugin", () => {
32
- const config: OpenCodeConfig = { plugin: ["opencode-agent-memory"] }
33
- const result = mergePlugins(config, "opencode-agent-memory")
34
- expect(result.plugin).toEqual(["opencode-agent-memory"])
32
+ const config: OpenCodeConfig = { plugin: ["mempalace"] }
33
+ const result = mergePlugins(config, "mempalace")
34
+ expect(result.plugin).toEqual(["mempalace"])
35
35
  })
36
36
 
37
37
  it("preserves other config fields", () => {
@@ -40,10 +40,10 @@ describe("config", () => {
40
40
  plugin: ["existing"],
41
41
  provider: { cursor: { name: "Cursor" } },
42
42
  }
43
- const result = mergePlugins(config, "opencode-agent-memory")
43
+ const result = mergePlugins(config, "mempalace")
44
44
  expect(result.model).toBe("gpt-4")
45
45
  expect(result.provider).toEqual({ cursor: { name: "Cursor" } })
46
- expect(result.plugin).toEqual(["existing", "opencode-agent-memory"])
46
+ expect(result.plugin).toEqual(["existing", "mempalace"])
47
47
  })
48
48
 
49
49
  it("readOttoConfig returns defaults when otto.json missing", () => {
@@ -2,13 +2,14 @@ import { describe, expect, it } from "vitest"
2
2
  import { getInstalledVersion, detectPackage, type InstalledPackage } from "./detect.js"
3
3
 
4
4
  describe("detect", () => {
5
- it("returns null for non-existent package", () => {
5
+ it("returns null for non-existent package", { timeout: 30_000 }, () => {
6
6
  const result = getInstalledVersion("nonexistent-package-xyz-123")
7
7
  expect(result).toBeNull()
8
8
  })
9
9
 
10
- // These tests require kimaki to be globally installed (skipped on CI)
11
- const describeIfInstalled = process.env.CI ? describe.skip : describe
10
+ // These tests require kimaki to be globally installed
11
+ const hasKimakiInstalled = getInstalledVersion("kimaki") !== null
12
+ const describeIfInstalled = hasKimakiInstalled ? describe : describe.skip
12
13
 
13
14
  describeIfInstalled("with kimaki installed", () => {
14
15
  it("detects an existing global package", () => {
@@ -0,0 +1,12 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { buildComposeCommand } from "./docker.js"
3
+
4
+ describe("docker", () => {
5
+ it("builds docker compose command with tenant path", () => {
6
+ const cmd = buildComposeCommand("/tmp/tenant-a", ["up", "-d"])
7
+ expect(cmd).toEqual({
8
+ command: "docker",
9
+ args: ["compose", "-f", "/tmp/tenant-a/compose.yml", "up", "-d"],
10
+ })
11
+ })
12
+ })
package/src/docker.ts ADDED
@@ -0,0 +1,23 @@
1
+ import { execSync } from "node:child_process"
2
+ import path from "node:path"
3
+
4
+ export interface DockerComposeCommand {
5
+ command: string
6
+ args: string[]
7
+ }
8
+
9
+ export function buildComposeCommand(tenantPath: string, subArgs: string[]): DockerComposeCommand {
10
+ const composeFile = path.join(path.resolve(tenantPath), "compose.yml")
11
+ return {
12
+ command: "docker",
13
+ args: ["compose", "-f", composeFile, ...subArgs],
14
+ }
15
+ }
16
+
17
+ export function runCompose(tenantPath: string, subArgs: string[]): void {
18
+ const built = buildComposeCommand(tenantPath, subArgs)
19
+ execSync(`${built.command} ${built.args.join(" ")}`, {
20
+ stdio: "inherit",
21
+ timeout: 120_000,
22
+ })
23
+ }