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
7 changes: 6 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,15 @@ const program = new Command();

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

program.addHelpText(
'after',
'\nNew here? Run: agentage vault add <name> --local, or agentage setup to connect an account.'
);

program.hook('preAction', () => {
if (program.opts().daemon === false) disableDaemon();
});
Expand Down
57 changes: 57 additions & 0 deletions src/commands/memory.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { MemoryClient } from '../lib/memory-client.js';
import { translateEngineMessage } from '../lib/memory-client.js';
import { runDelete, runEdit, runList, runRead, runSearch, runWrite } from './memory.js';

const client = (): MemoryClient => ({
Expand Down Expand Up @@ -110,4 +111,60 @@ describe('memory command wiring', () => {
expect(c.delete).toHaveBeenCalledWith('a.md', { vault: undefined });
expect(logs.join()).toContain('git history');
});

it('edit with no --old/--new/--body errors instead of a silent no-op', async () => {
const c = client();
await expect(runEdit('a.md', {}, c)).rejects.toThrow(
'specify --old/--new for a replacement or --body to overwrite'
);
expect(c.edit).not.toHaveBeenCalled();
});

it('write rejects invalid --frontmatter JSON with a friendly hint', async () => {
const c = client();
await expect(runWrite('a.md', { body: 'x', frontmatter: '{bad' }, c)).rejects.toThrow(
/--frontmatter must be a JSON object, e\.g\. '\{"tags":\["x"\]\}'/
);
expect(c.write).not.toHaveBeenCalled();
});

it('write rejects non-object --frontmatter JSON (array/scalar)', async () => {
const c = client();
await expect(runWrite('a.md', { body: 'x', frontmatter: '[1,2]' }, c)).rejects.toThrow(
/must be a JSON object/
);
await expect(runWrite('a.md', { body: 'x', frontmatter: '42' }, c)).rejects.toThrow(
/must be a JSON object/
);
expect(c.write).not.toHaveBeenCalled();
});

it('write with no --body on a TTY errors instead of blocking on stdin', async () => {
const c = client();
const original = process.stdin.isTTY;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
try {
await expect(runWrite('a.md', {}, c)).rejects.toThrow(
'provide --body, or pipe content on stdin'
);
} finally {
Object.defineProperty(process.stdin, 'isTTY', { value: original, configurable: true });
}
expect(c.write).not.toHaveBeenCalled();
});
});

describe('engine error translation', () => {
it('rewrites the memory__list MCP vocabulary to a CLI command', () => {
const engine =
'Unknown vault "@nosuchvault". Use memory__list with no folder to see available vaults.';
const out = translateEngineMessage(engine);
expect(out).not.toContain('memory__list');
expect(out).toContain('Run `agentage vault list` to see available vaults.');
expect(out).toContain('Unknown vault "@nosuchvault".');
});

it('passes through messages with no known engine pattern unchanged', () => {
expect(translateEngineMessage('not found: a.md')).toBe('not found: a.md');
});
});
35 changes: 29 additions & 6 deletions src/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { type Command } from 'commander';
import { type TreeEntry } from '@agentage/memory-core';
import { ensureDaemon } from '../lib/daemon-client.js';
import { daemonDisabled } from '../lib/daemon-pref.js';
import { createDirectClient, type MemoryClient } from '../lib/memory-client.js';
import {
createDirectClient,
translateEngineMessage,
type MemoryClient,
} from '../lib/memory-client.js';
import { loadVaultsConfig } from '../lib/vaults.js';

// Default engine path (DO3/DO4): the daemon - single writer, autostarted - when reachable; the
Expand All @@ -21,6 +25,21 @@ const readStdin = async (): Promise<string> => {
return Buffer.concat(chunks).toString('utf-8');
};

// --frontmatter must be a JSON object; a raw parser error or a non-object value is unfriendly.
const parseFrontmatter = (raw?: string): Record<string, unknown> | undefined => {
if (!raw) return undefined;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (err) {
const detail = err instanceof Error ? err.message : String(err);
throw new Error(`--frontmatter must be a JSON object, e.g. '{"tags":["x"]}': ${detail}`);
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
throw new Error(`--frontmatter must be a JSON object, e.g. '{"tags":["x"]}'`);
return parsed as Record<string, unknown>;
};

const emit = (json: boolean, data: unknown, human: () => void): void => {
if (json) console.log(JSON.stringify(data, null, 2));
else human();
Expand Down Expand Up @@ -67,10 +86,11 @@ export const runWrite = async (
client?: MemoryClient
): Promise<void> => {
const c = client ?? (await resolveClient());
const body = opts.body !== undefined && opts.body !== '-' ? opts.body : await readStdin();
const frontmatter = opts.frontmatter
? (JSON.parse(opts.frontmatter) as Record<string, unknown>)
: undefined;
const inlineBody = opts.body !== undefined && opts.body !== '-';
if (!inlineBody && process.stdin.isTTY)
throw new Error('provide --body, or pipe content on stdin');
const body = inlineBody ? opts.body! : await readStdin();
const frontmatter = parseFrontmatter(opts.frontmatter);
const out = await c.write(ref, body, { vault: opts.vault, frontmatter });
emit(opts.json ?? false, out, () => console.log(chalk.green(`Wrote ${out.path}`)));
};
Expand All @@ -81,6 +101,8 @@ export const runEdit = async (
client?: MemoryClient
): Promise<void> => {
const c = client ?? (await resolveClient());
if (opts.old === undefined && opts.body === undefined)
throw new Error('specify --old/--new for a replacement or --body to overwrite');
const op =
opts.old !== undefined
? { mode: 'str_replace' as const, old_str: opts.old, new_str: opts.new ?? '' }
Expand Down Expand Up @@ -117,7 +139,8 @@ export const runDelete = async (

const guard = (fn: () => Promise<void>): Promise<void> =>
fn().catch((err: unknown) => {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
const message = translateEngineMessage(err instanceof Error ? err.message : String(err));
console.error(chalk.red(message));
process.exitCode = 1;
});

Expand Down
2 changes: 1 addition & 1 deletion src/commands/vault.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ export const runVaultList = (opts: { json?: boolean }, deps: VaultDeps = default
return;
}
if (names.length === 0) {
deps.log('No vaults registered. Add one with `agentage vault add <name>`.');
deps.log('No vaults registered. Add one with `agentage vault add <name> --local`.');
return;
}
for (const name of names) {
Expand Down
45 changes: 44 additions & 1 deletion src/lib/file-lock.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { execFile } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
Expand Down Expand Up @@ -42,6 +50,41 @@ describe('file lock', () => {
releaseFileLock(target);
});

// chmod-based unwritability does not apply to root, so skip these there.
const asRoot = typeof process.getuid === 'function' && process.getuid() === 0;

it.skipIf(asRoot)('fails fast on a permission error instead of treating it as contention', () => {
// An unwritable lock dir: writeFileSync({flag:'wx'}) throws EACCES/EPERM, which never clears.
const roDir = join(dir, 'readonly');
mkdirSync(roDir);
const roTarget = join(roDir, 'data.json');
chmodSync(roDir, 0o500);
try {
expect(() => acquireFileLock(roTarget)).toThrow(/permission denied/);
} finally {
chmodSync(roDir, 0o700);
}
});

it.skipIf(asRoot)(
'withFileLock surfaces a permission error immediately, never spinning MAX_WAIT_MS',
async () => {
const roDir = join(dir, 'readonly2');
mkdirSync(roDir);
const roTarget = join(roDir, 'data.json');
chmodSync(roDir, 0o500);
const started = Date.now();
try {
await expect(withFileLock(roTarget, () => 'unreachable')).rejects.toThrow(
/permission denied/
);
expect(Date.now() - started).toBeLessThan(2_000);
} finally {
chmodSync(roDir, 0o700);
}
}
);

it('releases idempotently', () => {
acquireFileLock(target);
releaseFileLock(target);
Expand Down
13 changes: 12 additions & 1 deletion src/lib/file-lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,18 @@ export const acquireFileLock = (target: string, now: number = Date.now()): boole
try {
writeFileSync(lockPath(target), `${process.pid} ${now}`, { flag: 'wx' });
return true;
} catch {
} catch (err) {
// Only EEXIST means contention. A permission/read-only error never clears, so failing fast
// beats burning the full MAX_WAIT_MS before reporting a generic "could not acquire lock".
const code = (err as NodeJS.ErrnoException).code;
if (code !== 'EEXIST') {
const path = lockPath(target);
throw new Error(
code === 'EACCES' || code === 'EPERM' || code === 'EROFS'
? `permission denied: ${path}`
: `could not write lock file ${path}${code ? ` (${code})` : ''}`
);
}
const held = heldAt(lockPath(target));
if (held !== null && now - held < LOCK_TTL_MS) return false;
if (!takeOverStale(target, now)) return false;
Expand Down
15 changes: 15 additions & 0 deletions src/lib/memory-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,21 @@ export interface DeleteResult {
deleted: boolean;
}

// Engine (memory-core) messages leak MCP tool vocabulary (`memory__list`, ...) unfit for a terminal.
// Rewrite the known patterns to CLI vocabulary at this seam; do not fork the engine.
const ENGINE_MESSAGE_MAP: ReadonlyArray<readonly [RegExp, string]> = [
[
/Use memory__list with no folder to see available vaults\.?/g,
'Run `agentage vault list` to see available vaults.',
],
];

export const translateEngineMessage = (message: string): string =>
ENGINE_MESSAGE_MAP.reduce(
(m, [pattern, replacement]) => m.replace(pattern, replacement),
message
);

export interface VerbOptions {
vault?: string;
}
Expand Down