kelocan 0.1.0

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/dist/help.js ADDED
@@ -0,0 +1,164 @@
1
+ export const ROOT_HELP = `kelocan — CLI for the Kelocan kanban board (thin client over the REST API)
2
+
3
+ Usage: kelocan <command> [args] [flags]
4
+
5
+ Setup:
6
+ init Install the kelocan MCP server + agent skills into your agents
7
+ doctor Check installed configs, skills, connectivity & auth (read-only)
8
+ mcp install --agent <name|all> Write only the MCP config entries
9
+ skills install --agent <name|all> Write only the skill files
10
+ uninstall --agent <name|all> Remove kelocan MCP entries & skills
11
+
12
+ Auth:
13
+ login Authenticate via browser (loopback) or --api-key
14
+ logout Remove stored credentials
15
+ whoami Show the current authenticated user
16
+
17
+ Read:
18
+ board show <board> Show board columns & task counts
19
+ task list <board> [--column X] List tasks (filter: --column, --label, --sprint)
20
+ task show <id> Show task detail
21
+ task prompt <id> Print the rendered agent prompt for a task
22
+
23
+ Write:
24
+ task create <board> --title "..." Create a task
25
+ task move <id> --to <column|role> Move task to a column
26
+ task comment <id> -m "..." Add a comment (-m or --file)
27
+
28
+ Agent workflow:
29
+ run start <task> --agent <name> Open an agent run (prints run_id)
30
+ run finish <run_id> --outcome <o> -m Close a run (succeeded|failed|cancelled)
31
+ subtask list <task> List sub-tasks
32
+ subtask toggle <id> Toggle a sub-task done/open
33
+
34
+ Global flags:
35
+ --json Machine-readable JSON output
36
+ --fields a,b,c Pick fields (with --json, or tab-separated text)
37
+ --help, -h Show help for a command
38
+
39
+ Config: ~/.config/kelocan/config.json (env KELOCAN_URL / KELOCAN_API_KEY / KELOCAN_WEB_URL override)
40
+ Exit codes: 0 ok · 1 validation · 2 auth · 3 network/server
41
+
42
+ Run 'kelocan <command> --help' for examples.`;
43
+ export const HELP = {
44
+ init: `kelocan init — install kelocan MCP + skills into your agents
45
+
46
+ Usage:
47
+ kelocan init Interactive: login → pick agents → install
48
+ kelocan init --agent devin,cursor Non-interactive for selected agents
49
+ kelocan init --agent all --auth api-key Force API-key auth headers (env ref)
50
+ Supported agents: devin, claude, cursor, codex, opencode, antigravity, hermes.
51
+ Only the 'kelocan' entry is merged — other MCP entries are never touched, the
52
+ original file is backed up to <config>.bak, and re-running is idempotent.`,
53
+ doctor: `kelocan doctor — verify the installation (read-only)
54
+
55
+ Usage: kelocan doctor [--json]
56
+ Checks each detected agent's MCP config entry & URL, installed skill files vs
57
+ the bundled copies, server reachability, and auth. Prints a fix per finding.
58
+ Exit code 1 when any check fails.`,
59
+ "mcp install": `kelocan mcp install — write only the MCP config entries
60
+
61
+ Usage: kelocan mcp install --agent <name|all> [--auth oauth|api-key]
62
+ Example: kelocan mcp install --agent codex`,
63
+ "skills install": `kelocan skills install — write only the skill files
64
+
65
+ Usage: kelocan skills install --agent <name|all>
66
+ Installs the bundled 'kelocan' and 'kelocan-review' skills into each agent's
67
+ skills directory. Same files can also be installed via
68
+ 'npx skills add <owner>/kelocan-cli'.`,
69
+ uninstall: `kelocan uninstall — remove kelocan from agents
70
+
71
+ Usage: kelocan uninstall --agent <name|all>
72
+ Removes ONLY the 'kelocan' MCP entry and the kelocan/kelocan-review skill
73
+ directories. Other MCP entries and skills are left untouched (config backed up
74
+ to <config>.bak first).`,
75
+ login: `kelocan login — authenticate this machine
76
+
77
+ Usage:
78
+ kelocan login Open browser authorize page (loopback flow)
79
+ kelocan login --api-key klc_... Headless: store an existing API key
80
+ kelocan login --url http://localhost:8001 Point at a different server
81
+ kelocan login --web-url http://localhost:5173 Override the web app URL
82
+ kelocan login --name my-laptop Key name shown in settings
83
+
84
+ The browser flow opens {web}/cli/authorize?port&state&name and waits for the
85
+ redirect to http://127.0.0.1:<port>/callback?key&state (5 minute timeout).`,
86
+ logout: `kelocan logout — remove stored credentials
87
+
88
+ Usage: kelocan logout
89
+ Removes api_key from ~/.config/kelocan/config.json (server URL is kept).`,
90
+ whoami: `kelocan whoami — show the current user
91
+
92
+ Usage: kelocan whoami [--json]
93
+ Example: kelocan whoami --fields name,email`,
94
+ "board show": `kelocan board show — show a board's columns and tasks
95
+
96
+ Usage: kelocan board show <board>
97
+ <board> may be a board name or UUID.
98
+ Examples:
99
+ kelocan board show kanban
100
+ kelocan board show d8d28757-... --json`,
101
+ "task list": `kelocan task list — list tasks on a board
102
+
103
+ Usage: kelocan task list <board> [filters]
104
+ --column <name|role|uuid> Filter by column (role: backlog|active|review|done)
105
+ --label <name|uuid> Filter by label (repeat via comma: --label bug,cli)
106
+ --sprint <active|backlog|uuid> Filter by sprint
107
+ Examples:
108
+ kelocan task list kanban --column "In Progress"
109
+ kelocan task list kanban --column review --json --fields id,title`,
110
+ "task show": `kelocan task show — task detail
111
+
112
+ Usage: kelocan task show <task-id> [--json]
113
+ Example: kelocan task show 8c8fb91c-... --fields title,description`,
114
+ "task prompt": `kelocan task prompt — print the agent prompt for a task
115
+
116
+ Usage: kelocan task prompt <task-id>
117
+ Prints the rendered prompt (work or review variant) to stdout.
118
+ Example: kelocan task prompt 8c8fb91c-... > /tmp/prompt.md`,
119
+ "task create": `kelocan task create — create a task
120
+
121
+ Usage: kelocan task create <board> --title "..." [options]
122
+ --column <name|role|uuid> Target column (default: backlog/"To Do")
123
+ --description <text> Task description
124
+ --kind <task|idea|feedback> Card kind (default: task)
125
+ --priority <low|medium|high|urgent> (default: medium)
126
+ --label <name,...> Attach labels by name or UUID
127
+ --sprint <active|backlog|uuid> Sprint assignment (default: backlog)
128
+ Examples:
129
+ kelocan task create kanban --title "Fix login" --column todo --label bug
130
+ kelocan task create kanban --title "Idea" --kind idea --priority low`,
131
+ "task move": `kelocan task move — move a task to another column
132
+
133
+ Usage: kelocan task move <task-id> --to <column|role> [--position N]
134
+ --to accepts a column name, workflow role (backlog|active|review|done), or UUID.
135
+ Examples:
136
+ kelocan task move 8c8fb91c-... --to "In Progress"
137
+ kelocan task move 8c8fb91c-... --to review`,
138
+ "task comment": `kelocan task comment — add a comment to a task
139
+
140
+ Usage: kelocan task comment <task-id> (-m "text" | --file path)
141
+ -m, --message <text> Comment body
142
+ --file <path> Attach a file (repeat via comma). With no -m, a text
143
+ file's content becomes the comment body.
144
+ Examples:
145
+ kelocan task comment 8c8fb91c-... -m "Progress: 50%"
146
+ kelocan task comment 8c8fb91c-... -m "see log" --file build.log`,
147
+ "run start": `kelocan run start — open an agent run on a task
148
+
149
+ Usage: kelocan run start <task-id> --agent <name> [--status "text"]
150
+ Prints the run_id — keep it for 'run finish'.
151
+ Example: kelocan run start 8c8fb91c-... --agent "Devin" --status "Investigating"`,
152
+ "run finish": `kelocan run finish — close an agent run
153
+
154
+ Usage: kelocan run finish <run-id> --outcome <succeeded|failed|cancelled> [-m "summary"]
155
+ Example: kelocan run finish ca01614d-... --outcome succeeded -m "PR opened"`,
156
+ "subtask list": `kelocan subtask list — list a task's sub-tasks
157
+
158
+ Usage: kelocan subtask list <task-id> [--json]
159
+ Example: kelocan subtask list 8c8fb91c-...`,
160
+ "subtask toggle": `kelocan subtask toggle — toggle a sub-task done/open
161
+
162
+ Usage: kelocan subtask toggle <subtask-id>
163
+ Example: kelocan subtask toggle 8e19ca38-...`,
164
+ };
@@ -0,0 +1,172 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { binOnPath, detectAgent, jsonEntry, removeTomlEntry, removeYamlEntry, resolveConfigPath, tomlEntry, upsertTomlEntry, upsertYamlEntry, yamlEntry, } from "./agents.js";
6
+ import { apiBase, readConfig } from "./config.js";
7
+ import { CliError } from "./errors.js";
8
+ import { removeJsonEntry, upsertJsonEntry } from "./jsonc.js";
9
+ import { installSkills, removeSkills } from "./skills.js";
10
+ /** Public MCP endpoint for the Kelocan server (env KELOCAN_MCP_URL wins). */
11
+ export function mcpUrl() {
12
+ if (process.env.KELOCAN_MCP_URL)
13
+ return process.env.KELOCAN_MCP_URL.replace(/\/+$/, "");
14
+ const base = apiBase(readConfig()).replace(/\/api\/v1$/, "");
15
+ return `${base}/mcp`;
16
+ }
17
+ function backupOnce(path) {
18
+ if (!existsSync(path))
19
+ return null;
20
+ const bak = `${path}.bak`;
21
+ copyFileSync(path, bak);
22
+ return bak;
23
+ }
24
+ function writeFile(path, text) {
25
+ mkdirSync(dirname(path), { recursive: true });
26
+ writeFileSync(path, text);
27
+ }
28
+ /** Write the kelocan MCP entry into an agent's config (or via its CLI). */
29
+ export function installMcpConfig(def, auth, home = homedir(), url = mcpUrl()) {
30
+ // Claude Code: prefer `claude mcp add` when the binary exists.
31
+ if (def.cli && binOnPath(def.cli.bin)) {
32
+ const existing = readFileSafe(resolveConfigPath(def, home));
33
+ let already = false;
34
+ if (existing) {
35
+ try {
36
+ const parsed = JSON.parse(existing);
37
+ already = parsed.mcpServers?.kelocan !== undefined;
38
+ }
39
+ catch {
40
+ /* fall through to CLI */
41
+ }
42
+ }
43
+ if (already) {
44
+ return { configPath: resolveConfigPath(def, home), action: "unchanged", viaCli: true };
45
+ }
46
+ const r = spawnSync(def.cli.bin, def.cli.args(url), { encoding: "utf8" });
47
+ if (r.status === 0) {
48
+ return { configPath: resolveConfigPath(def, home), action: "cli", viaCli: true };
49
+ }
50
+ // fall back to file editing
51
+ }
52
+ const path = resolveConfigPath(def, home);
53
+ const text = readFileSafe(path);
54
+ let res;
55
+ switch (def.format) {
56
+ case "json":
57
+ res = upsertJsonEntry(text, def.containerKey, "kelocan", jsonEntry(def, url, auth));
58
+ break;
59
+ case "toml":
60
+ res = upsertTomlEntry(text, tomlEntry(url));
61
+ break;
62
+ case "yaml":
63
+ res = upsertYamlEntry(text, yamlEntry(url, auth));
64
+ break;
65
+ }
66
+ if (!res.changed)
67
+ return { configPath: path, action: "unchanged" };
68
+ const backup = backupOnce(path);
69
+ writeFile(path, res.text);
70
+ return { configPath: path, action: "written", backup };
71
+ }
72
+ /** Remove the kelocan MCP entry (and only that) from an agent's config. */
73
+ export function uninstallMcpConfig(def, home = homedir()) {
74
+ if (def.cli && binOnPath(def.cli.bin)) {
75
+ const r = spawnSync(def.cli.bin, ["mcp", "remove", "kelocan"], { encoding: "utf8" });
76
+ if (r.status === 0) {
77
+ return { configPath: resolveConfigPath(def, home), action: "cli" };
78
+ }
79
+ // fall through to file editing
80
+ }
81
+ const path = resolveConfigPath(def, home);
82
+ const text = readFileSafe(path);
83
+ if (text === null)
84
+ return { configPath: path, action: "absent" };
85
+ let res;
86
+ switch (def.format) {
87
+ case "json":
88
+ res = removeJsonEntry(text, def.containerKey, "kelocan");
89
+ break;
90
+ case "toml":
91
+ res = removeTomlEntry(text);
92
+ break;
93
+ case "yaml":
94
+ res = removeYamlEntry(text);
95
+ break;
96
+ }
97
+ if (!res.changed)
98
+ return { configPath: path, action: "absent" };
99
+ const backup = backupOnce(path);
100
+ writeFile(path, res.text);
101
+ return { configPath: path, action: "removed", backup };
102
+ }
103
+ function readFileSafe(path) {
104
+ if (!existsSync(path))
105
+ return null;
106
+ try {
107
+ return readFileSync(path, "utf8");
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ /** Install MCP config + bundled skills for one agent. */
114
+ export function installAgent(det, auth, home = homedir()) {
115
+ const warnings = [];
116
+ if (auth === "api-key" && det.def.defaultAuth === "oauth") {
117
+ warnings.push(`--auth api-key: writing a Bearer header referencing $KELOCAN_API_KEY ` +
118
+ `(env var — no literal secret is stored).`);
119
+ }
120
+ const config = installMcpConfig(det.def, auth, home);
121
+ const skills = det.skillDirs.map((dir) => ({ dir, results: installSkills(dir) }));
122
+ return { agent: det.def.id, config, skills, warnings };
123
+ }
124
+ /** Remove kelocan MCP entry + kelocan skills for one agent. */
125
+ export function uninstallAgent(det, home = homedir()) {
126
+ const config = uninstallMcpConfig(det.def, home);
127
+ const skillsRemoved = det.skillDirs.flatMap((dir) => removeSkills(dir));
128
+ return { agent: det.def.id, config, skillsRemoved };
129
+ }
130
+ /**
131
+ * Verify the MCP endpoint responds: POST a JSON-RPC `initialize`.
132
+ * Uses the stored API key when present (OAuth installs are verified by the
133
+ * agent client itself later).
134
+ */
135
+ export async function probeMcp(url, apiKey) {
136
+ const headers = {
137
+ "Content-Type": "application/json",
138
+ Accept: "application/json, text/event-stream",
139
+ };
140
+ if (apiKey)
141
+ headers.Authorization = `Bearer ${apiKey}`;
142
+ const body = JSON.stringify({
143
+ jsonrpc: "2.0",
144
+ id: 1,
145
+ method: "initialize",
146
+ params: {
147
+ protocolVersion: "2025-03-26",
148
+ capabilities: {},
149
+ clientInfo: { name: "kelocan-cli", version: "0.1.0" },
150
+ },
151
+ });
152
+ let res;
153
+ try {
154
+ res = await fetch(url, { method: "POST", headers, body });
155
+ }
156
+ catch (e) {
157
+ throw new CliError(`Cannot reach MCP endpoint ${url}: ${e instanceof Error ? e.message : e}`, 3);
158
+ }
159
+ if (!res.ok) {
160
+ throw new CliError(`MCP endpoint ${url} returned HTTP ${res.status}`, res.status === 401 || res.status === 403 ? 2 : 3);
161
+ }
162
+ const text = await res.text();
163
+ // response may be JSON or SSE ("data: {...}")
164
+ const jsonLine = text
165
+ .split("\n")
166
+ .map((l) => l.trim())
167
+ .find((l) => l.startsWith("{") || l.startsWith("data: {"));
168
+ const payload = jsonLine ? JSON.parse(jsonLine.replace(/^data:\s*/, "")) : {};
169
+ const name = payload?.result?.serverInfo?.name;
170
+ return name ? `connected (server: ${name})` : "connected";
171
+ }
172
+ export { detectAgent };
package/dist/jsonc.js ADDED
@@ -0,0 +1,297 @@
1
+ import { CliError } from "./errors.js";
2
+ /**
3
+ * Minimal JSON/JSONC surgery helpers.
4
+ * stripJsonc parses leniently (comments + trailing commas removed); the upsert /
5
+ * remove functions edit the *text* so comments and formatting of other entries
6
+ * are preserved.
7
+ */
8
+ export function stripJsonc(text) {
9
+ let out = "";
10
+ let i = 0;
11
+ const n = text.length;
12
+ while (i < n) {
13
+ const c = text[i];
14
+ if (c === '"') {
15
+ const end = skipString(text, i);
16
+ out += text.slice(i, end);
17
+ i = end;
18
+ }
19
+ else if (c === "/" && text[i + 1] === "/") {
20
+ while (i < n && text[i] !== "\n")
21
+ i++;
22
+ }
23
+ else if (c === "/" && text[i + 1] === "*") {
24
+ i += 2;
25
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/"))
26
+ i++;
27
+ i += 2;
28
+ }
29
+ else {
30
+ out += c;
31
+ i++;
32
+ }
33
+ }
34
+ // trailing commas: ",}" or ",]" outside strings
35
+ return out.replace(/,(\s*[}\]])/g, "$1");
36
+ }
37
+ export function parseJsonc(text) {
38
+ try {
39
+ return JSON.parse(stripJsonc(text));
40
+ }
41
+ catch (e) {
42
+ throw new CliError(`Config file is not valid JSON: ${e instanceof Error ? e.message : e}`);
43
+ }
44
+ }
45
+ function skipWs(text, i) {
46
+ const n = text.length;
47
+ while (i < n) {
48
+ const c = text[i];
49
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
50
+ i++;
51
+ }
52
+ else if (c === "/" && text[i + 1] === "/") {
53
+ while (i < n && text[i] !== "\n")
54
+ i++;
55
+ }
56
+ else if (c === "/" && text[i + 1] === "*") {
57
+ i += 2;
58
+ while (i < n && !(text[i] === "*" && text[i + 1] === "/"))
59
+ i++;
60
+ i += 2;
61
+ }
62
+ else {
63
+ break;
64
+ }
65
+ }
66
+ return i;
67
+ }
68
+ function skipString(text, i) {
69
+ // i is at opening quote
70
+ i++;
71
+ const n = text.length;
72
+ while (i < n) {
73
+ const c = text[i];
74
+ if (c === "\\")
75
+ i += 2;
76
+ else if (c === '"')
77
+ return i + 1;
78
+ else
79
+ i++;
80
+ }
81
+ return i;
82
+ }
83
+ function skipValue(text, i) {
84
+ i = skipWs(text, i);
85
+ const c = text[i];
86
+ if (c === '"')
87
+ return skipString(text, i);
88
+ if (c === "{" || c === "[") {
89
+ const open = c;
90
+ const close = open === "{" ? "}" : "]";
91
+ let depth = 1;
92
+ i++;
93
+ const n = text.length;
94
+ while (i < n && depth > 0) {
95
+ const ch = text[i];
96
+ if (ch === '"') {
97
+ i = skipString(text, i);
98
+ }
99
+ else if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
100
+ i = skipWs(text, i);
101
+ }
102
+ else {
103
+ if (ch === open)
104
+ depth++;
105
+ else if (ch === close)
106
+ depth--;
107
+ i++;
108
+ }
109
+ }
110
+ return i;
111
+ }
112
+ // literal: number/true/false/null
113
+ const m = /^[a-zA-Z0-9_+\-.eE]+/.exec(text.slice(i));
114
+ return i + (m ? m[0].length : 1);
115
+ }
116
+ function matchingBrace(text, openIdx) {
117
+ // openIdx at '{'
118
+ let depth = 1;
119
+ let i = openIdx + 1;
120
+ const n = text.length;
121
+ while (i < n && depth > 0) {
122
+ const ch = text[i];
123
+ if (ch === '"')
124
+ i = skipString(text, i);
125
+ else if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*"))
126
+ i = skipWs(text, i);
127
+ else {
128
+ if (ch === "{")
129
+ depth++;
130
+ else if (ch === "}")
131
+ depth--;
132
+ i++;
133
+ }
134
+ }
135
+ return i - 1; // index of '}'
136
+ }
137
+ function listMembers(text, openBrace, closeBrace) {
138
+ const members = [];
139
+ let i = openBrace + 1;
140
+ let pendingComma = -1;
141
+ while (true) {
142
+ i = skipWs(text, i);
143
+ if (i >= closeBrace)
144
+ break;
145
+ if (text[i] === ",") {
146
+ pendingComma = i;
147
+ i++;
148
+ continue;
149
+ }
150
+ if (text[i] !== '"') {
151
+ throw new CliError("Config file structure is not a plain JSON object.");
152
+ }
153
+ const keyStart = i;
154
+ const keyEnd = skipString(text, i);
155
+ const key = text.slice(keyStart + 1, keyEnd - 1);
156
+ i = skipWs(text, keyEnd);
157
+ if (text[i] !== ":") {
158
+ throw new CliError("Config file structure is not a plain JSON object.");
159
+ }
160
+ const valueStart = skipWs(text, i + 1);
161
+ const valueEnd = skipValue(text, valueStart);
162
+ members.push({
163
+ key,
164
+ keyStart,
165
+ colonIdx: i,
166
+ valueStart,
167
+ valueEnd,
168
+ commaAfter: -1,
169
+ commaBefore: pendingComma,
170
+ });
171
+ pendingComma = -1;
172
+ i = skipWs(text, valueEnd);
173
+ if (text[i] === ",") {
174
+ members[members.length - 1].commaAfter = i;
175
+ pendingComma = i;
176
+ i++;
177
+ }
178
+ }
179
+ return members;
180
+ }
181
+ /** Detect the indent used by members of an object (first member's leading ws). */
182
+ function memberIndent(text, members, fallbackIndent) {
183
+ if (members.length === 0)
184
+ return fallbackIndent;
185
+ const first = members[0];
186
+ const lineStart = text.lastIndexOf("\n", first.keyStart) + 1;
187
+ const ws = /^[ \t]*/.exec(text.slice(lineStart, first.keyStart))?.[0] ?? "";
188
+ return ws || fallbackIndent;
189
+ }
190
+ function rootObject(text) {
191
+ const open = skipWs(text, 0);
192
+ if (text[open] !== "{") {
193
+ throw new CliError("Config file does not contain a top-level JSON object.");
194
+ }
195
+ return { open, close: matchingBrace(text, open) };
196
+ }
197
+ function inlineValue(value) {
198
+ return JSON.stringify(value);
199
+ }
200
+ /** Pretty-print with continuation lines aligned to `indent`. */
201
+ function prettyValue(value, indent) {
202
+ return JSON.stringify(value, null, 2).split("\n").join("\n" + indent);
203
+ }
204
+ /**
205
+ * Insert or replace `entryKey` inside root object's `containerKey` object.
206
+ * Preserves comments and all other entries. Idempotent: returns changed=false
207
+ * when the existing entry already deep-equals `entry`.
208
+ */
209
+ export function upsertJsonEntry(text, containerKey, entryKey, entry) {
210
+ if (!text || text.trim() === "") {
211
+ return {
212
+ text: `{\n "${containerKey}": {\n "${entryKey}": ${prettyValue(entry, " ")}\n }\n}\n`,
213
+ changed: true,
214
+ };
215
+ }
216
+ const { open, close } = rootObject(text);
217
+ const members = listMembers(text, open, close);
218
+ const container = members.find((m) => m.key === containerKey);
219
+ const rootIndent = memberIndent(text, members, " ");
220
+ if (!container) {
221
+ const snippet = `"${containerKey}": {\n${rootIndent} "${entryKey}": ` +
222
+ `${prettyValue(entry, rootIndent + " ")}\n${rootIndent}}`;
223
+ if (members.length === 0) {
224
+ const out = text.slice(0, open + 1) + ` ${snippet} ` + text.slice(close);
225
+ return { text: out, changed: true };
226
+ }
227
+ const last = members[members.length - 1];
228
+ const at = last.commaAfter !== -1 ? last.commaAfter + 1 : last.valueEnd;
229
+ const insertion = `${last.commaAfter !== -1 ? "" : ","}\n${rootIndent}${snippet}`;
230
+ const out = text.slice(0, at) + insertion + text.slice(at);
231
+ return { text: out, changed: true };
232
+ }
233
+ const cOpen = skipWs(text, container.valueStart);
234
+ if (text[cOpen] !== "{") {
235
+ throw new CliError(`Existing "${containerKey}" entry is not an object — refusing to modify it.`);
236
+ }
237
+ const cClose = container.valueEnd - 1;
238
+ const cMembers = listMembers(text, cOpen, cClose);
239
+ const cIndent = memberIndent(text, cMembers, rootIndent + " ");
240
+ const existing = cMembers.find((m) => m.key === entryKey);
241
+ if (existing) {
242
+ const parsed = parseJsonc(text.slice(existing.valueStart, existing.valueEnd));
243
+ if (JSON.stringify(parsed) === JSON.stringify(entry)) {
244
+ return { text, changed: false };
245
+ }
246
+ const out = text.slice(0, existing.valueStart) +
247
+ prettyValue(entry, cIndent) +
248
+ text.slice(existing.valueEnd);
249
+ return { text: out, changed: true };
250
+ }
251
+ if (cMembers.length === 0) {
252
+ const inner = `{ "${entryKey}": ${inlineValue(entry)} }`;
253
+ const out = text.slice(0, cOpen) + inner + text.slice(cClose + 1);
254
+ return { text: out, changed: true };
255
+ }
256
+ const last = cMembers[cMembers.length - 1];
257
+ const at = last.commaAfter !== -1 ? last.commaAfter + 1 : last.valueEnd;
258
+ const insertion = `${last.commaAfter !== -1 ? "" : ","}\n${cIndent}"${entryKey}": ${prettyValue(entry, cIndent)}`;
259
+ const out = text.slice(0, at) + insertion + text.slice(at);
260
+ return { text: out, changed: true };
261
+ }
262
+ /**
263
+ * Remove `entryKey` from root object's `containerKey` (container kept, even if
264
+ * it becomes `{}`). Returns changed=false when nothing to remove.
265
+ */
266
+ export function removeJsonEntry(text, containerKey, entryKey) {
267
+ if (text.trim() === "")
268
+ return { text, changed: false };
269
+ const { open, close } = rootObject(text);
270
+ const members = listMembers(text, open, close);
271
+ const container = members.find((m) => m.key === containerKey);
272
+ if (!container)
273
+ return { text, changed: false };
274
+ const cOpen = skipWs(text, container.valueStart);
275
+ if (text[cOpen] !== "{")
276
+ return { text, changed: false };
277
+ const cClose = container.valueEnd - 1;
278
+ const cMembers = listMembers(text, cOpen, cClose);
279
+ const target = cMembers.find((m) => m.key === entryKey);
280
+ if (!target)
281
+ return { text, changed: false };
282
+ let cutStart = target.keyStart;
283
+ let cutEnd = target.valueEnd;
284
+ if (target.commaAfter !== -1) {
285
+ cutEnd = target.commaAfter + 1;
286
+ }
287
+ else if (target.commaBefore !== -1) {
288
+ cutStart = target.commaBefore;
289
+ }
290
+ // also swallow the trailing newline+indent of the removed line
291
+ const nl = text.indexOf("\n", cutEnd);
292
+ if (nl !== -1 && nl < cClose && text.slice(cutEnd, nl).trim() === "") {
293
+ cutEnd = nl + 1;
294
+ }
295
+ const out = text.slice(0, cutStart) + text.slice(cutEnd);
296
+ return { text: out, changed: true };
297
+ }