@jipsoft/factusync-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 JipSoft Labs
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,211 @@
1
+ # factusync — FactuSync CLI for agents and scripts
2
+
3
+ `factusync` issues and reads Ecuadorian SRI electronic documents in FactuSync from a shell. It is a
4
+ thin client of the FactuSync MCP endpoint: every command calls one MCP tool, with the same input,
5
+ the same API-key scopes, the same error texts and the same idempotency as the MCP tools.
6
+
7
+ It is written to be used by an AI agent (for example Pi, which has no MCP support) and by shell
8
+ scripts. Every command prints JSON.
9
+
10
+ ## Run it
11
+
12
+ ```bash
13
+ # Once published to npm:
14
+ npx @jipsoft/factusync-cli context
15
+ npm install -g @jipsoft/factusync-cli && factusync context
16
+
17
+ # From the FactuSync repository:
18
+ pnpm --filter @jipsoft/factusync-cli build
19
+ node packages/factusync-cli/dist/cli.js context
20
+ ```
21
+
22
+ Requires Node.js 22 or newer.
23
+
24
+ ## Configuration (environment only)
25
+
26
+ | Variable | Required | Meaning |
27
+ |---|---|---|
28
+ | `FACTUSYNC_API_KEY` | yes | A FactuSync API key. Sent as the `X-API-Key` header. |
29
+ | `FACTUSYNC_MCP_URL` | no | The MCP endpoint. Default `https://factusync-api.jipsoft.com/mcp`. |
30
+
31
+ The key is **never** accepted as a flag (`--api-key`, `--key` are rejected as unknown options), so it
32
+ does not end up in shell history or in the process list. Never print the key or paste it into a
33
+ conversation.
34
+
35
+ A key's **SRI environment** (testing or production) is fixed when the key is created: every document
36
+ created through it goes to that environment. `factusync context` tells you which one it is.
37
+
38
+ ## The rule: create is not emit
39
+
40
+ 1. `factusync invoice create --file invoice.json` makes a **DRAFT** and returns a preview with the taxes
41
+ and totals FactuSync computed. Nothing is sent to the SRI.
42
+ 2. Show that preview to the user and get their approval.
43
+ 3. `factusync document emit <documentId> --yes` sends the draft to the SRI. With a production key this
44
+ issues a **legally binding** tax document. `--yes` is required; without it the command does nothing
45
+ and exits 2.
46
+ 4. Emission is asynchronous: poll `factusync documents get <documentId>` until `status` is `AUTHORIZED`
47
+ or `REJECTED` (the SRI reason is in `failure`).
48
+ 5. `factusync ride <documentId> --out invoice.pdf` downloads the RIDE (PDF) once it is `AUTHORIZED`.
49
+
50
+ **Idempotency:** `externalReference` (for example your order id) identifies the invoice. Creating
51
+ again with the same `externalReference` returns the existing document with `alreadyExisted: true` —
52
+ as it is stored, even if the new input differs — instead of a second invoice. Retrying a create after
53
+ a timeout is therefore safe. Emit is not idempotent in that sense: only a `DRAFT` can be emitted.
54
+
55
+ ## Commands
56
+
57
+ Each command needs one scope on the API key. A key without it cannot see or call that tool
58
+ (`TOOL_NOT_AVAILABLE`). `factusync tools` lists what the current key can call.
59
+
60
+ | Command | MCP tool | Scope |
61
+ |---|---|---|
62
+ | `factusync context` | `factusync_context` | `documents:read` |
63
+ | `factusync id lookup <identification>` | `factusync_lookup_id` | `validation:read` |
64
+ | `factusync documents list [--type --status --from --to --recipient --limit --offset]` | `factusync_list_documents` | `documents:read` |
65
+ | `factusync documents get <id>` | `factusync_get_document` | `documents:read` |
66
+ | `factusync invoice create --file <path\|->` | `factusync_create_invoice` | `documents:write` |
67
+ | `factusync document emit <id> --yes` | `factusync_emit_document` | `documents:emit` |
68
+ | `factusync ride <id> [--out file.pdf] [--xml file.xml]` | `factusync_get_ride` | `documents:ride` (+ `documents:read` for the XML) |
69
+ | `factusync tools` | `tools/list` | none |
70
+
71
+ Every command accepts `--pretty` (indented JSON) and `--help` / `-h`. `factusync --help` and
72
+ `factusync --version` work without a key.
73
+
74
+ ### `factusync context`
75
+
76
+ Start here. Prints `{ company: { ruc, legalName, tradeName, defaultEstablishment, defaultEmissionPoint },
77
+ keyEnvironment, companyEnvironment, quota: { period, used, limit, status, remainingIncludingGrace,
78
+ graceEndsAt }, certificate: { status, notAfter } }`. `keyEnvironment` (`testing` / `production`) is
79
+ where documents created with this key go. `certificate.status` is `VALID`, `EXPIRED`, `MISSING` or
80
+ `UNKNOWN_EXPIRY`: nothing can be emitted without a valid signing certificate.
81
+
82
+ ### `factusync id lookup <identification>`
83
+
84
+ `identification` is a cédula (10 digits) or RUC (13 digits). Prints `{ id, source, active, legalName,
85
+ tradeName, taxStatus, taxpayerType, registeredAddress, email }`. Use `legalName` as the buyer name.
86
+
87
+ ### `factusync documents list`
88
+
89
+ | Flag | Tool argument | Value |
90
+ |---|---|---|
91
+ | `--type` | `documentTypeCode` | `01` invoice, `03` purchase settlement, `04` credit note, `05` debit note, `06` waybill, `07` withholding |
92
+ | `--status` | `status` | e.g. `DRAFT`, `SENT_TO_SRI`, `AUTHORIZED`, `REJECTED` |
93
+ | `--from` / `--to` | `issuedFrom` / `issuedTo` | `YYYY-MM-DD`, inclusive, the SRI issue date. Drafts have none: a date filter never returns drafts |
94
+ | `--recipient` | `recipientIdentification` | Buyer cédula or RUC, digits only |
95
+ | `--limit` | `limit` | 1 to 50, default 20 |
96
+ | `--offset` | `offset` | Rows to skip: the `nextOffset` of the previous page |
97
+
98
+ Prints `{ items: [{ id, documentTypeCode, status, number, accessKey, environment,
99
+ recipientIdentification, recipientName, total, issueDate, createdAt }], hasMore, nextOffset }`,
100
+ newest first.
101
+
102
+ ### `factusync documents get <id>`
103
+
104
+ Prints one document without its line items: `id`, `documentTypeCode`, `status`, `number`
105
+ (`001-001-000000123`), `establishment`, `emissionPoint`, `sequenceNumber`, `accessKey`, `authorizedAt`,
106
+ `failure` (the SRI rejection, when there is one), `cancellation`, `createdAt`, `updatedAt` and the
107
+ last state the SRI reported (`sriObservedState`, `sriDiverges`).
108
+
109
+ ### `factusync invoice create --file <path|->`
110
+
111
+ The file (or stdin, with `--file -`) is exactly the `factusync_create_invoice` input, one JSON object:
112
+
113
+ ```json
114
+ {
115
+ "externalReference": "order-1001",
116
+ "buyer": {
117
+ "idType": "05",
118
+ "idNumber": "1710034065",
119
+ "name": "MARIA PEREZ",
120
+ "email": "maria@example.com",
121
+ "address": "Av. Amazonas N34-56, Quito"
122
+ },
123
+ "lines": [
124
+ {
125
+ "mainCode": "SKU-1",
126
+ "description": "Widget",
127
+ "quantity": 2,
128
+ "unitPrice": 10,
129
+ "discount": 1,
130
+ "ivaRateCode": "4"
131
+ }
132
+ ],
133
+ "payment": { "methodCode": "20" },
134
+ "additionalInfo": { "Pedido": "order-1001" }
135
+ }
136
+ ```
137
+
138
+ - `externalReference` (required): your idempotency key, up to 200 characters.
139
+ - `establishment`, `emissionPoint` (optional): 3 digits each; default to the company defaults.
140
+ - `buyer` (required): `idType` `04` RUC (13 digits), `05` cédula (10 digits), `06` passport, `07`
141
+ consumidor final (then `idNumber` must be `9999999999999` and `name` `CONSUMIDOR FINAL`); `name`;
142
+ optional `email` (the authorized invoice is emailed there) and `address`.
143
+ - `lines` (required, 1 to 200, `mainCode` unique): `quantity` > 0, `unitPrice` ≥ 0 **before discount
144
+ and before IVA**, optional `discount` as an amount for the whole line, and `ivaRateCode` — the SRI
145
+ IVA rate **code**, not the percentage (`4` = 15%, `0` = 0%, `7` = exempt), checked against the live
146
+ catalog.
147
+ - `payment` (optional): one SRI payment `methodCode` for the whole amount (for example `01` without
148
+ the financial system, `20` other with the financial system), plus `term` and `timeUnit` (for example
149
+ `"dias"`) together for credit.
150
+ - `additionalInfo` (optional): key/value pairs printed on the RIDE; the key is what the buyer reads.
151
+
152
+ FactuSync computes every tax and total. Prints `{ documentId, status: "DRAFT", alreadyExisted,
153
+ externalReference, environment, establishment, emissionPoint, number, buyer, lines: [{ …, taxRate,
154
+ taxableBase, taxAmount }], totals, payments }`.
155
+
156
+ ### `factusync document emit <id> --yes`
157
+
158
+ Only after the user approved the draft. Prints `{ documentId, jobId, status, next }`; then follow the
159
+ document with `documents get`. Only `DRAFT` documents can be emitted.
160
+
161
+ ### `factusync ride <id> [--out file.pdf] [--xml file.xml]`
162
+
163
+ Only for `AUTHORIZED` documents. Prints `{ documentId, status, number, accessKey, rideUrl, rideDownload,
164
+ xml?, xmlTruncated? }`.
165
+
166
+ - `--out file.pdf` downloads the PDF from `rideUrl` with the same key and adds `rideFile` to the output.
167
+ The key is only sent when `rideUrl` is on the same origin as `FACTUSYNC_MCP_URL`
168
+ (otherwise `RIDE_URL_UNTRUSTED`).
169
+ - `--xml file.xml` writes the authorized XML, adds `xmlFile` and leaves `xml` out of the output. The
170
+ XML is only returned when the key also has `documents:read` (otherwise `XML_NOT_AVAILABLE`); an XML
171
+ the server truncated is not written (`XML_TRUNCATED`).
172
+ - If any check fails, no file is written.
173
+
174
+ ### `factusync tools`
175
+
176
+ Prints `{ tools: [{ name, title, description, command }] }`: the tools this key can call and the CLI
177
+ command for each.
178
+
179
+ ## Output and errors
180
+
181
+ - Success: the tool's structured result as compact JSON on stdout, one line (`--pretty` indents it).
182
+ - Failure: nothing on stdout; one JSON object on stderr: `{ "code": "…", "message": "…", "details": … }`
183
+ (`details` only when the server sent them). `code` is FactuSync's API error code when the server
184
+ gave one (for example `INVALID_STATE_TRANSITION`, `NOT_FOUND`, `VALIDATION_FAILED`,
185
+ `UNKNOWN_TAX_RATE`, `EXTERNAL_REFERENCE_IN_USE`), otherwise one of the codes below.
186
+
187
+ | Exit | Meaning | Codes |
188
+ |---|---|---|
189
+ | 0 | OK | |
190
+ | 1 | The tool answered with an error | the API code, `TOOL_ERROR`, `TOOL_NOT_AVAILABLE`, `INVALID_INPUT`, `MCP_ERROR`, `XML_NOT_AVAILABLE`, `XML_TRUNCATED`, `RIDE_URL_UNTRUSTED`, `RIDE_DOWNLOAD_FAILED` |
191
+ | 2 | Usage error: nothing was sent | `USAGE`, `CONFIRMATION_REQUIRED`, `INVALID_INPUT_FILE`, `OUTPUT_FILE_NOT_WRITABLE` |
192
+ | 3 | Authentication or network | `MISSING_API_KEY`, `UNAUTHORIZED`, `FORBIDDEN`, `HTTP_ERROR`, `NETWORK_ERROR`, `TIMEOUT` |
193
+
194
+ A missing `FACTUSYNC_API_KEY` fails with exit 3 before any network request.
195
+
196
+ ## Example session
197
+
198
+ ```bash
199
+ export FACTUSYNC_API_KEY=… # from a secret store, never typed into a conversation
200
+ factusync context --pretty
201
+ factusync id lookup 1710034065
202
+ factusync invoice create --file invoice.json # → documentId, preview
203
+ # … the user approves the preview …
204
+ factusync document emit "$DOCUMENT_ID" --yes
205
+ factusync documents get "$DOCUMENT_ID" # until AUTHORIZED or REJECTED
206
+ factusync ride "$DOCUMENT_ID" --out invoice.pdf --xml invoice.xml
207
+ ```
208
+
209
+ ## License
210
+
211
+ MIT. See [LICENSE](LICENSE).
package/dist/cli.js ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import { run } from './run.js';
3
+ process.exitCode = await run(process.argv.slice(2), process.env, {
4
+ stdout: (text) => process.stdout.write(text),
5
+ stderr: (text) => process.stderr.write(text),
6
+ readStdin: async () => {
7
+ const chunks = [];
8
+ for await (const chunk of process.stdin)
9
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)));
10
+ return Buffer.concat(chunks).toString('utf8');
11
+ },
12
+ fetch: globalThis.fetch,
13
+ });
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The MCP tools the CLI calls and the API-key scope each one needs on the server. Mirrors
3
+ * `MCP_TOOL_REQUIRED_SCOPES` in the backend; `mcp-tool-contract.json` pins the two together
4
+ * (see `test/tool-contract.spec.ts` and the backend's `mcp-cli-contract.spec.ts`).
5
+ */
6
+ export const TOOL_SCOPES = {
7
+ factusync_context: 'documents:read',
8
+ factusync_lookup_id: 'validation:read',
9
+ factusync_list_documents: 'documents:read',
10
+ factusync_get_document: 'documents:read',
11
+ factusync_create_invoice: 'documents:write',
12
+ factusync_emit_document: 'documents:emit',
13
+ factusync_get_ride: 'documents:ride',
14
+ };
15
+ export const COMMANDS = [
16
+ {
17
+ words: ['context'],
18
+ tool: 'factusync_context',
19
+ usage: 'factusync context',
20
+ summary: 'Company, SRI environment of the key, plan quota and signing certificate. Start here.',
21
+ positionals: [],
22
+ flags: {},
23
+ },
24
+ {
25
+ words: ['id', 'lookup'],
26
+ tool: 'factusync_lookup_id',
27
+ usage: 'factusync id lookup <identification>',
28
+ summary: 'Validates a cédula (10 digits) or RUC (13 digits) and returns the legal name the SRI expects.',
29
+ positionals: [{ name: 'identification', argument: 'identification' }],
30
+ flags: {},
31
+ },
32
+ {
33
+ words: ['documents', 'list'],
34
+ tool: 'factusync_list_documents',
35
+ usage: 'factusync documents list [--type <code>] [--status <status>] [--from <YYYY-MM-DD>] [--to <YYYY-MM-DD>] [--recipient <id>] [--limit <n>] [--offset <n>]',
36
+ summary: 'Lists documents, newest first, as compact summaries. Page with --limit (max 50) and nextOffset.',
37
+ positionals: [],
38
+ flags: {
39
+ type: { type: 'string', argument: 'documentTypeCode', description: '01 invoice, 03 purchase settlement, 04 credit note, 05 debit note, 06 waybill, 07 withholding' },
40
+ status: { type: 'string', argument: 'status', description: 'Document status, e.g. DRAFT, AUTHORIZED, REJECTED' },
41
+ from: { type: 'string', argument: 'issuedFrom', description: 'SRI issue date from, inclusive (YYYY-MM-DD). Excludes drafts' },
42
+ to: { type: 'string', argument: 'issuedTo', description: 'SRI issue date to, inclusive (YYYY-MM-DD). Excludes drafts' },
43
+ recipient: { type: 'string', argument: 'recipientIdentification', description: 'Buyer cédula or RUC, digits only' },
44
+ limit: { type: 'string', argument: 'limit', integer: true, description: 'Page size, 1 to 50 (server default 20)' },
45
+ offset: { type: 'string', argument: 'offset', integer: true, description: 'Rows to skip: pass the nextOffset of the previous page' },
46
+ },
47
+ },
48
+ {
49
+ words: ['documents', 'get'],
50
+ tool: 'factusync_get_document',
51
+ usage: 'factusync documents get <id>',
52
+ summary: 'One document: status, number, access key, SRI rejection reason, cancellation request.',
53
+ positionals: [{ name: 'id', argument: 'id' }],
54
+ flags: {},
55
+ notes: ['Emission is asynchronous: call this again to follow SENT_TO_SRI → AUTHORIZED or REJECTED.'],
56
+ },
57
+ {
58
+ words: ['invoice', 'create'],
59
+ tool: 'factusync_create_invoice',
60
+ usage: 'factusync invoice create --file <path|->',
61
+ summary: 'Creates a DRAFT invoice from a JSON file (or stdin with -) and returns its preview with computed taxes and totals.',
62
+ positionals: [],
63
+ flags: {
64
+ file: { type: 'string', description: 'JSON file with the invoice input, or - to read it from stdin' },
65
+ },
66
+ inputFromFile: true,
67
+ notes: [
68
+ 'Nothing is sent to the SRI. Show the preview to the user, then run factusync document emit <id> --yes.',
69
+ 'Idempotent on externalReference: the same value returns the same document (alreadyExisted: true).',
70
+ ],
71
+ },
72
+ {
73
+ words: ['document', 'emit'],
74
+ tool: 'factusync_emit_document',
75
+ usage: 'factusync document emit <id> --yes',
76
+ summary: "Sends a DRAFT document to the SRI in the key's environment. Production keys issue legally binding documents.",
77
+ positionals: [{ name: 'id', argument: 'id' }],
78
+ flags: {
79
+ yes: { type: 'boolean', description: 'Required. Confirms the user approved this draft' },
80
+ },
81
+ requiresConfirmation: true,
82
+ notes: ['Asynchronous: follow the result with factusync documents get <id>.'],
83
+ },
84
+ {
85
+ words: ['ride'],
86
+ tool: 'factusync_get_ride',
87
+ usage: 'factusync ride <id> [--out <file.pdf>] [--xml <file.xml>]',
88
+ summary: 'For an AUTHORIZED document: the RIDE (PDF) URL, and the authorized XML when the key may read documents.',
89
+ positionals: [{ name: 'id', argument: 'id' }],
90
+ flags: {
91
+ out: { type: 'string', description: 'Download the RIDE PDF to this file (sent with the same API key)' },
92
+ xml: { type: 'string', description: 'Write the authorized XML to this file (needs documents:read too)' },
93
+ },
94
+ },
95
+ {
96
+ words: ['tools'],
97
+ tool: null,
98
+ usage: 'factusync tools',
99
+ summary: 'Lists the MCP tools this API key can call, with the CLI command for each.',
100
+ positionals: [],
101
+ flags: {},
102
+ },
103
+ ];
104
+ /** The longest command whose words start `argv`, so `documents list` wins over a shorter match. */
105
+ export function findCommand(argv) {
106
+ return [...COMMANDS]
107
+ .sort((a, b) => b.words.length - a.words.length)
108
+ .find((command) => command.words.every((word, index) => argv[index] === word));
109
+ }
110
+ export function commandForTool(tool) {
111
+ return COMMANDS.find((command) => command.tool === tool);
112
+ }
package/dist/config.js ADDED
@@ -0,0 +1,24 @@
1
+ import { CliFailure, EXIT_AUTH_OR_NETWORK, usageFailure } from './failure.js';
2
+ export const DEFAULT_MCP_URL = 'https://factusync-api.jipsoft.com/mcp';
3
+ /**
4
+ * Configuration comes from the environment only. The key in particular is never a flag: a flag
5
+ * lands in shell history and in the process list, where other users on the machine can read it.
6
+ */
7
+ export function readConfig(env) {
8
+ const rawUrl = env['FACTUSYNC_MCP_URL']?.trim() || DEFAULT_MCP_URL;
9
+ let mcpUrl;
10
+ try {
11
+ mcpUrl = new URL(rawUrl);
12
+ }
13
+ catch {
14
+ throw usageFailure(`FACTUSYNC_MCP_URL is not a valid URL: ${rawUrl}`);
15
+ }
16
+ if (mcpUrl.protocol !== 'https:' && mcpUrl.protocol !== 'http:') {
17
+ throw usageFailure(`FACTUSYNC_MCP_URL must be an http(s) URL: ${rawUrl}`);
18
+ }
19
+ const apiKey = env['FACTUSYNC_API_KEY']?.trim();
20
+ if (!apiKey) {
21
+ throw new CliFailure(EXIT_AUTH_OR_NETWORK, 'MISSING_API_KEY', 'FACTUSYNC_API_KEY is not set. Export your FactuSync API key in that variable; it is never accepted as a flag.');
22
+ }
23
+ return { apiKey, mcpUrl };
24
+ }
@@ -0,0 +1,26 @@
1
+ export const EXIT_OK = 0;
2
+ export const EXIT_TOOL_ERROR = 1;
3
+ export const EXIT_USAGE = 2;
4
+ export const EXIT_AUTH_OR_NETWORK = 3;
5
+ /** A failure the CLI reports as `{code, message, details?}` on stderr, with its exit code. */
6
+ export class CliFailure extends Error {
7
+ exitCode;
8
+ code;
9
+ details;
10
+ constructor(exitCode, code, message, details) {
11
+ super(message);
12
+ this.exitCode = exitCode;
13
+ this.code = code;
14
+ this.details = details;
15
+ }
16
+ get body() {
17
+ return {
18
+ code: this.code,
19
+ message: this.message,
20
+ ...(this.details !== undefined ? { details: this.details } : {}),
21
+ };
22
+ }
23
+ }
24
+ export function usageFailure(message) {
25
+ return new CliFailure(EXIT_USAGE, 'USAGE', message);
26
+ }
package/dist/help.js ADDED
@@ -0,0 +1,59 @@
1
+ import { COMMANDS, TOOL_SCOPES } from './commands.js';
2
+ import { DEFAULT_MCP_URL } from './config.js';
3
+ const COMMON = [
4
+ 'Environment:',
5
+ ' FACTUSYNC_API_KEY Required. Your FactuSync API key. Never accepted as a flag.',
6
+ ` FACTUSYNC_MCP_URL Optional. Default ${DEFAULT_MCP_URL}`,
7
+ '',
8
+ 'Output: the tool result as JSON on stdout (--pretty indents it).',
9
+ 'Errors: JSON {code, message, details?} on stderr.',
10
+ 'Exit codes: 0 ok, 1 tool error, 2 usage error, 3 authentication or network error.',
11
+ ];
12
+ function scopeOf(command) {
13
+ return command.tool ? TOOL_SCOPES[command.tool] : '-';
14
+ }
15
+ export function rootHelp() {
16
+ const width = Math.max(...COMMANDS.map((command) => command.words.join(' ').length));
17
+ const rows = COMMANDS.map((command) => ` ${command.words.join(' ').padEnd(width)} ${scopeOf(command).padEnd(15)} ${command.summary}`);
18
+ return [
19
+ 'Usage: factusync <command> [arguments] [--pretty]',
20
+ '',
21
+ 'FactuSync (Ecuador SRI e-invoicing) for AI agents and scripts, through the FactuSync MCP endpoint.',
22
+ '',
23
+ `Commands (required API-key scope):`,
24
+ ...rows,
25
+ '',
26
+ 'Create is not emit: invoice create makes a DRAFT; show it to the user, then document emit <id> --yes.',
27
+ '',
28
+ ...COMMON,
29
+ '',
30
+ "Run 'factusync <command> --help' for a command's arguments. 'factusync --version' prints the version.",
31
+ '',
32
+ ].join('\n');
33
+ }
34
+ export function commandHelp(command) {
35
+ const flags = Object.entries(command.flags).map(([name, flag]) => {
36
+ const value = flag.type === 'string' ? ` <${flag.integer ? 'n' : 'value'}>` : '';
37
+ const target = flag.argument ? ` (tool argument ${flag.argument})` : '';
38
+ return ` --${name}${value}: ${flag.description}${target}`;
39
+ });
40
+ return [
41
+ `Usage: ${command.usage} [--pretty]`,
42
+ '',
43
+ command.summary,
44
+ '',
45
+ command.tool ? `MCP tool: ${command.tool}. Required API-key scope: ${TOOL_SCOPES[command.tool]}.` : 'Uses tools/list: shows only what this key can call.',
46
+ ...(command.positionals.length > 0
47
+ ? ['', 'Arguments:', ...command.positionals.map((positional) => ` <${positional.name}>: tool argument ${positional.argument}`)]
48
+ : []),
49
+ '',
50
+ 'Options:',
51
+ ...flags,
52
+ ' --pretty: indent the JSON output',
53
+ ' --help, -h: this help',
54
+ ...(command.notes && command.notes.length > 0 ? ['', ...command.notes] : []),
55
+ '',
56
+ ...COMMON,
57
+ '',
58
+ ].join('\n');
59
+ }
@@ -0,0 +1,148 @@
1
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
2
+ import { StreamableHTTPClientTransport, StreamableHTTPError } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
3
+ import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
4
+ import { TOOL_SCOPES } from './commands.js';
5
+ import { CliFailure, EXIT_AUTH_OR_NETWORK, EXIT_TOOL_ERROR } from './failure.js';
6
+ import { CLI_VERSION } from './version.js';
7
+ /**
8
+ * Opens a stateless Streamable HTTP connection to FactuSync's `POST /mcp` with the key as
9
+ * `X-API-Key`, runs `action`, and closes it. Every SDK or transport failure leaves here already
10
+ * turned into a `CliFailure` with its exit code.
11
+ */
12
+ export async function withMcpSession(config, action) {
13
+ const transport = new StreamableHTTPClientTransport(config.mcpUrl, {
14
+ requestInit: { headers: { 'X-API-Key': config.apiKey } },
15
+ fetch: config.fetch,
16
+ });
17
+ const client = new Client({ name: 'factusync-cli', version: CLI_VERSION });
18
+ const options = config.timeoutMs !== undefined ? { timeout: config.timeoutMs } : undefined;
19
+ try {
20
+ await client.connect(transport, options);
21
+ return await action({
22
+ callTool: async (tool, args) => {
23
+ let result;
24
+ try {
25
+ result = (await client.callTool({ name: tool, arguments: args }, undefined, options));
26
+ }
27
+ catch (error) {
28
+ throw toolCallFailure(tool, error);
29
+ }
30
+ if (result.isError)
31
+ throw toolResultFailure(tool, result);
32
+ return result.structuredContent ?? parseTextContent(result);
33
+ },
34
+ listTools: async () => {
35
+ const tools = [];
36
+ let cursor;
37
+ do {
38
+ const page = await client.listTools(cursor !== undefined ? { cursor } : undefined, options);
39
+ tools.push(...page.tools);
40
+ cursor = page.nextCursor;
41
+ } while (cursor);
42
+ return tools;
43
+ },
44
+ });
45
+ }
46
+ catch (error) {
47
+ throw transportFailure(error);
48
+ }
49
+ finally {
50
+ await client.close().catch(() => undefined);
51
+ }
52
+ }
53
+ /** A successful result without `structuredContent`: its first text block, as JSON when it is JSON. */
54
+ function parseTextContent(result) {
55
+ const text = textBlocks(result)[0] ?? '';
56
+ try {
57
+ const parsed = JSON.parse(text);
58
+ if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed))
59
+ return parsed;
60
+ }
61
+ catch {
62
+ // not JSON: returned as text below
63
+ }
64
+ return { text };
65
+ }
66
+ function textBlocks(result) {
67
+ return result.content.flatMap((block) => (block.type === 'text' ? [block.text] : []));
68
+ }
69
+ const CODED_MESSAGE = /^([A-Z][A-Z0-9_]*): ([\s\S]*)$/;
70
+ const SDK_ERROR_MESSAGE = /^MCP error (-?\d+): ([\s\S]*)$/;
71
+ /**
72
+ * A tool error as the server wrote it: `CODE: message` (FactuSync's API error text) plus an
73
+ * optional `{"details": …}` block, or plain text. The SDK's own errors (`MCP error -32602: …`)
74
+ * arrive the same way: an unknown tool means this key's scopes do not include it.
75
+ */
76
+ export function toolResultFailure(tool, result) {
77
+ const [first = '', ...rest] = textBlocks(result);
78
+ const details = rest.map(detailsOf).find((value) => value !== undefined);
79
+ const sdkError = SDK_ERROR_MESSAGE.exec(first);
80
+ if (sdkError)
81
+ return sdkFailure(tool, Number(sdkError[1]), sdkError[2] ?? '');
82
+ const coded = CODED_MESSAGE.exec(first);
83
+ if (coded)
84
+ return new CliFailure(EXIT_TOOL_ERROR, coded[1], coded[2], details);
85
+ return new CliFailure(EXIT_TOOL_ERROR, 'TOOL_ERROR', first || 'The tool failed without a message.', details);
86
+ }
87
+ function detailsOf(text) {
88
+ try {
89
+ const parsed = JSON.parse(text);
90
+ if (parsed !== null && typeof parsed === 'object' && 'details' in parsed)
91
+ return parsed.details;
92
+ }
93
+ catch {
94
+ // not a details block
95
+ }
96
+ return undefined;
97
+ }
98
+ function sdkFailure(tool, code, message) {
99
+ if (code === ErrorCode.MethodNotFound || /^Tool \S+ (not found|disabled)$/.test(message)) {
100
+ return new CliFailure(EXIT_TOOL_ERROR, 'TOOL_NOT_AVAILABLE', `This API key cannot use ${tool}: it needs the '${TOOL_SCOPES[tool]}' scope. Run 'factusync tools' to see what it can call.`);
101
+ }
102
+ if (message.startsWith('Input validation error'))
103
+ return new CliFailure(EXIT_TOOL_ERROR, 'INVALID_INPUT', message);
104
+ return new CliFailure(EXIT_TOOL_ERROR, 'TOOL_ERROR', message);
105
+ }
106
+ /** `callTool` threw instead of returning an error result: a JSON-RPC error, or the transport. */
107
+ function toolCallFailure(tool, error) {
108
+ if (error instanceof McpError && error.code !== ErrorCode.RequestTimeout && error.code !== ErrorCode.ConnectionClosed) {
109
+ return sdkFailure(tool, error.code, error.message.replace(/^MCP error -?\d+: /, ''));
110
+ }
111
+ return error;
112
+ }
113
+ /** Anything that is not already a `CliFailure`: authentication, HTTP, connection or timeout. */
114
+ export function transportFailure(error) {
115
+ if (error instanceof CliFailure)
116
+ return error;
117
+ if (error instanceof StreamableHTTPError) {
118
+ if (error.code === 401) {
119
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'UNAUTHORIZED', 'FactuSync rejected the API key (HTTP 401). Check FACTUSYNC_API_KEY.');
120
+ }
121
+ if (error.code === 403) {
122
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'FORBIDDEN', 'FactuSync refused this API key (HTTP 403).');
123
+ }
124
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'HTTP_ERROR', `The MCP endpoint answered HTTP ${error.code}.`, { status: error.code });
125
+ }
126
+ if (error instanceof McpError && error.code === ErrorCode.RequestTimeout) {
127
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'TIMEOUT', 'FactuSync did not answer in time.');
128
+ }
129
+ if (error instanceof McpError && error.code !== ErrorCode.ConnectionClosed) {
130
+ return new CliFailure(EXIT_TOOL_ERROR, 'MCP_ERROR', error.message.replace(/^MCP error -?\d+: /, ''), { mcpCode: error.code });
131
+ }
132
+ return networkFailure(error);
133
+ }
134
+ /** `fetch` rejections: refused, unresolvable, reset or aborted connections. */
135
+ export function networkFailure(error) {
136
+ if (error instanceof CliFailure)
137
+ return error;
138
+ if (error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError')) {
139
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'TIMEOUT', 'FactuSync did not answer in time.');
140
+ }
141
+ if (error instanceof TypeError || (error instanceof McpError && error.code === ErrorCode.ConnectionClosed)) {
142
+ const cause = error.cause;
143
+ const reason = typeof cause?.code === 'string' ? ` (${cause.code})` : '';
144
+ return new CliFailure(EXIT_AUTH_OR_NETWORK, 'NETWORK_ERROR', `Could not reach FactuSync${reason}. Check FACTUSYNC_MCP_URL and the network.`);
145
+ }
146
+ const message = error instanceof Error ? error.message : String(error);
147
+ return new CliFailure(EXIT_TOOL_ERROR, 'UNEXPECTED_ERROR', message);
148
+ }
package/dist/run.js ADDED
@@ -0,0 +1,232 @@
1
+ import { readFile, writeFile } from 'node:fs/promises';
2
+ import { parseArgs } from 'node:util';
3
+ import { commandForTool, findCommand } from './commands.js';
4
+ import { readConfig } from './config.js';
5
+ import { CliFailure, EXIT_AUTH_OR_NETWORK, EXIT_OK, EXIT_TOOL_ERROR, EXIT_USAGE, usageFailure } from './failure.js';
6
+ import { commandHelp, rootHelp } from './help.js';
7
+ import { networkFailure, withMcpSession } from './mcp-session.js';
8
+ import { CLI_VERSION } from './version.js';
9
+ /** Same default as the SDK's MCP requests, for the RIDE download. */
10
+ const DEFAULT_TIMEOUT_MS = 60_000;
11
+ /** Runs one CLI invocation and returns its exit code. Never throws. */
12
+ export async function run(argv, env, io) {
13
+ try {
14
+ return await execute(argv, env, io);
15
+ }
16
+ catch (error) {
17
+ const failure = error instanceof CliFailure ? error : networkFailure(error);
18
+ io.stderr(`${JSON.stringify(failure.body)}\n`);
19
+ return failure.exitCode;
20
+ }
21
+ }
22
+ async function execute(argv, env, io) {
23
+ if (argv.length === 0) {
24
+ throw usageFailure("Missing command. Usage: factusync <command> [arguments]. Run 'factusync --help'.");
25
+ }
26
+ if (argv[0].startsWith('-'))
27
+ return rootFlags(argv, io);
28
+ const parsed = parseCommand(argv);
29
+ if (parsed.values['help']) {
30
+ io.stdout(commandHelp(parsed.command));
31
+ return EXIT_OK;
32
+ }
33
+ const { command } = parsed;
34
+ if (command.requiresConfirmation && parsed.values['yes'] !== true) {
35
+ throw new CliFailure(EXIT_USAGE, 'CONFIRMATION_REQUIRED', `${command.words.join(' ')} issues a real tax document with the SRI. Show the draft to the user first, then run it again with --yes.`);
36
+ }
37
+ const args = await toolArguments(parsed, io);
38
+ const config = readConfig(env);
39
+ const connection = { ...config, fetch: io.fetch, ...(io.timeoutMs !== undefined ? { timeoutMs: io.timeoutMs } : {}) };
40
+ const output = await withMcpSession(connection, async (session) => {
41
+ if (command.tool === null)
42
+ return listTools(session);
43
+ const result = await session.callTool(command.tool, args);
44
+ return command.tool === 'factusync_get_ride' ? saveRide(result, parsed, config, io) : result;
45
+ });
46
+ io.stdout(`${JSON.stringify(output, null, parsed.pretty ? 2 : undefined)}\n`);
47
+ return EXIT_OK;
48
+ }
49
+ function rootFlags(argv, io) {
50
+ let values;
51
+ try {
52
+ ({ values } = parseArgs({
53
+ args: [...argv],
54
+ options: { help: { type: 'boolean', short: 'h' }, version: { type: 'boolean' } },
55
+ strict: true,
56
+ allowPositionals: false,
57
+ }));
58
+ }
59
+ catch (error) {
60
+ throw usageFailure(`${parseErrorMessage(error)} Run 'factusync --help'.`);
61
+ }
62
+ if (values.version) {
63
+ io.stdout(`${CLI_VERSION}\n`);
64
+ return EXIT_OK;
65
+ }
66
+ if (values.help) {
67
+ io.stdout(rootHelp());
68
+ return EXIT_OK;
69
+ }
70
+ throw usageFailure("Missing command. Run 'factusync --help'.");
71
+ }
72
+ function parseCommand(argv) {
73
+ const command = findCommand(argv);
74
+ if (!command) {
75
+ const words = argv.filter((token) => !token.startsWith('-')).slice(0, 2).join(' ');
76
+ throw usageFailure(`Unknown command '${words}'. Run 'factusync --help'.`);
77
+ }
78
+ const options = {
79
+ pretty: { type: 'boolean' },
80
+ help: { type: 'boolean', short: 'h' },
81
+ };
82
+ for (const [name, flag] of Object.entries(command.flags))
83
+ options[name] = { type: flag.type };
84
+ let result;
85
+ try {
86
+ result = parseArgs({ args: argv.slice(command.words.length), options, strict: true, allowPositionals: true });
87
+ }
88
+ catch (error) {
89
+ throw usageFailure(`${parseErrorMessage(error)} Run 'factusync ${command.words.join(' ')} --help'.`);
90
+ }
91
+ const parsed = { command, positionals: result.positionals, values: result.values, pretty: result.values['pretty'] === true };
92
+ if (!parsed.values['help'] && parsed.positionals.length !== command.positionals.length) {
93
+ throw usageFailure(`Usage: ${command.usage}`);
94
+ }
95
+ return parsed;
96
+ }
97
+ /**
98
+ * Node's message names the option and nothing after it, except for an inline `--flag=value`, where
99
+ * it would echo the value: cut at `=`, so a key typed as `--api-key=…` never reaches stderr.
100
+ */
101
+ function parseErrorMessage(error) {
102
+ const message = error instanceof Error ? error.message : String(error);
103
+ const unknown = /Unknown option '([^'=]+)/.exec(message);
104
+ if (unknown)
105
+ return `Unknown option '${unknown[1]}'.`;
106
+ return message.split('\n')[0].replace(/=[^']*/g, '');
107
+ }
108
+ async function toolArguments(parsed, io) {
109
+ const { command } = parsed;
110
+ if (command.inputFromFile)
111
+ return readInputFile(parsed.values['file'], command, io);
112
+ const args = {};
113
+ command.positionals.forEach((positional, index) => {
114
+ args[positional.argument] = parsed.positionals[index];
115
+ });
116
+ for (const [name, flag] of Object.entries(command.flags)) {
117
+ const value = parsed.values[name];
118
+ if (!flag.argument || value === undefined)
119
+ continue;
120
+ if (flag.integer) {
121
+ if (typeof value !== 'string' || !/^\d+$/.test(value))
122
+ throw usageFailure(`--${name} must be a whole number, got '${String(value)}'.`);
123
+ args[flag.argument] = Number(value);
124
+ }
125
+ else {
126
+ args[flag.argument] = value;
127
+ }
128
+ }
129
+ return args;
130
+ }
131
+ async function readInputFile(file, command, io) {
132
+ if (typeof file !== 'string' || file === '')
133
+ throw usageFailure(`Usage: ${command.usage}`);
134
+ let text;
135
+ try {
136
+ text = file === '-' ? await io.readStdin() : await readFile(file, 'utf8');
137
+ }
138
+ catch (error) {
139
+ const reason = error.code ?? 'unreadable';
140
+ throw new CliFailure(EXIT_USAGE, 'INVALID_INPUT_FILE', `Cannot read ${file} (${reason}).`);
141
+ }
142
+ let input;
143
+ try {
144
+ input = JSON.parse(text);
145
+ }
146
+ catch (error) {
147
+ throw new CliFailure(EXIT_USAGE, 'INVALID_INPUT_FILE', `${file === '-' ? 'stdin' : file} is not valid JSON: ${error.message}`);
148
+ }
149
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) {
150
+ throw new CliFailure(EXIT_USAGE, 'INVALID_INPUT_FILE', `${file === '-' ? 'stdin' : file} must hold one JSON object: the tool input.`);
151
+ }
152
+ return input;
153
+ }
154
+ async function listTools(session) {
155
+ const tools = await session.listTools();
156
+ return {
157
+ tools: tools.map((tool) => ({
158
+ name: tool.name,
159
+ title: tool.title ?? tool.annotations?.title ?? null,
160
+ description: tool.description ?? null,
161
+ command: commandForTool(tool.name)?.usage ?? null,
162
+ })),
163
+ };
164
+ }
165
+ /**
166
+ * `ride --out/--xml`: every check runs before anything is written, so a failure leaves no file.
167
+ * The PDF is fetched with the key only from the MCP endpoint's own origin: the key never goes
168
+ * to a host the user did not configure, whatever URL a response carries.
169
+ */
170
+ async function saveRide(result, parsed, config, io) {
171
+ const out = parsed.values['out'];
172
+ const xmlPath = parsed.values['xml'];
173
+ const tool = 'factusync_get_ride';
174
+ if (typeof xmlPath === 'string') {
175
+ if (typeof result['xml'] !== 'string') {
176
+ throw new CliFailure(EXIT_TOOL_ERROR, 'XML_NOT_AVAILABLE', `${tool} returned no XML: this API key needs the documents:read scope as well as documents:ride.`);
177
+ }
178
+ if (result['xmlTruncated'] === true) {
179
+ throw new CliFailure(EXIT_TOOL_ERROR, 'XML_TRUNCATED', 'The server truncated this XML; it was not written. Download it from the FactuSync API instead.');
180
+ }
181
+ }
182
+ let pdf;
183
+ if (typeof out === 'string')
184
+ pdf = await downloadRide(result['rideUrl'], config, io);
185
+ const saved = { ...result };
186
+ try {
187
+ if (pdf && typeof out === 'string') {
188
+ await writeFile(out, pdf);
189
+ saved['rideFile'] = out;
190
+ }
191
+ if (typeof xmlPath === 'string') {
192
+ await writeFile(xmlPath, result['xml'], 'utf8');
193
+ delete saved['xml'];
194
+ saved['xmlFile'] = xmlPath;
195
+ }
196
+ }
197
+ catch (error) {
198
+ throw new CliFailure(EXIT_USAGE, 'OUTPUT_FILE_NOT_WRITABLE', `Cannot write the output file: ${error.message}`);
199
+ }
200
+ return saved;
201
+ }
202
+ async function downloadRide(rideUrl, config, io) {
203
+ let url;
204
+ try {
205
+ url = new URL(String(rideUrl));
206
+ }
207
+ catch {
208
+ throw new CliFailure(EXIT_TOOL_ERROR, 'RIDE_URL_MISSING', 'The tool result has no valid rideUrl.');
209
+ }
210
+ if (url.origin !== config.mcpUrl.origin) {
211
+ throw new CliFailure(EXIT_TOOL_ERROR, 'RIDE_URL_UNTRUSTED', `The RIDE URL is on ${url.origin}, not on ${config.mcpUrl.origin} (FACTUSYNC_MCP_URL); the API key is not sent there.`);
212
+ }
213
+ let response;
214
+ try {
215
+ response = await io.fetch(url, {
216
+ headers: { 'X-API-Key': config.apiKey },
217
+ signal: AbortSignal.timeout(io.timeoutMs ?? DEFAULT_TIMEOUT_MS),
218
+ });
219
+ }
220
+ catch (error) {
221
+ throw networkFailure(error);
222
+ }
223
+ if (response.status === 401 || response.status === 403) {
224
+ throw new CliFailure(EXIT_AUTH_OR_NETWORK, response.status === 401 ? 'UNAUTHORIZED' : 'FORBIDDEN', `FactuSync refused the RIDE download (HTTP ${response.status}).`);
225
+ }
226
+ if (!response.ok) {
227
+ throw new CliFailure(EXIT_TOOL_ERROR, 'RIDE_DOWNLOAD_FAILED', `The RIDE download answered HTTP ${response.status}.`, {
228
+ status: response.status,
229
+ });
230
+ }
231
+ return new Uint8Array(await response.arrayBuffer());
232
+ }
@@ -0,0 +1,3 @@
1
+ import { createRequire } from 'node:module';
2
+ /** `../package.json` from both `src/` (tests) and `dist/` (the published binary). */
3
+ export const CLI_VERSION = createRequire(import.meta.url)('../package.json').version;
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@jipsoft/factusync-cli",
3
+ "version": "0.1.0",
4
+ "description": "FactuSync command line for AI agents (Pi) and shell scripts: Ecuadorian SRI e-invoicing through the FactuSync MCP endpoint",
5
+ "license": "MIT",
6
+ "keywords": [
7
+ "factusync",
8
+ "sri",
9
+ "ecuador",
10
+ "facturacion-electronica",
11
+ "mcp",
12
+ "cli"
13
+ ],
14
+ "homepage": "https://jipsoft.com/factusync/mcp",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/jipsoft-labs/jipsoft-global.git",
18
+ "directory": "packages/factusync-cli"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/jipsoft-labs/jipsoft-global/issues"
22
+ },
23
+ "type": "module",
24
+ "bin": {
25
+ "factusync": "dist/cli.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md",
30
+ "LICENSE"
31
+ ],
32
+ "engines": {
33
+ "node": ">=22"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "scripts": {
39
+ "build": "rm -rf dist && tsc --project tsconfig.build.json && chmod +x dist/cli.js",
40
+ "test": "pnpm run build && pnpm exec vitest run",
41
+ "typecheck": "tsc --project tsconfig.json --noEmit",
42
+ "prepublishOnly": "pnpm run test"
43
+ },
44
+ "dependencies": {
45
+ "@modelcontextprotocol/sdk": "1.30.1",
46
+ "zod": "^3.25.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^22.0.0",
50
+ "typescript": "^5.7.0",
51
+ "vitest": "^4.0.8"
52
+ }
53
+ }