Skip to content

Commit c5a6462

Browse files
feat(memory): install AgentMemory service during setup
Co-Authored-By: First Fluke <our.first.fluke@gmail.com>
1 parent 07c206d commit c5a6462

4 files changed

Lines changed: 163 additions & 4 deletions

File tree

cli/commands/memory/command.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,13 +127,15 @@ export function registerMemory(program: Command): void {
127127
service
128128
.command("install")
129129
.description("Install AgentMemory launchd/systemd service integration")
130+
.option("--port <port>", "Loopback REST port", "3111")
130131
.option("--dry-run", "Preview service install without writing files"),
131132
).action(
132133
runAction(
133134
async (options) => {
134135
printAgentMemoryServiceInstall(
135136
resolveJsonMode(options),
136137
options.dryRun,
138+
options.port,
137139
);
138140
},
139141
{ supportsJsonOutput: true },

cli/commands/memory/memory.test.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ describe("memory commands", () => {
127127
const result = await setupAgentMemory({
128128
homeDir: projectDir,
129129
env: { OMA_NO_AGENTMEMORY: "1" },
130+
platform: "darwin",
130131
dryRun: false,
131132
install: true,
132133
async installer() {
@@ -139,15 +140,27 @@ describe("memory commands", () => {
139140
expect(result).toMatchObject({
140141
installRequested: true,
141142
installExitCode: 0,
143+
service: {
144+
supported: true,
145+
wroteFile: true,
146+
},
142147
startRequested: false,
143148
});
149+
expect(result.service?.servicePath).toContain("LaunchAgents");
150+
const serviceFile = readFileSync(
151+
result.service?.servicePath ?? "",
152+
"utf-8",
153+
);
154+
expect(serviceFile).toContain("III_REST_PORT");
155+
expect(serviceFile).toContain("/usr/bin/env");
144156
});
145157

146158
it("skips install command in setup dry-run mode", async () => {
147159
let installCount = 0;
148160
const result = await setupAgentMemory({
149161
homeDir: projectDir,
150162
env: { OMA_NO_AGENTMEMORY: "1" },
163+
platform: "darwin",
151164
dryRun: true,
152165
install: true,
153166
async installer() {
@@ -161,7 +174,38 @@ describe("memory commands", () => {
161174
installRequested: true,
162175
installSkipped: true,
163176
dryRun: true,
177+
service: {
178+
supported: true,
179+
dryRun: true,
180+
wroteFile: false,
181+
},
164182
});
183+
expect(result.service?.content).toContain("agentmemory");
184+
});
185+
186+
it("does not install the service when setup package install fails", async () => {
187+
await expect(
188+
setupAgentMemory({
189+
homeDir: projectDir,
190+
env: { OMA_NO_AGENTMEMORY: "1" },
191+
platform: "darwin",
192+
install: true,
193+
async installer() {
194+
return { status: 1, error: "install failed" };
195+
},
196+
}),
197+
).rejects.toThrow("install failed");
198+
199+
expect(
200+
existsSync(
201+
join(
202+
projectDir,
203+
"Library",
204+
"LaunchAgents",
205+
"dev.oma.agentmemory.plist",
206+
),
207+
),
208+
).toBe(false);
165209
});
166210

167211
it("previews AgentMemory daemon start without writing endpoint or pid files", async () => {
@@ -183,18 +227,21 @@ describe("memory commands", () => {
183227
expect(existsSync(result.pidPath)).toBe(false);
184228
});
185229

186-
it("returns deferred service install surface for supported platforms", () => {
230+
it("previews service install files for supported platforms", () => {
187231
expect(
188232
installAgentMemoryService({
189233
homeDir: projectDir,
190234
platform: "darwin",
191235
dryRun: true,
236+
port: 3444,
192237
}),
193238
).toMatchObject({
194239
action: "install",
195240
platform: "darwin",
196241
supported: true,
197242
dryRun: true,
243+
wroteFile: false,
244+
content: expect.stringContaining("3444"),
198245
});
199246
});
200247

cli/commands/memory/memory.ts

Lines changed: 110 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const AGENTMEMORY_INSTALL_COMMAND = "bun install -g @agentmemory/agentmemory";
3838
const AGENTMEMORY_START_COMMAND = "agentmemory";
3939
const DEFAULT_AGENTMEMORY_PORT = 3111;
4040
const OMA_AGENTMEMORY_PID_FILE = "oma-agentmemory.pid";
41+
const LAUNCHD_AGENTMEMORY_LABEL = "dev.oma.agentmemory";
4142

4243
type AgentMemoryInstaller = () => Promise<{
4344
status: number | null;
@@ -135,6 +136,69 @@ function writeOwnedPid(homeDir: string, pid: number): void {
135136
writeFileSync(pidPath, `${pid}\n`, { encoding: "utf-8", mode: 0o600 });
136137
}
137138

139+
function servicePathEnvironment(homeDir: string): string {
140+
return [
141+
join(homeDir, ".bun", "bin"),
142+
join(homeDir, ".local", "bin"),
143+
"/opt/homebrew/bin",
144+
"/usr/local/bin",
145+
"/usr/bin",
146+
"/bin",
147+
"/usr/sbin",
148+
"/sbin",
149+
].join(":");
150+
}
151+
152+
function renderLaunchdService(args: { homeDir: string; port: number }): string {
153+
return `<?xml version="1.0" encoding="UTF-8"?>
154+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
155+
<plist version="1.0">
156+
<dict>
157+
<key>Label</key>
158+
<string>${LAUNCHD_AGENTMEMORY_LABEL}</string>
159+
<key>ProgramArguments</key>
160+
<array>
161+
<string>/usr/bin/env</string>
162+
<string>agentmemory</string>
163+
</array>
164+
<key>EnvironmentVariables</key>
165+
<dict>
166+
<key>PATH</key>
167+
<string>${servicePathEnvironment(args.homeDir)}</string>
168+
<key>III_REST_PORT</key>
169+
<string>${args.port}</string>
170+
</dict>
171+
<key>RunAtLoad</key>
172+
<true/>
173+
<key>KeepAlive</key>
174+
<true/>
175+
<key>StandardOutPath</key>
176+
<string>/tmp/oma-agentmemory.out.log</string>
177+
<key>StandardErrorPath</key>
178+
<string>/tmp/oma-agentmemory.err.log</string>
179+
</dict>
180+
</plist>
181+
`;
182+
}
183+
184+
function renderSystemdService(args: { homeDir: string; port: number }): string {
185+
return `[Unit]
186+
Description=OMA AgentMemory daemon
187+
After=network.target
188+
189+
[Service]
190+
Type=simple
191+
Environment=PATH=${servicePathEnvironment(args.homeDir)}
192+
Environment=III_REST_PORT=${args.port}
193+
ExecStart=/usr/bin/env agentmemory
194+
Restart=on-failure
195+
RestartSec=2
196+
197+
[Install]
198+
WantedBy=default.target
199+
`;
200+
}
201+
138202
export async function initMemory(
139203
jsonMode = false,
140204
forceMode = false,
@@ -189,6 +253,7 @@ export async function setupAgentMemory(
189253
dryRun?: boolean;
190254
install?: boolean;
191255
start?: boolean;
256+
platform?: NodeJS.Platform;
192257
installer?: AgentMemoryInstaller;
193258
} = {},
194259
): Promise<MemorySetupResult> {
@@ -200,6 +265,7 @@ export async function setupAgentMemory(
200265
let installExitCode: number | null | undefined;
201266
let installSkipped: boolean | undefined;
202267
let installError: string | undefined;
268+
let service: MemoryServiceResult | undefined;
203269
let daemon: MemoryDaemonResult | undefined;
204270

205271
const nextConfig: AgentMemoryEndpointConfig | null = args.endpoint
@@ -232,6 +298,14 @@ export async function setupAgentMemory(
232298
);
233299
}
234300
}
301+
if (args.dryRun || installExitCode === 0) {
302+
service = installAgentMemoryService({
303+
homeDir,
304+
platform: args.platform ?? process.platform,
305+
dryRun: args.dryRun,
306+
port: port ?? DEFAULT_AGENTMEMORY_PORT,
307+
});
308+
}
235309
}
236310

237311
if (args.start) {
@@ -265,6 +339,7 @@ export async function setupAgentMemory(
265339
installExitCode,
266340
installSkipped,
267341
installError,
342+
service,
268343
startRequested: args.start === true,
269344
daemon,
270345
installCommand: AGENTMEMORY_INSTALL_COMMAND,
@@ -431,27 +506,50 @@ export async function controlAgentMemoryDaemon(args: {
431506
}
432507

433508
export function installAgentMemoryService(
434-
args: { homeDir?: string; platform?: NodeJS.Platform; dryRun?: boolean } = {},
509+
args: {
510+
homeDir?: string;
511+
platform?: NodeJS.Platform;
512+
dryRun?: boolean;
513+
port?: number | string;
514+
} = {},
435515
): MemoryServiceResult {
436516
const homeDir = args.homeDir ?? homedir();
437517
const platform = args.platform ?? process.platform;
518+
const port = parsePositivePort(args.port) ?? DEFAULT_AGENTMEMORY_PORT;
438519
const servicePath =
439520
platform === "darwin"
440521
? join(homeDir, "Library", "LaunchAgents", "dev.oma.agentmemory.plist")
441522
: platform === "linux"
442523
? join(homeDir, ".config", "systemd", "user", "oma-agentmemory.service")
443524
: undefined;
525+
const content =
526+
platform === "darwin"
527+
? renderLaunchdService({ homeDir, port })
528+
: platform === "linux"
529+
? renderSystemdService({ homeDir, port })
530+
: undefined;
531+
532+
let wroteFile = false;
533+
if (servicePath && content && !args.dryRun) {
534+
mkdirSync(dirname(servicePath), { recursive: true, mode: 0o700 });
535+
writeFileSync(servicePath, content, { encoding: "utf-8", mode: 0o600 });
536+
wroteFile = true;
537+
}
444538

445539
return {
446540
action: "install",
447541
platform,
448542
supported: servicePath !== undefined,
449543
dryRun: args.dryRun === true,
450544
servicePath,
545+
wroteFile,
546+
content: args.dryRun ? content : undefined,
451547
message:
452548
servicePath === undefined
453549
? `AgentMemory service install is not supported on ${platform}`
454-
: "AgentMemory service file generation is deferred to PR6; use memory:daemon start for now",
550+
: args.dryRun
551+
? "AgentMemory service file would be written"
552+
: "AgentMemory service file installed",
455553
};
456554
}
457555

@@ -598,6 +696,12 @@ export async function printAgentMemorySetup(
598696
: pc.red(String(result.installExitCode ?? "unknown"));
599697
lines.push(`Install result: ${installState}`);
600698
}
699+
if (result.service?.servicePath) {
700+
lines.push(`Service: ${pc.cyan(result.service.servicePath)}`);
701+
lines.push(
702+
`Service file: ${result.service.wroteFile ? pc.green("installed") : pc.yellow(result.service.dryRun ? "preview" : "not written")}`,
703+
);
704+
}
601705
lines.push(`Start: ${pc.cyan(result.startCommand)}`);
602706
if (result.daemon?.startedPid)
603707
lines.push(`Started PID: ${result.daemon.startedPid}`);
@@ -645,8 +749,9 @@ export async function printAgentMemoryDaemon(
645749
export function printAgentMemoryServiceInstall(
646750
jsonMode = false,
647751
dryRun = false,
752+
port?: number | string,
648753
): void {
649-
const result = installAgentMemoryService({ dryRun });
754+
const result = installAgentMemoryService({ dryRun, port });
650755
if (jsonMode) {
651756
console.log(JSON.stringify(result, null, 2));
652757
return;
@@ -656,7 +761,9 @@ export function printAgentMemoryServiceInstall(
656761
[
657762
`Platform: ${result.platform}`,
658763
result.servicePath ? `Path: ${pc.cyan(result.servicePath)}` : null,
764+
`Wrote file: ${result.wroteFile ? pc.green("yes") : "no"}`,
659765
result.supported ? pc.yellow(result.message) : pc.red(result.message),
766+
result.content ? `\n${result.content.trimEnd()}` : null,
660767
]
661768
.filter((line): line is string => line !== null)
662769
.join("\n"),

cli/types/memory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export interface MemorySetupResult {
4545
installExitCode?: number | null;
4646
installSkipped?: boolean;
4747
installError?: string;
48+
service?: MemoryServiceResult;
4849
startRequested: boolean;
4950
daemon?: MemoryDaemonResult;
5051
installCommand: string;
@@ -74,5 +75,7 @@ export interface MemoryServiceResult {
7475
supported: boolean;
7576
dryRun: boolean;
7677
servicePath?: string;
78+
wroteFile: boolean;
79+
content?: string;
7780
message: string;
7881
}

0 commit comments

Comments
 (0)