Skip to content
Closed
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
98 changes: 67 additions & 31 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,63 @@ const LEGACY_SUPERMEMORY_SCRIPTS = [
const SCRIPT_DIR = getScriptDir();
const DIST_HOOKS_DIR = join(SCRIPT_DIR, "hooks");

class ConfigParseError extends Error {
constructor(filePath: string, parser: string, cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
super(
[
`Failed to parse ${filePath}.`,
"",
`The existing configuration contains invalid ${parser}.`,
message,
"",
"No changes were made.",
"Please fix the syntax error and rerun the command.",
].join("\n"),
);
this.name = "ConfigParseError";
}
}

function ensureCodexDir() {
mkdirSync(CODEX_DIR, { recursive: true });
mkdirSync(SUPERMEMORY_HOOKS_DIR, { recursive: true });
}

function readConfigToml(): Record<string, unknown> {
if (!existsSync(CODEX_CONFIG_TOML)) return {};

try {
const content = readFileSync(CODEX_CONFIG_TOML, "utf-8");
return TOML.parse(content) as Record<string, unknown>;
} catch (error) {
throw new ConfigParseError(CODEX_CONFIG_TOML, "TOML", error);
}
}

function readHooksJson(): HookEvents {
if (!existsSync(CODEX_HOOKS_JSON)) return {};

try {
const content = readFileSync(CODEX_HOOKS_JSON, "utf-8");
return normalizeHookEvents(JSON.parse(content));
} catch (error) {
throw new ConfigParseError(CODEX_HOOKS_JSON, "JSON", error);
}
}

function validateWritableCodexConfig(): void {
readConfigToml();
readHooksJson();
}

function mergeConfigToml(enable: boolean) {
if (!enable && !existsSync(CODEX_CONFIG_TOML)) {
// Nothing to disable — file doesn't exist yet.
return;
}

let config: Record<string, unknown> = {};
if (existsSync(CODEX_CONFIG_TOML)) {
try {
const content = readFileSync(CODEX_CONFIG_TOML, "utf-8");
config = TOML.parse(content) as Record<string, unknown>;
} catch {
// start fresh
}
}
const config = readConfigToml();

// Toggle the codex_hooks feature flag.
if (!config.features) config.features = {};
Expand Down Expand Up @@ -192,15 +229,7 @@ function mergeHooksJson(add: boolean) {
return;
}

let hooks: HookEvents = {};
if (existsSync(CODEX_HOOKS_JSON)) {
try {
const content = readFileSync(CODEX_HOOKS_JSON, "utf-8");
hooks = normalizeHookEvents(JSON.parse(content));
} catch {
// start fresh
}
}
const hooks = readHooksJson();

if (add) {
const recallCmd = `node ${RECALL_SCRIPT}`;
Expand Down Expand Up @@ -256,6 +285,7 @@ function mergeHooksJson(add: boolean) {
function install() {
console.log("Installing codex-supermemory...\n");

validateWritableCodexConfig();
ensureCodexDir();

const hadExistingConfig = existsSync(CONFIG_FILE);
Expand Down Expand Up @@ -334,6 +364,7 @@ Optional: Enable debug logging:
function uninstall() {
console.log("Uninstalling codex-supermemory...\n");

validateWritableCodexConfig();
mergeHooksJson(false);
console.log(`✓ Removed hooks from ${CODEX_HOOKS_JSON}`);

Expand Down Expand Up @@ -416,17 +447,22 @@ function status() {
}

const command = process.argv[2];
switch (command) {
case "install":
install();
break;
case "uninstall":
uninstall();
break;
case "status":
status();
break;
default:
console.log("Usage: codex-supermemory <install|uninstall|status>");
process.exit(1);
try {
switch (command) {
case "install":
install();
break;
case "uninstall":
uninstall();
break;
case "status":
status();
break;
default:
console.log("Usage: codex-supermemory <install|uninstall|status>");
process.exit(1);
}
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}
77 changes: 76 additions & 1 deletion test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, statSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -39,6 +39,21 @@ function runCli(cliBin, cmd, tmpDir) {
});
}

function snapshotFile(path) {
return {
content: readFileSync(path, "utf-8"),
size: statSync(path).size,
mtimeMs: statSync(path).mtimeMs,
};
}

function assertFileUnchanged(path, snapshot) {
const current = statSync(path);
assert.equal(readFileSync(path, "utf-8"), snapshot.content);
assert.equal(current.size, snapshot.size);
assert.equal(current.mtimeMs, snapshot.mtimeMs);
}

function readToml(path) {
return TOML.parse(readFileSync(path, "utf-8"));
}
Expand Down Expand Up @@ -528,6 +543,66 @@ describe("integration: install/uninstall", () => {
const config = readToml(configPath);
assert.ok(!config.features, "features table should not exist after uninstall");
});

test("install aborts and preserves config.toml when TOML parsing fails", (t) => {
const { tmpDir, configPath } = setupCodexHome(t);
const invalidConfig = 'model = "gpt-5"\n[features\ncodex_hooks = true\n';
writeFileSync(configPath, invalidConfig);
const before = snapshotFile(configPath);

const result = runCli(cliBin, "install", tmpDir);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Failed to parse/);
assert.match(result.stderr, /config\.toml/);
assert.match(result.stderr, /No changes were made/);
assertFileUnchanged(configPath, before);
});

test("uninstall aborts and preserves config.toml when TOML parsing fails", (t) => {
const { tmpDir, configPath } = setupCodexHome(t);
const invalidConfig = 'model = "gpt-5"\n[features\ncodex_hooks = true\n';
writeFileSync(configPath, invalidConfig);
const before = snapshotFile(configPath);

const result = runCli(cliBin, "uninstall", tmpDir);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Failed to parse/);
assert.match(result.stderr, /config\.toml/);
assert.match(result.stderr, /No changes were made/);
assertFileUnchanged(configPath, before);
});

test("install aborts and preserves hooks.json when JSON parsing fails", (t) => {
const { tmpDir, codexDir } = setupCodexHome(t);
const hooksPath = join(codexDir, "hooks.json");
writeFileSync(hooksPath, '{ "hooks": { "Stop": [ }');
const before = snapshotFile(hooksPath);

const result = runCli(cliBin, "install", tmpDir);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Failed to parse/);
assert.match(result.stderr, /hooks\.json/);
assert.match(result.stderr, /No changes were made/);
assertFileUnchanged(hooksPath, before);
});

test("uninstall aborts and preserves hooks.json when JSON parsing fails", (t) => {
const { tmpDir, codexDir } = setupCodexHome(t);
const hooksPath = join(codexDir, "hooks.json");
writeFileSync(hooksPath, '{ "hooks": { "Stop": [ }');
const before = snapshotFile(hooksPath);

const result = runCli(cliBin, "uninstall", tmpDir);

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Failed to parse/);
assert.match(result.stderr, /hooks\.json/);
assert.match(result.stderr, /No changes were made/);
assertFileUnchanged(hooksPath, before);
});
});


Expand Down