itfin-mcp 0.1.2 → 0.1.4

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 CHANGED
@@ -12,19 +12,34 @@ You need macOS, Node.js 22+ and Chrome, Edge, Brave, Arc or Chromium.
12
12
  ```bash
13
13
  npx itfin-mcp@latest install
14
14
  ```
15
- 2. Pick the apps: Claude Code, Codex, Claude Desktop. Space ticks or unticks one, Enter confirms.
15
+ 2. Pick the apps and, for Claude Code and Codex, the [mode](#mcp-or-cli-mode). Space ticks or unticks one, Enter confirms.
16
16
  3. Type your company name: `acme` for `https://acme.itfin.io`.
17
17
  4. Claude Desktop only: restart it (Cmd+Q, then open it).
18
18
  5. Start a new session and say **"log in to ITFin"**.
19
19
 
20
20
  ## Update
21
21
 
22
- Run the same command again. It installs the latest version from npm, keeps your apps and settings and asks nothing.
22
+ Run the same command again. It installs the latest version from npm, keeps your apps, modes and settings and asks nothing.
23
23
 
24
24
  Installs from before the npm package (a git clone in `~/.itfin-mcp`) are replaced on the first update.
25
25
 
26
26
  If the update changed anything, it stops the ITFin servers that are still running the old version. Then start a new session (in Claude Code, `/mcp` → reconnect itfin also works) and restart Claude Desktop (Cmd+Q, then open it).
27
27
 
28
+ ## MCP or CLI mode
29
+
30
+ Each app uses ITFin in one of two modes:
31
+
32
+ - **MCP** (default): the app runs ITFin as an MCP server and the agent calls its tools.
33
+ - **CLI**: the agent runs the `itfin` command in its shell, guided by an `itfin` skill. It can pipe the JSON output into `jq`, loop over days or combine it with `git` in one command.
34
+
35
+ Claude Desktop has no shell, so it is always MCP. To switch an app, run the install with `--mode`, e.g. `npx itfin-mcp@latest install --mode claude=cli`. Switching removes what the old mode installed.
36
+
37
+ CLI mode installs:
38
+
39
+ - `~/.local/bin/itfin`, the command (`itfin --help` lists the commands, `itfin <command> --help` their flags);
40
+ - the `itfin` skill in `~/.claude/skills` (Claude Code) or `~/.agents/skills` (Codex);
41
+ - permission to run read-only commands without asking: allow rules in `~/.claude/settings.json`, or `~/.codex/rules/itfin.rules` for Codex, which also lets them reach ITFin from the Codex sandbox. Writes, leave and reopen requests and `itfin login` still ask. `--no-permissions` skips this, and updates remember it until you pass `--permissions`.
42
+
28
43
  ## Log in (once a week)
29
44
 
30
45
  1. Say **"log in to ITFin"**.
@@ -34,7 +49,7 @@ ITFin tokens last 7 days and can't be extended. A macOS notification reminds you
34
49
 
35
50
  ## Settings
36
51
 
37
- Add flags to the install command to change a setting:
52
+ Settings live in `~/.itfin-mcp/config.json`. Add flags to the install command to change a setting:
38
53
 
39
54
  ```bash
40
55
  npx itfin-mcp@latest install --browser brave --work-start 09:00
@@ -43,6 +58,8 @@ npx itfin-mcp@latest install --browser brave --work-start 09:00
43
58
  | Flag | Default | Sets |
44
59
  |---|---|---|
45
60
  | `--clients claude,codex,desktop` | asked | Apps to install for |
61
+ | `--mode claude=cli,codex=mcp` | `mcp` | Mode per app: `mcp` or `cli` |
62
+ | `--no-permissions` / `--permissions` | `--permissions` | Whether CLI mode allows read-only `itfin` commands without asking |
46
63
  | `--company acme` | asked | Workspace `https://acme.itfin.io` |
47
64
  | `--url <address>` | | Workspace not on `itfin.io` |
48
65
  | `--browser` | `chrome` | `chrome`, `edge`, `brave`, `arc`, `chromium` or a path to the browser |
@@ -52,6 +69,8 @@ npx itfin-mcp@latest install --browser brave --work-start 09:00
52
69
 
53
70
  ## Tools
54
71
 
72
+ In CLI mode each tool is a command without the `itfin_` prefix: `itfin_get_entries` is `itfin get-entries`.
73
+
55
74
  | Tool | Does |
56
75
  |---|---|
57
76
  | `itfin_login` / `itfin_auth_status` | Log in; check when the token expires |
@@ -59,7 +78,7 @@ npx itfin-mcp@latest install --browser brave --work-start 09:00
59
78
  | `itfin_get_entries` | Your time entries, and which days are open, closed or in the future |
60
79
  | `itfin_create_entry` / `itfin_update_entry` / `itfin_delete_entry` | Change time entries |
61
80
  | `itfin_get_workspace_settings` | Minimum comment length; whether reopen requests are on |
62
- | `itfin_request_reopen` / `itfin_list_reopen_requests` | Ask a manager to reopen closed days; see your requests |
81
+ | `itfin_request_reopen` / `itfin_list_reopen_requests` / `itfin_cancel_reopen_request` | Ask a manager to reopen closed days; see or cancel your requests |
63
82
  | `itfin_list_leave_types` | Leave types and reasons you can use |
64
83
  | `itfin_request_leave` | Request full days, or some hours of one day |
65
84
  | `itfin_list_leave_requests` / `itfin_cancel_leave_request` | See or cancel your leave requests |
package/dist/cli.js ADDED
@@ -0,0 +1,188 @@
1
+ /** `itfin_get_entries` → `get-entries`. */
2
+ export function commandName(tool) {
3
+ return tool.replace(/^itfin_/, "").replaceAll("_", "-");
4
+ }
5
+ const kebab = (s) => s.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
6
+ function schemaTypes(s) {
7
+ const own = s.type === undefined ? [] : Array.isArray(s.type) ? s.type : [s.type];
8
+ return [...own, ...(s.anyOf ?? []).flatMap(schemaTypes), ...(s.oneOf ?? []).flatMap(schemaTypes)];
9
+ }
10
+ /** Turns a flag's text into the JSON value its schema expects; the server still validates it. */
11
+ function coerce(raw, s) {
12
+ const types = schemaTypes(s);
13
+ if (raw === "null" && types.includes("null"))
14
+ return null;
15
+ if (types.includes("boolean") && (raw === "true" || raw === "false"))
16
+ return raw === "true";
17
+ if (types.includes("string"))
18
+ return raw;
19
+ if ((types.includes("integer") || types.includes("number")) && raw.trim() !== "" && !Number.isNaN(Number(raw)))
20
+ return Number(raw);
21
+ return raw;
22
+ }
23
+ class UsageError extends Error {
24
+ }
25
+ /** Parses `--flag value`, `--flag=value` and `--json '{...}'` against a tool's input schema. */
26
+ function parseArgs(tool, argv) {
27
+ const schema = tool.inputSchema;
28
+ const props = schema.properties ?? {};
29
+ const byFlag = new Map(Object.keys(props).flatMap((p) => [[kebab(p), p], [p, p]]));
30
+ const args = {};
31
+ for (let i = 0; i < argv.length; i++) {
32
+ const arg = argv[i];
33
+ if (!arg.startsWith("--"))
34
+ throw new UsageError(`Unexpected argument: ${arg}`);
35
+ const eq = arg.indexOf("=");
36
+ const flag = arg.slice(2, eq === -1 ? undefined : eq);
37
+ let raw = eq === -1 ? undefined : arg.slice(eq + 1);
38
+ if (flag === "json") {
39
+ raw ??= argv[++i];
40
+ if (raw === undefined)
41
+ throw new UsageError("--json needs a JSON object.");
42
+ try {
43
+ Object.assign(args, JSON.parse(raw));
44
+ }
45
+ catch {
46
+ throw new UsageError("--json is not valid JSON.");
47
+ }
48
+ continue;
49
+ }
50
+ const prop = byFlag.get(flag);
51
+ if (!prop)
52
+ throw new UsageError(`Unknown flag --${flag} for ${commandName(tool.name)}. See itfin ${commandName(tool.name)} --help.`);
53
+ const propSchema = props[prop];
54
+ if (raw === undefined) {
55
+ const next = argv[i + 1];
56
+ if (next === undefined || next.startsWith("--")) {
57
+ if (!schemaTypes(propSchema).includes("boolean"))
58
+ throw new UsageError(`--${flag} needs a value.`);
59
+ raw = "true";
60
+ }
61
+ else {
62
+ raw = next;
63
+ i++;
64
+ }
65
+ }
66
+ args[prop] = coerce(raw, propSchema);
67
+ }
68
+ return args;
69
+ }
70
+ function flagLines(tool) {
71
+ const schema = tool.inputSchema;
72
+ const required = new Set(schema.required ?? []);
73
+ return Object.entries(schema.properties ?? {}).map(([name, s]) => {
74
+ const types = schemaTypes(s).filter((t) => t !== "null");
75
+ const type = s.pattern === "^\\d{4}-\\d{2}-\\d{2}$" ? "YYYY-MM-DD" : [...new Set(types)].join(" | ");
76
+ const notes = [type, required.has(name) ? "required" : "optional"].filter(Boolean).join(", ");
77
+ return `--${kebab(name)} (${notes})${s.description ? `: ${s.description}` : ""}`;
78
+ });
79
+ }
80
+ function commandHelp(tool) {
81
+ const flags = flagLines(tool);
82
+ return [`itfin ${commandName(tool.name)}`, "", tool.description ?? "", "", ...(flags.length ? ["Flags:", ...flags.map((f) => ` ${f}`)] : ["No flags."])].join("\n");
83
+ }
84
+ function overview(tools) {
85
+ const width = Math.max(...tools.map((t) => commandName(t.name).length));
86
+ return [
87
+ "Usage: itfin <command> [--flag value ...]",
88
+ "",
89
+ "Commands:",
90
+ ...tools.map((t) => ` ${commandName(t.name).padEnd(width)} ${t.annotations?.title ?? t.title ?? ""}`),
91
+ "",
92
+ "itfin <command> --help shows a command's flags. Output is JSON on stdout; errors are JSON on stderr with exit code 1.",
93
+ ].join("\n");
94
+ }
95
+ /** Read-only commands, which installers may allow without asking. */
96
+ export function readOnlyCommands(tools) {
97
+ return tools.filter((t) => t.annotations?.readOnlyHint === true).map((t) => commandName(t.name));
98
+ }
99
+ /** The agent skill for CLI mode: the server instructions, how to call the CLI, and every command. */
100
+ export function skillMarkdown(tools, instructions) {
101
+ return [
102
+ "---",
103
+ "name: itfin",
104
+ "description: Read and change the user's ITFin data with the `itfin` command (time entries, reportable projects, reopen requests for closed days, leave requests, ITFin login). Use it for any ITFin work, including instructions that name itfin_* tools.",
105
+ "---",
106
+ "",
107
+ "# ITFin",
108
+ "",
109
+ "<!-- Generated by itfin-mcp install. Changes are overwritten on the next update. -->",
110
+ "",
111
+ instructions,
112
+ "",
113
+ "## Calling it",
114
+ "",
115
+ "- Run `itfin <command> --flag value`. Flags are the parameters below; `--json '{...}'` passes them all at once.",
116
+ "- Success prints JSON on stdout with exit code 0, so pipe it into `jq`, loop over days or save it to a file as needed. Failure prints `{\"error\":{\"code\":...,\"message\":...}}` on stderr with exit code 1.",
117
+ "- Instructions that name an ITFin MCP tool mean the matching command: `itfin_get_entries` is `itfin get-entries`.",
118
+ "- Read commands can be combined and looped freely. Time entry writes can be looped too, but only for changes the user asked for.",
119
+ "- `request-leave` and `request-reopen` go to the user's manager: one request per command, never in a loop or script, and only when the user asked for that request.",
120
+ "- `login` opens the sign-in window and returns at once. The user gets a notification after signing in; check with `itfin auth-status`.",
121
+ "",
122
+ "## Commands",
123
+ "",
124
+ ...tools.flatMap((t) => {
125
+ const flags = flagLines(t);
126
+ return [`### itfin ${commandName(t.name)}`, "", t.description ?? "", "", ...(flags.length ? [...flags.map((f) => `- \`${f.slice(0, f.indexOf(" "))}\`${f.slice(f.indexOf(" "))}`), ""] : [])];
127
+ }),
128
+ ].join("\n");
129
+ }
130
+ function errorJson(code, message) {
131
+ return JSON.stringify({ error: { code, message } });
132
+ }
133
+ /** Runs one CLI invocation and returns its exit code. */
134
+ export async function runCli(argv, deps) {
135
+ const { tools } = await deps.client.listTools();
136
+ const [command, ...rest] = argv;
137
+ if (command === undefined || command === "help" || command === "--help" || command === "-h") {
138
+ deps.out(overview(tools));
139
+ return 0;
140
+ }
141
+ if (command === "__skill") {
142
+ deps.out(skillMarkdown(tools, deps.client.getInstructions() ?? ""));
143
+ return 0;
144
+ }
145
+ if (command === "__read-only") {
146
+ deps.out(readOnlyCommands(tools).join("\n"));
147
+ return 0;
148
+ }
149
+ const tool = tools.find((t) => commandName(t.name) === command || t.name === command);
150
+ if (!tool) {
151
+ deps.err(errorJson("USAGE", `Unknown command: ${command}. Run itfin --help.`));
152
+ return 1;
153
+ }
154
+ if (rest.includes("--help") || rest.includes("-h")) {
155
+ deps.out(commandHelp(tool));
156
+ return 0;
157
+ }
158
+ let args;
159
+ try {
160
+ args = parseArgs(tool, rest);
161
+ }
162
+ catch (err) {
163
+ if (!(err instanceof UsageError))
164
+ throw err;
165
+ deps.err(errorJson("USAGE", err.message));
166
+ return 1;
167
+ }
168
+ if (tool.name === "itfin_login") {
169
+ deps.out(JSON.stringify(await deps.startLogin()));
170
+ return 0;
171
+ }
172
+ const res = (await deps.client.callTool({ name: tool.name, arguments: args }));
173
+ const text = res.content.find((c) => c.type === "text")?.text ?? "";
174
+ if (!res.isError) {
175
+ deps.out(text);
176
+ return 0;
177
+ }
178
+ // Tool errors are already JSON; argument validation errors from the SDK are plain text.
179
+ let isJson = true;
180
+ try {
181
+ JSON.parse(text);
182
+ }
183
+ catch {
184
+ isJson = false;
185
+ }
186
+ deps.err(isJson ? text : errorJson("VALIDATION", text));
187
+ return 1;
188
+ }
package/dist/config.js CHANGED
@@ -1,14 +1,37 @@
1
- export function loadConfig(env = process.env) {
2
- const workspaceUrl = env.ITFIN_URL?.trim().replace(/\/+$/, "");
1
+ import { readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ /** Where the installer keeps the settings, unless ITFIN_CONFIG points elsewhere. */
5
+ export const DEFAULT_CONFIG_FILE = join(homedir(), ".itfin-mcp", "config.json");
6
+ function readConfigFile(file) {
7
+ let text;
8
+ try {
9
+ text = readFileSync(file, "utf8");
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ try {
15
+ return JSON.parse(text);
16
+ }
17
+ catch {
18
+ throw new Error(`${file} is not valid JSON. Run npx itfin-mcp@latest install to rewrite it.`);
19
+ }
20
+ }
21
+ /** Settings come from the config file; ITFIN_* env vars override single values. */
22
+ export function loadConfig(env = process.env, file = env.ITFIN_CONFIG?.trim() || DEFAULT_CONFIG_FILE) {
23
+ const saved = readConfigFile(file);
24
+ const value = (name, fromFile) => env[name]?.trim() || fromFile?.trim() || undefined;
25
+ const workspaceUrl = value("ITFIN_URL", saved.url)?.replace(/\/+$/, "");
3
26
  if (!workspaceUrl)
4
- throw new Error("ITFIN_URL is required, e.g. https://acme.itfin.io");
5
- const workStart = env.ITFIN_WORK_START?.trim() || "10:00";
27
+ throw new Error("No ITFin workspace configured. Run npx itfin-mcp@latest install, or set ITFIN_URL, e.g. https://acme.itfin.io");
28
+ const workStart = value("ITFIN_WORK_START", saved.workStart) ?? "10:00";
6
29
  if (!/^\d{1,2}:\d{2}$/.test(workStart))
7
30
  throw new Error("ITFIN_WORK_START must be HH:mm, e.g. 10:00");
8
31
  return {
9
32
  workspaceUrl,
10
- browser: env.ITFIN_BROWSER?.trim() || "chrome",
33
+ browser: value("ITFIN_BROWSER", saved.browser) ?? "chrome",
11
34
  workStart,
12
- timezone: env.ITFIN_TIMEZONE?.trim() || Intl.DateTimeFormat().resolvedOptions().timeZone,
35
+ timezone: value("ITFIN_TIMEZONE", saved.timezone) ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
13
36
  };
14
37
  }
package/dist/index.js CHANGED
@@ -1,22 +1,89 @@
1
1
  #!/usr/bin/env node
2
- import { spawnSync } from "node:child_process";
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { dirname, join } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
6
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
7
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { runCli } from "./cli.js";
5
10
  import { systemClock } from "./clock.js";
6
- import { loadConfig } from "./config.js";
11
+ import { DEFAULT_CONFIG_FILE, loadConfig } from "./config.js";
12
+ import { LaunchdReminderScheduler } from "./reminders.js";
7
13
  import { createItfinServer } from "./server.js";
8
14
  import { KeychainTokenStore } from "./tokenStore.js";
9
- // `npx itfin-mcp install [flags]` runs the installer shipped in the package. Without arguments this
10
- // is the MCP server itself.
15
+ // `npx itfin-mcp install [flags]` runs the installer shipped in the package. `itfin-mcp cli ...` is
16
+ // CLI mode (the installer's `itfin` command runs it). Without arguments this is the MCP server.
11
17
  if (process.argv[2] === "install") {
12
18
  const script = fileURLToPath(new URL("../scripts/install.sh", import.meta.url));
13
19
  const result = spawnSync("bash", [script, ...process.argv.slice(3)], { stdio: "inherit" });
14
20
  process.exit(result.status ?? 1);
15
21
  }
16
22
  const config = loadConfig();
23
+ const reminders = new LaunchdReminderScheduler();
17
24
  const server = createItfinServer({
18
25
  config,
19
26
  clock: systemClock,
20
27
  tokenStore: new KeychainTokenStore("itfin-mcp", config.workspaceUrl),
28
+ reminders,
21
29
  });
22
- await server.connect(new StdioServerTransport());
30
+ if (process.argv[2] !== "cli") {
31
+ await server.connect(new StdioServerTransport());
32
+ }
33
+ else {
34
+ const client = new Client({ name: "itfin-cli", version: "0" });
35
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
36
+ await Promise.all([server.connect(serverTransport), client.connect(clientTransport)]);
37
+ const argv = process.argv.slice(3);
38
+ if (argv[0] === "__login-wait")
39
+ await waitForLogin(client);
40
+ else
41
+ process.exitCode = await runCli(argv, { client, out: (t) => console.log(t), err: (t) => console.error(t), startLogin: startLoginWaiter });
42
+ }
43
+ /** Marks a running background login, so a second `itfin login` doesn't open another window. */
44
+ function loginPidFile() {
45
+ return join(dirname(process.env.ITFIN_CONFIG?.trim() || DEFAULT_CONFIG_FILE), "login.pid");
46
+ }
47
+ function isRunning(pid) {
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ }
52
+ catch {
53
+ return false;
54
+ }
55
+ }
56
+ /** Shell tools time out long before a person signs in, so the login waits in a detached process. */
57
+ async function startLoginWaiter() {
58
+ const pidFile = loginPidFile();
59
+ const pid = Number(readFileSafe(pidFile));
60
+ if (pid && isRunning(pid))
61
+ return { status: "waiting", message: "The login window is already open. The user will get a notification once they have signed in." };
62
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), "cli", "__login-wait"], { detached: true, stdio: "ignore", env: process.env });
63
+ child.unref();
64
+ mkdirSync(dirname(pidFile), { recursive: true });
65
+ writeFileSync(pidFile, String(child.pid));
66
+ return { status: "waiting", message: "A login window is opening. The user will get a notification once they have signed in; check with itfin auth-status." };
67
+ }
68
+ async function waitForLogin(client) {
69
+ const pidFile = loginPidFile();
70
+ process.on("exit", () => {
71
+ if (readFileSafe(pidFile) === String(process.pid))
72
+ rmSync(pidFile, { force: true });
73
+ });
74
+ const res = (await client.callTool({ name: "itfin_login", arguments: {} }));
75
+ const data = JSON.parse(res.content[0]?.text ?? "{}");
76
+ // "waiting": the server keeps watching and notifies on its own.
77
+ if (res.isError)
78
+ await reminders.notify("ITFin", `Login failed: ${data.error?.message ?? "unknown error"}`);
79
+ else if (data.status === "logged_in")
80
+ await reminders.notify("ITFin", "Logged in to ITFin.");
81
+ }
82
+ function readFileSafe(file) {
83
+ try {
84
+ return readFileSync(file, "utf8").trim();
85
+ }
86
+ catch {
87
+ return undefined;
88
+ }
89
+ }
package/dist/server.js CHANGED
@@ -146,7 +146,9 @@ export function createItfinServer(deps) {
146
146
  return me.Id;
147
147
  }
148
148
  async function reopenRequests() {
149
- return itfin.get("/v1/requests/my", { "filter[requestType]": "OpenReporting", page: 1, size: 50 });
149
+ const requests = await itfin.get("/v1/requests/my", { "filter[requestType]": "OpenReporting", page: 1, size: 50 });
150
+ // Also filtered here, so itfin_cancel_reopen_request can't reach a leave request if ITFin ignores the filter.
151
+ return requests.filter((r) => (r.RequestType ?? "OpenReporting") === "OpenReporting");
150
152
  }
151
153
  async function reopenRequestsCovering(date) {
152
154
  return (await reopenRequests()).map(toReopenRequest).filter((r) => r.from <= date && date <= r.to);
@@ -348,6 +350,23 @@ export function createItfinServer(deps) {
348
350
  }
349
351
  return fileReopenRequest({ from: args.from, to: args.to, reason: args.reason });
350
352
  }));
353
+ server.registerTool("itfin_cancel_reopen_request", {
354
+ title: "Cancel a reopen request",
355
+ description: "Cancels (deletes) one of the user's reopen requests by id (from itfin_list_reopen_requests). Only call this when the user asked to cancel that request. " +
356
+ "Works for pending requests; whether an approved one can still be cancelled is up to ITFin.",
357
+ inputSchema: { id: z.number().int() },
358
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
359
+ }, ({ id }) => handle(async () => {
360
+ // Look the id up among the user's reopen requests, so this tool can't cancel other kinds of requests.
361
+ const request = (await reopenRequests()).find((r) => r.Id === id);
362
+ if (!request)
363
+ throw new ToolError("NOT_FOUND", `No reopen request ${id} among the user's recent reopen requests.`);
364
+ if ([...CLOSED_REQUEST_STATUSES, "Declined"].includes(request.Status)) {
365
+ throw new ToolError("VALIDATION", `Reopen request ${id} is already ${request.Status}.`, { request: toReopenRequest(request) });
366
+ }
367
+ await itfin.request("DELETE", `/v1/requests/${id}`);
368
+ return { cancelled: id, request: toReopenRequest(request) };
369
+ }));
351
370
  /** Leave types the user can request, as the web app's "Request time off" form offers them. */
352
371
  async function leaveTypes() {
353
372
  const [types, settings] = await Promise.all([
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "itfin-mcp",
3
- "version": "0.1.2",
4
- "description": "Local MCP server for ITFin: projects, time entries and ITFin token lifecycle",
3
+ "version": "0.1.4",
4
+ "description": "ITFin for AI agents, as a local MCP server or a CLI: projects, time entries, leave and reopen requests",
5
5
  "keywords": [
6
6
  "mcp",
7
7
  "itfin",
@@ -0,0 +1,70 @@
1
+ // JSON edits for install.sh. Usage: node install-helpers.mjs <command> ...
2
+ // read-config <config> prints url, browser, workStart, timezone, permissions and apps as KEY=value
3
+ // write-config <config> <url> <browser> <workStart> <timezone> <permissions yes|no> <id=mode>...
4
+ // saves settings, app modes and the permissions choice; keeps what the installer added
5
+ // claude-permissions <settings> <config> [command...]
6
+ // replaces the Bash(itfin <command>:*) allow rules this installer
7
+ // added before with rules for the given commands; prints the new ones
8
+ // desktop-register <desktop config> <node> <entry> [KEY=value...]
9
+ // sets mcpServers.itfin and keeps the rest of the file (old one as .bak)
10
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
+ import { dirname } from "node:path";
12
+
13
+ const [command, ...args] = process.argv.slice(2);
14
+
15
+ function readJson(file) {
16
+ const text = existsSync(file) ? readFileSync(file, "utf8") : "";
17
+ return { text, json: text.trim() ? JSON.parse(text) : {} };
18
+ }
19
+
20
+ function writeJson(file, json) {
21
+ mkdirSync(dirname(file), { recursive: true });
22
+ writeFileSync(file, JSON.stringify(json, null, 2) + "\n");
23
+ }
24
+
25
+ switch (command) {
26
+ case "read-config": {
27
+ const { json } = readJson(args[0]);
28
+ for (const key of ["url", "browser", "workStart", "timezone"]) if (json[key]) console.log(`${key}=${json[key]}`);
29
+ if (typeof json.allowReadOnlyCommands === "boolean") console.log(`permissions=${json.allowReadOnlyCommands ? "yes" : "no"}`);
30
+ console.log(`apps=${Object.entries(json.apps ?? {}).map(([app, mode]) => `${app}=${mode}`).join(" ")}`);
31
+ break;
32
+ }
33
+ case "write-config": {
34
+ const [file, url, browser, workStart, timezone, permissions, ...apps] = args;
35
+ const { json } = readJson(file);
36
+ const settings = Object.fromEntries(Object.entries({ url, browser, workStart, timezone }).filter(([, v]) => v));
37
+ writeJson(file, { ...settings, apps: Object.fromEntries(apps.map((a) => a.split("="))), allowReadOnlyCommands: permissions !== "no", added: json.added ?? {} });
38
+ break;
39
+ }
40
+ case "claude-permissions": {
41
+ const [settingsFile, configFile, ...commands] = args;
42
+ const config = readJson(configFile).json;
43
+ const previous = new Set(config.added?.claudePermissions ?? []);
44
+ const wanted = commands.map((c) => `Bash(itfin ${c}:*)`);
45
+ if (previous.size || wanted.length) {
46
+ const { text, json: settings } = readJson(settingsFile);
47
+ if (text) writeFileSync(settingsFile + ".bak", text);
48
+ const allow = (settings.permissions?.allow ?? []).filter((rule) => !previous.has(rule));
49
+ const added = wanted.filter((rule) => !allow.includes(rule));
50
+ settings.permissions = { ...settings.permissions, allow: [...allow, ...added] };
51
+ writeJson(settingsFile, settings);
52
+ config.added = { ...config.added, claudePermissions: added };
53
+ writeJson(configFile, config);
54
+ for (const rule of added) console.log(rule);
55
+ }
56
+ break;
57
+ }
58
+ case "desktop-register": {
59
+ const [file, node, entry, ...pairs] = args;
60
+ const { text, json } = readJson(file);
61
+ if (text) writeFileSync(file + ".bak", text);
62
+ const env = Object.fromEntries(pairs.map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
63
+ json.mcpServers = { ...json.mcpServers, itfin: { command: node, args: [entry], env } };
64
+ writeJson(file, json);
65
+ break;
66
+ }
67
+ default:
68
+ console.error(`Unknown command: ${command}`);
69
+ process.exit(1);
70
+ }
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env bash
2
- # Quick install for itfin-mcp: installs or updates the npm package in ~/.itfin-mcp and registers the
3
- # MCP server with Claude Code, Codex and Claude Desktop. Usage:
4
- # npx itfin-mcp install [--company acme] [--clients claude,codex,desktop] [--browser chrome] [--work-start 10:00] [--timezone Europe/Kyiv]
2
+ # Quick install for itfin-mcp: installs or updates the npm package in ~/.itfin-mcp and sets up
3
+ # Claude Code, Codex and Claude Desktop, each in one mode: MCP (the app runs the MCP server) or CLI
4
+ # (the agent runs the `itfin` command, guided by an `itfin` skill). Claude Desktop is always MCP. Usage:
5
+ # npx itfin-mcp install [--company acme] [--clients claude,codex,desktop] [--mode claude=cli,codex=mcp]
6
+ # [--no-permissions | --permissions] [--browser chrome] [--work-start 10:00] [--timezone Europe/Kyiv]
5
7
  # curl -fsSL https://raw.githubusercontent.com/steven-tailor/itfin-mcp/main/scripts/install.sh | bash -s -- [same flags]
6
- # The first install asks which apps to install for and the company name. Updates reuse the apps and
7
- # settings of the previous install.
8
+ # The first install asks which apps to install for, in which mode, and the company name. Updates
9
+ # reuse the apps, modes and settings of the previous install, kept in <dir>/config.json.
8
10
  set -euo pipefail
9
11
 
10
12
  DIR="${ITFIN_MCP_DIR:-$HOME/.itfin-mcp}"
@@ -12,13 +14,21 @@ DIR="${ITFIN_MCP_DIR:-$HOME/.itfin-mcp}"
12
14
  PACKAGE="${ITFIN_MCP_PACKAGE:-itfin-mcp@latest}"
13
15
  CODEX_CONFIG="${CODEX_HOME:-$HOME/.codex}/config.toml"
14
16
  DESKTOP_CONFIG="$HOME/Library/Application Support/Claude/claude_desktop_config.json"
15
- URL="" BROWSER="" WORK_START="" TIMEZONE="" CLIENTS=""
17
+ CLAUDE_SETTINGS="$HOME/.claude/settings.json"
18
+ CLAUDE_SKILL="$HOME/.claude/skills/itfin"
19
+ CODEX_SKILL="$HOME/.agents/skills/itfin"
20
+ CODEX_RULES="${CODEX_HOME:-$HOME/.codex}/rules/itfin.rules"
21
+ BIN="$HOME/.local/bin/itfin"
22
+ URL="" BROWSER="" WORK_START="" TIMEZONE="" CLIENTS="" MODES="" PERMISSIONS=""
16
23
 
17
24
  while [[ $# -gt 0 ]]; do
18
25
  case "$1" in
19
26
  --company) URL="https://$2.itfin.io"; shift 2 ;;
20
27
  --url) URL="$2"; shift 2 ;;
21
28
  --clients) CLIENTS="${2//,/ }"; shift 2 ;;
29
+ --mode) MODES="${2//,/ }"; shift 2 ;;
30
+ --no-permissions) PERMISSIONS=no; shift ;;
31
+ --permissions) PERMISSIONS=yes; shift ;;
22
32
  --browser) BROWSER="$2"; shift 2 ;;
23
33
  --work-start) WORK_START="$2"; shift 2 ;;
24
34
  --timezone) TIMEZONE="$2"; shift 2 ;;
@@ -78,33 +88,76 @@ if [[ -n "$STALE" ]]; then
78
88
  kill $STALE 2>/dev/null || true
79
89
  fi
80
90
 
81
- # Apps to register with: claude (Claude Code), codex, desktop (Claude Desktop).
82
- # Updates keep the apps of the previous install. A first install asks, with every app found checked.
83
- if [[ -z "$CLIENTS" ]]; then
84
- command -v claude >/dev/null && claude mcp get itfin >/dev/null 2>&1 && CLIENTS+="claude "
85
- [[ -f "$CODEX_CONFIG" ]] && grep -q '^\[mcp_servers\.itfin\]$' "$CODEX_CONFIG" && CLIENTS+="codex "
86
- [[ -f "$DESKTOP_CONFIG" ]] && grep -q '"itfin"' "$DESKTOP_CONFIG" && CLIENTS+="desktop "
91
+ CONFIG="$DIR/config.json"
92
+ HELPERS="$PKG/scripts/install-helpers.mjs"
93
+ SAVED=""
94
+ if [[ -f "$CONFIG" ]]; then SAVED="$(node "$HELPERS" read-config "$CONFIG")" || fail "$CONFIG is not valid JSON. Fix or delete it, then run the install again."; fi
95
+ saved() { printf '%s\n' "$SAVED" | sed -n "s/^$1=//p" | head -1; }
96
+ # Mode of app $2 in a list like "claude=cli desktop=mcp".
97
+ mode_in() { local pair; for pair in $1; do [[ "${pair%%=*}" == "$2" ]] && echo "${pair#*=}"; done; return 0; }
98
+ # Sets app $1 to mode $2 in APPS.
99
+ set_mode() { local pair out=""; for pair in $APPS; do [[ "${pair%%=*}" == "$1" ]] || out+="$pair "; done; APPS="$out$1=$2"; }
100
+
101
+ # Apps and their modes: claude (Claude Code), codex, desktop (Claude Desktop); mcp or cli.
102
+ # Updates keep the apps and modes of the previous install. Installs from before config.json are MCP.
103
+ PREV_APPS="$(saved apps)"
104
+ if [[ -z "$PREV_APPS" ]]; then
105
+ command -v claude >/dev/null && claude mcp get itfin >/dev/null 2>&1 && PREV_APPS+="claude=mcp "
106
+ [[ -f "$CODEX_CONFIG" ]] && grep -q '^\[mcp_servers\.itfin\]$' "$CODEX_CONFIG" && PREV_APPS+="codex=mcp "
107
+ [[ -f "$DESKTOP_CONFIG" ]] && grep -q '"itfin"' "$DESKTOP_CONFIG" && PREV_APPS+="desktop=mcp "
108
+ fi
109
+ APPS=""
110
+ if [[ -n "$CLIENTS$MODES" ]]; then
111
+ for client in $CLIENTS; do mode="$(mode_in "$PREV_APPS" "$client")"; set_mode "$client" "${mode:-mcp}"; done
112
+ for pair in $MODES; do [[ "$pair" == *=* ]] || fail "Use --mode app=mode, e.g. --mode claude=cli."; set_mode "${pair%%=*}" "${pair#*=}"; done
113
+ else
114
+ APPS="$PREV_APPS"
87
115
  fi
88
- if [[ -z "$CLIENTS" ]]; then
116
+ if [[ -z "$APPS" ]]; then
89
117
  FOUND=()
90
- command -v claude >/dev/null && FOUND+=("claude=Claude Code")
91
- command -v codex >/dev/null && FOUND+=("codex=Codex")
92
- [[ -d /Applications/Claude.app || -f "$DESKTOP_CONFIG" ]] && FOUND+=("desktop=Claude Desktop")
118
+ command -v claude >/dev/null && FOUND+=("claude=Claude Code=mcp,cli")
119
+ command -v codex >/dev/null && FOUND+=("codex=Codex=mcp,cli")
120
+ [[ -d /Applications/Claude.app || -f "$DESKTOP_CONFIG" ]] && FOUND+=("desktop=Claude Desktop=mcp")
93
121
  [[ ${#FOUND[@]} -gt 0 ]] || fail "Install Claude Code, Codex or Claude Desktop first."
94
122
  if (: </dev/tty) 2>/dev/null; then
95
- CLIENTS="$(node "$PKG/scripts/pick-clients.mjs" "${FOUND[@]}")" || fail "Cancelled."
123
+ APPS="$(node "$PKG/scripts/pick-clients.mjs" "${FOUND[@]}")" || fail "Cancelled."
96
124
  else
97
- for found in "${FOUND[@]}"; do CLIENTS+="${found%%=*} "; done
125
+ for found in "${FOUND[@]}"; do APPS+="${found%%=*}=mcp "; done
98
126
  fi
99
127
  fi
100
- CLIENTS=" $(echo $CLIENTS) "
101
- for client in $CLIENTS; do
102
- [[ "$client" =~ ^(claude|codex|desktop)$ ]] || fail "Unknown app: $client. Use claude, codex or desktop."
128
+ APPS="$(echo $APPS)"
129
+ for pair in $APPS; do
130
+ app="${pair%%=*}" mode="${pair#*=}"
131
+ [[ "$app" =~ ^(claude|codex|desktop)$ ]] || fail "Unknown app: $app. Use claude, codex or desktop."
132
+ [[ "$mode" =~ ^(mcp|cli)$ ]] || fail "Unknown mode for $app: $mode. Use mcp or cli."
133
+ [[ "$pair" != "desktop=cli" ]] || fail "Claude Desktop has no shell, so it can only use MCP mode."
134
+ done
135
+ # Apps of the previous install that this run doesn't name keep their mode, and are set up again too,
136
+ # so every registration points at the new code and the shared config.json.
137
+ ALL_APPS="$APPS"
138
+ for pair in $PREV_APPS; do [[ -n "$(mode_in "$APPS" "${pair%%=*}")" ]] || ALL_APPS+=" $pair"; done
139
+ mode_of() { mode_in "$ALL_APPS" "$1"; }
140
+ uses_cli() { [[ " $ALL_APPS " == *"=cli "* ]]; }
141
+
142
+ # The itfin command for CLI mode. Never replace an itfin command that isn't ours.
143
+ WRAPPER_MARK="# itfin-mcp CLI wrapper"
144
+ if uses_cli && [[ -e "$BIN" ]] && ! grep -qF "$WRAPPER_MARK" "$BIN"; then
145
+ fail "$BIN already exists and is not the itfin-mcp CLI. Remove or rename it, or keep every app in MCP mode."
146
+ fi
147
+ # Likewise the skill and the Codex rules file: only files this installer wrote are replaced or removed.
148
+ SKILL_MARK="Generated by itfin-mcp install"
149
+ RULES_MARK="# Written by itfin-mcp install"
150
+ ours() { [[ ! -e "$1" ]] || grep -qF "$2" "$1" 2>/dev/null; }
151
+ for pair in "claude=$CLAUDE_SKILL" "codex=$CODEX_SKILL"; do
152
+ skill="${pair#*=}"
153
+ [[ "$(mode_of "${pair%%=*}")" != cli || ! -e "$skill" ]] || { [[ -f "$skill/SKILL.md" ]] && ours "$skill/SKILL.md" "$SKILL_MARK"; } \
154
+ || fail "$skill already exists and is not the itfin-mcp skill. Remove or rename it, or keep that app in MCP mode."
103
155
  done
104
- wants() { [[ "$CLIENTS" == *" $1 "* ]]; }
156
+ [[ "$(mode_of codex)" != cli ]] || ours "$CODEX_RULES" "$RULES_MARK" \
157
+ || fail "$CODEX_RULES already exists and was not written by itfin-mcp. Remove or rename it."
105
158
 
106
- # Settings of the previous install, from the Claude Code registration, the Codex config or the
107
- # Claude Desktop config.
159
+ # Settings: flags, then config.json, then (for installs from before config.json) the env of the
160
+ # previous MCP registrations in Claude Code, Codex or Claude Desktop.
108
161
  PREV=""
109
162
  if command -v claude >/dev/null; then PREV+="$(claude mcp get itfin 2>/dev/null || true)"$'\n'; fi
110
163
  if [[ -f "$CODEX_CONFIG" ]]; then PREV+="$(cat "$CODEX_CONFIG")"$'\n'; fi
@@ -124,10 +177,17 @@ prev() {
124
177
  }'
125
178
  }
126
179
 
180
+ [[ -n "$URL" ]] || URL="$(saved url)"
127
181
  [[ -n "$URL" ]] || URL="$(prev ITFIN_URL)"
182
+ [[ -n "$BROWSER" ]] || BROWSER="$(saved browser)"
128
183
  [[ -n "$BROWSER" ]] || BROWSER="$(prev ITFIN_BROWSER)"
184
+ [[ -n "$WORK_START" ]] || WORK_START="$(saved workStart)"
129
185
  [[ -n "$WORK_START" ]] || WORK_START="$(prev ITFIN_WORK_START)"
186
+ [[ -n "$TIMEZONE" ]] || TIMEZONE="$(saved timezone)"
130
187
  [[ -n "$TIMEZONE" ]] || TIMEZONE="$(prev ITFIN_TIMEZONE)"
188
+ # Whether CLI mode may allow read-only commands without asking: a flag, else the previous choice.
189
+ [[ -n "$PERMISSIONS" ]] || PERMISSIONS="$(saved permissions)"
190
+ [[ -n "$PERMISSIONS" ]] || PERMISSIONS=yes
131
191
 
132
192
  if [[ -z "$URL" ]]; then
133
193
  COMPANY="$(ask "Company name in ITFin (acme for https://acme.itfin.io)")"
@@ -141,58 +201,101 @@ URL="${URL%/}"
141
201
  [[ "$URL" =~ ^https?://[^/]+$ ]] || fail "Workspace address must look like https://acme.itfin.io"
142
202
  echo "→ Workspace: $URL"
143
203
 
204
+ # Both modes read the settings from config.json. Settings left empty use the defaults (chrome,
205
+ # 10:00, system timezone).
206
+ node "$HELPERS" write-config "$CONFIG" "$URL" "$BROWSER" "$WORK_START" "$TIMEZONE" "$PERMISSIONS" $ALL_APPS
207
+
144
208
  ENTRY="$PKG/dist/index.js"
145
209
  # Absolute path, because Claude Desktop does not see version-manager shims on PATH.
146
210
  NODE="$(command -v node)"
147
- # Settings left empty are not passed, so the server uses its defaults (chrome, 10:00, system timezone).
148
- ENV_PAIRS=()
149
- for pair in "ITFIN_URL=$URL" "ITFIN_BROWSER=$BROWSER" "ITFIN_WORK_START=$WORK_START" "ITFIN_TIMEZONE=$TIMEZONE"; do
150
- [[ -n "${pair#*=}" ]] && ENV_PAIRS+=("$pair")
151
- done
152
- ENV_ARGS=()
153
- for pair in "${ENV_PAIRS[@]}"; do ENV_ARGS+=(-e "$pair"); done
211
+ ENV_PAIRS=("ITFIN_CONFIG=$CONFIG")
212
+ ENV_ARGS=(-e "ITFIN_CONFIG=$CONFIG")
213
+ cli() { ITFIN_CONFIG="$CONFIG" "$NODE" "$ENTRY" cli "$@"; }
214
+
215
+ # The CLI skill in folder $1. Removing it deletes only our SKILL.md, and the folder if nothing else is left.
216
+ install_skill() { mkdir -p "$1"; cli __skill >"$1/SKILL.md"; }
217
+ remove_skill() { [[ -f "$1/SKILL.md" ]] && ours "$1/SKILL.md" "$SKILL_MARK" && rm -f "$1/SKILL.md" && rmdir "$1" 2>/dev/null; return 0; }
218
+ # Removes the Codex rules file if this installer wrote it.
219
+ remove_rules() { [[ -f "$CODEX_RULES" ]] && ours "$CODEX_RULES" "$RULES_MARK" && rm -f "$CODEX_RULES"; return 0; }
220
+ READ_ONLY=()
221
+ if uses_cli && [[ "$PERMISSIONS" == yes ]]; then
222
+ while IFS= read -r command; do [[ -n "$command" ]] && READ_ONLY+=("$command"); done < <(cli __read-only)
223
+ fi
154
224
 
155
- if wants claude; then
225
+ if [[ -n "$(mode_of claude)" ]]; then
156
226
  command -v claude >/dev/null || fail "The claude CLI was not found."
157
- echo "→ Registering the MCP server with Claude Code (user scope)"
158
227
  claude mcp remove itfin --scope user >/dev/null 2>&1 || true
159
- claude mcp add itfin --scope user "${ENV_ARGS[@]}" -- "$NODE" "$ENTRY"
228
+ if [[ "$(mode_of claude)" == cli ]]; then
229
+ echo "→ Setting up Claude Code in CLI mode (skill $CLAUDE_SKILL)"
230
+ install_skill "$CLAUDE_SKILL"
231
+ PERMS="$(node "$HELPERS" claude-permissions "$CLAUDE_SETTINGS" "$CONFIG" ${READ_ONLY[@]+"${READ_ONLY[@]}"})" \
232
+ || echo "! Could not update $CLAUDE_SETTINGS (is it valid JSON?), so Claude Code will ask before every itfin command."
233
+ [[ -n "$PERMS" ]] && echo "→ Allowed read-only itfin commands in $CLAUDE_SETTINGS: $(echo $PERMS)"
234
+ else
235
+ echo "→ Registering the MCP server with Claude Code (user scope)"
236
+ remove_skill "$CLAUDE_SKILL"
237
+ node "$HELPERS" claude-permissions "$CLAUDE_SETTINGS" "$CONFIG" >/dev/null || true
238
+ claude mcp add itfin --scope user "${ENV_ARGS[@]}" -- "$NODE" "$ENTRY"
239
+ fi
160
240
  fi
161
241
 
162
- if wants codex; then
242
+ if [[ -n "$(mode_of codex)" ]]; then
163
243
  command -v codex >/dev/null || fail "The codex CLI was not found."
164
- echo "→ Registering the MCP server with Codex"
165
244
  codex mcp remove itfin >/dev/null 2>&1 || true
166
- codex mcp add itfin "${ENV_ARGS[@]/#-e/--env}" -- "$NODE" "$ENTRY"
167
- # itfin_login waits up to 3 minutes; Codex gives up on tool calls after 60 seconds by default.
168
- sed -i '' '/^\[mcp_servers\.itfin\]$/a\
245
+ if [[ "$(mode_of codex)" == cli ]]; then
246
+ echo "→ Setting up Codex in CLI mode (skill $CODEX_SKILL)"
247
+ install_skill "$CODEX_SKILL"
248
+ remove_rules
249
+ if [[ ${#READ_ONLY[@]} -gt 0 ]]; then
250
+ # Allowed commands run outside the Codex sandbox, which blocks the network and the Keychain.
251
+ mkdir -p "$(dirname "$CODEX_RULES")"
252
+ { echo "$RULES_MARK: read-only itfin commands. Rewritten on every update."
253
+ for command in "${READ_ONLY[@]}"; do echo "prefix_rule(pattern=[\"itfin\", \"$command\"], decision=\"allow\")"; done
254
+ } >"$CODEX_RULES"
255
+ echo "→ Allowed read-only itfin commands in $CODEX_RULES"
256
+ fi
257
+ else
258
+ echo "→ Registering the MCP server with Codex"
259
+ remove_skill "$CODEX_SKILL"
260
+ remove_rules
261
+ codex mcp add itfin "${ENV_ARGS[@]/#-e/--env}" -- "$NODE" "$ENTRY"
262
+ # itfin_login waits up to 3 minutes; Codex gives up on tool calls after 60 seconds by default.
263
+ sed -i '' '/^\[mcp_servers\.itfin\]$/a\
169
264
  tool_timeout_sec = 240
170
265
  ' "$CODEX_CONFIG"
266
+ fi
171
267
  fi
172
268
 
173
269
  DESKTOP=""
174
- if wants desktop; then
270
+ if [[ -n "$(mode_of desktop)" ]]; then
175
271
  echo "→ Registering the MCP server with Claude Desktop"
176
272
  # Replaces only mcpServers.itfin and keeps the rest of the file. The old file is kept as .bak.
177
- if node -e '
178
- const fs = require("fs");
179
- const [file, command, entry, ...pairs] = process.argv.slice(1);
180
- const text = fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
181
- const config = text.trim() ? JSON.parse(text) : {};
182
- if (text) fs.writeFileSync(file + ".bak", text);
183
- const env = Object.fromEntries(pairs.map((p) => [p.slice(0, p.indexOf("=")), p.slice(p.indexOf("=") + 1)]));
184
- config.mcpServers = { ...config.mcpServers, itfin: { command, args: [entry], env } };
185
- fs.mkdirSync(require("path").dirname(file), { recursive: true });
186
- fs.writeFileSync(file, JSON.stringify(config, null, 2) + "\n");
187
- ' "$DESKTOP_CONFIG" "$NODE" "$ENTRY" "${ENV_PAIRS[@]}"; then
273
+ if node "$HELPERS" desktop-register "$DESKTOP_CONFIG" "$NODE" "$ENTRY" "${ENV_PAIRS[@]}"; then
188
274
  DESKTOP=1
189
275
  else
190
276
  echo "! Could not update $DESKTOP_CONFIG (is it valid JSON?), so Claude Desktop was not registered."
191
277
  fi
192
278
  fi
193
279
 
280
+ PATH_HINT=""
281
+ if uses_cli; then
282
+ echo "→ Installing the itfin command in $BIN"
283
+ mkdir -p "$(dirname "$BIN")"
284
+ printf '#!/bin/sh\n%s, written by npx itfin-mcp install.\nITFIN_CONFIG="${ITFIN_CONFIG:-%s}" exec "%s" "%s" cli "$@"\n' "$WRAPPER_MARK" "$CONFIG" "$NODE" "$ENTRY" >"$BIN"
285
+ chmod +x "$BIN"
286
+ [[ ":$PATH:" == *":$(dirname "$BIN"):"* ]] || PATH_HINT=1
287
+ elif [[ -f "$BIN" ]] && grep -qF "$WRAPPER_MARK" "$BIN"; then
288
+ rm -f "$BIN"
289
+ fi
290
+
194
291
  echo
195
- echo "✓ itfin-mcp $NEW_VERSION is installed in $DIR"
292
+ SUMMARY=""
293
+ for pair in $ALL_APPS; do
294
+ case "${pair%%=*}" in claude) name="Claude Code" ;; codex) name="Codex" ;; desktop) name="Claude Desktop" ;; esac
295
+ SUMMARY+="${SUMMARY:+, }$name: $(echo "${pair#*=}" | tr '[:lower:]' '[:upper:]')"
296
+ done
297
+ echo "✓ itfin-mcp $NEW_VERSION is installed in $DIR ($SUMMARY)"
298
+ [[ -n "$PATH_HINT" ]] && echo "! $(dirname "$BIN") is not on your PATH. Add it: echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> ~/.zshrc"
196
299
  # Claude Desktop reads its config only at startup. Not quit here: it may be running this script.
197
300
  [[ -n "$DESKTOP" ]] && echo "Restart Claude Desktop (Cmd+Q, then open it) to load the server."
198
301
  [[ -n "${STALE:-}" ]] && echo "Open sessions lost the ITFin tools: start a new session, or reconnect itfin with /mcp in Claude Code."
@@ -1,22 +1,36 @@
1
- // Checkbox list for install.sh: which apps to register the MCP server with.
2
- // Usage: node pick-clients.mjs id=Label ... Prints the chosen ids, one per line.
1
+ // Checkbox list for install.sh: which apps to install for, and in which mode.
2
+ // Usage: node pick-clients.mjs id=Label=modes=current ... (modes: comma-separated, e.g. mcp,cli)
3
+ // Prints the chosen apps as id=mode, one per line.
3
4
  // Talks to /dev/tty, because install.sh itself usually comes from a pipe (curl | bash).
4
5
  import { openSync } from "node:fs";
5
6
  import { ReadStream, WriteStream } from "node:tty";
6
7
  import checkbox from "@inquirer/checkbox";
7
8
 
9
+ const MODE_LABELS = { mcp: "MCP", cli: "CLI" };
10
+
8
11
  const input = new ReadStream(openSync("/dev/tty", "r"));
9
12
  const output = new WriteStream(openSync("/dev/tty", "w"));
10
13
 
14
+ const choices = process.argv.slice(2).flatMap((arg) => {
15
+ const [id, name, modes, current] = arg.split("=");
16
+ const list = modes.split(",");
17
+ return list.map((mode) => ({
18
+ value: `${id}=${mode}`,
19
+ name: list.length > 1 ? `${name} (${MODE_LABELS[mode]})` : name,
20
+ checked: mode === (current || "mcp"),
21
+ }));
22
+ });
23
+
11
24
  try {
12
25
  const chosen = await checkbox(
13
26
  {
14
- message: "Install for",
15
- choices: process.argv.slice(2).map((arg) => {
16
- const [value, name] = arg.split("=");
17
- return { value, name, checked: true };
18
- }),
27
+ message: "Install for (MCP: the app runs ITFin tools; CLI: the agent runs the itfin command in its shell)",
28
+ choices,
19
29
  required: true,
30
+ validate: (picked) => {
31
+ const apps = picked.map((c) => c.value.split("=")[0]);
32
+ return new Set(apps).size === apps.length || "Pick one mode per app.";
33
+ },
20
34
  },
21
35
  { input, output },
22
36
  );