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 +23 -4
- package/dist/cli.js +188 -0
- package/dist/config.js +29 -6
- package/dist/index.js +72 -5
- package/dist/server.js +20 -1
- package/package.json +2 -2
- package/scripts/install-helpers.mjs +70 -0
- package/scripts/install.sh +155 -52
- package/scripts/pick-clients.mjs +21 -7
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
|
|
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
|
-
|
|
2
|
-
|
|
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("
|
|
5
|
-
const workStart =
|
|
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:
|
|
33
|
+
browser: value("ITFIN_BROWSER", saved.browser) ?? "chrome",
|
|
11
34
|
workStart,
|
|
12
|
-
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.
|
|
10
|
-
// is the MCP server
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
+
}
|
package/scripts/install.sh
CHANGED
|
@@ -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
|
|
3
|
-
#
|
|
4
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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 "$
|
|
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
|
-
|
|
123
|
+
APPS="$(node "$PKG/scripts/pick-clients.mjs" "${FOUND[@]}")" || fail "Cancelled."
|
|
96
124
|
else
|
|
97
|
-
for found in "${FOUND[@]}"; do
|
|
125
|
+
for found in "${FOUND[@]}"; do APPS+="${found%%=*}=mcp "; done
|
|
98
126
|
fi
|
|
99
127
|
fi
|
|
100
|
-
|
|
101
|
-
for
|
|
102
|
-
|
|
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
|
-
|
|
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
|
|
107
|
-
# Claude Desktop
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
|
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 -
|
|
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
|
-
|
|
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."
|
package/scripts/pick-clients.mjs
CHANGED
|
@@ -1,22 +1,36 @@
|
|
|
1
|
-
// Checkbox list for install.sh: which apps to
|
|
2
|
-
// Usage: node pick-clients.mjs id=Label ...
|
|
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
|
|
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
|
);
|