indiecrm-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Sinewe
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,39 @@
1
+ # IndieCRM CLI
2
+
3
+ The IndieCRM CLI works with the workspaces, pipelines, contacts, companies, and deals in [IndieCRM](https://indiecrm.app). It uses IndieCRM's authenticated MCP API, so data access follows your signed-in account and workspace permissions.
4
+
5
+ Requires Node.js 22.12 or newer.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g indiecrm-cli
11
+ indiecrm login
12
+ ```
13
+
14
+ The login command opens IndieCRM in your browser for sign-in and consent. It saves the OAuth session in `~/.config/indiecrm-cli/auth.json` with owner-only permissions. Set `INDIECRM_CONFIG_DIR` to use another configuration directory. For automation, set `INDIECRM_ACCESS_TOKEN` to an existing IndieCRM OAuth access token instead of storing a local session.
15
+
16
+ ## Use
17
+
18
+ ```bash
19
+ indiecrm workspaces
20
+ indiecrm pipeline <app-id>
21
+ indiecrm deals <app-id> --status open
22
+ indiecrm contacts <app-id> --search Jane
23
+ indiecrm companies <app-id> --search Acme
24
+ indiecrm deal:create <app-id> "New opportunity" --value 500 --currency EUR
25
+ indiecrm deal:move <deal-id> <stage-id>
26
+ indiecrm activity:log <app-id> <deal-id> "Sent proposal" --kind email
27
+ ```
28
+
29
+ Commands print JSON for shell scripts and agents. Start with `indiecrm workspaces` to find the app ID for each workspace. `indiecrm tools` shows the live API tool catalog, and `indiecrm call <tool> --input '{"appId":"..."}'` exposes any supported tool directly. `indiecrm status` checks for a local session; `indiecrm logout` removes it.
30
+
31
+ ## Development
32
+
33
+ ```bash
34
+ npm ci
35
+ npm test
36
+ npm pack --dry-run
37
+ ```
38
+
39
+ This repository was forked from `salesprompter-cli`. The IndieCRM package has its own command, authentication, API connection, and release version. Salesprompter commands are not part of the published package.
@@ -0,0 +1,191 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { createServer } from "node:http";
4
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { Client, StreamableHTTPClientTransport, UnauthorizedError, } from "@modelcontextprotocol/client";
8
+ import { version } from "./version.js";
9
+ const CALLBACK_PORT = 48931;
10
+ const CALLBACK_PATH = "/oauth/callback";
11
+ const CALLBACK_URL = `http://127.0.0.1:${CALLBACK_PORT}${CALLBACK_PATH}`;
12
+ const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
13
+ export const MCP_URL = "https://indiecrm.app/mcp";
14
+ export function sessionPath() {
15
+ return path.join(process.env["INDIECRM_CONFIG_DIR"] || path.join(os.homedir(), ".config", "indiecrm-cli"), "auth.json");
16
+ }
17
+ async function readSession() {
18
+ try {
19
+ const parsed = JSON.parse(await readFile(sessionPath(), "utf8"));
20
+ return { clients: parsed.clients ?? {}, tokens: parsed.tokens };
21
+ }
22
+ catch (error) {
23
+ if (error.code === "ENOENT")
24
+ return { clients: {} };
25
+ throw error;
26
+ }
27
+ }
28
+ async function writeSession(session) {
29
+ const file = sessionPath();
30
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
31
+ await writeFile(file, JSON.stringify(session, null, 2) + "\n", { mode: 0o600 });
32
+ await import("node:fs/promises").then(({ chmod }) => chmod(file, 0o600));
33
+ }
34
+ export async function logout() {
35
+ await rm(sessionPath(), { force: true });
36
+ }
37
+ export async function hasSession() {
38
+ return Boolean(process.env["INDIECRM_ACCESS_TOKEN"] || (await readSession()).tokens?.access_token);
39
+ }
40
+ class IndieCrmOAuthProvider {
41
+ session;
42
+ interactive;
43
+ redirectUrl = CALLBACK_URL;
44
+ clientMetadata = {
45
+ client_name: "IndieCRM CLI",
46
+ redirect_uris: [CALLBACK_URL],
47
+ application_type: "native",
48
+ token_endpoint_auth_method: "none",
49
+ grant_types: ["authorization_code", "refresh_token"],
50
+ response_types: ["code"],
51
+ };
52
+ discovery;
53
+ verifier;
54
+ stateValue;
55
+ constructor(session, interactive) {
56
+ this.session = session;
57
+ this.interactive = interactive;
58
+ }
59
+ state() {
60
+ this.stateValue = randomUUID();
61
+ return this.stateValue;
62
+ }
63
+ get expectedState() {
64
+ return this.stateValue;
65
+ }
66
+ clientInformation(ctx) {
67
+ return ctx ? this.session.clients[ctx.issuer] : undefined;
68
+ }
69
+ async saveClientInformation(info, ctx) {
70
+ if (!ctx)
71
+ throw new Error("OAuth issuer was not supplied by the server");
72
+ this.session.clients[ctx.issuer] = info;
73
+ await writeSession(this.session);
74
+ }
75
+ tokens() {
76
+ return this.session.tokens;
77
+ }
78
+ async saveTokens(tokens) {
79
+ this.session.tokens = tokens;
80
+ await writeSession(this.session);
81
+ }
82
+ redirectToAuthorization(url) {
83
+ if (!this.interactive)
84
+ throw new Error("Your IndieCRM session has expired. Run `indiecrm login`.");
85
+ console.log(`Open this URL to sign in to IndieCRM:\n${url.href}\n`);
86
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
87
+ const args = process.platform === "win32" ? ["/c", "start", "", url.href] : [url.href];
88
+ const child = spawn(opener, args, { stdio: "ignore", detached: true });
89
+ child.on("error", () => { });
90
+ child.unref();
91
+ }
92
+ saveCodeVerifier(verifier) {
93
+ this.verifier = verifier;
94
+ }
95
+ codeVerifier() {
96
+ if (!this.verifier)
97
+ throw new Error("Missing OAuth code verifier");
98
+ return this.verifier;
99
+ }
100
+ saveDiscoveryState(state) {
101
+ this.discovery = state;
102
+ }
103
+ discoveryState() {
104
+ return this.discovery;
105
+ }
106
+ }
107
+ function makeClient(provider) {
108
+ const client = new Client({ name: "indiecrm-cli", version });
109
+ const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { authProvider: provider });
110
+ return { client, transport };
111
+ }
112
+ export async function login() {
113
+ const session = await readSession();
114
+ delete session.tokens;
115
+ const provider = new IndieCrmOAuthProvider(session, true);
116
+ let resolveCallback;
117
+ let rejectCallback;
118
+ const callback = new Promise((resolve, reject) => {
119
+ resolveCallback = resolve;
120
+ rejectCallback = reject;
121
+ });
122
+ const server = createServer((request, response) => {
123
+ const url = new URL(request.url ?? "/", CALLBACK_URL);
124
+ if (request.method !== "GET" || url.pathname !== CALLBACK_PATH) {
125
+ response.writeHead(404).end();
126
+ return;
127
+ }
128
+ if (url.searchParams.get("state") !== provider.expectedState) {
129
+ response.writeHead(400, { "Content-Type": "text/plain" }).end("Invalid sign-in state. Return to the CLI and retry.");
130
+ return;
131
+ }
132
+ response.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
133
+ response.end("<!doctype html><html><body><h1>IndieCRM CLI connected</h1><p>You can close this tab.</p></body></html>");
134
+ resolveCallback(url.searchParams);
135
+ });
136
+ try {
137
+ await new Promise((resolve, reject) => {
138
+ server.once("error", reject);
139
+ server.listen(CALLBACK_PORT, "127.0.0.1", resolve);
140
+ });
141
+ const { client, transport } = makeClient(provider);
142
+ try {
143
+ await client.connect(transport);
144
+ await client.close();
145
+ console.log("Already signed in to IndieCRM.");
146
+ return;
147
+ }
148
+ catch (error) {
149
+ if (!(error instanceof UnauthorizedError))
150
+ throw error;
151
+ }
152
+ const timer = setTimeout(() => rejectCallback(new Error("Sign-in timed out after five minutes")), LOGIN_TIMEOUT_MS);
153
+ let params;
154
+ try {
155
+ params = await callback;
156
+ }
157
+ finally {
158
+ clearTimeout(timer);
159
+ }
160
+ if (params.has("error"))
161
+ throw new Error("IndieCRM sign-in was declined or failed");
162
+ await transport.finishAuth(params);
163
+ const connected = makeClient(provider);
164
+ try {
165
+ await connected.client.connect(connected.transport);
166
+ }
167
+ finally {
168
+ await connected.client.close();
169
+ }
170
+ console.log("Signed in to IndieCRM.");
171
+ }
172
+ finally {
173
+ server.close();
174
+ }
175
+ }
176
+ export async function connect() {
177
+ const envToken = process.env["INDIECRM_ACCESS_TOKEN"]?.trim();
178
+ if (envToken) {
179
+ const client = new Client({ name: "indiecrm-cli", version });
180
+ await client.connect(new StreamableHTTPClientTransport(new URL(MCP_URL), {
181
+ authProvider: { token: async () => envToken },
182
+ }));
183
+ return client;
184
+ }
185
+ const session = await readSession();
186
+ if (!session.tokens)
187
+ throw new Error("Sign in first with `indiecrm login` or set INDIECRM_ACCESS_TOKEN.");
188
+ const { client, transport } = makeClient(new IndieCrmOAuthProvider(session, false));
189
+ await client.connect(transport);
190
+ return client;
191
+ }
@@ -0,0 +1,3 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ export const version = require("../../package.json").version;
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { connect, hasSession, login, logout } from "./indiecrm/auth.js";
4
+ import { version } from "./indiecrm/version.js";
5
+ const program = new Command()
6
+ .name("indiecrm")
7
+ .description("Work with your IndieCRM workspaces, contacts, companies, and deals.")
8
+ .version(version);
9
+ function positiveInt(value) {
10
+ const parsed = Number(value);
11
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200)
12
+ throw new Error("Limit must be between 1 and 200");
13
+ return parsed;
14
+ }
15
+ function nonnegativeNumber(value) {
16
+ const parsed = Number(value);
17
+ if (!Number.isFinite(parsed) || parsed < 0)
18
+ throw new Error("Value must be a nonnegative number");
19
+ return parsed;
20
+ }
21
+ function print(value) {
22
+ console.log(JSON.stringify(value, null, 2));
23
+ }
24
+ async function call(name, args) {
25
+ const client = await connect();
26
+ try {
27
+ const result = await client.callTool({ name, arguments: args });
28
+ if (result.isError) {
29
+ const message = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
30
+ throw new Error(message || `${name} failed`);
31
+ }
32
+ if (result.structuredContent) {
33
+ print(result.structuredContent);
34
+ return;
35
+ }
36
+ const text = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
37
+ try {
38
+ print(JSON.parse(text));
39
+ }
40
+ catch {
41
+ console.log(text);
42
+ }
43
+ }
44
+ finally {
45
+ await client.close();
46
+ }
47
+ }
48
+ program.command("login").description("Sign in through indiecrm.app").action(login);
49
+ program.command("logout").description("Remove the local sign-in session").action(async () => {
50
+ await logout();
51
+ console.log("Signed out of IndieCRM CLI.");
52
+ });
53
+ program.command("status").description("Show whether a local session is available").action(async () => {
54
+ print({ signedIn: await hasSession() });
55
+ });
56
+ program.command("workspaces").description("List workspaces and app IDs").action(() => call("list_workspaces", {}));
57
+ program.command("pipeline <app-id>").description("List pipelines and stages for an app")
58
+ .action((appId) => call("list_pipeline", { appId }));
59
+ program.command("deals <app-id>").description("List deals for an app")
60
+ .option("--status <status>", "open, won, or lost")
61
+ .option("--limit <n>", "maximum rows (1-200)", positiveInt)
62
+ .action((appId, options) => {
63
+ if (options.status && !["open", "won", "lost"].includes(options.status))
64
+ throw new Error("Status must be open, won, or lost");
65
+ return call("list_deals", { appId, ...options });
66
+ });
67
+ program.command("contacts <app-id>").description("List or search contacts")
68
+ .option("--search <name>", "case-insensitive name search")
69
+ .option("--limit <n>", "maximum rows (1-200)", positiveInt)
70
+ .action((appId, options) => call("list_contacts", { appId, ...options }));
71
+ program.command("companies <app-id>").description("List or search companies")
72
+ .option("--search <name>", "case-insensitive name search")
73
+ .option("--limit <n>", "maximum rows (1-200)", positiveInt)
74
+ .action((appId, options) => call("list_companies", { appId, ...options }));
75
+ program.command("deal:create <app-id> <title>").description("Create a deal")
76
+ .option("--value <amount>", "deal value", nonnegativeNumber)
77
+ .option("--currency <code>", "three-letter currency code")
78
+ .option("--stage <id>", "stage ID")
79
+ .option("--contact <id>", "contact ID")
80
+ .option("--company <id>", "company ID")
81
+ .option("--notes <text>", "notes")
82
+ .action((appId, title, options) => {
83
+ const { stage, contact, company, ...rest } = options;
84
+ return call("create_deal", { appId, title, ...rest, ...(stage ? { stageId: stage } : {}), ...(contact ? { contactId: contact } : {}), ...(company ? { companyId: company } : {}) });
85
+ });
86
+ program.command("deal:move <deal-id> <stage-id>").description("Move a deal to another stage")
87
+ .action((dealId, stageId) => call("move_deal", { dealId, stageId }));
88
+ program.command("activity:log <app-id> <deal-id> <body>").description("Add a note, call, email, or meeting to a deal")
89
+ .option("--kind <kind>", "note, call, email, or meeting", "note")
90
+ .action((appId, dealId, body, options) => {
91
+ if (!["note", "call", "email", "meeting"].includes(options.kind))
92
+ throw new Error("Kind must be note, call, email, or meeting");
93
+ return call("log_activity", { appId, dealId, body, kind: options.kind });
94
+ });
95
+ program.command("tools").description("List the live IndieCRM API tools").action(async () => {
96
+ const client = await connect();
97
+ try {
98
+ print((await client.listTools()).tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })));
99
+ }
100
+ finally {
101
+ await client.close();
102
+ }
103
+ });
104
+ program.command("call <tool>").description("Call an IndieCRM API tool with JSON arguments")
105
+ .requiredOption("--input <json>", "JSON object matching the tool schema")
106
+ .action((tool, options) => {
107
+ const input = JSON.parse(options.input);
108
+ if (!input || Array.isArray(input) || typeof input !== "object")
109
+ throw new Error("Input must be a JSON object");
110
+ return call(tool, input);
111
+ });
112
+ program.parseAsync(process.argv).catch((error) => {
113
+ console.error(error instanceof Error ? error.message : String(error));
114
+ process.exitCode = 1;
115
+ });
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "indiecrm-cli",
3
+ "version": "0.1.0",
4
+ "description": "Command-line access to IndieCRM workspaces, contacts, companies, and deals",
5
+ "author": "Daniel Sinewe <hello@danielsinewe.com>",
6
+ "type": "module",
7
+ "bin": { "indiecrm": "dist/indiecrm-cli.js" },
8
+ "files": ["dist", "README.md", "LICENSE"],
9
+ "scripts": {
10
+ "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.indiecrm.json",
11
+ "check": "tsc --noEmit -p tsconfig.indiecrm.json",
12
+ "test": "npm run build && node --test tests/indiecrm-cli.test.mjs"
13
+ },
14
+ "engines": { "node": ">=22.12.0" },
15
+ "publishConfig": { "access": "public" },
16
+ "keywords": ["indiecrm", "cli", "crm", "sales", "mcp"],
17
+ "homepage": "https://indiecrm.app",
18
+ "repository": { "type": "git", "url": "git+https://github.com/danielsinewe/indiecrm-cli.git" },
19
+ "bugs": { "url": "https://github.com/danielsinewe/indiecrm-cli/issues" },
20
+ "license": "MIT",
21
+ "dependencies": {
22
+ "@modelcontextprotocol/client": "^2.1.0",
23
+ "commander": "^14.0.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/node": "^24.3.0",
27
+ "typescript": "^5.9.2"
28
+ }
29
+ }