kodelyth-ecc 2.24.8 → 2.24.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,128 @@
2
2
 
3
3
  All notable changes to Kodelyth ECC are documented here.
4
4
 
5
+ ## v2.24.10 — the same config-destroying read, now on settings.json (October 2026)
6
+
7
+ **Data-loss fix.** 2.24.9 fixed a nullable JSON read coalesced to `{}` in
8
+ `register-self.js`, which destroyed `~/.claude.json`. Grepping the codebase for
9
+ that pattern found it a second time, in `register-hooks.js`, on a file that
10
+ matters more.
11
+
12
+ ```js
13
+ const settings = readJsonOrNull(settingsPath) || {};
14
+ // …
15
+ const body = JSON.stringify({ ...settings, hooks: merged }, null, 2);
16
+ ```
17
+
18
+ Same shape, same consequence: a `settings.json` that merely failed to parse was
19
+ indistinguishable from one that did not exist, so the merge wrote it back
20
+ carrying only the hooks block. Measured on a realistic file: **477 bytes in, 106
21
+ bytes out** — losing `permissions.allow` and `.deny`, `env`, `model`,
22
+ `statusLine`, and the user's *own* hook entries. Losing permissions silently
23
+ re-prompts them for every tool; losing hooks silently stops their quality gates.
24
+
25
+ Worth recording how it survived review: the **write** here was already hardened to
26
+ preserve the file's mode, with a comment explaining that an installer has no
27
+ business changing a user config's permissions. Someone thought carefully about the
28
+ write and not at all about the read.
29
+
30
+ ### What changed
31
+
32
+ `readSettings` now returns `absent` / `present` / `unreadable`, and an unreadable
33
+ config returns status `unreadable-settings` with the parse reason, writing
34
+ nothing. The existing `readJsonOrNull` is kept for ECC's own shipped `hooks.json`,
35
+ where null on failure is correct — that is a file we ship, not one a user can
36
+ hand-edit into a broken state.
37
+
38
+ ### A documented behaviour was deliberately reversed
39
+
40
+ A test existed for this case: *"a corrupt settings.json does not lose the hooks"*,
41
+ asserting status `ok` with ECC's three hook events written. The intent — don't
42
+ lose ECC's hooks over a broken config — sounds right until it is priced. The only
43
+ way to write into a file that will not parse is to discard everything else in it.
44
+
45
+ ECC's hooks not installing is recoverable: fix the JSON, re-run. The user's
46
+ permissions and env are not. The test is rewritten rather than deleted, asserting
47
+ the opposite contract with the trade-off stated in the test body.
48
+
49
+ **693 tests across 44 files.**
50
+
51
+ ### Also: brand art pointed at the wrong site
52
+
53
+ Four ECC cards printed `kodelyth.com` — the parent company site — rather than
54
+ `ecc.kodelyth.com`, where ECC actually lives. `og-image`, `github-social-preview`
55
+ and `fb-post-launch` all advertise ECC, so anyone reading the card and typing the
56
+ URL landed somewhere else. `section-author` keeps the parent domain, since that
57
+ one is about the company rather than the product.
58
+
59
+ ## v2.24.9 — a malformed Claude config was silently replaced on install (October 2026)
60
+
61
+ **Data-loss fix. Upgrade before running an install on a machine whose
62
+ `~/.claude.json` may have been hand-edited.**
63
+
64
+ MCP self-registration read the user's Claude config, merged ECC's server entry in,
65
+ and wrote it back:
66
+
67
+ ```js
68
+ const existing = readJson(file) || {};
69
+ const next = { ...existing, mcpServers: servers };
70
+ writeJson(file, next);
71
+ ```
72
+
73
+ `readJson` returned `null` for **every** failure. So a config that merely failed to
74
+ parse — one trailing comma from a hand-edit, a half-written file, a bad mount —
75
+ was indistinguishable from a config that did not exist, and `|| {}` turned it into
76
+ an empty object. The merge then wrote that back.
77
+
78
+ Measured on a realistic config with two other MCP servers, a `projects` block with
79
+ history, and user settings: **366 bytes in, 140 bytes out**. Every other MCP
80
+ server, every project entry, the theme and `autoUpdates` — gone. No error, no
81
+ warning, no backup.
82
+
83
+ This runs automatically during install. So `npx kodelyth-ecc` on a machine whose
84
+ `.claude.json` had any syntax error destroyed the user's Claude configuration.
85
+
86
+ ### What changed
87
+
88
+ `readConfig` now returns one of three states instead of a nullable value:
89
+
90
+ | state | meaning | action |
91
+ |---|---|---|
92
+ | `absent` | missing, empty, or whitespace-only | safe to treat as `{}` and create |
93
+ | `present` | parsed to an object | merge normally |
94
+ | `unreadable` | exists but will not parse, or is not an object | **refuse** |
95
+
96
+ Refusing is the point: there is no safe way to merge into a file whose contents
97
+ cannot be reconstructed. The file is left byte-identical and the error names it
98
+ along with the parse failure. `unregisterInFile` applies the same rule on the way
99
+ out — removing an entry from a file we cannot read means writing a file we cannot
100
+ reconstruct.
101
+
102
+ Writes now go through `safeFs.replaceFilePreservingMode`: atomic via
103
+ temp-and-rename, so a crash or a full disk partway through cannot truncate the
104
+ config — the same data loss by a different route — and the file keeps its existing
105
+ permissions rather than having them widened.
106
+
107
+ ### Two mistakes caught while fixing it
108
+
109
+ The first version of the fix called `replaceFileAtomic` without its `mode`
110
+ argument, which threw on **every** path including valid configs. Testing the happy
111
+ path, not just the bug, is what surfaced it; verifying only that the refusal
112
+ worked would have shipped an installer that never registered anything.
113
+
114
+ The mode-preservation test then asserted `mode === 0o600` and failed Windows CI
115
+ with `438 !== 384`. Windows has no POSIX permission bits, so `chmod` there is close
116
+ to a no-op and node reports `0666` regardless. The property worth testing was never
117
+ the literal value but that the write does not *change* the mode, which is true on
118
+ every platform.
119
+
120
+ Eight tests, where this file previously had none: the three read states,
121
+ preservation of unrelated config on a valid merge, byte-identical refusal across
122
+ four kinds of malformed input, creation from absent and empty, idempotence,
123
+ unregister's refusal, mode preservation, and no stray temp files.
124
+
125
+ **690 tests across 44 files.**
126
+
5
127
  ## v2.24.8 — adversarial pass: swarm, dashboard, supply-chain (October 2026)
6
128
 
7
129
  Three defects found by attacking the remaining unprobed surfaces rather than
package/CLAUDE.md CHANGED
@@ -26,7 +26,7 @@ scripts/ → Node.js utilities: MCP server, dashboard, swarm, replay, router
26
26
  bundles/ → 3 power bundles (indie-hacker, red-team, enterprise)
27
27
  actions/ → GitHub Action (CI/CD integration for PR review)
28
28
  docs/ → Feature docs (arena.md, mcp.md, dashboard.md, swarm.md, replay.md, evolve.md, supply-chain.md)
29
- tests/ → 682 passing tests across 43 test files
29
+ tests/ → 693 passing tests across 44 test files
30
30
  ```
31
31
 
32
32
  ## Running Tests
package/README.md CHANGED
@@ -118,7 +118,7 @@ Most "AI agent kits" are folders of markdown files you have to remember the name
118
118
  | Quality hooks | ✓ 44 | Some | ✗ | ✗ |
119
119
  | IDE platforms | **11** (Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, Roo Code, Aider, Kimi, Gemini CLI) | 1-2 | 1 | Varies |
120
120
  | Telemetry | **None** | Varies | None | Varies |
121
- | Test coverage | ✓ 682 tests | ✗ | ✗ | ✗ |
121
+ | Test coverage | ✓ 693 tests | ✗ | ✗ | ✗ |
122
122
  | Distributed via | `npx`, curl, clone | Manual | Manual | Manual |
123
123
 
124
124
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kodelyth-ecc",
3
- "version": "2.24.8",
3
+ "version": "2.24.10",
4
4
  "description": "Production-grade AI coding toolkit — 70 agents (incl. devil-mode adversarial crew), 196 skills, 103 commands, parallel multi-agent commands, semantic intent routing, self-learning memory, and a built-in MCP server (16 tools / 6 prompts / 377 resources) that bridges to Claude Desktop, LangGraph, AutoGen, CrewAI, and OpenAI Agents SDK. Works on macOS, Linux and Windows with Claude Code, Windsurf, Cursor, Codex, Antigravity, OpenCode, Cline, RooCode, Aider, Kimi, and Gemini CLI.",
5
5
  "author": "Kodelyth <github.com/sifxprime>",
6
6
  "license": "MIT",
@@ -36,6 +36,10 @@ const path = require('path');
36
36
  const { mergeHookEntries } = require('../lib/install/apply.js');
37
37
  const { replaceFilePreservingMode } = require('../lib/safe-fs.js');
38
38
 
39
+ // ECC's OWN shipped hooks.json. Null on failure is right here: it is a file we
40
+ // ship, a caller cannot have hand-edited it into existence, and the
41
+ // 'invalid-hooks-config' status below is the correct response to it being wrong.
42
+ // The user's settings.json is a different matter — see readSettings.
39
43
  function readJsonOrNull(file) {
40
44
  try {
41
45
  const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -45,6 +49,40 @@ function readJsonOrNull(file) {
45
49
  }
46
50
  }
47
51
 
52
+ // Reading settings.json has THREE outcomes, and collapsing them destroys data.
53
+ //
54
+ // This used to be readJsonOrNull() returning null for every failure, with the
55
+ // caller writing `|| {}`. A settings.json that merely failed to parse — one
56
+ // trailing comma from a hand-edit — was therefore indistinguishable from one that
57
+ // did not exist, and the merge wrote it back carrying only the hooks block.
58
+ // Measured on a realistic file: 477 bytes in, 106 bytes out, losing
59
+ // permissions.allow and .deny, env, model, statusLine, and the user's OWN hook
60
+ // entries. Losing permissions silently re-prompts them for every tool; losing
61
+ // hooks silently stops their quality gates.
62
+ //
63
+ // This is the same defect fixed in scripts/mcp/register-self.js for ~/.claude.json.
64
+ // Note the write below was already hardened to preserve file mode — the write was
65
+ // careful and the read was not, which is how it survived review.
66
+ function readSettings(file) {
67
+ let raw;
68
+ try {
69
+ raw = fs.readFileSync(file, 'utf8');
70
+ } catch (err) {
71
+ if (err.code === 'ENOENT') return { state: 'absent', data: {} };
72
+ return { state: 'unreadable', reason: err.message };
73
+ }
74
+ if (raw.trim() === '') return { state: 'absent', data: {} };
75
+ try {
76
+ const parsed = JSON.parse(raw);
77
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
78
+ return { state: 'unreadable', reason: 'top-level value is not an object' };
79
+ }
80
+ return { state: 'present', data: parsed };
81
+ } catch (err) {
82
+ return { state: 'unreadable', reason: err.message };
83
+ }
84
+ }
85
+
48
86
  /**
49
87
  * @param {string} targetRoot e.g. ~/.claude
50
88
  * @param {string} [hooksJsonPath] defaults to <targetRoot>/hooks/hooks.json
@@ -67,7 +105,21 @@ function registerHooks(targetRoot, hooksJsonPath) {
67
105
  }
68
106
 
69
107
  const settingsPath = path.join(targetRoot, 'settings.json');
70
- const settings = readJsonOrNull(settingsPath) || {};
108
+ const read = readSettings(settingsPath);
109
+ if (read.state === 'unreadable') {
110
+ // Refuse rather than merge. There is no safe way to write a file whose current
111
+ // contents cannot be reconstructed, and "best effort" here means discarding
112
+ // whatever did not parse.
113
+ return {
114
+ status: 'unreadable-settings',
115
+ reason: read.reason,
116
+ events: 0,
117
+ added: 0,
118
+ total: 0,
119
+ settingsPath,
120
+ };
121
+ }
122
+ const settings = read.data;
71
123
  const existing = settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks)
72
124
  ? settings.hooks
73
125
  : {};
@@ -7,6 +7,7 @@
7
7
  'use strict';
8
8
 
9
9
  const fs = require('fs');
10
+ const safeFs = require('../lib/safe-fs');
10
11
  const os = require('os');
11
12
  const path = require('path');
12
13
 
@@ -24,13 +25,48 @@ function claudeDesktopConfigPath() {
24
25
  return path.join(home, '.config', 'Claude', 'claude_desktop_config.json');
25
26
  }
26
27
 
27
- function readJson(file) {
28
- try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; }
28
+ // Reading this file has THREE outcomes, and collapsing them is destructive.
29
+ //
30
+ // readJson used to return null for every failure, and the caller did
31
+ // `readJson(file) || {}`. So a config that merely failed to parse — one trailing
32
+ // comma from a hand-edit, a half-written file, a bad mount — was treated as an
33
+ // empty config, and the merge wrote it back containing only ECC's own entry.
34
+ // Measured on a realistic config: 366 bytes in, 140 bytes out, with every other
35
+ // MCP server, every project entry and every setting gone. Silently, during an
36
+ // install the user did not think was risky.
37
+ //
38
+ // Absent is safe to treat as empty. Unreadable is not.
39
+ function readConfig(file) {
40
+ let raw;
41
+ try {
42
+ raw = fs.readFileSync(file, 'utf8');
43
+ } catch (err) {
44
+ if (err.code === 'ENOENT') return { state: 'absent', data: {} };
45
+ return { state: 'unreadable', reason: err.message };
46
+ }
47
+ // An empty or whitespace-only file is effectively absent; JSON.parse would
48
+ // throw on it and we would otherwise refuse to ever register.
49
+ if (raw.trim() === '') return { state: 'absent', data: {} };
50
+ try {
51
+ const data = JSON.parse(raw);
52
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
53
+ return { state: 'unreadable', reason: 'top-level value is not an object' };
54
+ }
55
+ return { state: 'present', data };
56
+ } catch (err) {
57
+ return { state: 'unreadable', reason: err.message };
58
+ }
29
59
  }
30
60
 
31
61
  function writeJson(file, obj) {
32
62
  fs.mkdirSync(path.dirname(file), { recursive: true });
33
- fs.writeFileSync(file, JSON.stringify(obj, null, 2));
63
+ // Atomic: write a temp file and rename. A crash or a full disk partway through
64
+ // a plain writeFileSync leaves the user with a truncated config, which is the
65
+ // same data loss by a different route.
66
+ // PreservingMode rather than Atomic: this is the user's own config file and it
67
+ // may carry permissions they chose. It keeps the existing mode when the file is
68
+ // already there and falls back to 0644 when creating it.
69
+ safeFs.replaceFilePreservingMode(file, JSON.stringify(obj, null, 2) + '\n');
34
70
  }
35
71
 
36
72
  function serverEntry() {
@@ -42,7 +78,15 @@ function serverEntry() {
42
78
  }
43
79
 
44
80
  function registerInFile(file, opts = {}) {
45
- const existing = readJson(file) || {};
81
+ const cfg = readConfig(file);
82
+ if (cfg.state === 'unreadable') {
83
+ // Refusing is the whole point. Overwriting would destroy whatever is in there.
84
+ throw new Error(
85
+ `refusing to modify ${file}: it exists but could not be parsed (${cfg.reason}). ` +
86
+ `Fix or move the file, then re-run. Nothing was written.`
87
+ );
88
+ }
89
+ const existing = cfg.data;
46
90
  const servers = existing.mcpServers || {};
47
91
  const already = servers[SERVER_NAME];
48
92
  const desired = serverEntry();
@@ -56,7 +100,10 @@ function registerInFile(file, opts = {}) {
56
100
  }
57
101
 
58
102
  function unregisterInFile(file) {
59
- const existing = readJson(file);
103
+ const cfg = readConfig(file);
104
+ // Same rule on the way out: an unparseable config is left exactly as it is.
105
+ if (cfg.state === 'unreadable') return { file, action: 'skipped-unreadable', reason: cfg.reason };
106
+ const existing = cfg.data;
60
107
  if (!existing || !existing.mcpServers || !existing.mcpServers[SERVER_NAME]) {
61
108
  return { file, action: 'not-present' };
62
109
  }
@@ -113,4 +160,7 @@ module.exports = {
113
160
  registerAll,
114
161
  unregisterAll,
115
162
  statusAll,
163
+ // Exported for tests: these must be exercisable without writing to a real
164
+ // ~/.claude.json, and the whole point of the fix is what they refuse to do.
165
+ _internals: { readConfig, registerInFile, unregisterInFile },
116
166
  };