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
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ resurrect patterns from it.
- `src/lib/` - origins (one FQDN -> service URLs), config (`~/.agentage`, 0600 auth.json),
oauth (DCR + PKCE), callback-server (one-shot localhost), api (bearer + refresh-once),
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 `chalk + commander + open`
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
3 changes: 2 additions & 1 deletion e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,14 @@ export interface CliMachine {
cleanup: () => void;
}

export const createCliMachine = (): CliMachine => {
export const createCliMachine = (extraEnv: NodeJS.ProcessEnv = {}): CliMachine => {
const configDir = mkdtempSync(join(tmpdir(), 'agentage-cli-e2e-'));
const env: NodeJS.ProcessEnv = {
...process.env,
AGENTAGE_CONFIG_DIR: configDir,
AGENTAGE_SITE_FQDN: TARGET_FQDN,
NO_COLOR: '1',
...extraEnv,
};

const exec = (args: string[], timeoutMs = 30_000): Promise<ExecResult> =>
Expand Down
76 changes: 16 additions & 60 deletions e2e/m1-requirements.test.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,24 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine } from './helpers.js';

// Hardening tier: drives the built CLI against a temp config dir to cover every M1
// acceptance criterion end to end. All offline except `update --check` (which the
// verb tolerates on any network state). @p0
// Hardening tier: drives the built CLI against a temp config dir to cover the config +
// registry acceptance criteria end to end. All offline except `update --check`. @p0
const SCHEMA_URL = 'https://agentage.io/schemas/vaults.schema.json';

test.describe('M1 config + vault registry (offline) @p0', () => {
test.describe('config + vault registry (offline) @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('add --git writes a valid git entry with the interval + $schema', async () => {
test('add --git writes a valid origin entry with the $schema link', async () => {
const machine = createCliMachine();
try {
const path = join(machine.configDir, 'work');
const res = await machine.exec([
'vault',
'add',
'work',
'--git',
'git@github.com:me/w.git',
'--path',
path,
'--interval',
'10m',
]);
const res = await machine.exec(['vault', 'add', 'work', '--git', 'git@github.com:me/w.git']);
expect(res.code, res.stderr).toBe(0);
const cfg = JSON.parse(readFileSync(join(machine.configDir, 'vaults.json'), 'utf-8'));
expect(cfg.$schema).toBe(SCHEMA_URL);
expect(cfg.vaults[0]).toMatchObject({
name: 'work',
type: 'git',
remote: 'git@github.com:me/w.git',
sync: { interval: '10m' },
});
expect(cfg.default).toBe('work');
expect(cfg.vaults.work.origin[0].remote).toBe('git@github.com:me/w.git');
} finally {
machine.cleanup();
}
Expand All @@ -53,13 +37,12 @@ test.describe('M1 config + vault registry (offline) @p0', () => {
test('a duplicate vault name is rejected', async () => {
const machine = createCliMachine();
try {
await machine.exec(['vault', 'add', 'a', '--local', '--path', join(machine.configDir, 'a')]);
await machine.exec(['vault', 'add', 'a', '--local', join(machine.configDir, 'a')]);
const res = await machine.exec([
'vault',
'add',
'a',
'--local',
'--path',
join(machine.configDir, 'a2'),
]);
expect(res.code).not.toBe(0);
Expand All @@ -77,7 +60,6 @@ test.describe('M1 config + vault registry (offline) @p0', () => {
'add',
'bad name',
'--local',
'--path',
join(machine.configDir, 'x'),
]);
expect(res.code).not.toBe(0);
Expand All @@ -89,63 +71,37 @@ test.describe('M1 config + vault registry (offline) @p0', () => {
test('an empty vaults.json still carries the $schema link', async () => {
const machine = createCliMachine();
try {
await machine.exec(['vault', 'add', 'v', '--local', '--path', join(machine.configDir, 'v')]);
await machine.exec(['vault', 'add', 'v', '--local', join(machine.configDir, 'v')]);
const rm = await machine.exec(['vault', 'remove', 'v']);
expect(rm.code, rm.stderr).toBe(0);
const cfg = JSON.parse(readFileSync(join(machine.configDir, 'vaults.json'), 'utf-8'));
expect(cfg).toMatchObject({ $schema: SCHEMA_URL, vaults: [] });
expect(cfg.$schema).toBe(SCHEMA_URL);
expect(Object.keys(cfg.vaults)).toHaveLength(0);
} finally {
machine.cleanup();
}
});

test('remove keeps the markdown on disk and drops the index db', async () => {
test('remove keeps the markdown on disk', async () => {
const machine = createCliMachine();
try {
const vaultDir = join(machine.configDir, 'notes');
await machine.exec(['vault', 'add', 'notes', '--local', '--path', vaultDir]);
await machine.exec(['vault', 'add', 'notes', '--local', vaultDir]);
writeFileSync(join(vaultDir, 'note.md'), '# keep me');
const indexDir = join(machine.configDir, 'index');
mkdirSync(indexDir, { recursive: true });
writeFileSync(join(indexDir, 'notes.db'), 'x');

const rm = await machine.exec(['vault', 'remove', 'notes']);
expect(rm.code, rm.stderr).toBe(0);
expect(existsSync(join(vaultDir, 'note.md'))).toBe(true);
expect(existsSync(join(indexDir, 'notes.db'))).toBe(false);
} finally {
machine.cleanup();
}
});

test('accepts a hand-written vaults.yaml, and JSON wins when both exist', async () => {
const machine = createCliMachine();
try {
writeFileSync(
join(machine.configDir, 'vaults.yaml'),
'version: 1\nvaults:\n - name: fromyaml\n type: local\n path: ~/y\n'
);
const yamlList = await machine.exec(['vault', 'list', '--json']);
expect(yamlList.code, yamlList.stderr).toBe(0);
expect(JSON.parse(yamlList.stdout)[0]).toMatchObject({ name: 'fromyaml' });

writeFileSync(
join(machine.configDir, 'vaults.json'),
JSON.stringify({ version: 1, vaults: [{ name: 'fromjson', type: 'local', path: '~/j' }] })
);
const jsonList = await machine.exec(['vault', 'list', '--json']);
expect(JSON.parse(jsonList.stdout)[0]).toMatchObject({ name: 'fromjson' });
} finally {
machine.cleanup();
}
});

test('a malformed config (unknown type) fails loudly', async () => {
test('a malformed config (entry with no path or origin) fails loudly', async () => {
const machine = createCliMachine();
try {
writeFileSync(
join(machine.configDir, 'vaults.json'),
JSON.stringify({ version: 1, vaults: [{ name: 'x', type: 'ftp' }] })
JSON.stringify({ version: 1, vaults: { x: {} } })
);
const res = await machine.exec(['vault', 'list']);
expect(res.code).not.toBe(0);
Expand Down
67 changes: 41 additions & 26 deletions e2e/memory-offline.test.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,41 @@
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine } from './helpers.js';

// The 6 memory verbs make zero network calls (DirectClient = fs + SQLite), so this is the
// full offline CRUD round trip against the built binary. @p0
// The 6 memory verbs run entirely over @agentage/memory-core's local git backend (fs + git,
// zero network). Blackhole any egress via unroutable proxies to prove offline. @p0
const BLACKHOLE = 'http://127.0.0.1:1';
const OFFLINE = {
http_proxy: BLACKHOLE,
https_proxy: BLACKHOLE,
HTTP_PROXY: BLACKHOLE,
HTTPS_PROXY: BLACKHOLE,
};

interface TreeEntry {
type: 'file' | 'folder';
path: string;
entries?: TreeEntry[];
}

const treeFiles = (entries: TreeEntry[]): string[] =>
entries.flatMap((e) => (e.type === 'file' ? [e.path] : e.entries ? treeFiles(e.entries) : []));

test.describe('offline memory CRUD @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('write -> search -> read -> edit -> list -> delete, no network', async () => {
const m = createCliMachine();
const m = createCliMachine(OFFLINE);
try {
const add = await m.exec([
'vault',
'add',
'main',
'--local',
'--path',
`${m.configDir}/main`,
]);
const vaultDir = join(m.configDir, 'main');
const add = await m.exec(['vault', 'add', 'main', '--local', vaultDir]);
expect(add.code, add.stderr).toBe(0);

const write = await m.exec([
'memory',
'write',
'@main/notes/q.md',
'notes/q.md',
'--body',
'the quokka is a marsupial',
]);
Expand All @@ -34,42 +47,44 @@ test.describe('offline memory CRUD @p0', () => {
'notes/q.md',
]);

const read = await m.exec(['memory', 'read', '@main/notes/q.md']);
const read = await m.exec(['memory', 'read', 'notes/q.md']);
expect(read.stdout).toContain('marsupial');

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

const list = await m.exec(['memory', 'list', '--json']);
expect(JSON.parse(list.stdout).entries.map((e: { path: string }) => e.path)).toEqual([
'notes/q.md',
]);
expect(treeFiles(JSON.parse(list.stdout).entries)).toEqual(['notes/q.md']);

const del = await m.exec(['memory', 'delete', '@main/notes/q.md']);
const del = await m.exec(['memory', 'delete', 'notes/q.md']);
expect(del.code, del.stderr).toBe(0);
expect(JSON.parse((await m.exec(['memory', 'list', '--json'])).stdout).entries).toHaveLength(
0
);
const after = await m.exec(['memory', 'list', '--json']);
expect(treeFiles(JSON.parse(after.stdout).entries)).toHaveLength(0);

// git-backed store: the vault folder is a repo carrying a commit per mutation
// (write + edit + delete = 3).
const log = execFileSync('git', ['-C', vaultDir, 'log', '--oneline'], { encoding: 'utf-8' });
expect(log.trim().split('\n').length).toBeGreaterThanOrEqual(3);
} finally {
m.cleanup();
}
});

test('a duplicate str_replace target reports the canonical error', async () => {
const m = createCliMachine();
const m = createCliMachine(OFFLINE);
try {
await m.exec(['vault', 'add', 'main', '--local', '--path', `${m.configDir}/main`]);
await m.exec(['memory', 'write', '@main/d.md', '--body', 'x x']);
const res = await m.exec(['memory', 'edit', '@main/d.md', '--old', 'x', '--new', 'y']);
await m.exec(['vault', 'add', 'main', '--local', join(m.configDir, 'main')]);
await m.exec(['memory', 'write', 'd.md', '--body', 'x x']);
const res = await m.exec(['memory', 'edit', 'd.md', '--old', 'x', '--new', 'y']);
expect(res.code).not.toBe(0);
expect(res.stderr + res.stdout).toContain('Multiple occurrences');
} finally {
Expand Down
16 changes: 8 additions & 8 deletions e2e/vault-offline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,39 +2,39 @@ import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine } from './helpers.js';

// The offline registry: add/list/remove work with no network and no auth. @p0
// The offline registry on the unified vaults.json: add/list/remove work with no network. @p0
test.describe('offline vault registry @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('add --local -> list -> remove round-trips with no network', async () => {
const machine = createCliMachine();
try {
const path = join(machine.configDir, 'scratch');
const added = await machine.exec(['vault', 'add', 'scratch', '--local', '--path', path]);
const added = await machine.exec(['vault', 'add', 'scratch', '--local', path]);
expect(added.code, added.stderr).toBe(0);

const listed = await machine.exec(['vault', 'list', '--json']);
expect(listed.code, listed.stderr).toBe(0);
const vaults = JSON.parse(listed.stdout) as Array<{ name: string; type: string }>;
expect(vaults).toHaveLength(1);
expect(vaults[0]).toMatchObject({ name: 'scratch', type: 'local' });
const vaults = JSON.parse(listed.stdout) as Record<string, { path?: string }>;
expect(Object.keys(vaults)).toEqual(['scratch']);
expect(vaults.scratch!.path).toBe(path);

const removed = await machine.exec(['vault', 'remove', 'scratch']);
expect(removed.code, removed.stderr).toBe(0);

const after = await machine.exec(['vault', 'list', '--json']);
expect(JSON.parse(after.stdout)).toHaveLength(0);
expect(Object.keys(JSON.parse(after.stdout))).toHaveLength(0);
} finally {
machine.cleanup();
}
});

test('vault add without --local/--git fails clearly (needs provisioning)', async () => {
test('vault add without --local/--git fails clearly', async () => {
const machine = createCliMachine();
try {
const res = await machine.exec(['vault', 'add', 'acct']);
expect(res.code).not.toBe(0);
expect(res.stderr + res.stdout).toContain('provisioning');
expect(res.stderr + res.stdout).toMatch(/--local|--git/);
} finally {
machine.cleanup();
}
Expand Down
19 changes: 16 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@
"prepublishOnly": "npm run verify"
},
"dependencies": {
"@agentage/memory-core": "^0.1.1",
"chalk": "latest",
"commander": "latest",
"open": "latest",
"yaml": "^2.9.0",
"zod": "^4.4.3"
"open": "latest"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.110.0",
Expand Down
Loading