@otto-assistant/otto 0.1.0

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 (76) hide show
  1. package/dist/cli.d.ts +3 -0
  2. package/dist/cli.d.ts.map +1 -0
  3. package/dist/cli.js +257 -0
  4. package/dist/cli.js.map +1 -0
  5. package/dist/config.d.ts +39 -0
  6. package/dist/config.d.ts.map +1 -0
  7. package/dist/config.js +264 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/config.test.d.ts +2 -0
  10. package/dist/config.test.d.ts.map +1 -0
  11. package/dist/config.test.js +202 -0
  12. package/dist/config.test.js.map +1 -0
  13. package/dist/detect.d.ts +9 -0
  14. package/dist/detect.d.ts.map +1 -0
  15. package/dist/detect.js +40 -0
  16. package/dist/detect.js.map +1 -0
  17. package/dist/detect.test.d.ts +2 -0
  18. package/dist/detect.test.d.ts.map +1 -0
  19. package/dist/detect.test.js +25 -0
  20. package/dist/detect.test.js.map +1 -0
  21. package/dist/health.d.ts +27 -0
  22. package/dist/health.d.ts.map +1 -0
  23. package/dist/health.js +78 -0
  24. package/dist/health.js.map +1 -0
  25. package/dist/health.test.d.ts +2 -0
  26. package/dist/health.test.d.ts.map +1 -0
  27. package/dist/health.test.js +33 -0
  28. package/dist/health.test.js.map +1 -0
  29. package/dist/index.d.ts +12 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +11 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/index.test.d.ts +2 -0
  34. package/dist/index.test.d.ts.map +1 -0
  35. package/dist/index.test.js +8 -0
  36. package/dist/index.test.js.map +1 -0
  37. package/dist/installer.d.ts +10 -0
  38. package/dist/installer.d.ts.map +1 -0
  39. package/dist/installer.js +50 -0
  40. package/dist/installer.js.map +1 -0
  41. package/dist/installer.test.d.ts +2 -0
  42. package/dist/installer.test.d.ts.map +1 -0
  43. package/dist/installer.test.js +43 -0
  44. package/dist/installer.test.js.map +1 -0
  45. package/dist/lifecycle.d.ts +4 -0
  46. package/dist/lifecycle.d.ts.map +1 -0
  47. package/dist/lifecycle.js +30 -0
  48. package/dist/lifecycle.js.map +1 -0
  49. package/dist/lifecycle.test.d.ts +2 -0
  50. package/dist/lifecycle.test.d.ts.map +1 -0
  51. package/dist/lifecycle.test.js +19 -0
  52. package/dist/lifecycle.test.js.map +1 -0
  53. package/dist/manifest.d.ts +18 -0
  54. package/dist/manifest.d.ts.map +1 -0
  55. package/dist/manifest.js +30 -0
  56. package/dist/manifest.js.map +1 -0
  57. package/dist/sync.d.ts +10 -0
  58. package/dist/sync.d.ts.map +1 -0
  59. package/dist/sync.js +39 -0
  60. package/dist/sync.js.map +1 -0
  61. package/package.json +41 -0
  62. package/src/cli.ts +291 -0
  63. package/src/config.test.ts +237 -0
  64. package/src/config.ts +362 -0
  65. package/src/detect.test.ts +28 -0
  66. package/src/detect.ts +52 -0
  67. package/src/health.test.ts +39 -0
  68. package/src/health.ts +114 -0
  69. package/src/index.test.ts +8 -0
  70. package/src/index.ts +28 -0
  71. package/src/installer.test.ts +52 -0
  72. package/src/installer.ts +62 -0
  73. package/src/lifecycle.test.ts +22 -0
  74. package/src/lifecycle.ts +30 -0
  75. package/src/manifest.ts +42 -0
  76. package/src/sync.ts +53 -0
@@ -0,0 +1,39 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ checkPackagePresence,
4
+ checkConfigHealth,
5
+ checkDirectoryHealth,
6
+ } from "./health.js"
7
+
8
+ describe("health", () => {
9
+ it("checkPackagePresence returns results for all manifest packages", { timeout: 30_000 }, () => {
10
+ const results = checkPackagePresence()
11
+ expect(results).toHaveLength(2) // opencode-ai + kimaki (not opencode-agent-memory — it's a plugin)
12
+ for (const r of results) {
13
+ expect(r).toHaveProperty("name")
14
+ expect(r).toHaveProperty("installed")
15
+ expect(r).toHaveProperty("status")
16
+ }
17
+ })
18
+
19
+ it("checkConfigHealth returns structured result", () => {
20
+ const result = checkConfigHealth()
21
+ expect(result).toHaveProperty("opencodeJson")
22
+ expect(result).toHaveProperty("ottoJson")
23
+ expect(result).toHaveProperty("agentMemoryJson")
24
+ expect(result).toHaveProperty("memoryPluginEnabled")
25
+ expect(result).toHaveProperty("subagentPolicyInjected")
26
+ expect(result).toHaveProperty("subagentThreadsEnabled")
27
+ expect(result).toHaveProperty("subagentThreadsAskBeforeDelete")
28
+ expect(result).toHaveProperty("subagentThreadsAutoDelete")
29
+ expect(result).toHaveProperty("plugins")
30
+ expect(result).toHaveProperty("kimakiRunning")
31
+ expect(Array.isArray(result.plugins)).toBe(true)
32
+ })
33
+
34
+ it("checkDirectoryHealth returns results", () => {
35
+ const results = checkDirectoryHealth()
36
+ expect(Array.isArray(results)).toBe(true)
37
+ expect(results.length).toBeGreaterThan(0)
38
+ })
39
+ })
package/src/health.ts ADDED
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs"
2
+ import path from "node:path"
3
+ import { getInstalledVersion } from "./detect.js"
4
+ import { readOpenCodeConfigState, readOttoConfig, type OpenCodeConfig } from "./config.js"
5
+ import { MANIFEST, OPENCODE_CONFIG_DIR, KIMAKI_DATA_DIR } from "./manifest.js"
6
+ import { isKimakiRunning } from "./lifecycle.js"
7
+
8
+ export interface HealthResult {
9
+ name: string
10
+ status: "ok" | "warn" | "error"
11
+ message: string
12
+ }
13
+
14
+ export interface PackageCheck {
15
+ name: string
16
+ installed: string | null
17
+ required: string
18
+ status: "ok" | "missing"
19
+ }
20
+
21
+ const SUBAGENT_POLICY_MARKER = "Otto subagent policy (must follow):"
22
+
23
+ function subagentPolicyInjectedInPrompts(config: OpenCodeConfig): boolean {
24
+ const agent = config.agent
25
+ if (!agent || typeof agent !== "object" || Array.isArray(agent)) {
26
+ return false
27
+ }
28
+ for (const value of Object.values(agent as Record<string, unknown>)) {
29
+ if (value && typeof value === "object" && !Array.isArray(value)) {
30
+ const prompt = (value as { prompt?: unknown }).prompt
31
+ if (typeof prompt === "string" && prompt.includes(SUBAGENT_POLICY_MARKER)) {
32
+ return true
33
+ }
34
+ }
35
+ }
36
+ return false
37
+ }
38
+
39
+ export function checkPackagePresence(): PackageCheck[] {
40
+ return Object.entries(MANIFEST.packages).map(([name, required]) => {
41
+ const installed = getInstalledVersion(name)
42
+ return { name, installed, required, status: installed ? ("ok" as const) : ("missing" as const) }
43
+ })
44
+ }
45
+
46
+ export interface ConfigHealth {
47
+ opencodeJson: "ok" | "missing" | "error"
48
+ ottoJson: "ok" | "missing"
49
+ agentMemoryJson: "ok" | "missing"
50
+ memoryPluginEnabled: boolean
51
+ subagentPolicyInjected: boolean
52
+ subagentThreadsEnabled: boolean
53
+ subagentThreadsAskBeforeDelete: boolean
54
+ subagentThreadsAutoDelete: boolean
55
+ plugins: string[]
56
+ kimakiRunning: boolean
57
+ }
58
+
59
+ export function checkConfigHealth(): ConfigHealth {
60
+ const configDir = OPENCODE_CONFIG_DIR()
61
+ const agentMemoryPath = path.join(configDir, "agent-memory.json")
62
+ const ottoJsonPath = path.join(configDir, "otto.json")
63
+
64
+ const state = readOpenCodeConfigState()
65
+ const opencodeJson: ConfigHealth["opencodeJson"] =
66
+ state.status === "missing" ? "missing" : state.status === "invalid" ? "error" : "ok"
67
+ const agentMemoryJson: ConfigHealth["agentMemoryJson"] = fs.existsSync(agentMemoryPath) ? "ok" : "missing"
68
+ const ottoJson: ConfigHealth["ottoJson"] = fs.existsSync(ottoJsonPath) ? "ok" : "missing"
69
+
70
+ const config = state.status === "ok" ? state.config : {}
71
+ const configuredPlugins = config.plugin ?? []
72
+ const memoryPluginEnabled = configuredPlugins.includes("opencode-agent-memory")
73
+
74
+ const subagentPolicyInjected = subagentPolicyInjectedInPrompts(config)
75
+
76
+ // Read Otto config from otto.json (separate from opencode.json)
77
+ const ottoConfig = readOttoConfig()
78
+ const subagentThreadsEnabled = ottoConfig.subagentThreads.enabled
79
+ const subagentThreadsAskBeforeDelete = ottoConfig.subagentThreads.askBeforeDelete
80
+ const subagentThreadsAutoDelete = ottoConfig.subagentThreads.autoDeleteOnComplete
81
+ const kimakiRunning = isKimakiRunning()
82
+
83
+ return {
84
+ opencodeJson,
85
+ ottoJson,
86
+ agentMemoryJson,
87
+ memoryPluginEnabled,
88
+ subagentPolicyInjected,
89
+ subagentThreadsEnabled,
90
+ subagentThreadsAskBeforeDelete,
91
+ subagentThreadsAutoDelete,
92
+ plugins: configuredPlugins,
93
+ kimakiRunning,
94
+ }
95
+ }
96
+
97
+ export function checkDirectoryHealth(): HealthResult[] {
98
+ const results: HealthResult[] = []
99
+ const dirs = [
100
+ { p: OPENCODE_CONFIG_DIR(), label: "opencode config dir" },
101
+ { p: path.join(OPENCODE_CONFIG_DIR(), "memory"), label: "opencode memory dir" },
102
+ { p: path.join(OPENCODE_CONFIG_DIR(), "journal"), label: "opencode journal dir" },
103
+ { p: KIMAKI_DATA_DIR(), label: "kimaki data dir" },
104
+ ]
105
+
106
+ for (const { p, label } of dirs) {
107
+ if (fs.existsSync(p)) {
108
+ results.push({ name: label, status: "ok", message: `exists: ${p}` })
109
+ } else {
110
+ results.push({ name: label, status: "warn", message: `missing: ${p}` })
111
+ }
112
+ }
113
+ return results
114
+ }
@@ -0,0 +1,8 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { ottoVersion } from "./index.js";
3
+
4
+ describe("otto", () => {
5
+ it("returns version string", () => {
6
+ expect(ottoVersion()).toMatch(/^\d+\.\d+\.\d+$/);
7
+ });
8
+ });
package/src/index.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { MANIFEST } from "./manifest.js"
2
+
3
+ export function ottoVersion(): string {
4
+ return MANIFEST.version
5
+ }
6
+
7
+ export { MANIFEST, OPENCODE_CONFIG_DIR, KIMAKI_DATA_DIR } from "./manifest.js"
8
+ export { getInstalledVersion, detectPackage } from "./detect.js"
9
+ export type { InstalledPackage } from "./detect.js"
10
+ export {
11
+ mergePlugins,
12
+ readOttoConfig,
13
+ writeOttoConfig,
14
+ OTTO_DEFAULTS,
15
+ buildSubagentThreadPolicy,
16
+ mergeAgentPrompts,
17
+ ensureSubagentThreadSkill,
18
+ readOpenCodeConfig,
19
+ readOpenCodeConfigState,
20
+ writeOpenCodeConfig,
21
+ ensureAgentMemoryConfig,
22
+ } from "./config.js"
23
+ export type { OpenCodeConfig, OttoConfig, OttoSubagentThreads, OttoConfigReadStatus, OpenCodeConfigReadStatus } from "./config.js"
24
+ export { installPackage, upgradePackage, installMissingPackages, planStableUpgrades } from "./installer.js"
25
+ export type { UpgradeMode } from "./installer.js"
26
+ export { hasKimakiBinary, isKimakiRunning, restartKimaki } from "./lifecycle.js"
27
+ export { checkPackagePresence, checkConfigHealth, checkDirectoryHealth } from "./health.js"
28
+ export type { HealthResult, PackageCheck, ConfigHealth } from "./health.js"
@@ -0,0 +1,52 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { installMissingPackages, planStableUpgrades } from "./installer.js"
3
+
4
+ describe("installer", () => {
5
+ it("installMissingPackages returns empty when all installed", () => {
6
+ const getInstalled = (_name: string) => "1.0.0"
7
+ const installed = installMissingPackages(getInstalled, () => "ok")
8
+ expect(installed).toEqual([])
9
+ })
10
+
11
+ it("installMissingPackages returns missing package names", () => {
12
+ const getInstalled = (name: string) => name === "kimaki" ? "1.0.0" : null
13
+ const installedNames: string[] = []
14
+ const install = (name: string) => { installedNames.push(name); return name }
15
+
16
+ const result = installMissingPackages(getInstalled, install)
17
+
18
+ expect(result).toContain("opencode-ai")
19
+ expect(result).not.toContain("kimaki")
20
+ // opencode-agent-memory is a plugin, not a global npm package
21
+ expect(result).not.toContain("opencode-agent-memory")
22
+ })
23
+
24
+ it("planStableUpgrades returns empty when all packages match pinned", () => {
25
+ const pinned = { a: "1.0.0", b: "2.0.0" }
26
+ const getInstalled = (name: string) => pinned[name as keyof typeof pinned] ?? null
27
+ const plan = planStableUpgrades(["a", "b"], getInstalled, pinned)
28
+ expect(plan).toEqual([])
29
+ })
30
+
31
+ it("planStableUpgrades lists packages whose version differs from pinned", () => {
32
+ const pinned = { "opencode-ai": "1.2.20", kimaki: "0.4.90" }
33
+ const getInstalled = (name: string) => (name === "kimaki" ? "0.4.90" : "1.0.0")
34
+ const plan = planStableUpgrades(["opencode-ai", "kimaki"], getInstalled, pinned)
35
+ expect(plan).toEqual([
36
+ { name: "opencode-ai", current: "1.0.0", target: "1.2.20" },
37
+ ])
38
+ })
39
+
40
+ it("planStableUpgrades includes not installed packages", () => {
41
+ const pinned = { x: "1.0.0" }
42
+ const getInstalled = () => null as string | null
43
+ const plan = planStableUpgrades(["x"], getInstalled, pinned)
44
+ expect(plan).toEqual([{ name: "x", current: null, target: "1.0.0" }])
45
+ })
46
+
47
+ it("planStableUpgrades throws when pinned entry missing for a package", () => {
48
+ expect(() =>
49
+ planStableUpgrades(["missing"], () => "1.0.0", {}),
50
+ ).toThrow("No pinned version for missing in manifest")
51
+ })
52
+ })
@@ -0,0 +1,62 @@
1
+ import { execSync } from "node:child_process"
2
+ import { MANIFEST } from "./manifest.js"
3
+
4
+ export type UpgradeMode = "stable" | "latest"
5
+
6
+ export function installPackage(name: string, version?: string): string {
7
+ const spec = version ? `${name}@${version}` : name
8
+ execSync(`npm install -g ${spec}`, {
9
+ encoding: "utf-8",
10
+ stdio: "pipe",
11
+ })
12
+ return spec
13
+ }
14
+
15
+ export function upgradePackage(name: string, mode: UpgradeMode): string {
16
+ if (mode === "stable") {
17
+ const pinned = MANIFEST.pinned[name]
18
+ if (!pinned) {
19
+ throw new Error(`No pinned version for ${name} in manifest`)
20
+ }
21
+ return installPackage(name, pinned)
22
+ }
23
+ return installPackage(name, "latest")
24
+ }
25
+
26
+ export function planStableUpgrades(
27
+ packageNames: string[],
28
+ getInstalled: (name: string) => string | null,
29
+ pinned: Record<string, string>,
30
+ ): { name: string; current: string | null; target: string }[] {
31
+ const upgrades: { name: string; current: string | null; target: string }[] = []
32
+ for (const name of packageNames) {
33
+ const target = pinned[name]
34
+ if (!target) {
35
+ throw new Error(`No pinned version for ${name} in manifest`)
36
+ }
37
+ const current = getInstalled(name)
38
+ if (current !== target) {
39
+ upgrades.push({ name, current, target })
40
+ }
41
+ }
42
+ return upgrades
43
+ }
44
+
45
+ export function installMissingPackages(
46
+ getInstalled: (name: string) => string | null,
47
+ install: (name: string, version?: string) => string = installPackage,
48
+ ): string[] {
49
+ const installed: string[] = []
50
+ for (const name of Object.keys(MANIFEST.packages)) {
51
+ const current = getInstalled(name)
52
+ if (!current) {
53
+ const pinned = MANIFEST.pinned[name]
54
+ if (!pinned) {
55
+ throw new Error(`No pinned version for ${name} in manifest`)
56
+ }
57
+ install(name, pinned)
58
+ installed.push(name)
59
+ }
60
+ }
61
+ return installed
62
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { hasKimakiBinary, isKimakiRunning, restartKimaki } from "./lifecycle.js"
3
+
4
+ describe("lifecycle", () => {
5
+ it("hasKimakiBinary returns boolean", () => {
6
+ const result = hasKimakiBinary()
7
+ expect(typeof result).toBe("boolean")
8
+ })
9
+
10
+ it("isKimakiRunning returns boolean", () => {
11
+ const result = isKimakiRunning()
12
+ expect(typeof result).toBe("boolean")
13
+ })
14
+
15
+ it("restartKimaki is a function", () => {
16
+ expect(typeof restartKimaki).toBe("function")
17
+ })
18
+
19
+ it("restartKimaki throws descriptive error when kimaki not found", () => {
20
+ expect(typeof restartKimaki).toBe("function")
21
+ })
22
+ })
@@ -0,0 +1,30 @@
1
+ import { execSync } from "node:child_process"
2
+
3
+ export function hasKimakiBinary(): boolean {
4
+ try {
5
+ execSync("which kimaki", { encoding: "utf-8", stdio: "pipe" })
6
+ return true
7
+ } catch {
8
+ return false
9
+ }
10
+ }
11
+
12
+ export function isKimakiRunning(): boolean {
13
+ try {
14
+ const output = execSync("pgrep -f kimaki", { encoding: "utf-8", stdio: "pipe" })
15
+ return output.trim().length > 0
16
+ } catch {
17
+ return false
18
+ }
19
+ }
20
+
21
+ export function restartKimaki(): void {
22
+ if (!hasKimakiBinary()) {
23
+ throw new Error("kimaki is not installed. Install it first with: npm install -g kimaki")
24
+ }
25
+ execSync("kimaki restart", {
26
+ encoding: "utf-8",
27
+ stdio: "pipe",
28
+ timeout: 30_000,
29
+ })
30
+ }
@@ -0,0 +1,42 @@
1
+ export interface Manifest {
2
+ version: string
3
+ /** Packages installed globally via npm (CLI tools) */
4
+ packages: Record<string, string>
5
+ /** Pinned versions for `otto upgrade stable` (global npm packages only) */
6
+ pinned: Record<string, string>
7
+ /** Plugins enabled via opencode.json plugin[] — opencode resolves them itself */
8
+ plugins: string[]
9
+ }
10
+
11
+ export const MANIFEST: Manifest = {
12
+ version: "0.1.0",
13
+ packages: {
14
+ "opencode-ai": ">=1.0.115",
15
+ "@otto-assistant/bridge": ">=0.4.90",
16
+ },
17
+ pinned: {
18
+ "opencode-ai": "1.2.20",
19
+ "@otto-assistant/bridge": "0.4.90",
20
+ },
21
+ plugins: [
22
+ "opencode-agent-memory",
23
+ ],
24
+ }
25
+
26
+ /** Upstream repositories for sync tracking */
27
+ export const UPSTREAM_REPOS: Record<string, { repo: string; upstream: string }> = {
28
+ "@otto-assistant/bridge": {
29
+ repo: "otto-assistant/bridge",
30
+ upstream: "remorses/kimaki",
31
+ },
32
+ }
33
+
34
+ export const OPENCODE_CONFIG_DIR = (): string => {
35
+ const home = process.env.HOME || process.env.USERPROFILE || "/root"
36
+ return `${home}/.config/opencode`
37
+ }
38
+
39
+ export const KIMAKI_DATA_DIR = (): string => {
40
+ const home = process.env.HOME || process.env.USERPROFILE || "/root"
41
+ return `${home}/.kimaki`
42
+ }
package/src/sync.ts ADDED
@@ -0,0 +1,53 @@
1
+ import { execSync } from "node:child_process"
2
+ import { UPSTREAM_REPOS } from "./manifest.js"
3
+
4
+ interface SyncTarget {
5
+ repo: string
6
+ upstream: string
7
+ branch: string
8
+ }
9
+
10
+ function getSyncTargets(): SyncTarget[] {
11
+ return Object.entries(UPSTREAM_REPOS).map(([_pkgName, info]) => ({
12
+ repo: info.repo,
13
+ upstream: info.upstream,
14
+ branch: "main",
15
+ }))
16
+ }
17
+
18
+ export async function syncUpstreams(): Promise<void> {
19
+ const targets = getSyncTargets()
20
+
21
+ if (targets.length === 0) {
22
+ console.log("No upstream repos configured for sync.")
23
+ return
24
+ }
25
+
26
+ // Check gh CLI is available
27
+ try {
28
+ execSync("gh --version", { stdio: "pipe" })
29
+ } catch {
30
+ console.error("Error: gh CLI is required for sync. Install: https://cli.github.com/")
31
+ process.exit(1)
32
+ }
33
+
34
+ console.log("Triggering upstream sync for all forked repos:\n")
35
+
36
+ for (const target of targets) {
37
+ console.log(` ${target.repo} ← ${target.upstream}`)
38
+ try {
39
+ execSync(
40
+ `gh workflow run sync-upstream.yml --repo ${target.repo} --ref ${target.branch}`,
41
+ { stdio: "pipe" },
42
+ )
43
+ console.log(` ✓ Sync triggered`)
44
+ } catch (err: unknown) {
45
+ const msg = err instanceof Error ? err.message : String(err)
46
+ console.error(` ✗ Failed: ${msg}`)
47
+ }
48
+ }
49
+
50
+ console.log("\nSync workflows triggered. Check status with: gh run list --repo <repo>")
51
+ }
52
+
53
+ export { getSyncTargets, UPSTREAM_REPOS }