indiecrm-cli 0.1.0 → 0.2.1
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/CHANGELOG.md +447 -0
- package/CODE_OF_CONDUCT.md +35 -0
- package/CONTRIBUTING.md +7 -0
- package/README.md +14 -2
- package/RESEARCH.md +346 -0
- package/SECURITY.md +35 -0
- package/dist/affiliate-copy.js +49 -0
- package/dist/auth.js +453 -0
- package/dist/bigquery.js +199 -0
- package/dist/chrome-browser.js +231 -0
- package/dist/cli.js +19652 -0
- package/dist/company-identity-review.js +78 -0
- package/dist/company-leads.js +422 -0
- package/dist/company-recovery.js +84 -0
- package/dist/deel-outreach.js +469 -0
- package/dist/deel-salesnav.js +368 -0
- package/dist/direct-path.js +326 -0
- package/dist/domain.js +53 -0
- package/dist/domainfinder.js +764 -0
- package/dist/engine.js +216 -0
- package/dist/historical-queries.js +189 -0
- package/dist/hunter-emailfinder.js +252 -0
- package/dist/icp-templates.js +171 -0
- package/dist/indiecrm/commands.js +108 -0
- package/dist/indiecrm-cli.js +2 -104
- package/dist/instantly.js +136 -0
- package/dist/io.js +21 -0
- package/dist/leadlists-funnel.js +148 -0
- package/dist/linkedin-companies.js +562 -0
- package/dist/linkedin-product-details.js +1203 -0
- package/dist/linkedin-product-search.js +1081 -0
- package/dist/linkedin-products.js +786 -0
- package/dist/linkedin-session-contracts.js +3 -0
- package/dist/linkedin-session.js +846 -0
- package/dist/providers.js +1 -0
- package/dist/research-browser-preference.js +37 -0
- package/dist/sales-navigator.js +1231 -0
- package/dist/salesnav-backfill.js +710 -0
- package/dist/sample-data.js +34 -0
- package/dist/session-recovery.js +62 -0
- package/dist/vendor/salesprompter-shared/extension-session-contracts.js +29 -0
- package/dist/vendor/salesprompter-shared/linkedin-session.js +22 -0
- package/dist/vendor/salesprompter-shared/phantombuster-contracts.js +16 -0
- package/dist/vendor/salesprompter-shared/session-vault-contracts.js +17 -0
- package/package.json +73 -14
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { IcpSchema } from "./domain.js";
|
|
2
|
+
const DEEL_SHARED_TEMPLATE = {
|
|
3
|
+
name: "Deel ICP",
|
|
4
|
+
description: "Mid-market and enterprise companies that hire or pay across borders and feel payroll, compliance, HRIS, onboarding, or contractor complexity.",
|
|
5
|
+
industries: [
|
|
6
|
+
"Software",
|
|
7
|
+
"Financial Services",
|
|
8
|
+
"Manufacturing",
|
|
9
|
+
"Retail",
|
|
10
|
+
"Logistics & Supply Chain",
|
|
11
|
+
"Healthcare",
|
|
12
|
+
"Information Technology & Services",
|
|
13
|
+
"Management Consulting"
|
|
14
|
+
],
|
|
15
|
+
companySizes: ["200-499", "500+"],
|
|
16
|
+
titles: [
|
|
17
|
+
"Chief People Officer",
|
|
18
|
+
"CHRO",
|
|
19
|
+
"VP People",
|
|
20
|
+
"Head of People",
|
|
21
|
+
"Head of People Operations",
|
|
22
|
+
"Head of People & Culture",
|
|
23
|
+
"VP HR",
|
|
24
|
+
"Head of HR",
|
|
25
|
+
"Head of Human Resources",
|
|
26
|
+
"HR Director",
|
|
27
|
+
"People Operations Manager",
|
|
28
|
+
"HR Operations Manager",
|
|
29
|
+
"Personalleitung",
|
|
30
|
+
"Leitung Personal",
|
|
31
|
+
"Leiter Personal",
|
|
32
|
+
"Leiter der Personalabteilung",
|
|
33
|
+
"Bereichsleiter Personal",
|
|
34
|
+
"Leiterin Personal",
|
|
35
|
+
"Head of Talent Acquisition",
|
|
36
|
+
"VP Talent Acquisition",
|
|
37
|
+
"Head of Payroll",
|
|
38
|
+
"Payroll Manager",
|
|
39
|
+
"HRIS Manager",
|
|
40
|
+
"CFO",
|
|
41
|
+
"Finance Director",
|
|
42
|
+
"General Counsel",
|
|
43
|
+
"Legal Operations Manager"
|
|
44
|
+
],
|
|
45
|
+
pains: [
|
|
46
|
+
"international hiring",
|
|
47
|
+
"global payroll",
|
|
48
|
+
"local compliance",
|
|
49
|
+
"worker classification",
|
|
50
|
+
"entity setup delays",
|
|
51
|
+
"multi-country onboarding",
|
|
52
|
+
"fragmented HR systems"
|
|
53
|
+
],
|
|
54
|
+
requiredSignals: [
|
|
55
|
+
"international expansion",
|
|
56
|
+
"multi-country payroll",
|
|
57
|
+
"distributed workforce",
|
|
58
|
+
"cross-border hiring",
|
|
59
|
+
"contractor workforce"
|
|
60
|
+
],
|
|
61
|
+
excludedSignals: ["single-country only", "local-only hiring"],
|
|
62
|
+
keywords: [
|
|
63
|
+
"global payroll",
|
|
64
|
+
"international hiring",
|
|
65
|
+
"global hiring",
|
|
66
|
+
"employer of record",
|
|
67
|
+
"EOR",
|
|
68
|
+
"contractor management",
|
|
69
|
+
"worker compliance",
|
|
70
|
+
"HRIS",
|
|
71
|
+
"onboarding",
|
|
72
|
+
"benefits",
|
|
73
|
+
"global mobility"
|
|
74
|
+
],
|
|
75
|
+
excludedKeywords: ["staffing agency", "recruitment agency", "temporary labor"]
|
|
76
|
+
};
|
|
77
|
+
const DEEL_MARKETS = {
|
|
78
|
+
global: {
|
|
79
|
+
regions: ["Global"],
|
|
80
|
+
countries: []
|
|
81
|
+
},
|
|
82
|
+
europe: {
|
|
83
|
+
regions: ["Europe"],
|
|
84
|
+
countries: ["DE", "AT", "CH", "NL", "GB", "FR", "SE", "DK"]
|
|
85
|
+
},
|
|
86
|
+
dach: {
|
|
87
|
+
regions: ["Europe", "DACH"],
|
|
88
|
+
countries: ["DE", "AT", "CH"]
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
const DEEL_HISTORICAL_HR_TITLE_EXPANSION = [
|
|
92
|
+
"Head of HR",
|
|
93
|
+
"Head of Human Resources",
|
|
94
|
+
"Head of People",
|
|
95
|
+
"Head of People Operations",
|
|
96
|
+
"Head of People & Culture",
|
|
97
|
+
"Head of People Relations",
|
|
98
|
+
"Head of Payroll",
|
|
99
|
+
"Head of HR Payroll",
|
|
100
|
+
"Head of HR Germany",
|
|
101
|
+
"Head of HR Business Partner",
|
|
102
|
+
"Head of HR Business Partners",
|
|
103
|
+
"Head of HR Management",
|
|
104
|
+
"Head of HR Administration",
|
|
105
|
+
"Head of HR Processes",
|
|
106
|
+
"Head of People Development",
|
|
107
|
+
"Leiter Personal",
|
|
108
|
+
"Leiter der Personalabteilung",
|
|
109
|
+
"Personalleitung",
|
|
110
|
+
"Geschaeftsbereichsleiter Personal",
|
|
111
|
+
"Geschaeftsbereichsleiter Personal",
|
|
112
|
+
"Bereichsleiter Personal",
|
|
113
|
+
"HR Director",
|
|
114
|
+
"Director HR",
|
|
115
|
+
"CHRO",
|
|
116
|
+
"Chief People Officer"
|
|
117
|
+
];
|
|
118
|
+
function mapHeadcountToIcpBucket(value) {
|
|
119
|
+
const normalized = value.trim();
|
|
120
|
+
if (normalized === "201-500") {
|
|
121
|
+
return "200-499";
|
|
122
|
+
}
|
|
123
|
+
if (["501-1000", "1001-5000", "5001-10,000", "5001-10.000", "10,000+", "10.000+", "10,001+"].includes(normalized)) {
|
|
124
|
+
return "500+";
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
export function buildHistoricalVendorIcp(vendor, market, report) {
|
|
129
|
+
if (vendor !== "deel") {
|
|
130
|
+
throw new Error(`Unsupported vendor template: ${vendor}`);
|
|
131
|
+
}
|
|
132
|
+
const includesHumanResources = report.functionValuesTop.some((entry) => entry.text === "Human Resources" && entry.selection === "INCLUDED");
|
|
133
|
+
if (!includesHumanResources) {
|
|
134
|
+
throw new Error("historical report does not contain included Human Resources function filters");
|
|
135
|
+
}
|
|
136
|
+
const marketTemplate = DEEL_MARKETS[market];
|
|
137
|
+
const companySizes = Array.from(new Set(report.headcountValuesTop
|
|
138
|
+
.filter((entry) => entry.selection === "INCLUDED")
|
|
139
|
+
.map((entry) => mapHeadcountToIcpBucket(entry.text))
|
|
140
|
+
.filter((value) => value !== null)));
|
|
141
|
+
return IcpSchema.parse({
|
|
142
|
+
name: `Deel Historical ICP ${market.toUpperCase()}`,
|
|
143
|
+
description: "DACH historical HR search pattern for Deel. Built from LinkedIn Sales Navigator query history and expanded to the observed HR leadership title family in the lead pool.",
|
|
144
|
+
industries: [],
|
|
145
|
+
companySizes: companySizes.length > 0 ? companySizes : ["200-499", "500+"],
|
|
146
|
+
regions: marketTemplate.regions,
|
|
147
|
+
countries: marketTemplate.countries,
|
|
148
|
+
titles: Array.from(new Set(DEEL_HISTORICAL_HR_TITLE_EXPANSION)),
|
|
149
|
+
pains: ["international hiring", "global payroll", "local compliance", "multi-country onboarding"],
|
|
150
|
+
requiredSignals: [
|
|
151
|
+
"human resources function",
|
|
152
|
+
"mid-market or enterprise company",
|
|
153
|
+
"DACH headquarters or operations"
|
|
154
|
+
],
|
|
155
|
+
excludedSignals: [],
|
|
156
|
+
keywords: [],
|
|
157
|
+
excludedKeywords: []
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
export function buildVendorIcp(vendor, market) {
|
|
161
|
+
if (vendor !== "deel") {
|
|
162
|
+
throw new Error(`Unsupported vendor template: ${vendor}`);
|
|
163
|
+
}
|
|
164
|
+
const marketTemplate = DEEL_MARKETS[market];
|
|
165
|
+
return IcpSchema.parse({
|
|
166
|
+
...DEEL_SHARED_TEMPLATE,
|
|
167
|
+
name: market === "global" ? "Deel ICP" : `Deel ICP ${market.toUpperCase()}`,
|
|
168
|
+
regions: marketTemplate.regions,
|
|
169
|
+
countries: marketTemplate.countries
|
|
170
|
+
});
|
|
171
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { connect, hasSession, login, logout } from "./auth.js";
|
|
2
|
+
function positiveInt(value) {
|
|
3
|
+
const parsed = Number(value);
|
|
4
|
+
if (!Number.isInteger(parsed) || parsed < 1 || parsed > 200)
|
|
5
|
+
throw new Error("Limit must be between 1 and 200");
|
|
6
|
+
return parsed;
|
|
7
|
+
}
|
|
8
|
+
function nonnegativeNumber(value) {
|
|
9
|
+
const parsed = Number(value);
|
|
10
|
+
if (!Number.isFinite(parsed) || parsed < 0)
|
|
11
|
+
throw new Error("Value must be a nonnegative number");
|
|
12
|
+
return parsed;
|
|
13
|
+
}
|
|
14
|
+
function print(value) {
|
|
15
|
+
console.log(JSON.stringify(value, null, 2));
|
|
16
|
+
}
|
|
17
|
+
async function call(name, args) {
|
|
18
|
+
const client = await connect();
|
|
19
|
+
try {
|
|
20
|
+
const result = await client.callTool({ name, arguments: args });
|
|
21
|
+
if (result.isError) {
|
|
22
|
+
const message = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
23
|
+
throw new Error(message || `${name} failed`);
|
|
24
|
+
}
|
|
25
|
+
if (result.structuredContent) {
|
|
26
|
+
print(result.structuredContent);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const text = result.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
|
|
30
|
+
try {
|
|
31
|
+
print(JSON.parse(text));
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
console.log(text);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
finally {
|
|
38
|
+
await client.close();
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function registerCrmCommands(program) {
|
|
42
|
+
const existingCommands = new Set(program.commands);
|
|
43
|
+
program.command("login").description("Sign in through indiecrm.app").action(login);
|
|
44
|
+
program.command("logout").description("Remove the local sign-in session").action(async () => {
|
|
45
|
+
await logout();
|
|
46
|
+
console.log("Signed out of IndieCRM CLI.");
|
|
47
|
+
});
|
|
48
|
+
program.command("status").description("Show whether a local session is available").action(async () => {
|
|
49
|
+
print({ signedIn: await hasSession() });
|
|
50
|
+
});
|
|
51
|
+
program.command("workspaces").description("List workspaces and app IDs").action(() => call("list_workspaces", {}));
|
|
52
|
+
program.command("pipeline <app-id>").description("List pipelines and stages for an app")
|
|
53
|
+
.action((appId) => call("list_pipeline", { appId }));
|
|
54
|
+
program.command("deals <app-id>").description("List deals for an app")
|
|
55
|
+
.option("--status <status>", "open, won, or lost")
|
|
56
|
+
.option("--limit <n>", "maximum rows (1-200)", positiveInt)
|
|
57
|
+
.action((appId, options) => {
|
|
58
|
+
if (options.status && !["open", "won", "lost"].includes(options.status))
|
|
59
|
+
throw new Error("Status must be open, won, or lost");
|
|
60
|
+
return call("list_deals", { appId, ...options });
|
|
61
|
+
});
|
|
62
|
+
program.command("contacts <app-id>").description("List or search contacts")
|
|
63
|
+
.option("--search <name>", "case-insensitive name search")
|
|
64
|
+
.option("--limit <n>", "maximum rows (1-200)", positiveInt)
|
|
65
|
+
.action((appId, options) => call("list_contacts", { appId, ...options }));
|
|
66
|
+
program.command("companies <app-id>").description("List or search companies")
|
|
67
|
+
.option("--search <name>", "case-insensitive name search")
|
|
68
|
+
.option("--limit <n>", "maximum rows (1-200)", positiveInt)
|
|
69
|
+
.action((appId, options) => call("list_companies", { appId, ...options }));
|
|
70
|
+
program.command("deal:create <app-id> <title>").description("Create a deal")
|
|
71
|
+
.option("--value <amount>", "deal value", nonnegativeNumber)
|
|
72
|
+
.option("--currency <code>", "three-letter currency code")
|
|
73
|
+
.option("--stage <id>", "stage ID")
|
|
74
|
+
.option("--contact <id>", "contact ID")
|
|
75
|
+
.option("--company <id>", "company ID")
|
|
76
|
+
.option("--notes <text>", "notes")
|
|
77
|
+
.action((appId, title, options) => {
|
|
78
|
+
const { stage, contact, company, ...rest } = options;
|
|
79
|
+
return call("create_deal", { appId, title, ...rest, ...(stage ? { stageId: stage } : {}), ...(contact ? { contactId: contact } : {}), ...(company ? { companyId: company } : {}) });
|
|
80
|
+
});
|
|
81
|
+
program.command("deal:move <deal-id> <stage-id>").description("Move a deal to another stage")
|
|
82
|
+
.action((dealId, stageId) => call("move_deal", { dealId, stageId }));
|
|
83
|
+
program.command("activity:log <app-id> <deal-id> <body>").description("Add a note, call, email, or meeting to a deal")
|
|
84
|
+
.option("--kind <kind>", "note, call, email, or meeting", "note")
|
|
85
|
+
.action((appId, dealId, body, options) => {
|
|
86
|
+
if (!["note", "call", "email", "meeting"].includes(options.kind))
|
|
87
|
+
throw new Error("Kind must be note, call, email, or meeting");
|
|
88
|
+
return call("log_activity", { appId, dealId, body, kind: options.kind });
|
|
89
|
+
});
|
|
90
|
+
program.command("tools").description("List the live IndieCRM API tools").action(async () => {
|
|
91
|
+
const client = await connect();
|
|
92
|
+
try {
|
|
93
|
+
print((await client.listTools()).tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })));
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await client.close();
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
program.command("call <tool>").description("Call an IndieCRM API tool with JSON arguments")
|
|
100
|
+
.requiredOption("--input <json>", "JSON object matching the tool schema")
|
|
101
|
+
.action((tool, options) => {
|
|
102
|
+
const input = JSON.parse(options.input);
|
|
103
|
+
if (!input || Array.isArray(input) || typeof input !== "object")
|
|
104
|
+
throw new Error("Input must be a JSON object");
|
|
105
|
+
return call(tool, input);
|
|
106
|
+
});
|
|
107
|
+
return new Set(program.commands.filter((command) => !existingCommands.has(command)));
|
|
108
|
+
}
|
package/dist/indiecrm-cli.js
CHANGED
|
@@ -1,114 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import {
|
|
3
|
+
import { registerCrmCommands } from "./indiecrm/commands.js";
|
|
4
4
|
import { version } from "./indiecrm/version.js";
|
|
5
5
|
const program = new Command()
|
|
6
6
|
.name("indiecrm")
|
|
7
7
|
.description("Work with your IndieCRM workspaces, contacts, companies, and deals.")
|
|
8
8
|
.version(version);
|
|
9
|
-
|
|
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
|
-
});
|
|
9
|
+
registerCrmCommands(program);
|
|
112
10
|
program.parseAsync(process.argv).catch((error) => {
|
|
113
11
|
console.error(error instanceof Error ? error.message : String(error));
|
|
114
12
|
process.exitCode = 1;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const DEFAULT_INSTANTLY_BASE_URL = process.env.SALESPROMPTER_INSTANTLY_BASE_URL ?? "https://api.instantly.ai/api/v2";
|
|
2
|
+
function normalizeBaseUrl(baseUrl) {
|
|
3
|
+
return baseUrl.replace(/\/+$/, "");
|
|
4
|
+
}
|
|
5
|
+
function normalizeEmail(email) {
|
|
6
|
+
return email.trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function splitContactName(contactName) {
|
|
9
|
+
const parts = contactName
|
|
10
|
+
.trim()
|
|
11
|
+
.split(/\s+/)
|
|
12
|
+
.filter((value) => value.length > 0);
|
|
13
|
+
if (parts.length === 0) {
|
|
14
|
+
return { firstName: "", lastName: "" };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
firstName: parts[0] ?? "",
|
|
18
|
+
lastName: parts.slice(1).join(" ")
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
function buildLeadPayload(lead, campaignId) {
|
|
22
|
+
const { firstName, lastName } = splitContactName(lead.contactName);
|
|
23
|
+
return {
|
|
24
|
+
email: lead.email,
|
|
25
|
+
first_name: firstName,
|
|
26
|
+
last_name: lastName,
|
|
27
|
+
company_name: lead.companyName,
|
|
28
|
+
campaign_id: campaignId,
|
|
29
|
+
custom_variables: {
|
|
30
|
+
companyName: lead.companyName,
|
|
31
|
+
companyName_original: lead.companyName,
|
|
32
|
+
companyName_cleaned: lead.companyName,
|
|
33
|
+
salesprompter_domain: lead.domain,
|
|
34
|
+
salesprompter_title: lead.title,
|
|
35
|
+
salesprompter_industry: lead.industry,
|
|
36
|
+
salesprompter_region: lead.region,
|
|
37
|
+
salesprompter_employee_count: lead.employeeCount,
|
|
38
|
+
salesprompter_source: lead.source,
|
|
39
|
+
salesprompter_score: lead.score,
|
|
40
|
+
salesprompter_grade: lead.grade,
|
|
41
|
+
salesprompter_buying_stage: lead.buyingStage,
|
|
42
|
+
salesprompter_crm_fit: lead.crmFit,
|
|
43
|
+
salesprompter_outreach_fit: lead.outreachFit,
|
|
44
|
+
salesprompter_signals: lead.signals.join(", "),
|
|
45
|
+
salesprompter_tech_stack: lead.techStack.join(", "),
|
|
46
|
+
salesprompter_rationale: lead.rationale.join(" | "),
|
|
47
|
+
...(lead.customVariables ?? {})
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function instantlyRequest(baseUrl, apiKey, path, init) {
|
|
52
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${path}`, {
|
|
53
|
+
...init,
|
|
54
|
+
headers: {
|
|
55
|
+
Authorization: `Bearer ${apiKey}`,
|
|
56
|
+
"Content-Type": "application/json",
|
|
57
|
+
...(init.headers ?? {})
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
const raw = await response.text();
|
|
61
|
+
const payload = raw.length > 0 ? JSON.parse(raw) : {};
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
throw new Error(`Instantly ${response.status} ${path} -> ${raw}`);
|
|
64
|
+
}
|
|
65
|
+
return payload;
|
|
66
|
+
}
|
|
67
|
+
async function listCampaignEmails(baseUrl, apiKey, campaignId) {
|
|
68
|
+
const emails = new Set();
|
|
69
|
+
let cursor;
|
|
70
|
+
for (let page = 0; page < 200; page += 1) {
|
|
71
|
+
const body = { campaign_id: campaignId, limit: 100 };
|
|
72
|
+
if (cursor) {
|
|
73
|
+
body.starting_after = cursor;
|
|
74
|
+
}
|
|
75
|
+
const payload = await instantlyRequest(baseUrl, apiKey, "/leads/list", {
|
|
76
|
+
method: "POST",
|
|
77
|
+
body: JSON.stringify(body)
|
|
78
|
+
});
|
|
79
|
+
for (const item of payload.items ?? []) {
|
|
80
|
+
const email = normalizeEmail(String(item.email ?? ""));
|
|
81
|
+
if (email.length > 0) {
|
|
82
|
+
emails.add(email);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
cursor = payload.next_starting_after;
|
|
86
|
+
if (!cursor) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return emails;
|
|
91
|
+
}
|
|
92
|
+
async function createInstantlyLead(baseUrl, apiKey, lead, campaignId) {
|
|
93
|
+
await instantlyRequest(baseUrl, apiKey, "/leads", {
|
|
94
|
+
method: "POST",
|
|
95
|
+
body: JSON.stringify(buildLeadPayload(lead, campaignId))
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
export class InstantlySyncProvider {
|
|
99
|
+
async sync(target, leads, options = {}) {
|
|
100
|
+
if (target !== "instantly") {
|
|
101
|
+
throw new Error(`InstantlySyncProvider cannot handle target: ${target}`);
|
|
102
|
+
}
|
|
103
|
+
const campaignId = options.instantlyCampaignId ?? process.env.INSTANTLY_CAMPAIGN_ID;
|
|
104
|
+
if (!campaignId) {
|
|
105
|
+
throw new Error("Instantly campaign id is required. Pass --campaign-id or set INSTANTLY_CAMPAIGN_ID.");
|
|
106
|
+
}
|
|
107
|
+
const apiKey = options.instantlyApiKey ?? process.env.INSTANTLY_API_KEY;
|
|
108
|
+
if (!apiKey) {
|
|
109
|
+
throw new Error("Instantly API key is required. Set INSTANTLY_API_KEY.");
|
|
110
|
+
}
|
|
111
|
+
const baseUrl = options.instantlyBaseUrl ?? DEFAULT_INSTANTLY_BASE_URL;
|
|
112
|
+
const existingEmails = options.allowDuplicates ? new Set() : await listCampaignEmails(baseUrl, apiKey, campaignId);
|
|
113
|
+
let skipped = 0;
|
|
114
|
+
let synced = 0;
|
|
115
|
+
for (const lead of leads) {
|
|
116
|
+
const email = normalizeEmail(lead.email);
|
|
117
|
+
if (!options.allowDuplicates && existingEmails.has(email)) {
|
|
118
|
+
skipped += 1;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
if (options.apply) {
|
|
122
|
+
await createInstantlyLead(baseUrl, apiKey, lead, campaignId);
|
|
123
|
+
}
|
|
124
|
+
existingEmails.add(email);
|
|
125
|
+
synced += 1;
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
target,
|
|
129
|
+
synced,
|
|
130
|
+
skipped,
|
|
131
|
+
dryRun: !options.apply,
|
|
132
|
+
provider: "instantly",
|
|
133
|
+
campaignId
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/io.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
export async function readJsonFile(filePath, schema) {
|
|
4
|
+
const content = await readFile(filePath, "utf8");
|
|
5
|
+
const parsed = JSON.parse(content);
|
|
6
|
+
return schema.parse(parsed);
|
|
7
|
+
}
|
|
8
|
+
export async function writeJsonFile(filePath, value) {
|
|
9
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
10
|
+
await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
11
|
+
}
|
|
12
|
+
export async function writeTextFile(filePath, value) {
|
|
13
|
+
await mkdir(path.dirname(filePath), { recursive: true });
|
|
14
|
+
await writeFile(filePath, value, "utf8");
|
|
15
|
+
}
|
|
16
|
+
export function splitCsv(value) {
|
|
17
|
+
return value
|
|
18
|
+
.split(",")
|
|
19
|
+
.map((entry) => entry.trim())
|
|
20
|
+
.filter((entry) => entry.length > 0);
|
|
21
|
+
}
|