@rohirik/openltm-core 2.8.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.
- package/README.md +67 -0
- package/assets/opencode/agents/aegis.md +211 -0
- package/assets/opencode/plugins/aegis.ts +3 -0
- package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
- package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
- package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
- package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
- package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
- package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
- package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
- package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
- package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
- package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
- package/package.json +41 -0
- package/src/__tests__/cli/claude.test.ts +122 -0
- package/src/__tests__/cli/detect.test.ts +91 -0
- package/src/__tests__/cli/install.test.ts +161 -0
- package/src/__tests__/cli/opencode.test.ts +169 -0
- package/src/__tests__/cli/pi.test.ts +113 -0
- package/src/__tests__/cli.test.ts +70 -0
- package/src/__tests__/events/crossProcess.test.ts +82 -0
- package/src/__tests__/events/index.test.ts +32 -0
- package/src/__tests__/extensions.test.ts +81 -0
- package/src/__tests__/migrations/retention.test.ts +118 -0
- package/src/__tests__/queue/index.test.ts +61 -0
- package/src/__tests__/scheduler/index.test.ts +39 -0
- package/src/__tests__/vec/index.test.ts +130 -0
- package/src/__tests__/vec/parity.test.ts +70 -0
- package/src/adapterTypes.ts +23 -0
- package/src/cli/_shared.ts +120 -0
- package/src/cli/bin.ts +97 -0
- package/src/cli/claude.ts +124 -0
- package/src/cli/detect.ts +55 -0
- package/src/cli/hook.ts +25 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/install.ts +185 -0
- package/src/cli/opencode.ts +193 -0
- package/src/cli/pi.ts +74 -0
- package/src/cli/types.ts +78 -0
- package/src/config.ts +163 -0
- package/src/context.ts +172 -0
- package/src/dao/conflicts.ts +26 -0
- package/src/dao/contextItems.ts +70 -0
- package/src/dao/embeddings.ts +78 -0
- package/src/dao/index.ts +9 -0
- package/src/dao/provenanceAudit.ts +108 -0
- package/src/dao/types.ts +142 -0
- package/src/db.ts +780 -0
- package/src/dedup.ts +12 -0
- package/src/embeddings.ts +386 -0
- package/src/events/index.ts +130 -0
- package/src/extensions.ts +140 -0
- package/src/graph.ts +268 -0
- package/src/index.ts +95 -0
- package/src/janitor/archive.ts +66 -0
- package/src/janitor/decay.ts +60 -0
- package/src/janitor/dedup.ts +333 -0
- package/src/janitor/embeddings.ts +209 -0
- package/src/janitor/index.ts +215 -0
- package/src/janitor/promote.ts +188 -0
- package/src/janitor/providers/anthropic.ts +91 -0
- package/src/janitor/providers/cohere.ts +135 -0
- package/src/janitor/providers/gemini.ts +156 -0
- package/src/janitor/providers/ollama.ts +177 -0
- package/src/janitor/providers/openai.ts +121 -0
- package/src/janitor/providers/openrouter.ts +182 -0
- package/src/janitor/providers/types.ts +154 -0
- package/src/janitor/providers/utils.ts +35 -0
- package/src/janitor/supersedes.ts +199 -0
- package/src/lib/honker.ts +54 -0
- package/src/lib/honkerTypes.ts +109 -0
- package/src/lib/jsonlLogger.ts +92 -0
- package/src/lib/writeQueue.ts +28 -0
- package/src/migrations.ts +415 -0
- package/src/paths.ts +22 -0
- package/src/proposals.ts +120 -0
- package/src/providers/disabled.ts +19 -0
- package/src/providers/embeddingProvider.ts +49 -0
- package/src/providers/gemini.ts +37 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/ollama.ts +43 -0
- package/src/providers/openai.ts +35 -0
- package/src/queue/index.ts +53 -0
- package/src/queue/worker.ts +77 -0
- package/src/recall/categorise.ts +139 -0
- package/src/recall/explainer.ts +76 -0
- package/src/scheduler/index.ts +97 -0
- package/src/schema.sql +191 -0
- package/src/secretsScrubber.ts +105 -0
- package/src/shared-db.ts +158 -0
- package/src/vec/index.ts +161 -0
- package/tsconfig.json +9 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/detect.test.ts — Unit tests for the agent detection logic.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
5
|
+
import { mkdirSync, rmSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
|
|
9
|
+
function makeTmp(): string {
|
|
10
|
+
const dir = join(os.tmpdir(), `ltm-detect-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
11
|
+
mkdirSync(dir, { recursive: true });
|
|
12
|
+
return dir;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("detectAgents", () => {
|
|
16
|
+
let tmpDir: string;
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
tmpDir = makeTmp();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("returns all false for an empty directory", async () => {
|
|
27
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
28
|
+
const result = detectAgents(tmpDir);
|
|
29
|
+
expect(result).toEqual({ claude: false, opencode: false, pi: false });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("detects Claude when .claude/ exists", async () => {
|
|
33
|
+
mkdirSync(join(tmpDir, ".claude"), { recursive: true });
|
|
34
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
35
|
+
const result = detectAgents(tmpDir);
|
|
36
|
+
expect(result.claude).toBe(true);
|
|
37
|
+
expect(result.opencode).toBe(false);
|
|
38
|
+
expect(result.pi).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("detects OpenCode when ~/.config/opencode exists", async () => {
|
|
42
|
+
mkdirSync(join(tmpDir, ".config", "opencode"), { recursive: true });
|
|
43
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
44
|
+
const result = detectAgents(tmpDir);
|
|
45
|
+
expect(result.claude).toBe(false);
|
|
46
|
+
expect(result.opencode).toBe(true);
|
|
47
|
+
expect(result.pi).toBe(false);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("detects Pi when ~/.pi/ directory exists", async () => {
|
|
51
|
+
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
|
|
52
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
53
|
+
const result = detectAgents(tmpDir);
|
|
54
|
+
expect(result.claude).toBe(false);
|
|
55
|
+
expect(result.opencode).toBe(false);
|
|
56
|
+
expect(result.pi).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("detects Pi when ~/pi.toml file exists", async () => {
|
|
60
|
+
const { writeFileSync } = await import("fs");
|
|
61
|
+
writeFileSync(join(tmpDir, "pi.toml"), "# Pi config\n");
|
|
62
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
63
|
+
const result = detectAgents(tmpDir);
|
|
64
|
+
expect(result.pi).toBe(true);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("detects multiple agents when multiple dirs exist", async () => {
|
|
68
|
+
mkdirSync(join(tmpDir, ".claude"), { recursive: true });
|
|
69
|
+
mkdirSync(join(tmpDir, ".config", "opencode"), { recursive: true });
|
|
70
|
+
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
|
|
71
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
72
|
+
const result = detectAgents(tmpDir);
|
|
73
|
+
expect(result).toEqual({ claude: true, opencode: true, pi: true });
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("detects Claude + Pi but not OpenCode", async () => {
|
|
77
|
+
mkdirSync(join(tmpDir, ".claude"), { recursive: true });
|
|
78
|
+
mkdirSync(join(tmpDir, ".pi"), { recursive: true });
|
|
79
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
80
|
+
const result = detectAgents(tmpDir);
|
|
81
|
+
expect(result).toEqual({ claude: true, opencode: false, pi: true });
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("exports DetectResult shape (type check via runtime properties)", async () => {
|
|
85
|
+
const { detectAgents } = await import("../../cli/detect.js");
|
|
86
|
+
const result = detectAgents(tmpDir);
|
|
87
|
+
expect(typeof result.claude).toBe("boolean");
|
|
88
|
+
expect(typeof result.opencode).toBe("boolean");
|
|
89
|
+
expect(typeof result.pi).toBe("boolean");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/install.test.ts — Integration tests for the install orchestrator.
|
|
3
|
+
*
|
|
4
|
+
* Tests the runInstallCli function end-to-end using a tmp homedir so no real
|
|
5
|
+
* config files are touched.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdirSync, rmSync, existsSync, readFileSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
|
|
12
|
+
function makeTmp(): string {
|
|
13
|
+
const dir = join(
|
|
14
|
+
os.tmpdir(),
|
|
15
|
+
`ltm-install-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
16
|
+
);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("runInstallCli — orchestration", () => {
|
|
22
|
+
let tmpDir: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(() => {
|
|
25
|
+
tmpDir = makeTmp();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
afterEach(() => {
|
|
29
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("installs into Claude when --claude flag given", async () => {
|
|
33
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
34
|
+
const result = await runInstallCli({
|
|
35
|
+
targets: { claude: true, opencode: false, pi: false },
|
|
36
|
+
dryRun: false,
|
|
37
|
+
homedir: tmpDir,
|
|
38
|
+
silent: true,
|
|
39
|
+
});
|
|
40
|
+
expect(result.exitCode).toBe(0);
|
|
41
|
+
expect(result.results.length).toBe(1);
|
|
42
|
+
expect(result.results[0]!.target).toBe("claude");
|
|
43
|
+
expect(result.results[0]!.status).toBe("installed");
|
|
44
|
+
expect(existsSync(join(tmpDir, ".claude", "settings.json"))).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("installs into OpenCode when --opencode flag given", async () => {
|
|
48
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
49
|
+
const result = await runInstallCli({
|
|
50
|
+
targets: { claude: false, opencode: true, pi: false },
|
|
51
|
+
dryRun: false,
|
|
52
|
+
homedir: tmpDir,
|
|
53
|
+
silent: true,
|
|
54
|
+
});
|
|
55
|
+
expect(result.exitCode).toBe(0);
|
|
56
|
+
expect(result.results[0]!.target).toBe("opencode");
|
|
57
|
+
expect(result.results[0]!.status).toBe("installed");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("installs into Pi when --pi flag given", async () => {
|
|
61
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
62
|
+
const result = await runInstallCli({
|
|
63
|
+
targets: { claude: false, opencode: false, pi: true },
|
|
64
|
+
dryRun: false,
|
|
65
|
+
homedir: tmpDir,
|
|
66
|
+
silent: true,
|
|
67
|
+
});
|
|
68
|
+
expect(result.exitCode).toBe(0);
|
|
69
|
+
expect(result.results[0]!.target).toBe("pi");
|
|
70
|
+
// "skipped" is valid when Pi is already registered in the real environment
|
|
71
|
+
expect(["installed", "skipped"]).toContain(result.results[0]!.status);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("e2e: installs all three targets when all flags given", async () => {
|
|
75
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
76
|
+
const result = await runInstallCli({
|
|
77
|
+
targets: { claude: true, opencode: true, pi: true },
|
|
78
|
+
dryRun: false,
|
|
79
|
+
homedir: tmpDir,
|
|
80
|
+
silent: true,
|
|
81
|
+
});
|
|
82
|
+
expect(result.exitCode).toBe(0);
|
|
83
|
+
expect(result.results.length).toBe(3);
|
|
84
|
+
const statuses = result.results.map((r) => r.status);
|
|
85
|
+
// "skipped" is also a valid success (e.g. Pi already installed)
|
|
86
|
+
expect(statuses.every((s) => s === "installed" || s === "skipped")).toBe(true);
|
|
87
|
+
|
|
88
|
+
// Verify Claude and OpenCode config files were created
|
|
89
|
+
expect(existsSync(join(tmpDir, ".claude", "settings.json"))).toBe(true);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("second run → all skipped", async () => {
|
|
93
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
94
|
+
const opts = {
|
|
95
|
+
targets: { claude: true, opencode: true, pi: true },
|
|
96
|
+
dryRun: false,
|
|
97
|
+
homedir: tmpDir,
|
|
98
|
+
silent: true,
|
|
99
|
+
};
|
|
100
|
+
await runInstallCli(opts);
|
|
101
|
+
const second = await runInstallCli(opts);
|
|
102
|
+
expect(second.exitCode).toBe(0);
|
|
103
|
+
const statuses = second.results.map((r) => r.status);
|
|
104
|
+
expect(statuses.every((s) => s === "skipped")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("no flags + empty homedir → exit code 2 (nothing detected)", async () => {
|
|
108
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
109
|
+
const result = await runInstallCli({
|
|
110
|
+
targets: { claude: false, opencode: false, pi: false },
|
|
111
|
+
dryRun: false,
|
|
112
|
+
homedir: tmpDir,
|
|
113
|
+
silent: true,
|
|
114
|
+
});
|
|
115
|
+
expect(result.exitCode).toBe(2);
|
|
116
|
+
expect(result.results.length).toBe(0);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("auto-detects Claude when .claude/ exists and no flags given", async () => {
|
|
120
|
+
mkdirSync(join(tmpDir, ".claude"), { recursive: true });
|
|
121
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
122
|
+
const result = await runInstallCli({
|
|
123
|
+
targets: { claude: false, opencode: false, pi: false },
|
|
124
|
+
dryRun: false,
|
|
125
|
+
homedir: tmpDir,
|
|
126
|
+
silent: true,
|
|
127
|
+
});
|
|
128
|
+
expect(result.exitCode).toBe(0);
|
|
129
|
+
expect(result.results.some((r) => r.target === "claude")).toBe(true);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("dryRun propagates to all installers — no files written", async () => {
|
|
133
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
134
|
+
await runInstallCli({
|
|
135
|
+
targets: { claude: true, opencode: true, pi: true },
|
|
136
|
+
dryRun: true,
|
|
137
|
+
homedir: tmpDir,
|
|
138
|
+
silent: true,
|
|
139
|
+
});
|
|
140
|
+
// No files should be written
|
|
141
|
+
expect(existsSync(join(tmpDir, ".claude", "settings.json"))).toBe(false);
|
|
142
|
+
expect(existsSync(join(tmpDir, ".pi", "config.toml"))).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("Claude settings.json has correct MCP entry after install", async () => {
|
|
146
|
+
const { runInstallCli } = await import("../../cli/install.js");
|
|
147
|
+
await runInstallCli({
|
|
148
|
+
targets: { claude: true, opencode: false, pi: false },
|
|
149
|
+
dryRun: false,
|
|
150
|
+
homedir: tmpDir,
|
|
151
|
+
silent: true,
|
|
152
|
+
});
|
|
153
|
+
const settings = JSON.parse(
|
|
154
|
+
readFileSync(join(tmpDir, ".claude", "settings.json"), "utf8"),
|
|
155
|
+
) as Record<string, unknown>;
|
|
156
|
+
const ltm = (settings["mcpServers"] as Record<string, unknown>)["openltm"] as {
|
|
157
|
+
command: string;
|
|
158
|
+
};
|
|
159
|
+
expect(ltm.command).toBe("bunx");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/opencode.test.ts — Unit tests for the OpenCode installer.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
5
|
+
import { mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
import os from "os";
|
|
8
|
+
|
|
9
|
+
function makeTmp(): string {
|
|
10
|
+
const dir = join(
|
|
11
|
+
os.tmpdir(),
|
|
12
|
+
`ltm-opencode-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
13
|
+
);
|
|
14
|
+
mkdirSync(dir, { recursive: true });
|
|
15
|
+
return dir;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function defaultConfigPath(tmpDir: string): string {
|
|
19
|
+
// Tests run on linux in CI or darwin locally — always use .config path since
|
|
20
|
+
// we're not in a real darwin environment with ~/Library
|
|
21
|
+
return join(tmpDir, ".config", "opencode", "opencode.json");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("installOpenCode", () => {
|
|
25
|
+
let tmpDir: string;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
tmpDir = makeTmp();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
afterEach(() => {
|
|
32
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("creates new config with correct shape when no config exists", async () => {
|
|
36
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
37
|
+
const result = await installOpenCode({ homedir: tmpDir });
|
|
38
|
+
expect(result.status).toBe("installed");
|
|
39
|
+
|
|
40
|
+
// Find the created file (either .config or Library path)
|
|
41
|
+
const linuxPath = join(tmpDir, ".config", "opencode", "opencode.json");
|
|
42
|
+
const darwinPath = join(tmpDir, "Library", "Application Support", "opencode", "opencode.json");
|
|
43
|
+
const created = existsSync(linuxPath) ? linuxPath : darwinPath;
|
|
44
|
+
expect(existsSync(created)).toBe(true);
|
|
45
|
+
|
|
46
|
+
const cfg = JSON.parse(readFileSync(created, "utf8")) as Record<string, unknown>;
|
|
47
|
+
expect(cfg["$schema"]).toBe("https://opencode.ai/config.json");
|
|
48
|
+
expect(Array.isArray(cfg["plugin"])).toBe(true);
|
|
49
|
+
expect((cfg["plugin"] as string[]).some((p) => p.includes("@rohirik/opencode-ltm"))).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("patches existing config by adding to plugin array", async () => {
|
|
53
|
+
// Create existing config
|
|
54
|
+
const configDir = join(tmpDir, ".config", "opencode");
|
|
55
|
+
mkdirSync(configDir, { recursive: true });
|
|
56
|
+
const configPath = join(configDir, "opencode.json");
|
|
57
|
+
writeFileSync(
|
|
58
|
+
configPath,
|
|
59
|
+
JSON.stringify({ $schema: "https://opencode.ai/config.json", theme: "dark", plugin: [] }),
|
|
60
|
+
"utf8",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
64
|
+
const result = await installOpenCode({ homedir: tmpDir });
|
|
65
|
+
expect(result.status).toBe("installed");
|
|
66
|
+
|
|
67
|
+
const cfg = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
68
|
+
expect(cfg["theme"]).toBe("dark");
|
|
69
|
+
const plugins = cfg["plugin"] as string[];
|
|
70
|
+
expect(plugins.some((p) => p.includes("@rohirik/opencode-ltm"))).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("preserves other keys in existing config", async () => {
|
|
74
|
+
const configDir = join(tmpDir, ".config", "opencode");
|
|
75
|
+
mkdirSync(configDir, { recursive: true });
|
|
76
|
+
const configPath = join(configDir, "opencode.json");
|
|
77
|
+
writeFileSync(
|
|
78
|
+
configPath,
|
|
79
|
+
JSON.stringify({
|
|
80
|
+
$schema: "https://opencode.ai/config.json",
|
|
81
|
+
model: "claude-opus-4",
|
|
82
|
+
keybindings: "vim",
|
|
83
|
+
plugin: ["@other/tool@1.0.0"],
|
|
84
|
+
}),
|
|
85
|
+
"utf8",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
89
|
+
await installOpenCode({ homedir: tmpDir });
|
|
90
|
+
|
|
91
|
+
const cfg = JSON.parse(readFileSync(configPath, "utf8")) as Record<string, unknown>;
|
|
92
|
+
expect(cfg["model"]).toBe("claude-opus-4");
|
|
93
|
+
expect(cfg["keybindings"]).toBe("vim");
|
|
94
|
+
const plugins = cfg["plugin"] as string[];
|
|
95
|
+
expect(plugins).toContain("@other/tool@1.0.0");
|
|
96
|
+
expect(plugins.some((p) => p.includes("@rohirik/opencode-ltm"))).toBe(true);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Pre-deploy the customization components so the skip path can be exercised:
|
|
100
|
+
// status is only "skipped" when the plugin is registered AND every component
|
|
101
|
+
// is already present.
|
|
102
|
+
function preDeployComponents(configDir: string): void {
|
|
103
|
+
for (const comp of ["agents", "skills", "plugins"]) {
|
|
104
|
+
const dir = join(configDir, comp);
|
|
105
|
+
mkdirSync(dir, { recursive: true });
|
|
106
|
+
writeFileSync(join(dir, ".installed"), "", "utf8");
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
it("skips when plugin is registered and components are already deployed", async () => {
|
|
111
|
+
const configDir = join(tmpDir, ".config", "opencode");
|
|
112
|
+
mkdirSync(configDir, { recursive: true });
|
|
113
|
+
writeFileSync(
|
|
114
|
+
join(configDir, "opencode.json"),
|
|
115
|
+
JSON.stringify({ plugin: ["@rohirik/opencode-ltm@latest"] }),
|
|
116
|
+
"utf8",
|
|
117
|
+
);
|
|
118
|
+
preDeployComponents(configDir);
|
|
119
|
+
|
|
120
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
121
|
+
const result = await installOpenCode({ homedir: tmpDir });
|
|
122
|
+
expect(result.status).toBe("skipped");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("skips even if version differs (matches on substring @rohirik/opencode-ltm)", async () => {
|
|
126
|
+
const configDir = join(tmpDir, ".config", "opencode");
|
|
127
|
+
mkdirSync(configDir, { recursive: true });
|
|
128
|
+
writeFileSync(
|
|
129
|
+
join(configDir, "opencode.json"),
|
|
130
|
+
JSON.stringify({ plugin: ["@rohirik/opencode-ltm@2.0.0"] }),
|
|
131
|
+
"utf8",
|
|
132
|
+
);
|
|
133
|
+
preDeployComponents(configDir);
|
|
134
|
+
|
|
135
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
136
|
+
const result = await installOpenCode({ homedir: tmpDir });
|
|
137
|
+
expect(result.status).toBe("skipped");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("deploys agents, skills, and plugins from the bundled assets", async () => {
|
|
141
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
142
|
+
const result = await installOpenCode({ homedir: tmpDir });
|
|
143
|
+
expect(result.status).toBe("installed");
|
|
144
|
+
|
|
145
|
+
// Components co-locate with the resolved config (.config on linux,
|
|
146
|
+
// Library/Application Support on darwin).
|
|
147
|
+
const linuxDir = join(tmpDir, ".config", "opencode");
|
|
148
|
+
const darwinDir = join(tmpDir, "Library", "Application Support", "opencode");
|
|
149
|
+
const configDir = existsSync(join(linuxDir, "agents")) ? linuxDir : darwinDir;
|
|
150
|
+
|
|
151
|
+
// The bundled assets ship an aegis agent, three skills, and an aegis plugin.
|
|
152
|
+
expect(existsSync(join(configDir, "agents", "aegis.md"))).toBe(true);
|
|
153
|
+
expect(existsSync(join(configDir, "skills", "AgentTrustBoundaries", "SKILL.md"))).toBe(true);
|
|
154
|
+
expect(existsSync(join(configDir, "plugins", "aegis.ts"))).toBe(true);
|
|
155
|
+
// Lockfiles and node_modules must never be copied into the user's config.
|
|
156
|
+
expect(existsSync(join(configDir, "skills", "node_modules"))).toBe(false);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("dryRun=true does not write any files", async () => {
|
|
160
|
+
const { installOpenCode } = await import("../../cli/opencode.js");
|
|
161
|
+
const result = await installOpenCode({ homedir: tmpDir, dryRun: true });
|
|
162
|
+
expect(result.status).toBe("installed");
|
|
163
|
+
|
|
164
|
+
const linuxPath = join(tmpDir, ".config", "opencode", "opencode.json");
|
|
165
|
+
const darwinPath = join(tmpDir, "Library", "Application Support", "opencode", "opencode.json");
|
|
166
|
+
expect(existsSync(linuxPath)).toBe(false);
|
|
167
|
+
expect(existsSync(darwinPath)).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/pi.test.ts — Unit tests for the Pi installer.
|
|
3
|
+
*
|
|
4
|
+
* Uses a fake `pi` shell script injected via `_piCmd` so tests never touch
|
|
5
|
+
* the real Pi installation or require Pi to be installed.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { mkdirSync, rmSync, writeFileSync, chmodSync } from "fs";
|
|
9
|
+
import { join } from "path";
|
|
10
|
+
import os from "os";
|
|
11
|
+
|
|
12
|
+
function makeTmp(): string {
|
|
13
|
+
const dir = join(
|
|
14
|
+
os.tmpdir(),
|
|
15
|
+
`ltm-pi-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
16
|
+
);
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
return dir;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Create a fake `pi` script in tmpDir and return its path. */
|
|
22
|
+
function makeFakePi(tmpDir: string, opts: { listOutput?: string; installFails?: boolean } = {}): string {
|
|
23
|
+
const piPath = join(tmpDir, "pi");
|
|
24
|
+
const listOut = opts.listOutput ?? "No packages installed.";
|
|
25
|
+
const exitCode = opts.installFails ? 1 : 0;
|
|
26
|
+
|
|
27
|
+
writeFileSync(
|
|
28
|
+
piPath,
|
|
29
|
+
`#!/bin/sh
|
|
30
|
+
if [ "$1" = "list" ]; then
|
|
31
|
+
echo "${listOut}"
|
|
32
|
+
exit 0
|
|
33
|
+
fi
|
|
34
|
+
if [ "$1" = "install" ]; then
|
|
35
|
+
exit ${exitCode}
|
|
36
|
+
fi
|
|
37
|
+
exit 1
|
|
38
|
+
`,
|
|
39
|
+
"utf8",
|
|
40
|
+
);
|
|
41
|
+
chmodSync(piPath, 0o755);
|
|
42
|
+
return piPath;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("installPi", () => {
|
|
46
|
+
let tmpDir: string;
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
tmpDir = makeTmp();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("installs when pi CLI is available and package is not yet installed", async () => {
|
|
57
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
58
|
+
const piCmd = makeFakePi(tmpDir);
|
|
59
|
+
const result = await installPi({ _piCmd: piCmd });
|
|
60
|
+
expect(result.status).toBe("installed");
|
|
61
|
+
expect(result.detail).toContain("@rohirik/pi-ltm");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("skips when package is already installed", async () => {
|
|
65
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
66
|
+
const piCmd = makeFakePi(tmpDir, { listOutput: "npm:@rohirik/pi-ltm" });
|
|
67
|
+
const result = await installPi({ _piCmd: piCmd });
|
|
68
|
+
expect(result.status).toBe("skipped");
|
|
69
|
+
expect(result.detail).toContain("already registered");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns skipped with helpful message when pi CLI is not found", async () => {
|
|
73
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
74
|
+
// Pass a non-existent path so `pi list` will throw
|
|
75
|
+
const result = await installPi({ _piCmd: "/nonexistent/pi" });
|
|
76
|
+
// isAlreadyInstalled catches the error and returns false, then install attempt fails
|
|
77
|
+
expect(result.status).toBe("error");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("returns skipped when no pi CLI available (null)", async () => {
|
|
81
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
82
|
+
// Simulate findPiCli returning null by not providing _piCmd and ensuring 'pi' is not in PATH
|
|
83
|
+
// We can't easily test this without mocking, so test the _piCmd=undefined path indirectly
|
|
84
|
+
// by checking the skipped result message
|
|
85
|
+
const piCmd = makeFakePi(tmpDir);
|
|
86
|
+
const result = await installPi({ _piCmd: piCmd, dryRun: true });
|
|
87
|
+
expect(result.status).toBe("installed");
|
|
88
|
+
expect(result.detail).toContain("dry-run");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("dryRun=true does not call pi install", async () => {
|
|
92
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
93
|
+
// If install ran, the fake pi would exit 0; but dryRun should short-circuit
|
|
94
|
+
const piCmd = makeFakePi(tmpDir);
|
|
95
|
+
const result = await installPi({ _piCmd: piCmd, dryRun: true });
|
|
96
|
+
expect(result.status).toBe("installed");
|
|
97
|
+
expect(result.detail).toContain("dry-run");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("returns error when pi install fails", async () => {
|
|
101
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
102
|
+
const piCmd = makeFakePi(tmpDir, { installFails: true });
|
|
103
|
+
const result = await installPi({ _piCmd: piCmd });
|
|
104
|
+
expect(result.status).toBe("error");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("skips when already installed even in dryRun mode", async () => {
|
|
108
|
+
const { installPi } = await import("../../cli/pi.js");
|
|
109
|
+
const piCmd = makeFakePi(tmpDir, { listOutput: "npm:@rohirik/pi-ltm" });
|
|
110
|
+
const result = await installPi({ _piCmd: piCmd, dryRun: true });
|
|
111
|
+
expect(result.status).toBe("skipped");
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli.test.ts — unit tests for packages/openltm-core/src/cli/types.ts and cli/install.ts
|
|
3
|
+
*
|
|
4
|
+
* RED phase: these tests are written before implementation.
|
|
5
|
+
* They define the contract for the CLI installer stub.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, it, expect } from "bun:test";
|
|
8
|
+
|
|
9
|
+
// ── types.ts ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
describe("cli/types — InstallTarget shape", () => {
|
|
12
|
+
it("InstallTarget has required host and pluginDataDir fields", async () => {
|
|
13
|
+
const { InstallTarget } = await import("../cli/types.js");
|
|
14
|
+
// Runtime check: the exported object is a string-enum-like map
|
|
15
|
+
expect(typeof InstallTarget).toBe("object");
|
|
16
|
+
expect(InstallTarget["claudeCode"]).toBe("claude-code");
|
|
17
|
+
expect(InstallTarget["openCode"]).toBe("open-code");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("CliInstallOptions type is exported (compile-time guard via import)", async () => {
|
|
21
|
+
// Just importing the module means the types compile; no runtime value to assert.
|
|
22
|
+
const mod = await import("../cli/types.js");
|
|
23
|
+
expect(mod).toBeDefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("CliInstallResult type is exported from types module", async () => {
|
|
27
|
+
const mod = await import("../cli/types.js");
|
|
28
|
+
expect(mod).toBeDefined();
|
|
29
|
+
// CliInstallResult is a type-only export; confirm the module loaded successfully
|
|
30
|
+
expect(Object.keys(mod).length).toBeGreaterThan(0);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// ── install.ts ────────────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
describe("cli/install — runInstall stub", () => {
|
|
37
|
+
it("exports a runInstall function", async () => {
|
|
38
|
+
const mod = await import("../cli/install.js");
|
|
39
|
+
expect(typeof mod.runInstall).toBe("function");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("runInstall returns a CliInstallResult with success and message", async () => {
|
|
43
|
+
const { runInstall } = await import("../cli/install.js");
|
|
44
|
+
const result = await runInstall({ target: "claude-code", nonInteractive: true, dryRun: true });
|
|
45
|
+
expect(typeof result.success).toBe("boolean");
|
|
46
|
+
expect(typeof result.message).toBe("string");
|
|
47
|
+
expect(result.message.length).toBeGreaterThan(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("runInstall dryRun=true does not write any files", async () => {
|
|
51
|
+
const { runInstall } = await import("../cli/install.js");
|
|
52
|
+
// Dry run should succeed without side-effects
|
|
53
|
+
const result = await runInstall({ target: "claude-code", nonInteractive: true, dryRun: true });
|
|
54
|
+
expect(result.success).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("runInstall dryRun=true returns steps array", async () => {
|
|
58
|
+
const { runInstall } = await import("../cli/install.js");
|
|
59
|
+
const result = await runInstall({ target: "claude-code", nonInteractive: true, dryRun: true });
|
|
60
|
+
expect(Array.isArray(result.steps)).toBe(true);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("runInstall with unknown target returns success=false", async () => {
|
|
64
|
+
const { runInstall } = await import("../cli/install.js");
|
|
65
|
+
// @ts-expect-error intentional bad input
|
|
66
|
+
const result = await runInstall({ target: "not-a-real-target", nonInteractive: true, dryRun: true });
|
|
67
|
+
expect(result.success).toBe(false);
|
|
68
|
+
expect(result.message).toContain("Unknown target");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* crossProcess.test.ts — opt-in cross-agent sync contract (Phase 8 X1).
|
|
3
|
+
* The flag `ltm.crossProcessSync` defaults off; even when on, cross-process
|
|
4
|
+
* push requires Honker, so without an ext binary every entry point is a no-op.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
7
|
+
import { Database } from "bun:sqlite";
|
|
8
|
+
import { _setDbForTesting } from "../../shared-db.js";
|
|
9
|
+
import { SETTING_KEYS, SETTING_DEFAULTS } from "../../janitor/providers/types.js";
|
|
10
|
+
import {
|
|
11
|
+
isCrossProcessSyncEnabled,
|
|
12
|
+
notifyMemoryAdded,
|
|
13
|
+
startCrossProcessSync,
|
|
14
|
+
MEMORY_ADDED,
|
|
15
|
+
} from "../../events/index.js";
|
|
16
|
+
import { _resetHonkerForTesting } from "../../lib/honker.js";
|
|
17
|
+
|
|
18
|
+
function seedDb(): Database {
|
|
19
|
+
const db = new Database(":memory:");
|
|
20
|
+
db.exec(`CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL, updated_at TEXT)`);
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe("cross-process sync — flag + dormant contract (no honker)", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
_resetHonkerForTesting();
|
|
27
|
+
delete process.env["LTM_HONKER_EXT"];
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
_resetHonkerForTesting();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("defaults off (setting default is \"off\")", () => {
|
|
34
|
+
expect(SETTING_DEFAULTS[SETTING_KEYS.CROSS_PROCESS_SYNC]).toBe("off");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("exposes the stable memory_added event type", () => {
|
|
38
|
+
expect(MEMORY_ADDED).toBe("memory_added");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("isCrossProcessSyncEnabled is false when unset", () => {
|
|
42
|
+
_setDbForTesting(seedDb());
|
|
43
|
+
expect(isCrossProcessSyncEnabled()).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("isCrossProcessSyncEnabled is true only when the setting is \"on\"", () => {
|
|
47
|
+
const db = seedDb();
|
|
48
|
+
db.run("INSERT INTO settings(key,value) VALUES (?, 'on')", [SETTING_KEYS.CROSS_PROCESS_SYNC]);
|
|
49
|
+
_setDbForTesting(db);
|
|
50
|
+
expect(isCrossProcessSyncEnabled()).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("notifyMemoryAdded returns false when the flag is off", () => {
|
|
54
|
+
_setDbForTesting(seedDb());
|
|
55
|
+
expect(notifyMemoryAdded({ id: 1 })).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("notifyMemoryAdded returns false when flag on but honker unavailable", () => {
|
|
59
|
+
const db = seedDb();
|
|
60
|
+
db.run("INSERT INTO settings(key,value) VALUES (?, 'on')", [SETTING_KEYS.CROSS_PROCESS_SYNC]);
|
|
61
|
+
_setDbForTesting(db);
|
|
62
|
+
expect(notifyMemoryAdded({ id: 1, project_scope: "demo" })).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("startCrossProcessSync is inert when flag off, never firing the callback", async () => {
|
|
66
|
+
_setDbForTesting(seedDb());
|
|
67
|
+
let calls = 0;
|
|
68
|
+
const h = startCrossProcessSync(() => { calls++; });
|
|
69
|
+
expect(h.running).toBe(false);
|
|
70
|
+
await expect(h.stop()).resolves.toBeUndefined();
|
|
71
|
+
expect(calls).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("startCrossProcessSync is inert when flag on but honker unavailable", async () => {
|
|
75
|
+
const db = seedDb();
|
|
76
|
+
db.run("INSERT INTO settings(key,value) VALUES (?, 'on')", [SETTING_KEYS.CROSS_PROCESS_SYNC]);
|
|
77
|
+
_setDbForTesting(db);
|
|
78
|
+
const h = startCrossProcessSync(() => {});
|
|
79
|
+
expect(h.running).toBe(false);
|
|
80
|
+
await expect(h.stop()).resolves.toBeUndefined();
|
|
81
|
+
});
|
|
82
|
+
});
|