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
31 changes: 25 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,43 @@ name: Publish Package

on:
push:
tags:
- 'v*'
branches: [main]

permissions:
id-token: write # Required for OIDC
contents: read
id-token: write # Required for npm OIDC trusted publishing
contents: write # Commit the version bump back to main

jobs:
publish:
# Skip the follow-up push from this workflow's own release commit.
if: ${{ !startsWith(github.event.head_commit.message, 'chore(release):') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
# Push the bump commit back to main (detached HEAD → main).
fetch-depth: 0

- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
node-version: "24"
registry-url: "https://registry.npmjs.org"

- run: npm ci

- name: Bump patch version
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
npm version patch --no-git-tag-version
VERSION="$(node -p "require('./package.json').version")"
git add package.json package-lock.json
git commit -m "chore(release): ${VERSION}"

# Build after the bump so the embedded PLUGIN_VERSION matches the publish.
- run: npm run build --if-present
- run: npm test
- run: npm publish

- name: Push version bump
run: git push origin HEAD:main
96 changes: 65 additions & 31 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,26 +60,61 @@ const LEGACY_SUPERMEMORY_SCRIPTS = [
const SCRIPT_DIR = getScriptDir();
const DIST_HOOKS_DIR = join(SCRIPT_DIR, "hooks");

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

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

/** Parse existing Codex config files, or throw before any install/uninstall writes. */
function assertCodexConfigReadable(): void {
readConfigToml();
readHooksJson();
}

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 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 configParseError(CODEX_HOOKS_JSON, "JSON", error);
}
}

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 +227,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 +283,7 @@ function mergeHooksJson(add: boolean) {
function install() {
console.log("Installing codex-supermemory...\n");

assertCodexConfigReadable();
ensureCodexDir();

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

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

Expand Down Expand Up @@ -416,17 +445,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);
}
28 changes: 27 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,38 @@ function loadRawConfig(): { config: CodexSupermemoryConfig; existed: boolean } {
const content = readFileSync(CONFIG_FILE, "utf-8");
return { config: JSON.parse(content) as CodexSupermemoryConfig, existed: true };
} catch {
// Soft-fail for runtime reads (hooks/skills); writers must use loadRawConfigForWrite.
return { config: {}, existed: true };
}
}
return { config: {}, existed: false };
}

/** Parse supermemory.json for write paths — never treat invalid JSON as empty. */
function loadRawConfigForWrite(): { config: CodexSupermemoryConfig; existed: boolean } {
if (!existsSync(CONFIG_FILE)) {
return { config: {}, existed: false };
}

try {
const content = readFileSync(CONFIG_FILE, "utf-8");
return { config: JSON.parse(content) as CodexSupermemoryConfig, existed: true };
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
throw new Error(
[
`Failed to parse ${CONFIG_FILE}.`,
"",
"The existing configuration contains invalid JSON.",
detail,
"",
"No changes were made.",
"Please fix the syntax error and rerun the command.",
].join("\n"),
);
}
}

const { config: fileConfig, existed: configExisted } = loadRawConfig();

function resolveCaptureEveryNTurns(config: CodexSupermemoryConfig): number {
Expand Down Expand Up @@ -227,7 +253,7 @@ export function validateContainerTag(tag: string): string | null {

/** Persist explicit recall/capture defaults for fresh installs or legacy upgrades. */
export function writeInstallDefaults(isExistingInstall: boolean): void {
const current = loadRawConfig().config;
const current = loadRawConfigForWrite().config;
const next: CodexSupermemoryConfig = { ...current };

if (isExistingInstall) {
Expand Down
86 changes: 86 additions & 0 deletions test/unit.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,92 @@ 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 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/);
assert.equal(readFileSync(configPath, "utf-8"), invalidConfig);
});

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 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/);
assert.equal(readFileSync(configPath, "utf-8"), invalidConfig);
});

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

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/);
assert.equal(readFileSync(hooksPath, "utf-8"), invalidHooks);
});

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

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/);
assert.equal(readFileSync(hooksPath, "utf-8"), invalidHooks);
});

test("install aborts and preserves supermemory.json when JSON parsing fails", (t) => {
const { tmpDir, codexDir } = setupCodexHome(t);
const supermemoryPath = join(codexDir, "supermemory.json");
const invalidConfig = '{ "apiKey": "sm_test", ';
writeFileSync(supermemoryPath, invalidConfig);

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

assert.notEqual(result.status, 0);
assert.match(result.stderr, /Failed to parse/);
assert.match(result.stderr, /supermemory\.json/);
assert.match(result.stderr, /No changes were made/);
assert.equal(readFileSync(supermemoryPath, "utf-8"), invalidConfig);
});

test("install merges into existing valid config.toml", (t) => {
const { tmpDir, configPath } = setupCodexHome(t);
writeFileSync(configPath, 'model = "gpt-5"\n\n[features]\nweb_search = true\n');

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

assert.equal(result.status, 0, `install should exit 0: ${result.stderr}`);
const config = readToml(configPath);
assert.equal(config.model, "gpt-5");
assert.equal(config.features.web_search, true);
assert.equal(config.features.codex_hooks, true);
});
});


Expand Down