feat: 添加本地 Memory/Vault 管理命令

- /local-memory: 本地记忆管理(store/entry CRUD、搜索、归档)
- /local-vault: 本地密钥保险库管理(加解密、keychain 集成)
- permissionValidation: vault 权限校验增强

Co-Authored-By: glm-5-turbo <zai-org@claude-code-best.win>
This commit is contained in:
claude-code-best
2026-05-09 23:04:20 +08:00
parent 2437040b5b
commit 4f0aa8615a
16 changed files with 2577 additions and 4 deletions

View File

@@ -0,0 +1,136 @@
import React from 'react';
import { Box, Text } from '@anthropic/ink';
import type { Theme } from '@anthropic/ink';
export type LocalMemoryViewProps =
| { mode: 'list'; stores: string[] }
| { mode: 'created'; store: string }
| { mode: 'stored'; store: string; key: string }
| { mode: 'fetched'; store: string; key: string; value: string }
| { mode: 'not-found'; store: string; key?: string }
| { mode: 'entries'; store: string; keys: string[] }
| { mode: 'archived'; store: string }
| { mode: 'error'; message: string };
export function LocalMemoryView(props: LocalMemoryViewProps): React.ReactNode {
if (props.mode === 'list') {
if (props.stores.length === 0) {
return (
<Box>
<Text dimColor>No memory stores found. Use /local-memory create &lt;store&gt; to create one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>Local Memory Stores ({props.stores.length})</Text>
</Box>
{props.stores.map(s => (
<Box key={s}>
<Text> </Text>
<Text color={'success' as keyof Theme}></Text>
<Text> {s}</Text>
</Box>
))}
</Box>
);
}
if (props.mode === 'created') {
return (
<Box>
<Text color={'success' as keyof Theme}></Text>
<Text> Store created: </Text>
<Text bold>{props.store}</Text>
</Box>
);
}
if (props.mode === 'stored') {
return (
<Box>
<Text color={'success' as keyof Theme}></Text>
<Text> Stored entry </Text>
<Text bold>{props.key}</Text>
<Text> in </Text>
<Text bold>{props.store}</Text>
</Box>
);
}
if (props.mode === 'fetched') {
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>{props.store}</Text>
<Text dimColor>/</Text>
<Text bold>{props.key}</Text>
</Box>
<Box>
<Text>{props.value}</Text>
</Box>
</Box>
);
}
if (props.mode === 'not-found') {
return (
<Box>
<Text color={'error' as keyof Theme}>Not found: </Text>
<Text bold>{props.store}</Text>
{props.key ? (
<>
<Text dimColor>/</Text>
<Text bold>{props.key}</Text>
</>
) : null}
</Box>
);
}
if (props.mode === 'entries') {
if (props.keys.length === 0) {
return (
<Box>
<Text dimColor>No entries in </Text>
<Text bold>{props.store}</Text>
<Text dimColor>. Use /local-memory store {props.store} &lt;key&gt; &lt;value&gt; to add one.</Text>
</Box>
);
}
return (
<Box flexDirection="column">
<Box marginBottom={1}>
<Text bold>{props.store}</Text>
<Text dimColor> ({props.keys.length} entries)</Text>
</Box>
{props.keys.map(k => (
<Box key={k}>
<Text> </Text>
<Text color={'success' as keyof Theme}>·</Text>
<Text> {k}</Text>
</Box>
))}
</Box>
);
}
if (props.mode === 'archived') {
return (
<Box>
<Text color={'success' as keyof Theme}></Text>
<Text> Archived store: </Text>
<Text bold>{props.store}</Text>
<Text dimColor> (renamed to {props.store}.archived)</Text>
</Box>
);
}
// mode === 'error'
return (
<Box>
<Text color={'error' as keyof Theme}>Error: {props.message}</Text>
</Box>
);
}

View File

@@ -0,0 +1,227 @@
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
// multiStore.ts has no log/debug/bun:bundle side effects — no mocks needed.
let callLocalMemory: typeof import('../launchLocalMemory.js').callLocalMemory
describe('callLocalMemory', () => {
let tmpDir: string
const messages: string[] = []
const onDone = (msg?: string) => {
if (msg) messages.push(msg)
}
beforeEach(async () => {
tmpDir = mkdtempSync(join(tmpdir(), 'lm-launch-test-'))
process.env['CLAUDE_CONFIG_DIR'] = tmpDir
messages.length = 0
const mod = await import('../launchLocalMemory.js')
callLocalMemory = mod.callLocalMemory
})
afterEach(() => {
rmSync(tmpDir, { recursive: true, force: true })
delete process.env['CLAUDE_CONFIG_DIR']
})
test('no args renders action panel without completing', async () => {
const node = await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'',
)
expect(node).not.toBeNull()
expect(messages).toHaveLength(0)
})
test('list sub-command with no stores', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'list',
)
expect(
messages.some(m => m.includes('No memory stores') || m.includes('0')),
).toBe(true)
})
test('create sub-command creates a store', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create test-store',
)
expect(messages.some(m => m.includes('test-store'))).toBe(true)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'list',
)
expect(messages.some(m => m.includes('1') || m.includes('store'))).toBe(
true,
)
})
test('store sub-command writes entry', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create notes',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'store notes hello Hello World entry',
)
expect(messages.some(m => m.includes('hello') || m.includes('notes'))).toBe(
true,
)
})
test('fetch sub-command retrieves stored entry', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create fetch-store',
)
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'store fetch-store mykey my entry value',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'fetch fetch-store mykey',
)
expect(
messages.some(m => m.includes('fetch-store') || m.includes('mykey')),
).toBe(true)
expect(messages.join('\n')).toContain('my entry value')
})
test('fetch for nonexistent key → not-found', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create empty-s',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'fetch empty-s nonexistent',
)
expect(
messages.some(m => m.includes('not found') || m.includes('nonexistent')),
).toBe(true)
})
test('entries sub-command lists keys in store', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create ent-store',
)
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'store ent-store alpha value-a',
)
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'store ent-store beta value-b',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'entries ent-store',
)
expect(messages.some(m => m.includes('2') || m.includes('ent-store'))).toBe(
true,
)
const allMessages = messages.join('\n')
expect(allMessages).toContain('alpha')
expect(allMessages).toContain('beta')
})
test('archive sub-command archives a store', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create to-archive',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'archive to-archive',
)
expect(
messages.some(m => m.includes('to-archive') || m.includes('rchiv')),
).toBe(true)
})
test('invalid sub-command shows usage', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'badcmd',
)
expect(
messages.some(
m => m.toLowerCase().includes('usage') || m.includes('badcmd'),
),
).toBe(true)
})
test('create duplicate store → error view', async () => {
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create dup-store',
)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'create dup-store',
)
expect(
messages.some(
m => m.toLowerCase().includes('failed') || m.includes('already exists'),
),
).toBe(true)
})
test('store in nonexistent store auto-creates directory', async () => {
// No explicit create — setEntry should auto-create dir
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'store auto-create-store key1 value1',
)
expect(
messages.some(m => m.includes('key1') || m.includes('auto-create-store')),
).toBe(true)
messages.length = 0
await callLocalMemory(
onDone as Parameters<typeof callLocalMemory>[0],
{} as Parameters<typeof callLocalMemory>[1],
'fetch auto-create-store key1',
)
expect(
messages.some(m => m.includes('auto-create-store') || m.includes('key1')),
).toBe(true)
expect(messages.join('\n')).toContain('value1')
})
})

View File

@@ -0,0 +1,106 @@
import { describe, test, expect } from 'bun:test'
import { parseLocalMemoryArgs } from '../parseArgs.js'
describe('parseLocalMemoryArgs', () => {
test('empty string → list', () => {
expect(parseLocalMemoryArgs('')).toEqual({ action: 'list' })
})
test('"list" → list', () => {
expect(parseLocalMemoryArgs('list')).toEqual({ action: 'list' })
})
test('create with store name', () => {
expect(parseLocalMemoryArgs('create my-store')).toEqual({
action: 'create',
store: 'my-store',
})
})
test('create without store name → invalid', () => {
expect(parseLocalMemoryArgs('create').action).toBe('invalid')
})
test('store with store, key, value', () => {
expect(parseLocalMemoryArgs('store my-store my-key my value here')).toEqual(
{
action: 'store',
store: 'my-store',
key: 'my-key',
value: 'my value here',
},
)
})
test('store without key → invalid', () => {
expect(parseLocalMemoryArgs('store my-store').action).toBe('invalid')
})
test('store without value → invalid', () => {
expect(parseLocalMemoryArgs('store my-store my-key').action).toBe('invalid')
})
test('fetch with store and key', () => {
expect(parseLocalMemoryArgs('fetch notes hello')).toEqual({
action: 'fetch',
store: 'notes',
key: 'hello',
})
})
test('fetch without key → invalid', () => {
expect(parseLocalMemoryArgs('fetch notes').action).toBe('invalid')
})
test('entries with store name', () => {
expect(parseLocalMemoryArgs('entries my-store')).toEqual({
action: 'entries',
store: 'my-store',
})
})
test('entries without store name → invalid', () => {
expect(parseLocalMemoryArgs('entries').action).toBe('invalid')
})
test('archive with store name', () => {
expect(parseLocalMemoryArgs('archive old-store')).toEqual({
action: 'archive',
store: 'old-store',
})
})
test('archive without store name → invalid', () => {
expect(parseLocalMemoryArgs('archive').action).toBe('invalid')
})
test('unknown sub-command → invalid with reason', () => {
const result = parseLocalMemoryArgs('frobnicate')
expect(result.action).toBe('invalid')
if (result.action === 'invalid') {
expect(result.reason).toContain('frobnicate')
}
})
test('"list" with trailing args still returns list action', () => {
// 'list extra' bypasses the short-circuit on line 33 and hits the
// tokens-based branch on line 41-43.
expect(parseLocalMemoryArgs('list extra-arg')).toEqual({ action: 'list' })
})
test('store sub-command with no args → invalid (missing store name)', () => {
const r = parseLocalMemoryArgs('store')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('store name')
}
})
test('fetch sub-command with no args → invalid (missing store name)', () => {
const r = parseLocalMemoryArgs('fetch')
expect(r.action).toBe('invalid')
if (r.action === 'invalid') {
expect(r.reason).toContain('store name')
}
})
})

View File

@@ -0,0 +1,22 @@
import type { Command } from '../../types/command.js';
const localMemoryCommand: Command = {
type: 'local-jsx',
name: 'local-memory',
aliases: ['lm'],
description:
'Manage local memory stores for notes and context. Stored in ~/.claude/local-memory/ — no API key required.',
// Avoid `<store>` / `<key>` / `<value>` in hint — REPL markdown renderer
// strips angle-bracketed words as HTML tags. Uppercase placeholders are
// visible. Same fix as /local-vault.
argumentHint: 'list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE',
isHidden: false,
isEnabled: () => true,
bridgeSafe: true,
load: async () => {
const m = await import('./launchLocalMemory.js');
return { call: m.callLocalMemory };
},
};
export default localMemoryCommand;

View File

@@ -0,0 +1,527 @@
import React from 'react';
import { Box, Dialog, Text, useInput } from '@anthropic/ink';
import type { LocalJSXCommandCall, LocalJSXCommandOnDone } from '../../types/command.js';
import {
listStores,
createStore,
setEntry,
getEntry,
listEntries,
archiveStore,
isValidStoreName,
} from '../../services/SessionMemory/multiStore.js';
import { isValidKey } from '../../utils/localValidate.js';
import TextInput from '../../components/TextInput.js';
import { LocalMemoryView } from './LocalMemoryView.js';
import { parseLocalMemoryArgs } from './parseArgs.js';
import { launchCommand } from '../_shared/launchCommand.js';
const USAGE =
'Usage: /local-memory list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE';
type LocalMemoryViewProps = React.ComponentProps<typeof LocalMemoryView>;
type LocalMemoryAction = {
label: string;
description: string;
run: () => void;
};
const ACTION_LABEL_COLUMN_WIDTH = 26;
function formatStoreList(stores: string[]): string {
if (stores.length === 0) {
return 'No memory stores found.';
}
return ['Local Memory Stores', ...stores.map(store => `- ${store}`)].join('\n');
}
function formatEntryList(store: string, keys: string[]): string {
if (keys.length === 0) {
return `No entries in "${store}".`;
}
return [`Entries in "${store}"`, ...keys.map(key => `- ${key}`)].join('\n');
}
// ── Interactive multi-step panel ───────────────────────────────────────────
// State machine:
// menu — pick an action
// collect-store — input STORE_NAME (Create/Store/Fetch/Entries/Archive)
// collect-key — input KEY (Store/Fetch)
// collect-value — input VALUE (Store)
// confirm-archive — Y/N confirmation (Archive)
// confirm-overwrite — Y/N confirmation (Store when key exists)
// Each step has inline validation; Esc cancels back to menu (or closes from menu).
type ActionKind = 'list' | 'create' | 'store' | 'fetch' | 'entries' | 'archive' | 'about';
type Step =
| { kind: 'menu' }
| { kind: 'collect-store'; action: ActionKind }
| { kind: 'collect-key'; action: ActionKind; store: string }
| { kind: 'collect-value'; action: ActionKind; store: string; key: string }
| {
kind: 'confirm-archive';
store: string;
}
| {
kind: 'confirm-overwrite';
store: string;
key: string;
value: string;
};
const MENU: Array<{
kind: ActionKind;
label: string;
description: string;
}> = [
{ kind: 'list', label: 'List', description: 'Show all stores' },
{
kind: 'create',
label: 'Create',
description: 'Create a new memory store',
},
{
kind: 'store',
label: 'Store',
description: 'Write an entry: store name + key + value',
},
{
kind: 'fetch',
label: 'Fetch',
description: 'Read an entry by store name + key',
},
{
kind: 'entries',
label: 'Entries',
description: 'List entry keys in a store',
},
{
kind: 'archive',
label: 'Archive',
description: 'Archive a store (rename to *.archived)',
},
{
kind: 'about',
label: 'About',
description: 'Show command syntax',
},
];
function LocalMemoryPanel({ onDone }: { onDone: LocalJSXCommandOnDone }): React.ReactNode {
const [step, setStep] = React.useState<Step>({ kind: 'menu' });
const [selectedIndex, setSelectedIndex] = React.useState(0);
const [textValue, setTextValue] = React.useState('');
const [cursorOffset, setCursorOffset] = React.useState(0);
const [error, setError] = React.useState<string | null>(null);
// Reset text/error when step transitions
const transition = React.useCallback((next: Step) => {
setStep(next);
setTextValue('');
setCursorOffset(0);
setError(null);
}, []);
const closeWith = React.useCallback((msg: string) => onDone(msg, { display: 'system' }), [onDone]);
// Run an action when it has all required inputs.
const runAction = React.useCallback(
(
action: ActionKind,
store: string | undefined,
key: string | undefined,
value: string | undefined,
opts: { confirmedOverwrite?: boolean } = {},
) => {
try {
if (action === 'list') {
closeWith(formatStoreList(listStores()));
return;
}
if (action === 'about') {
closeWith(USAGE);
return;
}
if (!store) {
setError('Internal: missing store');
return;
}
if (action === 'create') {
createStore(store);
closeWith(`Store created: ${store}`);
return;
}
if (action === 'entries') {
const keys = listEntries(store);
closeWith(formatEntryList(store, keys));
return;
}
if (action === 'archive') {
archiveStore(store);
closeWith(`Archived store: ${store}`);
return;
}
if (action === 'fetch') {
if (!key) {
setError('Internal: missing key');
return;
}
const v = getEntry(store, key);
if (v === null) {
closeWith(`Entry not found: ${store}/${key}`);
return;
}
closeWith(`Entry fetched: ${store}/${key}\n\n${v}`);
return;
}
if (action === 'store') {
if (!key || value === undefined) {
setError('Internal: missing key or value');
return;
}
// Confirm overwrite if key already exists (safety prompt)
if (!opts.confirmedOverwrite && getEntry(store, key) !== null) {
transition({
kind: 'confirm-overwrite',
store,
key,
value,
});
return;
}
setEntry(store, key, value);
closeWith(`Stored ${store}/${key} (${value.length} chars)`);
return;
}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
},
[closeWith, transition],
);
// ── Menu step ──────────────────────────────────────────────────────────
useInput(
(input, key) => {
if (step.kind !== 'menu') return;
if (key.upArrow) {
setSelectedIndex(idx => Math.max(0, idx - 1));
return;
}
if (key.downArrow) {
setSelectedIndex(idx => Math.min(MENU.length - 1, idx + 1));
return;
}
if (key.return) {
const choice = MENU[selectedIndex];
if (!choice) return;
if (choice.kind === 'list' || choice.kind === 'about') {
runAction(choice.kind, undefined, undefined, undefined);
return;
}
// Everything else needs a store
transition({ kind: 'collect-store', action: choice.kind });
return;
}
// Quick-key shortcuts: 1..7
const n = Number(input);
if (Number.isInteger(n) && n >= 1 && n <= MENU.length) {
setSelectedIndex(n - 1);
}
},
{ isActive: step.kind === 'menu' },
);
// ── confirm-archive / confirm-overwrite Y/N handling ───────────────────
useInput(
(input, key) => {
if (step.kind !== 'confirm-archive' && step.kind !== 'confirm-overwrite') {
return;
}
if (key.escape) {
transition({ kind: 'menu' });
return;
}
const ch = input.toLowerCase();
if (ch === 'y' || key.return) {
if (step.kind === 'confirm-archive') {
runAction('archive', step.store, undefined, undefined);
} else {
runAction('store', step.store, step.key, step.value, {
confirmedOverwrite: true,
});
}
} else if (ch === 'n') {
transition({ kind: 'menu' });
}
},
{
isActive: step.kind === 'confirm-archive' || step.kind === 'confirm-overwrite',
},
);
// Esc to back-step in collect-* steps
useInput(
(_input, key) => {
if (step.kind !== 'collect-store' && step.kind !== 'collect-key' && step.kind !== 'collect-value') {
return;
}
if (key.escape) {
// Walk back one step
if (step.kind === 'collect-value') {
transition({
kind: 'collect-key',
action: step.action,
store: step.store,
});
return;
}
if (step.kind === 'collect-key') {
transition({ kind: 'collect-store', action: step.action });
return;
}
// collect-store → menu
transition({ kind: 'menu' });
}
},
{
isActive: step.kind === 'collect-store' || step.kind === 'collect-key' || step.kind === 'collect-value',
},
);
// ── Render ──────────────────────────────────────────────────────────────
if (step.kind === 'menu') {
return (
<Dialog
title="Local Memory"
subtitle={`${MENU.length} actions`}
onCancel={() => closeWith('Local memory panel dismissed')}
color="background"
hideInputGuide
>
<Box flexDirection="column">
{MENU.map((m, i) => (
<Box key={m.kind} flexDirection="row">
<Text>{`${i === selectedIndex ? '' : ' '} ${m.label}`.padEnd(ACTION_LABEL_COLUMN_WIDTH)}</Text>
<Text dimColor>{m.description}</Text>
</Box>
))}
<Box marginTop={1}>
<Text dimColor>/ or 1-7 select · Enter run · Esc close</Text>
</Box>
</Box>
</Dialog>
);
}
// Confirmation prompts
if (step.kind === 'confirm-archive') {
return (
<Dialog title="Confirm Archive" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
<Box flexDirection="column">
<Text>Archive store "{step.store}"? This renames it to *.archived.</Text>
<Box marginTop={1}>
<Text dimColor>y/Enter = archive · n/Esc = cancel</Text>
</Box>
</Box>
</Dialog>
);
}
if (step.kind === 'confirm-overwrite') {
return (
<Dialog title="Confirm Overwrite" onCancel={() => transition({ kind: 'menu' })} color="warning" hideInputGuide>
<Box flexDirection="column">
<Text>
Entry "{step.store}/{step.key}" already exists. Overwrite with new value ({step.value.length} chars)?
</Text>
<Box marginTop={1}>
<Text dimColor>y/Enter = overwrite · n/Esc = cancel</Text>
</Box>
</Box>
</Dialog>
);
}
// collect-* steps share the same TextInput render
const fieldLabel = step.kind === 'collect-store' ? 'STORE NAME' : step.kind === 'collect-key' ? 'KEY NAME' : 'VALUE';
const placeholder =
step.kind === 'collect-store'
? 'e.g. my-notes'
: step.kind === 'collect-key'
? 'e.g. todo-2026-05-08'
: 'free text';
const validateAndAdvance = (raw: string) => {
const trimmed = raw.trim();
if (step.kind === 'collect-store') {
if (!trimmed) {
setError('Store name required');
return;
}
if (!isValidStoreName(trimmed)) {
setError('Invalid store name (no /, \\, :, null byte, or leading dot; max 255 chars)');
return;
}
// Action-specific completion
if (step.action === 'create' || step.action === 'entries' || step.action === 'archive') {
if (step.action === 'archive') {
transition({ kind: 'confirm-archive', store: trimmed });
} else {
runAction(step.action, trimmed, undefined, undefined);
}
} else {
// Store / Fetch — need key next
transition({
kind: 'collect-key',
action: step.action,
store: trimmed,
});
}
return;
}
if (step.kind === 'collect-key') {
if (!trimmed) {
setError('Key required');
return;
}
if (!isValidKey(trimmed)) {
setError('Invalid key (allowed: letters/digits/._- only; no leading dot; not a Windows reserved name)');
return;
}
if (step.action === 'fetch') {
runAction('fetch', step.store, trimmed, undefined);
} else {
// store action — collect value next
transition({
kind: 'collect-value',
action: 'store',
store: step.store,
key: trimmed,
});
}
return;
}
if (step.kind === 'collect-value') {
// Value can be empty (allowed). Just submit.
runAction('store', step.store, step.key, raw);
}
};
return (
<Dialog
title={`Local Memory · ${step.kind.replace('collect-', '').toUpperCase()}`}
onCancel={() => transition({ kind: 'menu' })}
color="background"
hideInputGuide
>
<Box flexDirection="column">
<Box>
<Text dimColor>{fieldLabel}</Text>
</Box>
<Box>
<Text>{'> '}</Text>
<TextInput
value={textValue}
onChange={v => {
setTextValue(v);
setError(null);
}}
cursorOffset={cursorOffset}
onChangeCursorOffset={setCursorOffset}
onSubmit={validateAndAdvance}
placeholder={placeholder}
columns={70}
showCursor
/>
</Box>
{error !== null && (
<Box marginTop={0}>
<Text color="warning"> {error}</Text>
</Box>
)}
<Box marginTop={1}>
<Text dimColor>Enter = next · Esc = back</Text>
</Box>
</Box>
</Dialog>
);
}
async function dispatchLocalMemory(
parsed: ReturnType<typeof parseLocalMemoryArgs>,
onDone: LocalJSXCommandOnDone,
): Promise<LocalMemoryViewProps | null> {
if (parsed.action === 'list') {
const stores = listStores();
onDone(formatStoreList(stores), { display: 'system' });
return null;
}
if (parsed.action === 'create') {
const { store } = parsed;
createStore(store);
onDone(`Store created: ${store}`, { display: 'system' });
return null;
}
if (parsed.action === 'store') {
const { store, key, value } = parsed;
setEntry(store, key, value);
onDone(`Stored entry "${key}" in store "${store}".`, { display: 'system' });
return null;
}
if (parsed.action === 'fetch') {
const { store, key } = parsed;
const value = getEntry(store, key);
if (value === null) {
onDone(`Entry not found: ${store}/${key}`, { display: 'system' });
return null;
}
onDone(`Entry fetched: ${store}/${key}\n${value}`, { display: 'system' });
return null;
}
if (parsed.action === 'entries') {
const { store } = parsed;
const keys = listEntries(store);
onDone(formatEntryList(store, keys), { display: 'system' });
return null;
}
if (parsed.action === 'archive') {
const { store } = parsed;
archiveStore(store);
onDone(`Archived store: ${store}`, { display: 'system' });
return null;
}
// Exhaustive guard
onDone(USAGE, { display: 'system' });
return null;
}
const callLocalMemoryDirect: LocalJSXCommandCall = launchCommand<
ReturnType<typeof parseLocalMemoryArgs>,
LocalMemoryViewProps
>({
commandName: 'local-memory',
parseArgs: (raw: string) => {
const result = parseLocalMemoryArgs(raw);
if (result.action === 'invalid') {
return { action: 'invalid' as const, reason: `${USAGE}\n${result.reason}` };
}
return result;
},
dispatch: dispatchLocalMemory,
View: LocalMemoryView,
errorView: (msg: string) => React.createElement(LocalMemoryView, { mode: 'error', message: msg }),
});
export const callLocalMemory: LocalJSXCommandCall = async (onDone, context, args) => {
if ((args ?? '').trim() === '') {
return <LocalMemoryPanel onDone={onDone} />;
}
return callLocalMemoryDirect(onDone, context, args);
};

View File

@@ -0,0 +1,122 @@
/**
* Parse the args string for the /local-memory command.
*
* Supported sub-commands:
* list → { action: 'list' }
* create <store> → { action: 'create', store }
* store <store> <key> <value> → { action: 'store', store, key, value }
* fetch <store> <key> → { action: 'fetch', store, key }
* entries <store> → { action: 'entries', store }
* archive <store> → { action: 'archive', store }
* (empty) → { action: 'list' }
* anything else → { action: 'invalid', reason }
*/
export type LocalMemoryArgs =
| { action: 'list' }
| { action: 'create'; store: string }
| { action: 'store'; store: string; key: string; value: string }
| { action: 'fetch'; store: string; key: string }
| { action: 'entries'; store: string }
| { action: 'archive'; store: string }
| { action: 'invalid'; reason: string }
// Markdown renderer in REPL eats `<store>` / `<key>` / `<value>` as if
// they were HTML tags. Use uppercase placeholders so users see the
// full usage line. (Same fix as src/commands/local-vault/parseArgs.ts.)
const USAGE =
'Usage: /local-memory list | create STORE | store STORE KEY VALUE | fetch STORE KEY | entries STORE | archive STORE'
export function parseLocalMemoryArgs(args: string): LocalMemoryArgs {
const trimmed = args.trim()
if (trimmed === '' || trimmed === 'list') {
return { action: 'list' }
}
const tokens = trimmed.split(/\s+/)
const subCmd = tokens[0]
// ── list ──────────────────────────────────────────────────────────────────
if (subCmd === 'list') {
return { action: 'list' }
}
// ── create ────────────────────────────────────────────────────────────────
if (subCmd === 'create') {
const store = tokens[1]
if (!store) {
return {
action: 'invalid',
reason: `create requires a store name. ${USAGE}`,
}
}
return { action: 'create', store }
}
// ── store ─────────────────────────────────────────────────────────────────
if (subCmd === 'store') {
const store = tokens[1]
const key = tokens[2]
if (!store) {
return {
action: 'invalid',
reason: `store requires a store name. ${USAGE}`,
}
}
if (!key) {
return { action: 'invalid', reason: `store requires a key. ${USAGE}` }
}
// D6: value is tokens[3..] joined, not substring math (handles store/key with repeated substrings)
const rest = tokens.slice(3).join(' ')
if (!rest) {
return { action: 'invalid', reason: `store requires a value. ${USAGE}` }
}
return { action: 'store', store, key, value: rest }
}
// ── fetch ─────────────────────────────────────────────────────────────────
if (subCmd === 'fetch') {
const store = tokens[1]
const key = tokens[2]
if (!store) {
return {
action: 'invalid',
reason: `fetch requires a store name. ${USAGE}`,
}
}
if (!key) {
return { action: 'invalid', reason: `fetch requires a key. ${USAGE}` }
}
return { action: 'fetch', store, key }
}
// ── entries ───────────────────────────────────────────────────────────────
if (subCmd === 'entries') {
const store = tokens[1]
if (!store) {
return {
action: 'invalid',
reason: `entries requires a store name. ${USAGE}`,
}
}
return { action: 'entries', store }
}
// ── archive ───────────────────────────────────────────────────────────────
if (subCmd === 'archive') {
const store = tokens[1]
if (!store) {
return {
action: 'invalid',
reason: `archive requires a store name. ${USAGE}`,
}
}
return { action: 'archive', store }
}
return {
action: 'invalid',
reason: `Unknown sub-command "${subCmd}". ${USAGE}`,
}
}