@fswap/mcp-vikunja 0.1.9 → 0.1.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.
Files changed (3) hide show
  1. package/README.md +3 -1
  2. package/dist/index.js +243 -5
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -15,7 +15,9 @@ Runs locally over stdio. No install step — clients launch it with `npx`.
15
15
 
16
16
  It asks for your Vikunja URL and token, verifies them against `/api/v1/user`, lets you pick a default project, and stores the answers in your OS config directory (mode `0600`).
17
17
 
18
- 3. Add the server to your client. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block shown below instead. `setup --print` shows these snippets again at any time.
18
+ 3. Add the server to your client. At the end, `setup` offers to do this for you in Claude Code, Claude Desktop, Cursor and Codex: clients it finds are pre-selected (except Claude Code when Claude Desktop is installed, because the desktop app's Code tab already loads `claude_desktop_config.json`), an existing `vikunja` entry is only replaced after you confirm, and any config file it changes gets a one-time `.bak` copy.
19
+
20
+ To add it by hand instead, use the snippets below. Every value asked in `setup` can be skipped with Enter; anything you skip goes into the `env` block instead. `setup --print` shows these snippets again at any time.
19
21
 
20
22
  **Claude Desktop** (`claude_desktop_config.json`) and **Cursor** (`~/.cursor/mcp.json` or `<project>/.cursor/mcp.json`):
21
23
 
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ import envPaths from "env-paths";
8
8
  import { z } from "zod";
9
9
  import os from "node:os";
10
10
  import * as p from "@clack/prompts";
11
+ import { spawnSync } from "node:child_process";
11
12
  //#region \0rolldown/runtime.js
12
13
  var __defProp = Object.defineProperty;
13
14
  var __esmMin = (fn, res, err) => () => {
@@ -1033,13 +1034,17 @@ function registerUserTools(server, vikunja) {
1033
1034
  }
1034
1035
  //#endregion
1035
1036
  //#region src/snippets.ts
1036
- function jsonSnippet(missing) {
1037
+ /** The `mcpServers.<key>` value used by JSON-configured clients. */
1038
+ function serverEntry(missing) {
1037
1039
  const server = {
1038
1040
  command: "npx",
1039
- args: ["-y", `${PACKAGE_NAME}@latest`]
1041
+ args: SERVER_ARGS
1040
1042
  };
1041
1043
  if (Object.keys(missing).length > 0) server.env = missing;
1042
- return JSON.stringify({ mcpServers: { [SERVER_KEY]: server } }, null, 2);
1044
+ return server;
1045
+ }
1046
+ function jsonSnippet(missing) {
1047
+ return JSON.stringify({ mcpServers: { [SERVER_KEY]: serverEntry(missing) } }, null, 2);
1043
1048
  }
1044
1049
  function tomlSnippet(missing) {
1045
1050
  const lines = [
@@ -1077,11 +1082,199 @@ function clientSnippets(missing = {}) {
1077
1082
  }
1078
1083
  ];
1079
1084
  }
1080
- var SERVER_KEY, ENV_URL, ENV_TOKEN;
1085
+ var SERVER_KEY, ENV_URL, ENV_TOKEN, SERVER_ARGS;
1081
1086
  var init_snippets = __esmMin((() => {
1082
1087
  SERVER_KEY = "vikunja";
1083
1088
  ENV_URL = "VIKUNJA_URL";
1084
1089
  ENV_TOKEN = "VIKUNJA_API_TOKEN";
1090
+ SERVER_ARGS = ["-y", `${PACKAGE_NAME}@latest`];
1091
+ }));
1092
+ //#endregion
1093
+ //#region src/install.ts
1094
+ function onPath(cmd) {
1095
+ const exts = process.platform === "win32" ? (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";") : [""];
1096
+ return (process.env.PATH ?? "").split(path.delimiter).filter(Boolean).some((dir) => exts.some((ext) => fs.existsSync(path.join(dir, cmd + ext))));
1097
+ }
1098
+ function runCommand(cmd, args) {
1099
+ const r = spawnSync(cmd, args, {
1100
+ encoding: "utf8",
1101
+ shell: process.platform === "win32"
1102
+ });
1103
+ return {
1104
+ ok: r.status === 0,
1105
+ output: `${r.stdout ?? ""}${r.stderr ?? ""}${r.error?.message ?? ""}`.trim()
1106
+ };
1107
+ }
1108
+ function claudeDesktopConfig(env) {
1109
+ if (env.platform === "darwin") return path.join(env.home, "Library", "Application Support", "Claude", "claude_desktop_config.json");
1110
+ if (env.platform === "win32") return path.join(env.appData ?? path.join(env.home, "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
1111
+ return path.join(env.home, ".config", "Claude", "claude_desktop_config.json");
1112
+ }
1113
+ function detectClients(env = hostEnv) {
1114
+ const dirExists = (file) => fs.existsSync(path.dirname(file));
1115
+ return [
1116
+ {
1117
+ id: "claude-code",
1118
+ label: "Claude Code",
1119
+ detected: env.which("claude"),
1120
+ location: CLAUDE_CODE_LOCATION,
1121
+ hint: "terminal / IDE"
1122
+ },
1123
+ {
1124
+ id: "claude-desktop",
1125
+ label: "Claude Desktop",
1126
+ detected: dirExists(claudeDesktopConfig(env)),
1127
+ location: claudeDesktopConfig(env),
1128
+ hint: "chat + Code tab"
1129
+ },
1130
+ {
1131
+ id: "cursor",
1132
+ label: "Cursor",
1133
+ detected: dirExists(cursorConfig(env)),
1134
+ location: cursorConfig(env)
1135
+ },
1136
+ {
1137
+ id: "codex",
1138
+ label: "Codex",
1139
+ detected: dirExists(codexConfig(env)) || env.which("codex"),
1140
+ location: codexConfig(env)
1141
+ }
1142
+ ];
1143
+ }
1144
+ /**
1145
+ * Detected clients to pre-select. Claude Code is left out when Claude Desktop is present: the desktop
1146
+ * app's Code tab also loads claude_desktop_config.json, so selecting both would register the server twice there.
1147
+ */
1148
+ function defaultSelection(clients) {
1149
+ const desktopFound = clients.some((c) => c.id === "claude-desktop" && c.detected);
1150
+ return clients.filter((c) => c.detected && !(desktopFound && c.id === "claude-code")).map((c) => c.id);
1151
+ }
1152
+ function readIfExists(file) {
1153
+ try {
1154
+ return fs.readFileSync(file, "utf8");
1155
+ } catch (err) {
1156
+ if (err.code === "ENOENT") return null;
1157
+ throw err;
1158
+ }
1159
+ }
1160
+ /** Writes `content`, keeping a copy of the original the first time an existing file is changed. */
1161
+ function writeWithBackup(file, original, content) {
1162
+ if (original !== null && !fs.existsSync(`${file}.bak`)) fs.writeFileSync(`${file}.bak`, original);
1163
+ fs.mkdirSync(path.dirname(file), { recursive: true });
1164
+ fs.writeFileSync(file, content);
1165
+ }
1166
+ function parseJsonConfig(file, text) {
1167
+ if (text === null || text.trim() === "") return {};
1168
+ try {
1169
+ const parsed = JSON.parse(text);
1170
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
1171
+ } catch {}
1172
+ throw new Error(`Could not parse ${file}; left it unchanged`);
1173
+ }
1174
+ function jsonHasEntry(file) {
1175
+ return Boolean(parseJsonConfig(file, readIfExists(file)).mcpServers?.[SERVER_KEY]);
1176
+ }
1177
+ function jsonInstall(file, missing) {
1178
+ const original = readIfExists(file);
1179
+ const config = parseJsonConfig(file, original);
1180
+ const replaced = Boolean(config.mcpServers?.[SERVER_KEY]);
1181
+ config.mcpServers = {
1182
+ ...config.mcpServers,
1183
+ [SERVER_KEY]: serverEntry(missing)
1184
+ };
1185
+ writeWithBackup(file, original, JSON.stringify(config, null, 2) + "\n");
1186
+ return {
1187
+ status: replaced ? "replaced" : "added",
1188
+ location: file
1189
+ };
1190
+ }
1191
+ function tomlHasEntry(text) {
1192
+ return text !== null && text.split("\n").some((line) => ownHeader.test(line));
1193
+ }
1194
+ function tomlWithoutEntry(text) {
1195
+ let inOwnSection = false;
1196
+ return text.split("\n").filter((line) => {
1197
+ if (anyHeader.test(line)) inOwnSection = ownHeader.test(line);
1198
+ return !inOwnSection;
1199
+ }).join("\n");
1200
+ }
1201
+ function codexInstall(file, missing) {
1202
+ const original = readIfExists(file);
1203
+ const rest = original === null ? "" : tomlWithoutEntry(original).trimEnd();
1204
+ writeWithBackup(file, original, (rest ? `${rest}\n\n` : "") + tomlSnippet(missing) + "\n");
1205
+ return {
1206
+ status: tomlHasEntry(original) ? "replaced" : "added",
1207
+ location: file
1208
+ };
1209
+ }
1210
+ function claude(env, args) {
1211
+ const r = env.run("claude", args);
1212
+ if (!r.ok) throw new Error(`claude ${args.join(" ")} failed${r.output ? `: ${r.output}` : ""}`);
1213
+ }
1214
+ function claudeCodeInstall(env, missing) {
1215
+ const replaced = hasEntry("claude-code", env);
1216
+ if (replaced) claude(env, [
1217
+ "mcp",
1218
+ "remove",
1219
+ SERVER_KEY,
1220
+ "-s",
1221
+ "user"
1222
+ ]);
1223
+ const envArgs = Object.entries(missing).flatMap(([k, v]) => ["-e", `${k}=${v}`]);
1224
+ claude(env, [
1225
+ "mcp",
1226
+ "add",
1227
+ SERVER_KEY,
1228
+ "-s",
1229
+ "user",
1230
+ ...envArgs,
1231
+ "--",
1232
+ "npx",
1233
+ ...SERVER_ARGS
1234
+ ]);
1235
+ return {
1236
+ status: replaced ? "replaced" : "added",
1237
+ location: CLAUDE_CODE_LOCATION
1238
+ };
1239
+ }
1240
+ /** Whether the client already has a server registered under SERVER_KEY. Throws if its config is unreadable. */
1241
+ function hasEntry(id, env = hostEnv) {
1242
+ switch (id) {
1243
+ case "claude-code": return env.run("claude", [
1244
+ "mcp",
1245
+ "get",
1246
+ SERVER_KEY
1247
+ ]).ok;
1248
+ case "claude-desktop": return jsonHasEntry(claudeDesktopConfig(env));
1249
+ case "cursor": return jsonHasEntry(cursorConfig(env));
1250
+ case "codex": return tomlHasEntry(readIfExists(codexConfig(env)));
1251
+ }
1252
+ }
1253
+ /** Adds (or overwrites) this server in the client's config. Throws with a readable message on failure. */
1254
+ function installClient(id, missing, env = hostEnv) {
1255
+ switch (id) {
1256
+ case "claude-code": return claudeCodeInstall(env, missing);
1257
+ case "claude-desktop": return jsonInstall(claudeDesktopConfig(env), missing);
1258
+ case "cursor": return jsonInstall(cursorConfig(env), missing);
1259
+ case "codex": return codexInstall(codexConfig(env), missing);
1260
+ }
1261
+ }
1262
+ var hostEnv, cursorConfig, codexConfig, CLAUDE_CODE_LOCATION, escapeRe, ownHeader, anyHeader;
1263
+ var init_install = __esmMin((() => {
1264
+ init_snippets();
1265
+ hostEnv = {
1266
+ home: os.homedir(),
1267
+ platform: process.platform,
1268
+ appData: process.env.APPDATA,
1269
+ which: onPath,
1270
+ run: runCommand
1271
+ };
1272
+ cursorConfig = (env) => path.join(env.home, ".cursor", "mcp.json");
1273
+ codexConfig = (env) => path.join(env.home, ".codex", "config.toml");
1274
+ CLAUDE_CODE_LOCATION = "user scope via claude mcp";
1275
+ escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1276
+ ownHeader = new RegExp(`^\\s*\\[\\s*mcp_servers\\.("?)${escapeRe(SERVER_KEY)}\\1\\s*(\\]|\\.)`);
1277
+ anyHeader = /^\s*\[/;
1085
1278
  }));
1086
1279
  //#endregion
1087
1280
  //#region src/setup.ts
@@ -1177,15 +1370,60 @@ async function runSetup(args = []) {
1177
1370
  const missing = {};
1178
1371
  if (!url) missing[ENV_URL] = "https://try.vikunja.io";
1179
1372
  if (!token) missing[ENV_TOKEN] = "tk_...";
1180
- if (Object.keys(missing).length > 0) p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Add them to the env block below, or run setup again.`);
1373
+ const updated = await addToClients(missing);
1374
+ if (Object.keys(missing).length > 0) {
1375
+ const where = updated ? "env block of the client config updated above" : "env block below";
1376
+ p.log.warn(`Still needed: ${Object.keys(missing).join(", ")}. Replace the placeholders in the ${where}, or run setup again.`);
1377
+ }
1181
1378
  p.outro(`Saved to ${file}`);
1379
+ if (updated) {
1380
+ console.log("Re-print MCP client config snippets any time with: setup --print");
1381
+ return;
1382
+ }
1182
1383
  console.log("Add one of these to your MCP client (plain text, safe to copy). Re-print any time with: setup --print");
1183
1384
  printSnippets(missing);
1184
1385
  }
1386
+ /** Offers to register the server in MCP clients. Returns true when at least one client was updated. */
1387
+ async function addToClients(missing) {
1388
+ const clients = detectClients();
1389
+ const selected = await p.multiselect({
1390
+ message: "Add to MCP clients? (Space to toggle, Enter to confirm, none to skip)",
1391
+ options: clients.map((c) => ({
1392
+ value: c.id,
1393
+ label: c.label,
1394
+ hint: [c.hint, c.detected ? "detected" : "not found"].filter(Boolean).join(" — ")
1395
+ })),
1396
+ initialValues: defaultSelection(clients),
1397
+ required: false
1398
+ });
1399
+ if (typeof selected === "symbol") abort();
1400
+ let updated = false;
1401
+ for (const client of clients.filter((c) => selected.includes(c.id))) try {
1402
+ if (hasEntry(client.id)) {
1403
+ const replace = await p.confirm({
1404
+ message: `${client.label} already has a "${SERVER_KEY}" server. Replace it?`,
1405
+ initialValue: false
1406
+ });
1407
+ if (typeof replace === "symbol") abort();
1408
+ if (!replace) {
1409
+ p.log.info(`${client.label}: skipped, existing entry kept`);
1410
+ continue;
1411
+ }
1412
+ }
1413
+ const result = installClient(client.id, missing);
1414
+ const restart = client.id === "claude-desktop" ? " — restart Claude Desktop to load it" : "";
1415
+ p.log.success(`${client.label}: ${result.status} (${result.location})${restart}`);
1416
+ updated = true;
1417
+ } catch (err) {
1418
+ p.log.error(`${client.label}: failed — ${err.message}`);
1419
+ }
1420
+ return updated;
1421
+ }
1185
1422
  var init_setup = __esmMin((() => {
1186
1423
  init_config();
1187
1424
  init_client();
1188
1425
  init_snippets();
1426
+ init_install();
1189
1427
  }));
1190
1428
  //#endregion
1191
1429
  //#region src/index.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fswap/mcp-vikunja",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "MCP server for Vikunja — manage tasks, assignees, comments, attachments, relations and kanban from Claude",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "dev": "tsdown --watch",
18
18
  "typecheck": "tsc --noEmit",
19
19
  "lint": "eslint src test",
20
- "test": "npm run build && node test/smoke.test.mjs",
20
+ "test": "npm run build && node test/smoke.test.mjs && node --test test/install.test.mjs",
21
21
  "check": "npm run lint && npm run typecheck && npm test",
22
22
  "start": "node dist/index.js",
23
23
  "setup": "node dist/index.js setup",