aiwg
Version:
Deployment tool and support utility for AI context. Copies agents, skills, commands, rules, and behaviors into the paths each AI platform reads (Claude Code, Codex, Copilot, Cursor, Warp, OpenClaw, and 6 more) so one source of truth works across 10 platfo
468 lines (464 loc) • 18.6 kB
JavaScript
/**
* Storage CLI — `aiwg storage <subcommand>`
*
* Subcommands:
* show — print effective config + resolved physical paths
* list-backends — inventory of compiled-in adapters with status
* test <subsystem> — round-trip read/write/list/delete through the
* configured backend
* migrate <subsystem> — copy entries from one backend to another
*
* @design @.aiwg/architecture/storage-design.md (§7)
* @issue #934
* @issue #954
* @issue #955
*/
import { randomUUID } from 'crypto';
import { existsSync } from 'fs';
import { mkdir, readFile, writeFile, appendFile } from 'fs/promises';
import { dirname, join, resolve as resolvePath } from 'path';
import { BACKEND_TYPES, FilesystemAdapter, ObsidianAdapter, LogseqAdapter, FortemiAdapter, SUBSYSTEM_KEYS, getLoadedConfig, initStorage, resolveStorage, resolveSubsystemRoot, storageConfigPath, } from './index.js';
export async function main(args) {
const subcommand = args[0];
const subArgs = args.slice(1);
const projectRoot = process.cwd();
switch (subcommand) {
case 'show':
await handleShow(projectRoot);
break;
case 'list-backends':
await handleListBackends(subArgs);
break;
case 'test':
await handleTest(projectRoot, subArgs);
break;
case 'migrate':
await handleMigrate(projectRoot, subArgs);
break;
default:
printUsage();
if (subcommand) {
throw new Error(`Unknown storage subcommand: ${subcommand}`);
}
}
}
async function handleShow(projectRoot) {
await initStorage(projectRoot);
const config = await getLoadedConfig(projectRoot);
const cfgPath = storageConfigPath(projectRoot);
if (!config) {
console.log(`No storage.config — every subsystem uses the default fs backend under .aiwg/.\n`);
console.log(`Expected location (when configured): ${cfgPath}\n`);
}
else {
console.log(`storage.config: ${cfgPath}`);
console.log(`schema version: ${config.version}`);
if (config.fallback)
console.log(`fallback: ${config.fallback}`);
console.log('');
}
console.log(`Subsystem Backend Resolved location`);
console.log(`───────────── ──────────── ──────────────────────────────────────────`);
for (const subsystem of SUBSYSTEM_KEYS) {
const backend = config?.backends?.[subsystem]?.type ?? 'fs';
const location = backend === 'fs'
? resolveSubsystemRoot(subsystem, projectRoot, config)
: describeBackendLocation(subsystem, config);
const sub = subsystem.padEnd(13);
const back = backend.padEnd(12);
console.log(` ${sub} ${back} ${location}`);
}
console.log('');
}
async function handleListBackends(args = []) {
const json = args.includes('--json');
if (json) {
const out = BACKEND_TYPES.map((t) => {
const status = backendStatus(t);
return {
type: t,
status: status.implemented ? 'ready' : 'stub',
note: status.note,
...(status.trackingIssue ? { tracking_issue: status.trackingIssue } : {}),
};
});
console.log(JSON.stringify(out, null, 2));
return;
}
console.log(`Compiled-in storage backends:\n`);
console.log(` STATUS TYPE NOTES`);
console.log(` ────── ──────────── ────────────────────────────────────────`);
for (const t of BACKEND_TYPES) {
const status = backendStatus(t);
const sym = status.implemented ? 'READY ' : 'STUB ';
console.log(` ${sym} ${t.padEnd(12)} ${status.note}`);
}
console.log('');
}
async function handleTest(projectRoot, args) {
const subsystem = args[0];
if (!subsystem || !SUBSYSTEM_KEYS.includes(subsystem)) {
throw new Error(`Usage: aiwg storage test <subsystem>\n` +
` Valid subsystems: ${SUBSYSTEM_KEYS.join(', ')}`);
}
await initStorage(projectRoot);
const adapter = await resolveStorage(subsystem);
const probePath = `.aiwg-storage-test/${randomUUID()}.txt`;
const probeContent = `aiwg storage test ${subsystem} ${new Date().toISOString()}\n`;
let stage = 'init';
try {
stage = 'write';
console.log(` ► write ${probePath}`);
await adapter.write(probePath, probeContent);
stage = 'read';
console.log(` ► read ${probePath}`);
const got = await adapter.read(probePath);
if (got !== probeContent) {
throw new Error(`read mismatch: expected ${JSON.stringify(probeContent)}, got ${JSON.stringify(got)}`);
}
stage = 'list';
console.log(` ► list prefix=.aiwg-storage-test/`);
const entries = await adapter.list('.aiwg-storage-test/');
if (!entries.some((e) => e.path === probePath)) {
throw new Error(`list missing probe path ${probePath}`);
}
stage = 'delete';
console.log(` ► delete ${probePath}`);
await adapter.delete(probePath);
const after = await adapter.read(probePath);
if (after !== null) {
throw new Error(`delete did not remove ${probePath}`);
}
console.log(`\n ✓ all 4 ops succeeded for subsystem "${subsystem}"\n`);
}
catch (err) {
throw new Error(`storage test failed at ${stage}: ${err.message}\n` +
` Subsystem: ${subsystem}\n Probe path: ${probePath}`);
}
}
/**
* `aiwg storage migrate <subsystem> --from <spec> --to <spec> [--dry-run]`
*
* Spec format: `<type>:<location>` where type is one of fs/obsidian/
* logseq/fortemi and location is:
* fs → directory path (relative to projectRoot or absolute)
* obsidian → vault directory (folder via `--from-folder`/`--to-folder`)
* logseq → graph directory
* fortemi → MCP server name (default: 'fortemi')
*
* Examples:
* aiwg storage migrate memory --from fs:.aiwg/memory --to obsidian:~/vault --to-folder AIWG/memory
* aiwg storage migrate kb --from fs:.aiwg/kb --to fortemi:fortemi --dry-run
*
* Walks source.list(''), reads each entry, writes to destination. Tracks
* progress in `.aiwg/.storage-cache/migrations/<id>.jsonl` so re-running
* resumes from the last successfully-migrated entry.
*
* @issue #955
*/
async function handleMigrate(projectRoot, args) {
const opts = parseMigrateArgs(args);
const source = buildAdapter(opts.subsystem, opts.from, projectRoot);
const destination = buildAdapter(opts.subsystem, opts.to, projectRoot);
if (locationsEqual(opts.from, opts.to, projectRoot)) {
throw new Error(`Refusing to migrate: source and destination resolve to the same location.\n ${describeSpec(opts.from)}\n ${describeSpec(opts.to)}`);
}
if (source.init)
await source.init();
if (destination.init)
await destination.init();
console.log(`storage migrate (${opts.dryRun ? 'DRY RUN' : 'live'})`);
console.log(` subsystem: ${opts.subsystem}`);
console.log(` from: ${describeSpec(opts.from)}`);
console.log(` to: ${describeSpec(opts.to)}`);
console.log('');
const entries = await source.list('');
if (entries.length === 0) {
console.log('No entries to migrate.');
return;
}
// Resume support: read the per-migration log; skip already-done paths
const migrationLogPath = resolvePath(projectRoot, '.aiwg', '.storage-cache', 'migrations', `${opts.subsystem}-${specSlug(opts.from)}-to-${specSlug(opts.to)}.jsonl`);
const completed = await readCompletedSet(migrationLogPath);
let copied = 0;
let skipped = 0;
let errored = 0;
for (const entry of entries) {
if (completed.has(entry.path)) {
skipped++;
console.log(` ✓ ${entry.path} (already migrated, skipped)`);
continue;
}
if (opts.dryRun) {
copied++;
console.log(` → ${entry.path} (would copy)`);
continue;
}
try {
const content = await source.read(entry.path);
if (content === null) {
errored++;
console.log(` ✗ ${entry.path} (read returned null)`);
continue;
}
await destination.write(entry.path, content);
await recordCompletion(migrationLogPath, entry.path);
copied++;
console.log(` ✓ ${entry.path}`);
}
catch (err) {
errored++;
console.log(` ✗ ${entry.path} (${err.message})`);
}
}
if (source.close)
await source.close();
if (destination.close)
await destination.close();
console.log('');
console.log(`Summary: copied=${copied} skipped=${skipped} errored=${errored} total=${entries.length}`);
if (!opts.dryRun) {
console.log(`Migration log: ${migrationLogPath}`);
}
if (errored > 0) {
throw new Error(`${errored} entr${errored === 1 ? 'y' : 'ies'} failed to migrate.`);
}
}
function parseMigrateArgs(args) {
let subsystem;
let from;
let to;
let fromFolder;
let toFolder;
let dryRun = false;
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--from')
from = args[++i];
else if (a === '--to')
to = args[++i];
else if (a === '--from-folder')
fromFolder = args[++i];
else if (a === '--to-folder')
toFolder = args[++i];
else if (a === '--dry-run')
dryRun = true;
else if (!a.startsWith('--') && !subsystem)
subsystem = a;
else
throw new Error(`Unknown migrate flag: ${a}`);
}
if (!subsystem || !SUBSYSTEM_KEYS.includes(subsystem)) {
throw new Error(`Usage: aiwg storage migrate <subsystem> --from <type>:<location> --to <type>:<location> [--dry-run]\n Valid subsystems: ${SUBSYSTEM_KEYS.join(', ')}`);
}
if (!from)
throw new Error('storage migrate: --from <type>:<location> is required');
if (!to)
throw new Error('storage migrate: --to <type>:<location> is required');
return {
subsystem: subsystem,
from: { ...parseSpec(from), ...(fromFolder ? { folder: fromFolder } : {}) },
to: { ...parseSpec(to), ...(toFolder ? { folder: toFolder } : {}) },
dryRun,
};
}
function parseSpec(raw) {
const idx = raw.indexOf(':');
if (idx === -1) {
throw new Error(`Invalid backend spec "${raw}" — expected <type>:<location>`);
}
const type = raw.slice(0, idx);
const location = raw.slice(idx + 1);
if (!BACKEND_TYPES.includes(type)) {
throw new Error(`Unknown backend type "${type}" in spec "${raw}". Valid: ${BACKEND_TYPES.join(', ')}`);
}
if (location.length === 0) {
throw new Error(`Empty location in backend spec "${raw}"`);
}
return { type: type, location };
}
function buildAdapter(subsystem, spec, projectRoot) {
switch (spec.type) {
case 'fs': {
const root = expandFsLocation(spec.location, projectRoot);
return new FilesystemAdapter(root);
}
case 'obsidian':
return new ObsidianAdapter({
type: 'obsidian',
vault: expandFsLocation(spec.location, projectRoot),
...(spec.folder ? { folder: spec.folder } : {}),
useCli: false,
});
case 'logseq':
return new LogseqAdapter({
type: 'logseq',
graph: expandFsLocation(spec.location, projectRoot),
useApi: false,
});
case 'fortemi':
return new FortemiAdapter({
subsystem,
config: { type: 'fortemi', mcpServer: spec.location },
});
case 'notion':
case 'anythingllm':
case 's3':
case 'webdav':
throw new Error(`storage migrate: backend "${spec.type}" not yet implemented. ` +
`See issues #959, #960, #962, #963 for tracking.`);
default: {
const _exhaustive = spec.type;
void _exhaustive;
throw new Error(`Unhandled backend type ${spec.type}`);
}
}
}
function expandFsLocation(loc, projectRoot) {
if (loc.startsWith('~/')) {
const { homedir } = require('os');
return join(homedir(), loc.slice(2));
}
if (loc === '~') {
const { homedir } = require('os');
return homedir();
}
if (loc.startsWith('/'))
return loc;
return resolvePath(projectRoot, loc);
}
function locationsEqual(a, b, projectRoot) {
if (a.type !== b.type)
return false;
if (a.type === 'fortemi')
return a.location === b.location;
// For file-shaped backends, compare resolved fs paths
return expandFsLocation(a.location, projectRoot) === expandFsLocation(b.location, projectRoot);
}
function describeSpec(spec) {
return spec.folder ? `${spec.type}:${spec.location} (folder=${spec.folder})` : `${spec.type}:${spec.location}`;
}
function specSlug(spec) {
return `${spec.type}-${spec.location.replace(/[^a-zA-Z0-9]/g, '_').slice(0, 32)}`;
}
async function readCompletedSet(logPath) {
if (!existsSync(logPath))
return new Set();
try {
const content = await readFile(logPath, 'utf-8');
const out = new Set();
for (const line of content.split('\n')) {
if (!line.trim())
continue;
try {
const obj = JSON.parse(line);
if (obj.path)
out.add(obj.path);
}
catch {
/* ignore malformed line */
}
}
return out;
}
catch {
return new Set();
}
}
async function recordCompletion(logPath, path) {
await mkdir(dirname(logPath), { recursive: true });
const line = JSON.stringify({ path, ts: new Date().toISOString() }) + '\n';
if (existsSync(logPath)) {
await appendFile(logPath, line, 'utf-8');
}
else {
await writeFile(logPath, line, 'utf-8');
}
}
function describeBackendLocation(subsystem, config) {
const b = config?.backends?.[subsystem];
if (!b)
return '(default fs)';
switch (b.type) {
case 'obsidian':
return `obsidian: ${b.vault}${b.folder ? '/' + b.folder : ''}`;
case 'logseq':
return `logseq: ${b.graph}${b.useApi === false ? ' (file mode)' : ' (HTTP API)'}`;
case 'notion':
return `notion: ${'pageId' in b.parent ? 'page=' + b.parent.pageId : 'database=' + b.parent.databaseId}`;
case 'anythingllm':
return `anythingllm: ${b.baseUrl}/${b.workspace}${b.folder ? '/' + b.folder : ''}`;
case 'fortemi':
return `fortemi: mcp=${b.mcpServer ?? 'fortemi'}${b.scheme ? ' scheme=' + b.scheme : ''}`;
case 's3':
return `s3: ${b.bucket}${b.prefix ? '/' + b.prefix : ''}${b.endpoint ? ' @ ' + b.endpoint : ''}`;
case 'webdav':
return `webdav: ${b.url}`;
default:
return `(${b.type})`;
}
}
const ISSUE_TRACKER_BASE = 'https://git.integrolabs.net/roctinam/aiwg/issues';
export function backendStatus(type) {
switch (type) {
case 'fs':
return { implemented: true, note: 'default backend — local filesystem' };
case 'obsidian':
return { implemented: true, note: 'fs-shaped vault writes; refuses .obsidian/' };
case 'logseq':
return { implemented: true, note: 'fs writes; YAML→property:: transform; refuses logseq/' };
case 'notion':
return {
implemented: false,
note: 'planned (#959) — REST + external_id upsert',
trackingIssue: `${ISSUE_TRACKER_BASE}/959`,
};
case 'anythingllm':
return {
implemented: false,
note: 'planned (#960) — multipart upload',
trackingIssue: `${ISSUE_TRACKER_BASE}/960`,
};
case 'fortemi':
return { implemented: true, note: 'MCP-routed via configured Fortemi server (alpha)' };
case 's3':
return {
implemented: false,
note: 'planned (#962) — phase 3',
trackingIssue: `${ISSUE_TRACKER_BASE}/962`,
};
case 'webdav':
return {
implemented: false,
note: 'planned (#963) — phase 3',
trackingIssue: `${ISSUE_TRACKER_BASE}/963`,
};
default: {
const _exhaustive = type;
void _exhaustive;
return { implemented: false, note: 'unknown' };
}
}
}
function printUsage() {
console.log(`Usage: aiwg storage <subcommand>
Subcommands:
show Print effective config + resolved physical paths
list-backends [--json] Inventory of compiled-in adapters; --json emits structured output including tracking_issue URL for stubs
test <subsystem> Round-trip read/write/list/delete through the configured backend
migrate <subsystem> Copy entries from one backend to another (#955)
--from <type>:<location> Source spec (fs:./dir, obsidian:~/vault, logseq:./graph, fortemi:server)
--to <type>:<location> Destination spec (same format)
--from-folder <folder> Optional Obsidian subfolder for source
--to-folder <folder> Optional Obsidian subfolder for destination
--dry-run Preview operations without writing
Subsystems: ${SUBSYSTEM_KEYS.join(', ')}
Examples:
aiwg storage show
aiwg storage list-backends
aiwg storage test activity_log
aiwg storage migrate memory --from fs:.aiwg/memory --to obsidian:~/vault --to-folder AIWG/memory --dry-run
aiwg storage migrate kb --from fs:.aiwg/kb --to fortemi:fortemi
See @.aiwg/architecture/storage-design.md for the design.`);
}
//# sourceMappingURL=cli.js.map