Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

The agentage CLI. Versioning RESTARTED at 0.0.x (old npm versions were
unpublished; earliest burned slot is 0.1.19 - stay below it until the line naturally
passes). Commands: `setup` (OAuth sign-in) + `status` only. The old
agent-runtime CLI (daemon, run/agents/machines/...) lives in git history only - do not
resurrect patterns from it.
passes). Commands: `setup` (OAuth sign-in), `status`, `vault`, `memory`, `daemon`. The old
agent-runtime CLI (run/agents/machines/...) lives in git history only - do not resurrect
its agent-runtime patterns (the local memory daemon was deliberately ported from it).

## Layout
- `src/cli.ts` - commander entry (excluded from coverage; keep logic out of it)
Expand All @@ -14,8 +14,14 @@ resurrect patterns from it.
status-info
- `src/lib/memory-client.ts` - the memory-verb seam; DirectClient wraps `@agentage/memory-core`
(the one local engine: git-per-vault backends + federation router). No FTS5/SQLite.
- `src/package-guard.test.ts` - CI guard: no daemon/agent-runtime remnants, runtime deps
stay exactly `@agentage/memory-core + chalk + commander + open`
- `src/lib/daemon-client.ts` - DaemonClient (MemoryClient over loopback HTTP) + `ensureDaemon`
autostart; verbs default to the daemon, DirectClient fallback (`--no-daemon`,
`AGENTAGE_NO_DAEMON=1`, or fork blocked)
- `src/daemon/` + `src/daemon-entry.ts` - the local daemon (node:http, 127.0.0.1 only): one
in-process engine serialises vault mutations; `agentage daemon start|stop|status`
- `src/package-guard.test.ts` - CI guard: no agent-runtime remnants (express/ws/sqlite/
core/platform/supabase), runtime deps stay exactly `@agentage/memory-core + chalk +
commander + open`

## Auth model
Fresh DCR public client per `setup` run (the redirect URI binds the ephemeral callback
Expand Down
127 changes: 127 additions & 0 deletions e2e/daemon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine, freePort, type CliMachine } from './helpers.js';

// M2.5 daemon tier: an explicitly managed daemon on an ephemeral port + isolated config dir owns
// the engine; the memory verbs route through it (proven by its served counter). Never touches a
// real daemon: the OS-picked port + AGENTAGE_CONFIG_DIR isolate everything, and stop only
// signals the pid this test started. @p0

const pidOf = (m: CliMachine): number | null => {
const p = join(m.configDir, 'daemon.pid');
return existsSync(p) ? Number.parseInt(readFileSync(p, 'utf-8').trim(), 10) : null;
};

const alive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};

const servedCount = (statusOut: string): number => {
const m = statusOut.match(/served\s+(\d+)/);
return m ? Number.parseInt(m[1], 10) : -1;
};

test.describe('daemon owns the engine @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('start -> six verbs through the daemon -> stop', async () => {
const port = await freePort();
// Re-enable the daemon path (helpers default it off) on the isolated port.
const m = createCliMachine({ AGENTAGE_NO_DAEMON: '', AGENTAGE_DAEMON_PORT: String(port) });
let daemonPid: number | null = null;
try {
const add = await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]);
expect(add.code, add.stderr).toBe(0);

const start = await m.exec(['daemon', 'start']);
expect(start.code, start.stderr).toBe(0);
expect(start.stdout).toContain('started');
daemonPid = pidOf(m);
expect(daemonPid, 'daemon.pid written').not.toBeNull();
expect(alive(daemonPid!)).toBe(true);

const before = await m.exec(['daemon', 'status']);
expect(before.code, before.stderr).toBe(0);
expect(before.stdout).toContain(`port ${port}`);
const served0 = servedCount(before.stdout);
expect(served0).toBeGreaterThanOrEqual(0);

const write = await m.exec(['memory', 'write', 'notes/d.md', '--body', 'daemon quokka']);
expect(write.code, write.stderr).toBe(0);

const search = await m.exec(['memory', 'search', 'quokka', '--json']);
expect(search.code, search.stderr).toBe(0);
expect(JSON.parse(search.stdout).results.map((r: { path: string }) => r.path)).toEqual([
'notes/d.md',
]);

const read = await m.exec(['memory', 'read', 'notes/d.md']);
expect(read.code, read.stderr).toBe(0);
expect(read.stdout).toContain('daemon quokka');

const edit = await m.exec([
'memory',
'edit',
'notes/d.md',
'--old',
'quokka',
'--new',
'wombat',
]);
expect(edit.code, edit.stderr).toBe(0);

const list = await m.exec(['memory', 'list', '--json']);
expect(list.code, list.stderr).toBe(0);

const del = await m.exec(['memory', 'delete', 'notes/d.md']);
expect(del.code, del.stderr).toBe(0);

// The daemon-side marker: its request counter advanced by the six verbs.
const after = await m.exec(['daemon', 'status']);
expect(after.code, after.stderr).toBe(0);
expect(servedCount(after.stdout)).toBeGreaterThanOrEqual(served0 + 6);
expect(after.stdout).toContain(`pid ${daemonPid}`);

const stop = await m.exec(['daemon', 'stop']);
expect(stop.code, stop.stderr).toBe(0);
expect(stop.stdout).toContain('stopped');
expect(existsSync(join(m.configDir, 'daemon.pid'))).toBe(false);
expect(existsSync(join(m.configDir, 'daemon.port'))).toBe(false);

// SIGTERM is asynchronous - poll until the process exits and the port frees.
const deadline = Date.now() + 5_000;
while (alive(daemonPid!) && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 100));
}
expect(alive(daemonPid!), 'daemon process terminated').toBe(false);

const status = await m.exec(['daemon', 'status']);
expect(status.stdout).toContain('not running');
} finally {
// Kill only the pid this test started, never a real daemon.
if (daemonPid !== null && alive(daemonPid)) process.kill(daemonPid, 'SIGKILL');
m.cleanup();
}
});

test('--no-daemon runs the verbs without spawning a daemon', async () => {
const port = await freePort();
const m = createCliMachine({ AGENTAGE_NO_DAEMON: '', AGENTAGE_DAEMON_PORT: String(port) });
try {
await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]);
const write = await m.exec(['--no-daemon', 'memory', 'write', 'a.md', '--body', 'direct']);
expect(write.code, write.stderr).toBe(0);
expect(existsSync(join(m.configDir, 'daemon.pid')), 'no daemon spawned').toBe(false);
const read = await m.exec(['--no-daemon', 'memory', 'read', 'a.md']);
expect(read.stdout).toContain('direct');
} finally {
m.cleanup();
}
});
});
18 changes: 18 additions & 0 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { execFile, spawn } from 'node:child_process';
import { randomBytes } from 'node:crypto';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, request as apiRequest, type APIRequestContext } from '@playwright/test';
Expand Down Expand Up @@ -48,6 +49,10 @@ export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine =
AGENTAGE_CONFIG_DIR: configDir,
AGENTAGE_SITE_FQDN: TARGET_FQDN,
NO_COLOR: '1',
// Default the offline tiers to the in-process engine: deterministic, never forks a daemon
// (the sandbox kills forked children), never probes the real :4243. The daemon tier opts
// back in with AGENTAGE_NO_DAEMON='' plus its own ephemeral AGENTAGE_DAEMON_PORT.
AGENTAGE_NO_DAEMON: '1',
...extraEnv,
};

Expand Down Expand Up @@ -85,6 +90,19 @@ export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine =
};
};

// An ephemeral loopback port for the daemon tier - the OS picks it, so tests never touch a
// real daemon's port.
export const freePort = (): Promise<number> =>
new Promise((resolve, reject) => {
const srv = createServer();
srv.listen(0, '127.0.0.1', () => {
const addr = srv.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
srv.close(() => (port > 0 ? resolve(port) : reject(new Error('no free port'))));
});
srv.on('error', reject);
});

// Poll the CLI's streamed output until the OAuth authorize URL is printed.
export const waitForAuthorizeUrl = async (
setup: SetupProcess,
Expand Down
13 changes: 12 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,32 @@
#!/usr/bin/env node

import { Command } from 'commander';
import { registerDaemon } from './commands/daemon-cmd.js';
import { registerMemory } from './commands/memory.js';
import { registerSetup } from './commands/setup.js';
import { registerStatus } from './commands/status.js';
import { registerUpdate } from './commands/update.js';
import { registerVault } from './commands/vault.js';
import { disableDaemon } from './lib/daemon-pref.js';
import { VERSION } from './utils/version.js';

const program = new Command();

program.name('agentage').description('The agentage CLI').version(VERSION);
program
.name('agentage')
.description('The agentage CLI')
.version(VERSION)
.option('--no-daemon', 'run memory verbs in-process instead of via the daemon');

program.hook('preAction', () => {
if (program.opts().daemon === false) disableDaemon();
});

registerSetup(program);
registerStatus(program);
registerVault(program);
registerMemory(program);
registerDaemon(program);
registerUpdate(program);

program.parseAsync().catch((err: unknown) => {
Expand Down
117 changes: 117 additions & 0 deletions src/commands/daemon-cmd.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { Command } from 'commander';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as lifecycle from '../daemon/lifecycle.js';
import * as dc from '../lib/daemon-client.js';
import { registerDaemon } from './daemon-cmd.js';

vi.mock('../daemon/lifecycle.js');
vi.mock('../lib/daemon-client.js');

const health = (over: Partial<dc.Health> = {}): dc.Health => ({
ok: true,
version: '1.2.3',
pid: 4242,
uptime: 12,
served: 7,
...over,
});

let logs: string[];

const run = async (args: string[]): Promise<void> => {
const program = new Command();
program.exitOverride();
registerDaemon(program);
await program.parseAsync(['node', 'agentage', ...args]);
};

beforeEach(() => {
vi.clearAllMocks();
logs = [];
vi.spyOn(console, 'log').mockImplementation((m: unknown) => void logs.push(String(m)));
vi.spyOn(console, 'error').mockImplementation((m: unknown) => void logs.push(String(m)));
vi.mocked(lifecycle.resolvePort).mockReturnValue(45000);
vi.mocked(dc.mismatchNotice).mockReturnValue(null);
process.exitCode = 0;
});
afterEach(() => {
vi.restoreAllMocks();
process.exitCode = 0;
});

describe('daemon status', () => {
it('reports not running when no pidfile is live', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false);
await run(['daemon', 'status']);
expect(logs.join()).toContain('not running');
});

it('prints pid, port, uptime, served, and version', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true);
vi.mocked(dc.health).mockResolvedValue(health());
await run(['daemon', 'status']);
const out = logs.join('\n');
expect(out).toContain('4242');
expect(out).toContain('45000');
expect(out).toContain('served 7');
expect(out).toContain('1.2.3');
});

it('appends a restart hint on a version mismatch', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true);
vi.mocked(dc.health).mockResolvedValue(health());
vi.mocked(dc.mismatchNotice).mockReturnValue('daemon version 1.2.3 != cli 9; restart');
await run(['daemon', 'status']);
expect(logs.join('\n')).toContain('restart');
});

it('flags a live pidfile whose daemon is unreachable', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true);
vi.mocked(dc.health).mockResolvedValue(null);
await run(['daemon', 'status']);
expect(logs.join()).toContain('unreachable');
});
});

describe('daemon start', () => {
it('is idempotent when one is already running', async () => {
vi.mocked(dc.health).mockResolvedValue(health());
await run(['daemon', 'start']);
expect(logs.join()).toContain('already running');
expect(dc.spawnDaemon).not.toHaveBeenCalled();
});

it('spawns and confirms readiness', async () => {
vi.mocked(dc.health)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(health({ pid: 99 }));
vi.mocked(dc.spawnDaemon).mockResolvedValue(true);
await run(['daemon', 'start']);
expect(logs.join()).toContain('started');
expect(logs.join()).toContain('99');
});

it('reports a failed spawn and sets a non-zero exit code', async () => {
vi.mocked(dc.health).mockResolvedValue(null);
vi.mocked(dc.spawnDaemon).mockResolvedValue(false);
await run(['daemon', 'start']);
expect(logs.join()).toContain('failed');
expect(process.exitCode).toBe(1);
});
});

describe('daemon stop', () => {
it('stops a running daemon', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(true);
await run(['daemon', 'stop']);
expect(lifecycle.stopDaemon).toHaveBeenCalled();
expect(logs.join()).toContain('stopped');
});

it('is a no-op when nothing is running', async () => {
vi.mocked(lifecycle.isDaemonRunning).mockReturnValue(false);
await run(['daemon', 'stop']);
expect(lifecycle.stopDaemon).not.toHaveBeenCalled();
expect(logs.join()).toContain('not running');
});
});
Loading