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/README.md +121 -0
- package/dist/agents.js +326 -0
- package/dist/api.js +86 -0
- package/dist/args.js +60 -0
- package/dist/cli.js +61 -0
- package/dist/commands.js +278 -0
- package/dist/config.js +70 -0
- package/dist/doctor.js +198 -0
- package/dist/errors.js +15 -0
- package/dist/help.js +164 -0
- package/dist/install.js +172 -0
- package/dist/jsonc.js +297 -0
- package/dist/login.js +93 -0
- package/dist/output.js +52 -0
- package/dist/resolve.js +74 -0
- package/dist/setup.js +222 -0
- package/dist/skills.js +78 -0
- package/dist/types.js +1 -0
- package/package.json +30 -0
- package/skills/kelocan/SKILL.md +54 -0
- package/skills/kelocan-review/SKILL.md +32 -0
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
|
+
};
|
package/dist/install.js
ADDED
|
@@ -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
|
+
}
|