@opsrev/acculynx-cli 1.0.0 → 1.2.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.
Files changed (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +190 -0
  3. package/dist/index.js +597 -15
  4. package/package.json +5 -3
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpsRev.ai
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,190 @@
1
+ # @opsrev/acculynx-cli
2
+
3
+ [![CI](https://github.com/opsrev/acculynx-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/opsrev/acculynx-cli/actions/workflows/ci.yml)
4
+
5
+ CLI for the AccuLynx roofing CRM API. Designed for AI agent consumption -- all output is JSON.
6
+
7
+ ## Installation
8
+
9
+ ```bash
10
+ npm install -g @opsrev/acculynx-cli
11
+ ```
12
+
13
+ ## Configuration
14
+
15
+ Copy the example env file and fill in your credentials:
16
+
17
+ ```bash
18
+ cp .env.example .env
19
+ ```
20
+
21
+ Your `.env` is gitignored and will never be committed.
22
+
23
+ ### Official API
24
+
25
+ | Env Var | Flag | Description |
26
+ |---------|------|-------------|
27
+ | `ACCULYNX_API_KEY` | `--api-key` | AccuLynx API key (required) |
28
+
29
+ API keys are created by your AccuLynx account administrator under Account Settings.
30
+
31
+ ### Unofficial Web API
32
+
33
+ The `unofficial` commands use cookie-based authentication against AccuLynx's internal web APIs. These provide access to features not available in the official API (job documents, messages, etc.).
34
+
35
+ | Env Var | Flag | Description |
36
+ |---------|------|-------------|
37
+ | `ACCULYNX_EMAIL` | `--email` | Your AccuLynx login email |
38
+ | `ACCULYNX_PASSWORD` | `--password` | Your AccuLynx password |
39
+ | `ACCULYNX_COMPANY_ID` | `--company-id` | Company GUID from location selector |
40
+
41
+ > **Note:** These endpoints are reverse-engineered from the AccuLynx web app and may break without notice.
42
+
43
+ ## Commands
44
+
45
+ ```
46
+ acculynx ping # Health check / verify API key
47
+ acculynx jobs list [--limit <n>] [--all] # List jobs (default: 25 results)
48
+ acculynx jobs get <jobId> # Get job details
49
+ acculynx jobs create # Create a job (JSON from stdin)
50
+ acculynx jobs search --query <text> # Search jobs
51
+ acculynx jobs contacts <jobId> # List job contacts
52
+ acculynx jobs estimates <jobId> # List job estimates
53
+ acculynx jobs financials <jobId> # Get job financials
54
+ acculynx jobs invoices <jobId> # List job invoices
55
+ acculynx jobs milestones <jobId> # List job milestone history
56
+ acculynx jobs payments <jobId> # List job payments
57
+ acculynx jobs history <jobId> # Get job change history
58
+ acculynx contacts list [--limit <n>] [--all] # List contacts (default: 25 results)
59
+ acculynx contacts get <contactId> # Get contact details
60
+ acculynx contacts create # Create a contact (JSON from stdin)
61
+ acculynx contacts search --query <text> # Search contacts
62
+ acculynx contacts emails <contactId> # List contact email addresses
63
+ acculynx contacts phones <contactId> # List contact phone numbers
64
+ acculynx estimates list [--limit <n>] [--all] # List estimates (default: 25 results)
65
+ acculynx estimates get <estimateId> # Get estimate details
66
+ acculynx estimates sections <estimateId> # List sections for an estimate
67
+ acculynx estimates section <estimateId> <secId> # Get section details
68
+ acculynx estimates items <estimateId> <secId> # List items in a section
69
+ ```
70
+
71
+ ### Unofficial commands
72
+
73
+ Requires `ACCULYNX_EMAIL`, `ACCULYNX_PASSWORD`, and `ACCULYNX_COMPANY_ID` in your `.env` (or passed as flags).
74
+
75
+ ```
76
+ acculynx unofficial companies # List available companies (discover your company ID)
77
+ acculynx unofficial login # Authenticate and cache session
78
+ acculynx unofficial logout # Remove cached session
79
+ acculynx unofficial sessions # List cached sessions
80
+ acculynx unofficial documents list <jobId> # List all document folders/files for a job
81
+ acculynx unofficial documents download <jobId> <fileId> [--output ./file.pdf] # Download a document
82
+ acculynx unofficial messages list <jobId> # List all messages/comments for a job
83
+ acculynx unofficial messages list <jobId> --type Email # Filter by type: Comment, Email, or Signatures
84
+ acculynx unofficial messages list <jobId> --count-only # Get message count only
85
+ acculynx unofficial messages post <jobId> <message> # Post a comment to a job
86
+ acculynx unofficial messages post <jobId> <message> --notify <userId,...> # Post and notify users
87
+ ```
88
+
89
+ #### Messages
90
+
91
+ List all messages, comments, and emails on a job. HTML email content is automatically converted to lightweight markdown (headers, links, lists preserved; images and styles stripped) for clean AI agent consumption.
92
+
93
+ ```bash
94
+ acculynx unofficial messages list <jobId> # all messages, newest first
95
+ acculynx unofficial messages list <jobId> --sort "createdDate|asc" # oldest first
96
+ acculynx unofficial messages list <jobId> --type Email # only emails
97
+ acculynx unofficial messages list <jobId> --count-only # just the count
98
+ ```
99
+
100
+ Post a comment to a job, optionally notifying (tagging) users:
101
+
102
+ ```bash
103
+ acculynx unofficial messages post <jobId> "Your message here"
104
+ acculynx unofficial messages post <jobId> "Check this out" --notify "userId1,userId2"
105
+ ```
106
+
107
+ User IDs for `--notify` come from the official users API (`/api/v2/users`).
108
+
109
+ **Getting started:** If you don't know your company ID, run `companies` first -- it logs in and returns all companies available to your account:
110
+
111
+ ```bash
112
+ npm run dev -- unofficial companies
113
+ # [{"id":"f104ba56-...","name":"My Roofing Co"},{"id":"7749e90f-...","name":"Sandbox"}]
114
+ ```
115
+
116
+ Copy the `id` into your `.env` as `ACCULYNX_COMPANY_ID`, then run `login` to cache the session.
117
+
118
+ Login caches session cookies to `~/.config/acculynx-cli/sessions/` so you only need to re-authenticate when the session expires. Multiple accounts/companies are supported -- each gets its own cached session.
119
+
120
+ ### Pagination
121
+
122
+ All list commands default to **25 results**. Use `--limit <n>` for a custom count or `--all` to fetch everything:
123
+
124
+ ```bash
125
+ acculynx jobs list # 25 results (default)
126
+ acculynx jobs list --limit 100 # 100 results
127
+ acculynx jobs list --all # all results
128
+ ```
129
+
130
+ ### Jobs list filters
131
+
132
+ - `--start-date <YYYY-MM-DD>` -- filter by start date
133
+ - `--end-date <YYYY-MM-DD>` -- filter by end date
134
+ - `--date-filter-type <type>` -- date field to filter on (default: CreatedDate)
135
+ - `--milestones <milestones>` -- filter by milestones
136
+ - `--sort-by <field>` -- CreatedDate, MilestoneDate, or ModifiedDate
137
+ - `--sort-order <order>` -- Ascending or Descending
138
+ - `--includes <fields>` -- contact, initialAppointment
139
+
140
+ ### Search
141
+
142
+ ```bash
143
+ acculynx jobs search --query "smith"
144
+ acculynx contacts search --query "smith"
145
+ acculynx contacts search --query "smith" --sort-by LastName --sort-order Descending
146
+ ```
147
+
148
+ ### Stdin commands
149
+
150
+ Commands that create resources accept JSON piped via stdin:
151
+
152
+ ```bash
153
+ echo '{"name": "New Job"}' | acculynx jobs create
154
+ ```
155
+
156
+ ## Output
157
+
158
+ All output is JSON to stdout. Errors are `{"error": "..."}` to stderr with exit code 1.
159
+
160
+ ```bash
161
+ # Pipe to jq for pretty-printing
162
+ acculynx jobs list --limit 5 | jq .
163
+
164
+ # Use in scripts
165
+ JOB_ID=$(acculynx jobs list --limit 1 | jq -r '.[0].id')
166
+ acculynx jobs get "$JOB_ID" | jq .
167
+ ```
168
+
169
+ ## Development
170
+
171
+ ```bash
172
+ cp .env.example .env # fill in your credentials
173
+ npm install
174
+ npm test # run unit tests
175
+ npm run dev -- --help # run in dev mode (auto-loads .env)
176
+ npm run build # build to dist/
177
+ ./test-integration.sh # run integration tests (requires ACCULYNX_API_KEY)
178
+ ```
179
+
180
+ The `npm run dev` script automatically loads your `.env` file via Node's `--env-file` flag (Node 20.6+). Examples:
181
+
182
+ ```bash
183
+ npm run dev -- unofficial login
184
+ npm run dev -- unofficial documents list <jobId>
185
+ npm run dev -- jobs list --limit 5
186
+ ```
187
+
188
+ ## License
189
+
190
+ MIT
package/dist/index.js CHANGED
@@ -84,7 +84,8 @@ function registerPingCommand(parentCmd, getClient2) {
84
84
 
85
85
  // src/api-helpers.ts
86
86
  var DEFAULT_PAGE_SIZE = 25;
87
- async function paginate(client, path, params, limit) {
87
+ var DEFAULT_LIMIT = 25;
88
+ async function paginate(client, path, params, limit = DEFAULT_LIMIT) {
88
89
  const results = [];
89
90
  let pageStartIndex = 0;
90
91
  while (true) {
@@ -95,7 +96,7 @@ async function paginate(client, path, params, limit) {
95
96
  };
96
97
  const data = await client.get(path, pageParams);
97
98
  results.push(...data.items);
98
- if (limit && results.length >= limit) {
99
+ if (limit !== Infinity && results.length >= limit) {
99
100
  return results.slice(0, limit);
100
101
  }
101
102
  if (data.items.length < DEFAULT_PAGE_SIZE) {
@@ -126,7 +127,7 @@ async function readStdin(stream = process.stdin) {
126
127
  // src/commands/jobs.ts
127
128
  function registerJobsCommands(parentCmd, getClient2) {
128
129
  const jobs = parentCmd.command("jobs").description("Job operations");
129
- jobs.command("list").description("List jobs (paginated)").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones").option("--sort-by <field>", "Sort by: CreatedDate, MilestoneDate, ModifiedDate").option("--sort-order <order>", "Ascending or Descending").option("--includes <fields>", "Include: contact, initialAppointment").option("--limit <n>", "Max total results").action(async (opts) => {
130
+ jobs.command("list").description("List jobs (paginated)").option("--start-date <date>", "Start date (YYYY-MM-DD)").option("--end-date <date>", "End date (YYYY-MM-DD)").option("--date-filter-type <type>", "Date field to filter on").option("--milestones <milestones>", "Filter by milestones").option("--sort-by <field>", "Sort by: CreatedDate, MilestoneDate, ModifiedDate").option("--sort-order <order>", "Ascending or Descending").option("--includes <fields>", "Include: contact, initialAppointment").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
130
131
  const params = {};
131
132
  if (opts.startDate) params.startDate = opts.startDate;
132
133
  if (opts.endDate) params.endDate = opts.endDate;
@@ -135,7 +136,7 @@ function registerJobsCommands(parentCmd, getClient2) {
135
136
  if (opts.sortBy) params.sortBy = opts.sortBy;
136
137
  if (opts.sortOrder) params.sortOrder = opts.sortOrder;
137
138
  if (opts.includes) params.includes = opts.includes;
138
- const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
139
+ const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
139
140
  const result = await paginate(getClient2(), "/jobs", params, limit);
140
141
  console.log(JSON.stringify(result));
141
142
  });
@@ -148,9 +149,10 @@ function registerJobsCommands(parentCmd, getClient2) {
148
149
  const result = await getClient2().post("/jobs", body);
149
150
  console.log(JSON.stringify(result));
150
151
  });
151
- jobs.command("search").description("Search jobs (pipe JSON search criteria to stdin)").action(async () => {
152
- const body = await readStdin();
153
- const result = await getClient2().post("/jobs/search", body);
152
+ jobs.command("search").description("Search jobs").requiredOption("--query <text>", "Search term (required)").action(async (opts) => {
153
+ const result = await getClient2().post("/jobs/search", {
154
+ SearchTerm: opts.query
155
+ });
154
156
  console.log(JSON.stringify(result));
155
157
  });
156
158
  const subResources = [
@@ -173,8 +175,8 @@ function registerJobsCommands(parentCmd, getClient2) {
173
175
  // src/commands/contacts.ts
174
176
  function registerContactsCommands(parentCmd, getClient2) {
175
177
  const contacts = parentCmd.command("contacts").description("Contact operations");
176
- contacts.command("list").description("List contacts (paginated)").option("--limit <n>", "Max total results").action(async (opts) => {
177
- const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
178
+ contacts.command("list").description("List contacts (paginated)").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
179
+ const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
178
180
  const result = await paginate(getClient2(), "/contacts", {}, limit);
179
181
  console.log(JSON.stringify(result));
180
182
  });
@@ -187,9 +189,12 @@ function registerContactsCommands(parentCmd, getClient2) {
187
189
  const result = await getClient2().post("/contacts", body);
188
190
  console.log(JSON.stringify(result));
189
191
  });
190
- contacts.command("search").description("Search contacts (pipe JSON search criteria to stdin)").action(async () => {
191
- const body = await readStdin();
192
- const result = await getClient2().post("/contacts/search", body);
192
+ contacts.command("search").description("Search contacts").requiredOption("--query <text>", "Search term (required)").option("--sort-by <field>", "Sort by field (default: LastName)", "LastName").option("--sort-order <order>", "Ascending or Descending (default: Ascending)", "Ascending").action(async (opts) => {
193
+ const result = await getClient2().post("/contacts/search", {
194
+ SearchTerm: opts.query,
195
+ SortBy: opts.sortBy,
196
+ SortOrder: opts.sortOrder
197
+ });
193
198
  console.log(JSON.stringify(result));
194
199
  });
195
200
  contacts.command("emails").argument("<contactId>", "Contact ID").description("List contact email addresses").action(async (contactId) => {
@@ -205,8 +210,8 @@ function registerContactsCommands(parentCmd, getClient2) {
205
210
  // src/commands/estimates.ts
206
211
  function registerEstimatesCommands(parentCmd, getClient2) {
207
212
  const estimates = parentCmd.command("estimates").description("Estimate operations");
208
- estimates.command("list").description("List estimates (paginated)").option("--limit <n>", "Max total results").action(async (opts) => {
209
- const limit = opts.limit ? parseInt(opts.limit, 10) : void 0;
213
+ estimates.command("list").description("List estimates (paginated)").option("--limit <n>", "Max total results (default: 25)").option("--all", "Fetch all results (no limit)").action(async (opts) => {
214
+ const limit = opts.all ? Infinity : opts.limit ? parseInt(opts.limit, 10) : void 0;
210
215
  const result = await paginate(getClient2(), "/estimates", {}, limit);
211
216
  console.log(JSON.stringify(result));
212
217
  });
@@ -228,11 +233,584 @@ function registerEstimatesCommands(parentCmd, getClient2) {
228
233
  });
229
234
  }
230
235
 
236
+ // src/unofficial/session-store.ts
237
+ import {
238
+ existsSync,
239
+ readFileSync,
240
+ writeFileSync,
241
+ mkdirSync,
242
+ readdirSync,
243
+ unlinkSync
244
+ } from "fs";
245
+ import { join } from "path";
246
+ import { homedir } from "os";
247
+
248
+ // src/unofficial/cookie-jar.ts
249
+ var CookieJar = class _CookieJar {
250
+ cookies = /* @__PURE__ */ new Map();
251
+ /** Extract and store cookies from a response's Set-Cookie headers. */
252
+ capture(url, response) {
253
+ const domain = new URL(url).hostname;
254
+ const jar = this.cookies.get(domain) ?? /* @__PURE__ */ new Map();
255
+ for (const header of response.headers.getSetCookie()) {
256
+ const [pair] = header.split(";");
257
+ const eqIdx = pair.indexOf("=");
258
+ if (eqIdx === -1) continue;
259
+ jar.set(pair.slice(0, eqIdx).trim(), pair.slice(eqIdx + 1).trim());
260
+ }
261
+ this.cookies.set(domain, jar);
262
+ }
263
+ /** Get a specific cookie value by name for a given URL. */
264
+ getCookie(url, name) {
265
+ const domain = new URL(url).hostname;
266
+ for (const [d, jar] of this.cookies) {
267
+ if (domain === d || domain.endsWith("." + d)) {
268
+ if (jar.has(name)) return jar.get(name);
269
+ }
270
+ }
271
+ return void 0;
272
+ }
273
+ /** Build a Cookie header string for a given URL. */
274
+ headerFor(url) {
275
+ const domain = new URL(url).hostname;
276
+ const parts = [];
277
+ for (const [d, jar] of this.cookies) {
278
+ if (domain === d || domain.endsWith("." + d)) {
279
+ for (const [k, v] of jar) parts.push(`${k}=${v}`);
280
+ }
281
+ }
282
+ return parts.join("; ");
283
+ }
284
+ /** Serialize for disk caching. */
285
+ toJSON() {
286
+ const out = {};
287
+ for (const [d, jar] of this.cookies) {
288
+ out[d] = Object.fromEntries(jar);
289
+ }
290
+ return out;
291
+ }
292
+ /** Restore from disk cache. */
293
+ static fromJSON(data) {
294
+ const jar = new _CookieJar();
295
+ for (const [d, pairs] of Object.entries(data)) {
296
+ jar.cookies.set(d, new Map(Object.entries(pairs)));
297
+ }
298
+ return jar;
299
+ }
300
+ };
301
+
302
+ // src/unofficial/session-store.ts
303
+ var CONFIG_DIR = join(
304
+ process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"),
305
+ "acculynx-cli"
306
+ );
307
+ var SESSIONS_DIR = join(CONFIG_DIR, "sessions");
308
+ function sessionKey(email, companyId) {
309
+ const slug = email.replace(/[@.]/g, "-").toLowerCase();
310
+ const prefix = companyId.slice(0, 8);
311
+ return `${slug}--${prefix}.json`;
312
+ }
313
+ function loadSession(email, companyId) {
314
+ const file = join(SESSIONS_DIR, sessionKey(email, companyId));
315
+ if (!existsSync(file)) return null;
316
+ try {
317
+ const data = JSON.parse(readFileSync(file, "utf-8"));
318
+ return {
319
+ jar: CookieJar.fromJSON(data.cookies),
320
+ companyId: data.companyId,
321
+ email: data.email
322
+ };
323
+ } catch {
324
+ return null;
325
+ }
326
+ }
327
+ function saveSession(session) {
328
+ mkdirSync(SESSIONS_DIR, { recursive: true, mode: 448 });
329
+ writeFileSync(
330
+ join(SESSIONS_DIR, sessionKey(session.email, session.companyId)),
331
+ JSON.stringify(
332
+ {
333
+ email: session.email,
334
+ companyId: session.companyId,
335
+ cookies: session.jar.toJSON()
336
+ },
337
+ null,
338
+ 2
339
+ ),
340
+ { mode: 384 }
341
+ );
342
+ }
343
+ function deleteSession(email, companyId) {
344
+ const file = join(SESSIONS_DIR, sessionKey(email, companyId));
345
+ if (!existsSync(file)) return false;
346
+ unlinkSync(file);
347
+ return true;
348
+ }
349
+ function listSessions() {
350
+ if (!existsSync(SESSIONS_DIR)) return [];
351
+ return readdirSync(SESSIONS_DIR).filter((f) => f.endsWith(".json")).map((f) => {
352
+ try {
353
+ const data = JSON.parse(
354
+ readFileSync(join(SESSIONS_DIR, f), "utf-8")
355
+ );
356
+ return { email: data.email, companyId: data.companyId, file: f };
357
+ } catch {
358
+ return null;
359
+ }
360
+ }).filter(Boolean);
361
+ }
362
+
363
+ // src/unofficial/unofficial-client.ts
364
+ function createUnofficialClient(session) {
365
+ const base = "https://my.acculynx.com";
366
+ async function doGet(path) {
367
+ const url = `${base}${path}`;
368
+ const resp = await fetch(url, {
369
+ headers: {
370
+ Cookie: session.jar.headerFor(url),
371
+ Accept: "application/json"
372
+ }
373
+ });
374
+ if (resp.status === 401 || resp.status === 302) {
375
+ throw new Error(
376
+ "Session expired \u2014 run `acculynx unofficial login` to re-authenticate"
377
+ );
378
+ }
379
+ if (!resp.ok) {
380
+ throw new Error(
381
+ `Unofficial API error (${resp.status}): ${await resp.text()}`
382
+ );
383
+ }
384
+ return resp.json();
385
+ }
386
+ async function doPost(path, body) {
387
+ const url = `${base}${path}`;
388
+ const headers = {
389
+ Cookie: session.jar.headerFor(url),
390
+ Accept: "application/json",
391
+ "Content-Type": "application/json"
392
+ };
393
+ const xsrf = session.jar.getCookie(url, "XSRF-TOKEN");
394
+ if (xsrf) {
395
+ headers["X-XSRF-TOKEN"] = xsrf;
396
+ }
397
+ const resp = await fetch(url, {
398
+ method: "POST",
399
+ headers,
400
+ body: JSON.stringify(body)
401
+ });
402
+ if (resp.status === 401 || resp.status === 302) {
403
+ throw new Error(
404
+ "Session expired \u2014 run `acculynx unofficial login` to re-authenticate"
405
+ );
406
+ }
407
+ if (!resp.ok) {
408
+ throw new Error(
409
+ `Unofficial API error (${resp.status}): ${await resp.text()}`
410
+ );
411
+ }
412
+ const text = await resp.text();
413
+ if (!text) return {};
414
+ return JSON.parse(text);
415
+ }
416
+ async function doGetBuffer(url) {
417
+ const fullUrl = url.startsWith("http") ? url : `${base}${url}`;
418
+ const resp = await fetch(fullUrl, {
419
+ headers: { Cookie: session.jar.headerFor(fullUrl) }
420
+ });
421
+ if (!resp.ok) {
422
+ throw new Error(`Download failed (${resp.status}): ${fullUrl}`);
423
+ }
424
+ return Buffer.from(await resp.arrayBuffer());
425
+ }
426
+ return { get: doGet, post: doPost, getBuffer: doGetBuffer };
427
+ }
428
+
429
+ // src/unofficial/session.ts
430
+ async function fetchFollowingCookies(jar, url, init) {
431
+ let currentUrl = url;
432
+ let response;
433
+ for (let i = 0; i < 10; i++) {
434
+ response = await fetch(currentUrl, {
435
+ ...init,
436
+ redirect: "manual",
437
+ headers: {
438
+ ...Object.fromEntries(
439
+ Object.entries(init?.headers ?? {})
440
+ ),
441
+ Cookie: jar.headerFor(currentUrl)
442
+ }
443
+ });
444
+ jar.capture(currentUrl, response);
445
+ const location = response.headers.get("Location");
446
+ if (!location || response.status < 300 || response.status >= 400) {
447
+ return response;
448
+ }
449
+ currentUrl = new URL(location, currentUrl).href;
450
+ init = { redirect: "manual" };
451
+ }
452
+ throw new Error("Too many redirects");
453
+ }
454
+ async function authenticate(email, password) {
455
+ const jar = new CookieJar();
456
+ const signinResp = await fetchFollowingCookies(
457
+ jar,
458
+ "https://my.acculynx.com/signin/"
459
+ );
460
+ const html = await signinResp.text();
461
+ const actionMatch = html.match(/<form[^>]*action="([^"]*)"/);
462
+ if (!actionMatch) throw new Error("Could not find login form action");
463
+ const formAction = actionMatch[1].replace(/&amp;/g, "&");
464
+ const thumbprint = html.match(/name="DeviceThumbprint"[^>]*value="([^"]*)"/)?.[1] ?? "";
465
+ const deviceName = html.match(/name="DeviceName"[^>]*value="([^"]*)"/)?.[1] ?? "Node.js CLI";
466
+ const body = new URLSearchParams({
467
+ Email: email,
468
+ Password: password,
469
+ DeviceThumbprint: thumbprint,
470
+ DeviceName: deviceName
471
+ });
472
+ const loginResp = await fetchFollowingCookies(jar, formAction, {
473
+ method: "POST",
474
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
475
+ body: body.toString()
476
+ });
477
+ const locationResp = await fetchFollowingCookies(
478
+ jar,
479
+ "https://my.acculynx.com/signin/LocationSelector"
480
+ );
481
+ const locationHtml = await locationResp.text();
482
+ return { jar, locationHtml };
483
+ }
484
+ async function listCompanies(email, password) {
485
+ const { locationHtml } = await authenticate(email, password);
486
+ const companyPattern = /SwitchCompany\?CompanyID=([0-9a-f-]+)"[^>]*>([^<]+)/gi;
487
+ const companies = [];
488
+ let match;
489
+ while ((match = companyPattern.exec(locationHtml)) !== null) {
490
+ companies.push({ id: match[1], name: match[2].trim() });
491
+ }
492
+ return companies;
493
+ }
494
+ async function login(email, password, companyId) {
495
+ const { jar } = await authenticate(email, password);
496
+ await fetchFollowingCookies(
497
+ jar,
498
+ `https://my.acculynx.com/signin/SwitchCompany?CompanyID=${companyId}`
499
+ );
500
+ return { jar, companyId, email };
501
+ }
502
+
503
+ // src/unofficial/commands/login.ts
504
+ function getCredentials(opts) {
505
+ const email = opts.email ?? process.env.ACCULYNX_EMAIL;
506
+ const password = opts.password ?? process.env.ACCULYNX_PASSWORD;
507
+ const companyId = opts.companyId ?? process.env.ACCULYNX_COMPANY_ID;
508
+ if (!email) {
509
+ throw new Error(
510
+ "Missing email: pass --email or set ACCULYNX_EMAIL"
511
+ );
512
+ }
513
+ if (!password) {
514
+ throw new Error(
515
+ "Missing password: pass --password or set ACCULYNX_PASSWORD"
516
+ );
517
+ }
518
+ if (!companyId) {
519
+ throw new Error(
520
+ "Missing company ID: pass --company-id or set ACCULYNX_COMPANY_ID"
521
+ );
522
+ }
523
+ return { email, password, companyId };
524
+ }
525
+ function registerLoginCommands(parentCmd) {
526
+ parentCmd.command("login").description("Authenticate and cache session cookies").option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
527
+ "--password <password>",
528
+ "AccuLynx password (env: ACCULYNX_PASSWORD)"
529
+ ).option(
530
+ "--company-id <id>",
531
+ "Company GUID (env: ACCULYNX_COMPANY_ID)"
532
+ ).action(async (opts) => {
533
+ const { email, password, companyId } = getCredentials(opts);
534
+ const session = await login(email, password, companyId);
535
+ saveSession(session);
536
+ console.log(
537
+ JSON.stringify({
538
+ status: "ok",
539
+ email,
540
+ companyId,
541
+ message: "Session cached successfully"
542
+ })
543
+ );
544
+ });
545
+ parentCmd.command("companies").description(
546
+ "List available companies for your account (discovers company IDs)"
547
+ ).option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
548
+ "--password <password>",
549
+ "AccuLynx password (env: ACCULYNX_PASSWORD)"
550
+ ).action(async (opts) => {
551
+ const email = opts.email ?? process.env.ACCULYNX_EMAIL;
552
+ const password = opts.password ?? process.env.ACCULYNX_PASSWORD;
553
+ if (!email) {
554
+ throw new Error(
555
+ "Missing email: pass --email or set ACCULYNX_EMAIL"
556
+ );
557
+ }
558
+ if (!password) {
559
+ throw new Error(
560
+ "Missing password: pass --password or set ACCULYNX_PASSWORD"
561
+ );
562
+ }
563
+ const companies = await listCompanies(email, password);
564
+ console.log(JSON.stringify(companies));
565
+ });
566
+ parentCmd.command("sessions").description("List cached sessions").action(() => {
567
+ const sessions = listSessions();
568
+ console.log(JSON.stringify(sessions));
569
+ });
570
+ parentCmd.command("logout").description("Remove a cached session").option("--email <email>", "AccuLynx email (env: ACCULYNX_EMAIL)").option(
571
+ "--company-id <id>",
572
+ "Company GUID (env: ACCULYNX_COMPANY_ID)"
573
+ ).action((opts) => {
574
+ const email = opts.email ?? process.env.ACCULYNX_EMAIL;
575
+ const companyId = opts.companyId ?? process.env.ACCULYNX_COMPANY_ID;
576
+ if (!email || !companyId) {
577
+ throw new Error(
578
+ "Both --email and --company-id are required (or set env vars)"
579
+ );
580
+ }
581
+ const removed = deleteSession(email, companyId);
582
+ console.log(
583
+ JSON.stringify({
584
+ status: removed ? "removed" : "not_found",
585
+ email,
586
+ companyId
587
+ })
588
+ );
589
+ });
590
+ }
591
+
592
+ // src/unofficial/commands/documents.ts
593
+ import { writeFileSync as writeFileSync2 } from "fs";
594
+ function registerDocumentsCommands(parentCmd, getClient2) {
595
+ const docs = parentCmd.command("documents").description("Job document operations");
596
+ docs.command("list").argument("<jobId>", "Job ID (GUID)").description("List all document folders and files for a job").action(async (jobId) => {
597
+ const folders = await getClient2().get(
598
+ `/api/v4/job-documents/${jobId}/job-document-folders`
599
+ );
600
+ const result = folders.map((folder) => ({
601
+ folder: folder.Name,
602
+ folderId: folder.Id,
603
+ fileCount: folder.FileCount,
604
+ files: folder.Files.map((f) => ({
605
+ id: f.Id,
606
+ name: f.Name,
607
+ extension: f.Extension,
608
+ size: f.Size,
609
+ mimeType: f.MIMEType,
610
+ href: f.Href,
611
+ createdBy: f.CreatedByDisplayName,
612
+ modifiedDate: f.ModifiedDateUTC
613
+ }))
614
+ }));
615
+ console.log(JSON.stringify(result));
616
+ });
617
+ docs.command("download").argument("<jobId>", "Job ID (GUID)").argument("<fileId>", "File ID (GUID)").option("--output <path>", "Write to file instead of stdout").description("Download a document file").action(async (jobId, fileId, opts) => {
618
+ const folders = await getClient2().get(
619
+ `/api/v4/job-documents/${jobId}/job-document-folders`
620
+ );
621
+ let targetFile;
622
+ for (const folder of folders) {
623
+ targetFile = folder.Files.find((f) => f.Id === fileId);
624
+ if (targetFile) break;
625
+ }
626
+ if (!targetFile) {
627
+ throw new Error(
628
+ `File ${fileId} not found in job ${jobId} documents`
629
+ );
630
+ }
631
+ const buffer = await getClient2().getBuffer(targetFile.Href);
632
+ if (opts.output) {
633
+ writeFileSync2(opts.output, buffer);
634
+ console.error(
635
+ JSON.stringify({
636
+ status: "ok",
637
+ file: targetFile.Name,
638
+ size: buffer.length,
639
+ output: opts.output
640
+ })
641
+ );
642
+ } else {
643
+ process.stdout.write(buffer);
644
+ }
645
+ });
646
+ }
647
+
648
+ // src/unofficial/commands/messages.ts
649
+ import TurndownService from "turndown";
650
+ function createTurndown() {
651
+ const td = new TurndownService({
652
+ headingStyle: "atx",
653
+ bulletListMarker: "-"
654
+ });
655
+ td.addRule("removeImages", {
656
+ filter: "img",
657
+ replacement: () => ""
658
+ });
659
+ td.addRule("removeStyle", {
660
+ filter: ["style", "script"],
661
+ replacement: () => ""
662
+ });
663
+ return td;
664
+ }
665
+ var looksLikeHtml = /<[a-z][\s\S]*?>/i;
666
+ function cleanMessage(html) {
667
+ if (!looksLikeHtml.test(html)) return html;
668
+ const td = createTurndown();
669
+ return td.turndown(html).replace(/\n{3,}/g, "\n\n").trim();
670
+ }
671
+ function registerMessagesCommands(parentCmd, getClient2) {
672
+ const messages = parentCmd.command("messages").description("Job message/comment operations");
673
+ messages.command("list").argument("<jobId>", "Job ID (GUID)").option(
674
+ "--sort <sort>",
675
+ "Sort field and direction (field|asc or field|desc)",
676
+ "createdDate|desc"
677
+ ).option("--count-only", "Return only the total count of messages").option(
678
+ "--type <type>",
679
+ "Filter by message type: Comment, Email, or Signatures"
680
+ ).description("List all messages and comments for a job").action(async (jobId, opts) => {
681
+ const params = new URLSearchParams({ sort: opts.sort });
682
+ if (opts.countOnly) {
683
+ params.set("countOnly", "true");
684
+ }
685
+ if (opts.type) {
686
+ params.append("filters", `type=${opts.type}`);
687
+ }
688
+ const data = await getClient2().get(
689
+ `/api/jobs/${jobId}/JobMessageFeed?${params.toString()}`
690
+ );
691
+ if (opts.countOnly) {
692
+ console.log(JSON.stringify({ totalRecords: data.TotalRecords }));
693
+ return;
694
+ }
695
+ const result = {
696
+ totalRecords: data.TotalRecords,
697
+ currentPage: data.CurrentPage,
698
+ totalPages: data.TotalPages,
699
+ messages: data.results.map((msg) => ({
700
+ id: msg.id,
701
+ jobId: msg.JobId,
702
+ type: msg.Type,
703
+ messageType: msg.MessageType,
704
+ message: cleanMessage(msg.Message),
705
+ subject: msg.Subject,
706
+ createdBy: msg.CreatedBy,
707
+ createdById: msg.CreatedByID,
708
+ createdDate: msg.CreatedDate,
709
+ latestMessage: msg.LatestMessage,
710
+ parentMessageId: msg.ParentMessageId,
711
+ attachmentOnly: msg.AttachmentOnly,
712
+ replyEmail: msg.ReplyEmail,
713
+ sentDate: msg.SentDate,
714
+ recipients: msg.Recipients.map((r) => ({
715
+ id: r.id,
716
+ companyUserId: r.companyUserId,
717
+ sent: r.Sent,
718
+ sentDate: r.SentDate,
719
+ delivered: r.Delivered,
720
+ deliveredDate: r.DeliveredDate,
721
+ failed: r.Failed,
722
+ failedDate: r.FailedDate,
723
+ failedDescription: r.FailedDescription
724
+ })),
725
+ attachments: msg.Attachments,
726
+ childMessageCount: msg.ChildMessages.length,
727
+ childMessages: msg.ChildMessages.map((child) => ({
728
+ id: child.id,
729
+ type: child.Type,
730
+ message: cleanMessage(child.Message),
731
+ createdBy: child.CreatedBy,
732
+ createdDate: child.CreatedDate
733
+ }))
734
+ }))
735
+ };
736
+ console.log(JSON.stringify(result));
737
+ });
738
+ messages.command("post").argument("<jobId>", "Job ID (GUID)").argument("<message>", "Message text to post").option(
739
+ "--notify <userIds>",
740
+ "Comma-separated user IDs (GUIDs) to notify"
741
+ ).description("Post a comment/message to a job, optionally notifying users").action(async (jobId, message, opts) => {
742
+ const emailRecipients = opts.notify ? opts.notify.split(",").map((id) => id.trim()) : [];
743
+ const data = await getClient2().post(
744
+ `/api/jobs/${jobId}/Messages`,
745
+ {
746
+ JobId: jobId,
747
+ Message: message,
748
+ RoleId: "1111111",
749
+ MessageType: "General Comment",
750
+ emailRecipients,
751
+ index: -1
752
+ }
753
+ );
754
+ console.log(
755
+ JSON.stringify({
756
+ id: data.JobMessageId,
757
+ jobId: data.JobId,
758
+ message: data.Message,
759
+ messageType: data.MessageType,
760
+ createdDate: data.CreatedDate,
761
+ notified: emailRecipients.length
762
+ })
763
+ );
764
+ });
765
+ }
766
+
767
+ // src/unofficial/commands/index.ts
768
+ function getSessionFromEnv() {
769
+ const email = process.env.ACCULYNX_EMAIL;
770
+ const companyId = process.env.ACCULYNX_COMPANY_ID;
771
+ if (!email || !companyId) {
772
+ throw new Error(
773
+ "Set ACCULYNX_EMAIL and ACCULYNX_COMPANY_ID (or run `acculynx unofficial login` first)"
774
+ );
775
+ }
776
+ const session = loadSession(email, companyId);
777
+ if (!session) {
778
+ throw new Error(
779
+ `No cached session for ${email} / ${companyId}. Run \`acculynx unofficial login\` first.`
780
+ );
781
+ }
782
+ return session;
783
+ }
784
+ function registerUnofficialCommands(parentCmd) {
785
+ const unofficial = parentCmd.command("unofficial").description(
786
+ "Unofficial web API commands (cookie-based auth, reverse-engineered)"
787
+ );
788
+ registerLoginCommands(unofficial);
789
+ let _client2 = null;
790
+ function getClient2() {
791
+ if (!_client2) {
792
+ _client2 = createUnofficialClient(getSessionFromEnv());
793
+ }
794
+ return _client2;
795
+ }
796
+ registerDocumentsCommands(unofficial, getClient2);
797
+ registerMessagesCommands(unofficial, getClient2);
798
+ }
799
+
231
800
  // src/index.ts
232
801
  var program = new Command();
233
802
  program.name("acculynx").description("AccuLynx CRM CLI for AI agents").version("0.1.0").exitOverride().configureOutput({
234
803
  writeOut: (str) => process.stdout.write(str),
235
- writeErr: (str) => process.stderr.write(JSON.stringify({ error: str.trim() }) + "\n")
804
+ writeErr: (str) => {
805
+ const trimmed = str.trim();
806
+ if (trimmed === "(outputHelp)") {
807
+ return;
808
+ } else if (trimmed.startsWith("Usage:") || trimmed.startsWith("error:")) {
809
+ process.stderr.write(str);
810
+ } else {
811
+ process.stderr.write(JSON.stringify({ error: trimmed }) + "\n");
812
+ }
813
+ }
236
814
  }).option("--api-key <key>", "AccuLynx API key (env: ACCULYNX_API_KEY)");
237
815
  var _client = null;
238
816
  function getConfigFromProgram() {
@@ -249,7 +827,11 @@ registerPingCommand(program, getClient);
249
827
  registerJobsCommands(program, getClient);
250
828
  registerContactsCommands(program, getClient);
251
829
  registerEstimatesCommands(program, getClient);
830
+ registerUnofficialCommands(program);
252
831
  program.parseAsync().catch((err) => {
832
+ if (err.message === "(outputHelp)" || err.message === "(version)") {
833
+ return;
834
+ }
253
835
  console.error(JSON.stringify({ error: err.message }));
254
836
  process.exit(1);
255
837
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opsrev/acculynx-cli",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "CLI for the AccuLynx roofing CRM API",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "build": "tsup src/index.ts --format esm --dts --clean",
11
- "dev": "tsx src/index.ts",
11
+ "dev": "tsx --env-file=.env src/index.ts",
12
12
  "test": "vitest run",
13
13
  "test:watch": "vitest",
14
14
  "prepublishOnly": "npm run build"
@@ -28,10 +28,12 @@
28
28
  "dist"
29
29
  ],
30
30
  "dependencies": {
31
- "commander": "^14.0.3"
31
+ "commander": "^14.0.3",
32
+ "turndown": "^7.2.2"
32
33
  },
33
34
  "devDependencies": {
34
35
  "@types/node": "^25.4.0",
36
+ "@types/turndown": "^5.0.6",
35
37
  "tsup": "^8.5.1",
36
38
  "tsx": "^4.21.0",
37
39
  "typescript": "^5.9.3",