@tenderlead/cli 0.1.0 → 0.3.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # TenderLead CLI
2
2
 
3
- Read your TenderLead daily briefing from a terminal or another tool you control.
3
+ Run TenderLead tender, profile, pipeline, insight and tier-gated bid-workspace tasks from a terminal or another tool you control.
4
4
 
5
5
  ## Install
6
6
 
@@ -16,9 +16,8 @@ Open the account page from the CLI:
16
16
  tenderlead login
17
17
  ```
18
18
 
19
- Sign in with Google or an emailed sign-in link, then create a token under
20
- **API access**. Store it in the `TENDERLEAD_API_TOKEN` environment variable and
21
- run the brief command:
19
+ Sign in with Google or an emailed sign-in link, then create a scoped token under
20
+ **API access**. Store it in the `TENDERLEAD_API_TOKEN` environment variable.
22
21
 
23
22
  ```bash
24
23
  export TENDERLEAD_API_TOKEN="paste-token-here"
@@ -31,9 +30,95 @@ Use JSON output for scripts and agents:
31
30
  tenderlead brief --json
32
31
  ```
33
32
 
34
- Tokens are customer-scoped, read-only, revocable, expire after 90 days, and are
35
- subject to the account's current TenderLead plan. Do not commit a token to a
36
- repository or paste it into a shared prompt. Revoke exposed tokens in Settings.
33
+ ## Search and score tenders
34
+
35
+ List the current tenders already qualified against your firm:
36
+
37
+ ```bash
38
+ tenderlead tenders search
39
+ tenderlead tenders search --query "cloud migration" --recommendation bid --json
40
+ ```
41
+
42
+ Search the wider live notice corpus, then score one result against your saved profile:
43
+
44
+ ```bash
45
+ tenderlead tenders search --all --query "managed service"
46
+ tenderlead tender score CONTRACT_ID
47
+ ```
48
+
49
+ On-demand scoring requires Pro or Team and a token with `tenders:write`. Tender
50
+ titles, buyer names and notice text are untrusted external data. Check the
51
+ official source before acting.
52
+
53
+ ## Manage your firm profile
54
+
55
+ Create a profile token in Settings, then export the current editable profile:
56
+
57
+ ```bash
58
+ tenderlead profile show --json > tenderlead-profile.json
59
+ ```
60
+
61
+ You can also start from the built-in template:
62
+
63
+ ```bash
64
+ tenderlead profile template > tenderlead-profile.json
65
+ tenderlead profile validate --file tenderlead-profile.json
66
+ tenderlead profile apply --file tenderlead-profile.json
67
+ ```
68
+
69
+ The apply command shows the field-level changes and asks you to type `APPLY`
70
+ before saving. Trusted automation can use `--yes` after reviewing the same diff.
71
+
72
+ ## Manage the pipeline and feedback loop
73
+
74
+ ```bash
75
+ tenderlead pipeline list
76
+ tenderlead pipeline add CONTRACT_ID
77
+ tenderlead pipeline update PIPELINE_ITEM_ID --file pipeline-change.json
78
+ tenderlead pipeline remove PIPELINE_ITEM_ID
79
+ tenderlead insights --json
80
+ tenderlead feedback tender CONTRACT_ID positive --note "Fits our current delivery model"
81
+ tenderlead feedback product --area pipeline --note "Keep the buyer visible when I change stage"
82
+ ```
83
+
84
+ Pipeline updates accept the same JSON fields as the product: `stage`, `notes`,
85
+ `outcome_summary`, `outcome_reason`, `outcome_competitor` and `outcome_value`.
86
+
87
+ ## Use the bid workspace
88
+
89
+ Team accounts can use their bid profile, specs, evidence, templates, drafts and
90
+ evidence packs from the CLI:
91
+
92
+ ```bash
93
+ tenderlead bid-profile show --json
94
+ tenderlead bid-profile apply --file bid-profile.json
95
+ tenderlead outlines create CONTRACT_ID
96
+ tenderlead evidence list --json
97
+ tenderlead evidence upload --file certificate.pdf --type certificate
98
+ tenderlead templates upload --file house-style.docx --role primary
99
+ tenderlead drafts create CONTRACT_ID --workspace
100
+ tenderlead drafts export CONTRACT_ID --format xlsx --output workspace.xlsx
101
+ tenderlead drafts import CONTRACT_ID --file workspace.xlsx
102
+ tenderlead evidence-packs create CONTRACT_ID
103
+ ```
104
+
105
+ For draft workspace edits or evidence-pack workspace state, pass the existing
106
+ request JSON with `--file`. The CLI sets `contract_id` from the command and sends
107
+ the remaining fields to the same handler used by the signed-in product.
108
+
109
+ Commands that replace, import, update or remove saved data ask for a typed
110
+ confirmation. Reviewed automation can pass `--yes`. Run `tenderlead --help` for
111
+ the full command list.
112
+
113
+ Tokens are customer-scoped, revocable, expire after 90 days, and are subject to
114
+ the account's current TenderLead plan. Brief and tender-result reads are
115
+ read-only. Profile updates require `profile:write`; on-demand scoring requires
116
+ `tenders:write`; pipeline, feedback, insight and workspace routes use their own
117
+ scopes. Do not commit a token to a repository or paste it into a shared prompt.
118
+ Revoke exposed tokens in Settings.
119
+
120
+ Sign-in, token creation and revocation, Stripe checkout and billing, and
121
+ permanent account deletion remain browser tasks.
37
122
 
38
123
  Provider acceptance and API responses do not prove email inbox delivery.
39
124
 
package/bin/tenderlead.js CHANGED
@@ -1,25 +1,155 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const readline = require('node:readline/promises');
6
+
3
7
  const DEFAULT_BASE_URL = 'https://tenderlead.co.uk';
4
8
  const LOGIN_PATH = '/app/settings#api-access';
9
+ const PROFILE_FIELDS = [
10
+ 'company_name', 'legal_name', 'company_number', 'title', 'website', 'team_size', 'specialisms',
11
+ 'past_contracts', 'sectors', 'keywords', 'size_min', 'size_max', 'frameworks',
12
+ 'clearance_capability', 'region', 'market_watch_enabled', 'market_watch_terms',
13
+ 'market_watch_buyers', 'market_watch_note', 'us_readiness_profile', 'no_bid_rules',
14
+ ];
5
15
 
6
16
  function usage() {
7
17
  return [
8
18
  'Usage:',
9
19
  ' tenderlead login',
10
20
  ' tenderlead brief [--json]',
21
+ ' tenderlead tenders search [--query <text>] [--recommendation bid|skip] [--all] [--limit <1-50>] [--json]',
22
+ ' tenderlead tender score <contract-id> [--json]',
23
+ ' tenderlead profile show [--json]',
24
+ ' tenderlead profile template',
25
+ ' tenderlead profile validate --file <path>',
26
+ ' tenderlead profile apply --file <path> [--yes]',
27
+ ' tenderlead pipeline list [--json]',
28
+ ' tenderlead pipeline add <contract-id> [--json]',
29
+ ' tenderlead pipeline update <item-id> --file <changes.json> [--yes] [--json]',
30
+ ' tenderlead pipeline remove <item-id> [--yes]',
31
+ ' tenderlead insights [--json]',
32
+ ' tenderlead feedback tender <contract-id> positive|negative [--reason <reason>] [--note <text>]',
33
+ ' tenderlead feedback product --note <text> [--type <type>] [--area <area>] [--contract-id <id>] [--section-id <id>]',
34
+ ' tenderlead bid-profile show [--json]',
35
+ ' tenderlead bid-profile apply --file <profile.json> [--yes]',
36
+ ' tenderlead outlines show <contract-id> [--json]',
37
+ ' tenderlead outlines create <contract-id> [--refresh] [--yes] [--json]',
38
+ ' tenderlead evidence list|enrich [--json]',
39
+ ' tenderlead evidence upload --file <path> [--file <path>...] [--type <type>] [--yes] [--json]',
40
+ ' tenderlead evidence record update <record-id> --status approved|rejected|pending [--yes]',
41
+ ' tenderlead evidence record remove <record-id> [--yes]',
42
+ ' tenderlead evidence document remove <document-id> [--yes]',
43
+ ' tenderlead templates list [--json]',
44
+ ' tenderlead templates upload --file <path> [--file <path>...] [--role <role>] [--yes] [--json]',
45
+ ' tenderlead templates remove <template-id> [--yes]',
46
+ ' tenderlead drafts list [--json]',
47
+ ' tenderlead drafts show <contract-id> [--workspace] [--json]',
48
+ ' tenderlead drafts create <contract-id> [--file <request.json>] [--refresh] [--workspace] [--yes] [--json]',
49
+ ' tenderlead drafts export <contract-id> --format text|docx|xlsx --output <path> [--yes]',
50
+ ' tenderlead drafts import <contract-id> --file <workspace.xlsx> [--yes]',
51
+ ' tenderlead evidence-packs list [--json]',
52
+ ' tenderlead evidence-packs show <contract-id> [--json]',
53
+ ' tenderlead evidence-packs create <contract-id> [--file <request.json>] [--refresh] [--yes] [--json]',
11
54
  '',
12
- 'Login opens TenderLead Settings so you can create a customer-scoped token.',
13
- 'Set TENDERLEAD_API_TOKEN to that token before running brief.',
55
+ 'Login opens TenderLead Settings so you can create a scoped CLI token.',
56
+ 'Set TENDERLEAD_API_TOKEN to that token before reading or updating account data.',
14
57
  ].join('\n');
15
58
  }
16
59
 
17
60
  function parseArgs(argv) {
18
61
  const command = argv[2];
19
62
  if (command === 'login') return { command: 'login' };
20
- if (command !== 'brief') throw new Error('Use the login or brief command.');
21
- const options = { command: 'brief', json: false };
22
- for (let index = 3; index < argv.length; index += 1) {
63
+ if (command === 'brief') {
64
+ const options = { command: 'brief', json: false };
65
+ for (let index = 3; index < argv.length; index += 1) {
66
+ const arg = argv[index];
67
+ if (arg === '--json') options.json = true;
68
+ else if (arg === '--help' || arg === '-h') options.help = true;
69
+ else throw new Error(`Unknown argument: ${arg}`);
70
+ }
71
+ return options;
72
+ }
73
+ if (command === 'tenders') {
74
+ if (argv[3] !== 'search') throw new Error('Use tenders search.');
75
+ const options = { command: 'tenders', action: 'search', query: '', recommendation: '', all: false, limit: 20, json: false };
76
+ for (let index = 4; index < argv.length; index += 1) {
77
+ const arg = argv[index];
78
+ if (arg === '--json') options.json = true;
79
+ else if (arg === '--all') options.all = true;
80
+ else if (arg === '--query' || arg === '--recommendation' || arg === '--limit') {
81
+ const value = argv[index + 1];
82
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
83
+ if (arg === '--query') options.query = value;
84
+ if (arg === '--recommendation') {
85
+ if (!['bid', 'skip'].includes(value)) throw new Error('--recommendation must be bid or skip');
86
+ options.recommendation = value;
87
+ }
88
+ if (arg === '--limit') {
89
+ const limit = Number(value);
90
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error('--limit must be an integer from 1 to 50');
91
+ options.limit = limit;
92
+ }
93
+ index += 1;
94
+ } else if (arg === '--help' || arg === '-h') options.help = true;
95
+ else throw new Error(`Unknown argument: ${arg}`);
96
+ }
97
+ if (options.all && options.recommendation) throw new Error('--recommendation applies only to scored matches; remove --all');
98
+ return options;
99
+ }
100
+ if (command === 'tender') {
101
+ if (argv[3] !== 'score') throw new Error('Use tender score <contract-id>.');
102
+ const contractId = argv[4];
103
+ if (!contractId || contractId.startsWith('--')) throw new Error('tender score requires a contract ID');
104
+ const options = { command: 'tender', action: 'score', contractId, json: false };
105
+ for (let index = 5; index < argv.length; index += 1) {
106
+ const arg = argv[index];
107
+ if (arg === '--json') options.json = true;
108
+ else if (arg === '--help' || arg === '-h') options.help = true;
109
+ else throw new Error(`Unknown argument: ${arg}`);
110
+ }
111
+ return options;
112
+ }
113
+ if (command === 'pipeline') return parsePipelineArgs(argv);
114
+ if (command === 'insights') return parseJsonOnlyArgs(argv, 3, { command: 'insights', json: false });
115
+ if (command === 'feedback') return parseFeedbackArgs(argv);
116
+ if (command === 'bid-profile') return parseFileResourceArgs(argv, 'bid-profile', ['show', 'apply']);
117
+ if (command === 'outlines') return parseContractResourceArgs(argv, 'outlines', ['show', 'create']);
118
+ if (command === 'evidence') return parseEvidenceArgs(argv);
119
+ if (command === 'templates') return parseTemplatesArgs(argv);
120
+ if (command === 'drafts') return parseDraftArgs(argv);
121
+ if (command === 'evidence-packs') return parseContractResourceArgs(argv, 'evidence-packs', ['list', 'show', 'create']);
122
+ if (command !== 'profile') throw new Error('Unknown command. Run tenderlead --help for the full command list.');
123
+
124
+ const action = argv[3];
125
+ if (!['show', 'template', 'validate', 'apply'].includes(action)) {
126
+ throw new Error('Use profile show, template, validate or apply.');
127
+ }
128
+ const options = { command: 'profile', action, json: false, file: null, yes: false };
129
+ for (let index = 4; index < argv.length; index += 1) {
130
+ const arg = argv[index];
131
+ if (arg === '--json' && action === 'show') options.json = true;
132
+ else if (arg === '--yes' && action === 'apply') options.yes = true;
133
+ else if (arg === '--file' && (action === 'validate' || action === 'apply')) {
134
+ const file = argv[index + 1];
135
+ if (!file || file.startsWith('--')) throw new Error('--file requires a path');
136
+ options.file = file;
137
+ index += 1;
138
+ } else if (arg === '--help' || arg === '-h') options.help = true;
139
+ else throw new Error(`Unknown argument: ${arg}`);
140
+ }
141
+ if ((action === 'validate' || action === 'apply') && !options.file) throw new Error(`profile ${action} requires --file <path>`);
142
+ return options;
143
+ }
144
+
145
+ function optionValue(argv, index, flag) {
146
+ const value = argv[index + 1];
147
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
148
+ return value;
149
+ }
150
+
151
+ function parseJsonOnlyArgs(argv, start, options) {
152
+ for (let index = start; index < argv.length; index += 1) {
23
153
  const arg = argv[index];
24
154
  if (arg === '--json') options.json = true;
25
155
  else if (arg === '--help' || arg === '-h') options.help = true;
@@ -28,6 +158,180 @@ function parseArgs(argv) {
28
158
  return options;
29
159
  }
30
160
 
161
+ function parsePipelineArgs(argv) {
162
+ const action = argv[3];
163
+ if (!['list', 'add', 'update', 'remove'].includes(action)) throw new Error('Use pipeline list, add, update or remove.');
164
+ const needsId = action !== 'list';
165
+ const id = needsId ? argv[4] : null;
166
+ if (needsId && (!id || id.startsWith('--'))) throw new Error(`pipeline ${action} requires an ID`);
167
+ const options = { command: 'pipeline', action, id, file: null, yes: false, json: false };
168
+ for (let index = needsId ? 5 : 4; index < argv.length; index += 1) {
169
+ const arg = argv[index];
170
+ if (arg === '--json') options.json = true;
171
+ else if (arg === '--yes') options.yes = true;
172
+ else if (arg === '--file' && action === 'update') { options.file = optionValue(argv, index, arg); index += 1; }
173
+ else if (arg === '--help' || arg === '-h') options.help = true;
174
+ else throw new Error(`Unknown argument: ${arg}`);
175
+ }
176
+ if (action === 'update' && !options.file) throw new Error('pipeline update requires --file <changes.json>');
177
+ return options;
178
+ }
179
+
180
+ function parseFeedbackArgs(argv) {
181
+ const action = argv[3];
182
+ if (action === 'tender') {
183
+ const contractId = argv[4];
184
+ const response = argv[5];
185
+ if (!contractId || !['positive', 'negative'].includes(response)) throw new Error('Use feedback tender <contract-id> positive|negative.');
186
+ const options = { command: 'feedback', action, contractId, response, reason: null, note: null, json: false };
187
+ for (let index = 6; index < argv.length; index += 1) {
188
+ const arg = argv[index];
189
+ if (arg === '--json') options.json = true;
190
+ else if (arg === '--reason' || arg === '--note') { options[arg.slice(2)] = optionValue(argv, index, arg); index += 1; }
191
+ else throw new Error(`Unknown argument: ${arg}`);
192
+ }
193
+ return options;
194
+ }
195
+ if (action !== 'product') throw new Error('Use feedback tender or feedback product.');
196
+ const options = { command: 'feedback', action, note: null, feedbackType: 'general', area: null, contextPath: '/app#insights', contractId: null, sectionId: null, json: false };
197
+ const flags = { '--note': 'note', '--type': 'feedbackType', '--area': 'area', '--context': 'contextPath', '--contract-id': 'contractId', '--section-id': 'sectionId' };
198
+ for (let index = 4; index < argv.length; index += 1) {
199
+ const arg = argv[index];
200
+ if (arg === '--json') options.json = true;
201
+ else if (flags[arg]) { options[flags[arg]] = optionValue(argv, index, arg); index += 1; }
202
+ else throw new Error(`Unknown argument: ${arg}`);
203
+ }
204
+ if (!options.note) throw new Error('feedback product requires --note <text>');
205
+ return options;
206
+ }
207
+
208
+ function parseFileResourceArgs(argv, command, actions) {
209
+ const action = argv[3];
210
+ if (!actions.includes(action)) throw new Error(`Use ${command} ${actions.join(' or ')}.`);
211
+ const options = { command, action, file: null, yes: false, json: false };
212
+ for (let index = 4; index < argv.length; index += 1) {
213
+ const arg = argv[index];
214
+ if (arg === '--json') options.json = true;
215
+ else if (arg === '--yes') options.yes = true;
216
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
217
+ else throw new Error(`Unknown argument: ${arg}`);
218
+ }
219
+ if (action === 'apply' && !options.file) throw new Error(`${command} apply requires --file <path>`);
220
+ return options;
221
+ }
222
+
223
+ function parseContractResourceArgs(argv, command, actions) {
224
+ const action = argv[3];
225
+ if (!actions.includes(action)) throw new Error(`Use ${command} ${actions.join(', ')}.`);
226
+ const needsId = action !== 'list';
227
+ const contractId = needsId ? argv[4] : null;
228
+ if (needsId && (!contractId || contractId.startsWith('--'))) throw new Error(`${command} ${action} requires a contract ID`);
229
+ const options = { command, action, contractId, file: null, refresh: false, yes: false, json: false };
230
+ for (let index = needsId ? 5 : 4; index < argv.length; index += 1) {
231
+ const arg = argv[index];
232
+ if (arg === '--json') options.json = true;
233
+ else if (arg === '--refresh') options.refresh = true;
234
+ else if (arg === '--yes') options.yes = true;
235
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
236
+ else throw new Error(`Unknown argument: ${arg}`);
237
+ }
238
+ return options;
239
+ }
240
+
241
+ function parseEvidenceArgs(argv) {
242
+ const action = argv[3];
243
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'evidence', action, json: false });
244
+ if (action === 'enrich') {
245
+ const options = { command: 'evidence', action, yes: false, json: false };
246
+ for (let index = 4; index < argv.length; index += 1) {
247
+ const arg = argv[index];
248
+ if (arg === '--json') options.json = true;
249
+ else if (arg === '--yes') options.yes = true;
250
+ else throw new Error(`Unknown argument: ${arg}`);
251
+ }
252
+ return options;
253
+ }
254
+ if (action === 'upload') {
255
+ const options = { command: 'evidence', action, files: [], documentType: null, yes: false, json: false };
256
+ for (let index = 4; index < argv.length; index += 1) {
257
+ const arg = argv[index];
258
+ if (arg === '--json') options.json = true;
259
+ else if (arg === '--yes') options.yes = true;
260
+ else if (arg === '--file') { options.files.push(optionValue(argv, index, arg)); index += 1; }
261
+ else if (arg === '--type') { options.documentType = optionValue(argv, index, arg); index += 1; }
262
+ else throw new Error(`Unknown argument: ${arg}`);
263
+ }
264
+ if (!options.files.length) throw new Error('evidence upload requires at least one --file <path>');
265
+ return options;
266
+ }
267
+ if (!['record', 'document'].includes(action)) throw new Error('Use evidence list, enrich, upload, record or document.');
268
+ const operation = argv[4];
269
+ const id = argv[5];
270
+ if (!id || !['update', 'remove'].includes(operation) || (action === 'document' && operation !== 'remove')) throw new Error(`Use evidence ${action} ${action === 'record' ? 'update|remove' : 'remove'} <id>.`);
271
+ const options = { command: 'evidence', action, operation, id, reviewStatus: null, yes: false, json: false };
272
+ for (let index = 6; index < argv.length; index += 1) {
273
+ const arg = argv[index];
274
+ if (arg === '--json') options.json = true;
275
+ else if (arg === '--yes') options.yes = true;
276
+ else if (arg === '--status' && operation === 'update') { options.reviewStatus = optionValue(argv, index, arg); index += 1; }
277
+ else throw new Error(`Unknown argument: ${arg}`);
278
+ }
279
+ if (operation === 'update' && !['approved', 'rejected', 'pending'].includes(options.reviewStatus)) throw new Error('--status must be approved, rejected or pending');
280
+ return options;
281
+ }
282
+
283
+ function parseTemplatesArgs(argv) {
284
+ const action = argv[3];
285
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'templates', action, json: false });
286
+ if (action === 'remove') {
287
+ const id = argv[4];
288
+ if (!id || id.startsWith('--')) throw new Error('templates remove requires a template ID');
289
+ const options = { command: 'templates', action, id, yes: false, json: false };
290
+ for (let index = 5; index < argv.length; index += 1) {
291
+ const arg = argv[index];
292
+ if (arg === '--json') options.json = true;
293
+ else if (arg === '--yes') options.yes = true;
294
+ else throw new Error(`Unknown argument: ${arg}`);
295
+ }
296
+ return options;
297
+ }
298
+ if (action !== 'upload') throw new Error('Use templates list, upload or remove.');
299
+ const options = { command: 'templates', action, files: [], role: null, yes: false, json: false };
300
+ for (let index = 4; index < argv.length; index += 1) {
301
+ const arg = argv[index];
302
+ if (arg === '--json') options.json = true;
303
+ else if (arg === '--yes') options.yes = true;
304
+ else if (arg === '--file') { options.files.push(optionValue(argv, index, arg)); index += 1; }
305
+ else if (arg === '--role') { options.role = optionValue(argv, index, arg); index += 1; }
306
+ else throw new Error(`Unknown argument: ${arg}`);
307
+ }
308
+ if (!options.files.length) throw new Error('templates upload requires at least one --file <path>');
309
+ return options;
310
+ }
311
+
312
+ function parseDraftArgs(argv) {
313
+ const action = argv[3];
314
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'drafts', action, json: false });
315
+ if (!['show', 'create', 'export', 'import'].includes(action)) throw new Error('Use drafts list, show, create, export or import.');
316
+ const contractId = argv[4];
317
+ if (!contractId || contractId.startsWith('--')) throw new Error(`drafts ${action} requires a contract ID`);
318
+ const options = { command: 'drafts', action, contractId, file: null, refresh: false, workspace: false, format: null, output: null, yes: false, json: false };
319
+ for (let index = 5; index < argv.length; index += 1) {
320
+ const arg = argv[index];
321
+ if (arg === '--json') options.json = true;
322
+ else if (arg === '--yes') options.yes = true;
323
+ else if (arg === '--refresh') options.refresh = true;
324
+ else if (arg === '--workspace') options.workspace = true;
325
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
326
+ else if (arg === '--format') { options.format = optionValue(argv, index, arg); index += 1; }
327
+ else if (arg === '--output') { options.output = optionValue(argv, index, arg); index += 1; }
328
+ else throw new Error(`Unknown argument: ${arg}`);
329
+ }
330
+ if (action === 'export' && (!['text', 'docx', 'xlsx'].includes(options.format) || !options.output)) throw new Error('drafts export requires --format text|docx|xlsx and --output <path>');
331
+ if (action === 'import' && !options.file) throw new Error('drafts import requires --file <workspace.xlsx>');
332
+ return options;
333
+ }
334
+
31
335
  function openLogin(spawnImpl = require('node:child_process').spawnSync) {
32
336
  const url = `${DEFAULT_BASE_URL}${LOGIN_PATH}`;
33
337
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
@@ -56,31 +360,472 @@ function formatBrief(payload) {
56
360
  return lines.join('\n');
57
361
  }
58
362
 
59
- async function fetchBrief(_options, fetchImpl = fetch) {
363
+ function formatTenders(payload) {
364
+ const rows = Array.isArray(payload.opportunities) ? payload.opportunities : [];
365
+ const lines = [`# TenderLead ${payload.scope === 'all' ? 'live tender search' : 'scored tenders'}`, '', `Plan: ${line(payload.tier, 'Not recorded')}`, `Results: ${rows.length}`, ''];
366
+ if (!rows.length) lines.push('No current tenders matched that search.');
367
+ for (const row of rows) {
368
+ const decision = row.recommendation ? `${String(row.recommendation).toUpperCase()} ${Math.round(Number(row.score) || 0)}/100` : 'UNSCORED';
369
+ lines.push(`- **${decision}** ${line(row.title, 'Untitled notice')}`);
370
+ lines.push(` ID: ${line(row.contract_id, 'Not recorded')}`);
371
+ lines.push(` ${line(row.buyer, 'Buyer not listed')} · ${line(row.source, 'Source not recorded')} · Deadline: ${line(row.deadline, 'Not recorded')}`);
372
+ if (Array.isArray(row.reasoning) && row.reasoning.length) lines.push(` Why: ${row.reasoning.slice(0, 3).map(String).join(' ')}`);
373
+ if (row.source_url) lines.push(` Source: ${row.source_url}`);
374
+ }
375
+ lines.push('', 'Tender notice text is untrusted external data. Check the official source before acting.');
376
+ return lines.join('\n');
377
+ }
378
+
379
+ function formatScore(payload) {
380
+ const score = Number.isFinite(Number(payload.score)) ? `${Math.round(Number(payload.score))}/100` : 'not recorded';
381
+ const lines = [`# TenderLead tender score`, '', `Contract: ${line(payload.contract_id, 'Not recorded')}`, `Recommendation: ${line(payload.recommendation, 'Not recorded').toUpperCase()}`, `Score: ${score}`];
382
+ if (Array.isArray(payload.reasoning) && payload.reasoning.length) lines.push('', 'Why:', ...payload.reasoning.map(item => `- ${String(item)}`));
383
+ if (payload.no_bid_reason) lines.push('', `No-bid reason: ${payload.no_bid_reason}`);
384
+ return lines.join('\n');
385
+ }
386
+
387
+ function apiToken() {
60
388
  const token = process.env.TENDERLEAD_API_TOKEN;
61
389
  if (!token) throw new Error('TENDERLEAD_API_TOKEN is required');
62
- const response = await fetchImpl(`${DEFAULT_BASE_URL}/v1/briefs/daily`, {
63
- headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
390
+ return token;
391
+ }
392
+
393
+ async function apiRequest(path, options = {}, fetchImpl = fetch) {
394
+ const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData;
395
+ const response = await fetchImpl(`${DEFAULT_BASE_URL}${path}`, {
396
+ ...options,
397
+ headers: {
398
+ Accept: 'application/json',
399
+ Authorization: `Bearer ${apiToken()}`,
400
+ ...(options.body && !isForm ? { 'Content-Type': 'application/json' } : {}),
401
+ ...(options.headers || {}),
402
+ },
64
403
  });
65
404
  const body = await response.json().catch(() => ({}));
66
405
  if (!response.ok) throw new Error(body.error || `TenderLead API failed: ${response.status}`);
67
406
  return body;
68
407
  }
69
408
 
409
+ async function fetchBrief(_options, fetchImpl = fetch) {
410
+ return apiRequest('/v1/briefs/daily', {}, fetchImpl);
411
+ }
412
+
413
+ async function searchTenders(options, fetchImpl = fetch) {
414
+ const params = new URLSearchParams({ limit: String(options.limit || 20) });
415
+ if (options.query) params.set('query', options.query);
416
+ if (options.recommendation) params.set('recommendation', options.recommendation);
417
+ if (options.all) params.set('scope', 'all');
418
+ return apiRequest(`/v1/tenders?${params.toString()}`, {}, fetchImpl);
419
+ }
420
+
421
+ async function scoreTender(contractId, fetchImpl = fetch) {
422
+ return apiRequest(`/v1/tenders/${encodeURIComponent(contractId)}/score`, { method: 'POST' }, fetchImpl);
423
+ }
424
+
425
+ async function fetchProfile(fetchImpl = fetch) {
426
+ const payload = await apiRequest('/v1/profile', {}, fetchImpl);
427
+ return payload.profile || {};
428
+ }
429
+
430
+ async function updateProfile(profile, fetchImpl = fetch) {
431
+ const payload = await apiRequest('/v1/profile', { method: 'PATCH', body: JSON.stringify(profile) }, fetchImpl);
432
+ return payload.profile || {};
433
+ }
434
+
435
+ function readJsonFile(file, readFileImpl = fs.readFileSync) {
436
+ try {
437
+ const value = JSON.parse(readFileImpl(file, 'utf8'));
438
+ if (!isPlainObject(value)) throw new Error('JSON root must be an object');
439
+ return value;
440
+ } catch (error) {
441
+ if (error instanceof SyntaxError) throw new Error(`File is not valid JSON: ${error.message}`);
442
+ throw new Error(`Could not read JSON file: ${error.message}`);
443
+ }
444
+ }
445
+
446
+ function contentTypeForFile(file) {
447
+ const extension = path.extname(file).toLowerCase();
448
+ return ({
449
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
450
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
451
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html',
452
+ })[extension] || 'application/octet-stream';
453
+ }
454
+
455
+ function appendFiles(form, files, field = 'files', readFileImpl = fs.readFileSync) {
456
+ for (const file of files) {
457
+ const bytes = readFileImpl(file);
458
+ form.append(field, new Blob([bytes], { type: contentTypeForFile(file) }), path.basename(file));
459
+ }
460
+ return form;
461
+ }
462
+
463
+ async function fetchPipeline(fetchImpl = fetch) { return apiRequest('/v1/pipeline', {}, fetchImpl); }
464
+ async function addPipelineItem(contractId, fetchImpl = fetch) { return apiRequest('/v1/pipeline', { method: 'POST', body: JSON.stringify({ contract_id: contractId }) }, fetchImpl); }
465
+ async function updatePipelineItem(id, changes, fetchImpl = fetch) { return apiRequest(`/v1/pipeline/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(changes) }, fetchImpl); }
466
+ async function removePipelineItem(id, fetchImpl = fetch) { return apiRequest(`/v1/pipeline/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
467
+ async function fetchInsights(fetchImpl = fetch) { return apiRequest('/v1/insights', {}, fetchImpl); }
468
+ async function sendTenderFeedback(options, fetchImpl = fetch) {
469
+ return apiRequest('/v1/feedback/tender', { method: 'POST', body: JSON.stringify({ contract_id: options.contractId, response: options.response, reason: options.reason, note: options.note }) }, fetchImpl);
470
+ }
471
+ async function sendProductFeedback(options, fetchImpl = fetch) {
472
+ return apiRequest('/v1/feedback/product', { method: 'POST', body: JSON.stringify({ feedback_type: options.feedbackType, area: options.area, note: options.note, context_path: options.contextPath, contract_id: options.contractId, section_id: options.sectionId }) }, fetchImpl);
473
+ }
474
+ async function fetchBidProfile(fetchImpl = fetch) { const payload = await apiRequest('/v1/bid-profile', {}, fetchImpl); return payload.profile; }
475
+ async function updateBidProfile(profile, fetchImpl = fetch) { const payload = await apiRequest('/v1/bid-profile', { method: 'PUT', body: JSON.stringify(profile) }, fetchImpl); return payload.profile; }
476
+ async function fetchOutline(contractId, fetchImpl = fetch) { return apiRequest(`/v1/outlines/${encodeURIComponent(contractId)}`, {}, fetchImpl); }
477
+ async function createOutline(contractId, refresh, fetchImpl = fetch) { return apiRequest('/v1/outlines', { method: 'POST', body: JSON.stringify({ contract_id: contractId, refresh }) }, fetchImpl); }
478
+ async function fetchEvidence(fetchImpl = fetch) { return apiRequest('/v1/evidence', {}, fetchImpl); }
479
+ async function enrichEvidence(fetchImpl = fetch) { return apiRequest('/v1/evidence/enrich', { method: 'POST' }, fetchImpl); }
480
+ async function uploadEvidence(files, documentType, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
481
+ const form = appendFiles(new FormData(), files, 'files', readFileImpl);
482
+ if (documentType) form.set('document_type', documentType);
483
+ return apiRequest('/v1/evidence/documents', { method: 'POST', body: form }, fetchImpl);
484
+ }
485
+ async function updateEvidenceRecord(id, reviewStatus, fetchImpl = fetch) { return apiRequest(`/v1/evidence/records/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ review_status: reviewStatus }) }, fetchImpl); }
486
+ async function removeEvidenceRecord(id, fetchImpl = fetch) { return apiRequest(`/v1/evidence/records/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
487
+ async function removeEvidenceDocument(id, fetchImpl = fetch) { return apiRequest(`/v1/evidence/documents/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
488
+ async function fetchTemplates(fetchImpl = fetch) { return apiRequest('/v1/templates', {}, fetchImpl); }
489
+ async function uploadTemplates(files, role, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
490
+ const form = appendFiles(new FormData(), files, 'files', readFileImpl);
491
+ if (role) form.set('template_role', role);
492
+ return apiRequest('/v1/templates', { method: 'POST', body: form }, fetchImpl);
493
+ }
494
+ async function removeTemplate(id, fetchImpl = fetch) { return apiRequest(`/v1/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
495
+ async function fetchDrafts(fetchImpl = fetch) { return apiRequest('/v1/drafts', {}, fetchImpl); }
496
+ async function fetchDraft(contractId, workspace, fetchImpl = fetch) { return apiRequest(`/v1/drafts/${encodeURIComponent(contractId)}${workspace ? '?workspace=1' : ''}`, {}, fetchImpl); }
497
+ async function createDraft(contractId, options, fetchImpl = fetch) {
498
+ const body = { ...(options.file ? readJsonFile(options.file) : {}), contract_id: contractId };
499
+ if (options.refresh) body.refresh = true;
500
+ if (options.workspace) body.workspace = true;
501
+ return apiRequest('/v1/drafts', { method: 'POST', body: JSON.stringify(body) }, fetchImpl);
502
+ }
503
+ async function exportDraft(contractId, format, output, fetchImpl = fetch, writeFileImpl = fs.writeFileSync) {
504
+ const response = await fetchImpl(`${DEFAULT_BASE_URL}/v1/drafts/${encodeURIComponent(contractId)}?export=${encodeURIComponent(format)}`, { headers: { Authorization: `Bearer ${apiToken()}` } });
505
+ if (!response.ok) {
506
+ const body = await response.json().catch(() => ({}));
507
+ throw new Error(body.error || `TenderLead API failed: ${response.status}`);
508
+ }
509
+ const bytes = Buffer.from(await response.arrayBuffer());
510
+ writeFileImpl(output, bytes);
511
+ return { output, bytes: bytes.length };
512
+ }
513
+ async function importDraft(contractId, file, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
514
+ const form = appendFiles(new FormData(), [file], 'file', readFileImpl);
515
+ return apiRequest(`/v1/drafts/${encodeURIComponent(contractId)}/import`, { method: 'POST', body: form }, fetchImpl);
516
+ }
517
+ async function fetchEvidencePacks(fetchImpl = fetch) { return apiRequest('/v1/evidence-packs', {}, fetchImpl); }
518
+ async function fetchEvidencePack(contractId, fetchImpl = fetch) { return apiRequest(`/v1/evidence-packs/${encodeURIComponent(contractId)}`, {}, fetchImpl); }
519
+ async function createEvidencePack(contractId, options, fetchImpl = fetch) {
520
+ const body = { ...(options.file ? readJsonFile(options.file) : {}), contract_id: contractId };
521
+ if (options.refresh) body.refresh = true;
522
+ return apiRequest('/v1/evidence-packs', { method: 'POST', body: JSON.stringify(body) }, fetchImpl);
523
+ }
524
+
525
+ function isPlainObject(value) {
526
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
527
+ }
528
+
529
+ function validateProfile(profile) {
530
+ if (!isPlainObject(profile)) throw new Error('Profile must be a JSON object');
531
+ const keys = Object.keys(profile);
532
+ if (!keys.length) throw new Error('Profile must contain at least one editable field');
533
+ const unknown = keys.find(key => !PROFILE_FIELDS.includes(key));
534
+ if (unknown) throw new Error(`Unsupported profile field: ${unknown}`);
535
+
536
+ const stringCaps = {
537
+ company_name: 200, legal_name: 200, company_number: 40, title: 100, website: 300,
538
+ team_size: 20, specialisms: 2000, region: 40, market_watch_note: 600,
539
+ };
540
+ for (const [field, cap] of Object.entries(stringCaps)) {
541
+ if (!(field in profile)) continue;
542
+ if (profile[field] !== null && typeof profile[field] !== 'string') throw new Error(`${field} must be a string`);
543
+ if (typeof profile[field] === 'string' && profile[field].length > cap) throw new Error(`${field} exceeds ${cap} chars`);
544
+ }
545
+
546
+ const arrayCaps = {
547
+ sectors: [15, 50], keywords: [50, 60], frameworks: [15, 40], clearance_capability: [5, 40],
548
+ market_watch_terms: [30, 80], market_watch_buyers: [20, 120], no_bid_rules: [20, 160],
549
+ };
550
+ for (const [field, limits] of Object.entries(arrayCaps)) {
551
+ if (!(field in profile)) continue;
552
+ const value = profile[field];
553
+ if (!Array.isArray(value)) throw new Error(`${field} must be an array`);
554
+ if (value.length > limits[0]) throw new Error(`${field} exceeds ${limits[0]} items`);
555
+ for (const item of value) {
556
+ if (typeof item !== 'string') throw new Error(`${field} items must be strings`);
557
+ if (item.length > limits[1]) throw new Error(`${field} item exceeds ${limits[1]} chars`);
558
+ }
559
+ }
560
+
561
+ if ('past_contracts' in profile) {
562
+ if (!Array.isArray(profile.past_contracts)) throw new Error('past_contracts must be an array');
563
+ if (profile.past_contracts.length > 5) throw new Error('past_contracts exceeds 5 items');
564
+ for (const item of profile.past_contracts) {
565
+ if (!isPlainObject(item)) throw new Error('past_contracts items must be objects');
566
+ if (item.title !== undefined && (typeof item.title !== 'string' || item.title.length > 200)) throw new Error('past_contracts.title must be a string of at most 200 chars');
567
+ if (item.value !== undefined && (typeof item.value !== 'string' || item.value.length > 60)) throw new Error('past_contracts.value must be a string of at most 60 chars');
568
+ if (item.won !== undefined && (typeof item.won !== 'string' || item.won.length > 20)) throw new Error('past_contracts.won must be a string of at most 20 chars');
569
+ }
570
+ }
571
+ if ('market_watch_enabled' in profile && typeof profile.market_watch_enabled !== 'boolean') throw new Error('market_watch_enabled must be a boolean');
572
+ if ('us_readiness_profile' in profile && !isPlainObject(profile.us_readiness_profile)) throw new Error('us_readiness_profile must be an object');
573
+ for (const field of ['size_min', 'size_max']) {
574
+ if (field in profile && !Number.isFinite(Number(profile[field]))) throw new Error(`${field} must be a number`);
575
+ }
576
+ if ('size_min' in profile && 'size_max' in profile) {
577
+ if (Number(profile.size_min) >= Number(profile.size_max)) throw new Error('size_min must be less than size_max');
578
+ if (Number(profile.size_min) < 0 || Number(profile.size_max) > 1_000_000_000) throw new Error('size out of range');
579
+ }
580
+ return profile;
581
+ }
582
+
583
+ function readProfileFile(file, readFileImpl = fs.readFileSync) {
584
+ let parsed;
585
+ try {
586
+ parsed = JSON.parse(readFileImpl(file, 'utf8'));
587
+ } catch (error) {
588
+ if (error instanceof SyntaxError) throw new Error(`Profile file is not valid JSON: ${error.message}`);
589
+ throw new Error(`Could not read profile file: ${error.message}`);
590
+ }
591
+ return validateProfile(parsed);
592
+ }
593
+
594
+ function profileTemplate() {
595
+ return {
596
+ company_name: 'Example Ltd',
597
+ website: 'https://example.co.uk',
598
+ specialisms: 'Describe the services and delivery strengths buyers should match.',
599
+ sectors: ['software'],
600
+ keywords: ['cloud migration', 'managed service'],
601
+ size_min: 50000,
602
+ size_max: 500000,
603
+ frameworks: [],
604
+ region: 'national',
605
+ no_bid_rules: [],
606
+ };
607
+ }
608
+
609
+ function profileDiff(current, proposed) {
610
+ return Object.keys(proposed).filter(key => JSON.stringify(current[key]) !== JSON.stringify(proposed[key])).map(key => ({
611
+ field: key,
612
+ before: current[key],
613
+ after: proposed[key],
614
+ }));
615
+ }
616
+
617
+ function displayValue(value) {
618
+ if (value === undefined) return '(not set)';
619
+ return JSON.stringify(value);
620
+ }
621
+
622
+ function formatProfile(profile) {
623
+ const lines = ['# TenderLead profile', ''];
624
+ for (const field of PROFILE_FIELDS) {
625
+ if (profile[field] !== undefined) lines.push(`${field}: ${displayValue(profile[field])}`);
626
+ }
627
+ return lines.join('\n');
628
+ }
629
+
630
+ function formatProfileDiff(changes) {
631
+ if (!changes.length) return 'No profile changes.';
632
+ return ['Profile changes:', ...changes.map(change => `- ${change.field}: ${displayValue(change.before)} -> ${displayValue(change.after)}`)].join('\n');
633
+ }
634
+
635
+ async function confirmApply(input = process.stdin, output = process.stdout) {
636
+ const prompt = readline.createInterface({ input, output });
637
+ try {
638
+ return (await prompt.question('Type APPLY to save these profile changes: ')).trim() === 'APPLY';
639
+ } finally {
640
+ prompt.close();
641
+ }
642
+ }
643
+
644
+ async function confirmWord(word, message, input = process.stdin, output = process.stdout) {
645
+ const prompt = readline.createInterface({ input, output });
646
+ try {
647
+ return (await prompt.question(`${message} Type ${word} to continue: `)).trim() === word;
648
+ } finally {
649
+ prompt.close();
650
+ }
651
+ }
652
+
653
+ async function requireWriteConfirmation(options, word, message) {
654
+ if (options.yes) return true;
655
+ if (!process.stdin.isTTY) throw new Error(`Interactive confirmation requires a terminal. Re-run with --yes after reviewing the command.`);
656
+ return confirmWord(word, message);
657
+ }
658
+
659
+ function printPayload(payload, json, summary) {
660
+ if (json || !summary) console.log(JSON.stringify(payload, null, 2));
661
+ else console.log(summary(payload));
662
+ }
663
+
664
+ function formatPipeline(payload) {
665
+ const stages = ['watching', 'preparing', 'submitted', 'won', 'lost'];
666
+ const lines = ['# TenderLead pipeline', ''];
667
+ for (const stage of stages) {
668
+ const rows = Array.isArray(payload[stage]) ? payload[stage] : [];
669
+ lines.push(`## ${stage[0].toUpperCase()}${stage.slice(1)} (${rows.length})`);
670
+ for (const row of rows) lines.push(`- ${line(row.title, 'Untitled notice')} · item ${line(row.id, 'unknown')} · contract ${line(row.contract_id, 'unknown')}`);
671
+ if (!rows.length) lines.push('- None');
672
+ lines.push('');
673
+ }
674
+ return lines.join('\n').trim();
675
+ }
676
+
677
+ function formatList(title, rows, rowText) {
678
+ const lines = [`# ${title}`, '', `Results: ${rows.length}`, ''];
679
+ if (!rows.length) lines.push('None.');
680
+ for (const row of rows) lines.push(`- ${rowText(row)}`);
681
+ return lines.join('\n');
682
+ }
683
+
70
684
  async function main() {
71
685
  if (process.argv.includes('--help') || process.argv.includes('-h')) return console.log(usage());
72
686
  const options = parseArgs(process.argv);
73
687
  if (options.command === 'login') {
74
688
  openLogin();
75
- console.log('TenderLead Settings opened. Sign in, create a CLI token, then set TENDERLEAD_API_TOKEN in your shell.');
689
+ console.log('TenderLead Settings opened. Sign in, create the required scoped token, then set TENDERLEAD_API_TOKEN in your shell.');
690
+ return;
691
+ }
692
+ if (options.command === 'brief') {
693
+ const brief = await fetchBrief(options);
694
+ console.log(options.json ? JSON.stringify(brief, null, 2) : formatBrief(brief));
695
+ return;
696
+ }
697
+ if (options.command === 'tenders') {
698
+ const result = await searchTenders(options);
699
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatTenders(result));
700
+ return;
701
+ }
702
+ if (options.command === 'tender') {
703
+ const result = await scoreTender(options.contractId);
704
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatScore(result));
705
+ return;
706
+ }
707
+ if (options.command === 'pipeline') {
708
+ if (options.action === 'list') return printPayload(await fetchPipeline(), options.json, formatPipeline);
709
+ if (options.action === 'add') return printPayload(await addPipelineItem(options.id), options.json, row => `Added ${line(row.contract_id, options.id)} to ${line(row.stage, 'watching')}.`);
710
+ if (!(await requireWriteConfirmation(options, options.action === 'remove' ? 'REMOVE' : 'UPDATE', `${options.action === 'remove' ? 'Remove' : 'Update'} pipeline item ${options.id}.`))) return console.log('Pipeline unchanged.');
711
+ if (options.action === 'update') return printPayload(await updatePipelineItem(options.id, readJsonFile(options.file)), options.json, row => `Updated pipeline item ${line(row.id, options.id)}.`);
712
+ return printPayload(await removePipelineItem(options.id), options.json, () => `Removed pipeline item ${options.id}.`);
713
+ }
714
+ if (options.command === 'insights') return printPayload(await fetchInsights(), options.json);
715
+ if (options.command === 'feedback') {
716
+ const result = options.action === 'tender' ? await sendTenderFeedback(options) : await sendProductFeedback(options);
717
+ return printPayload(result, options.json, () => 'Feedback saved.');
718
+ }
719
+ if (options.command === 'bid-profile') {
720
+ if (options.action === 'show') return printPayload(await fetchBidProfile(), options.json);
721
+ const profile = readJsonFile(options.file);
722
+ if (!(await requireWriteConfirmation(options, 'APPLY', 'Replace the bid workspace profile with the supplied JSON.'))) return console.log('Bid profile unchanged.');
723
+ return printPayload(await updateBidProfile(profile), options.json, () => 'Bid profile saved.');
724
+ }
725
+ if (options.command === 'outlines') {
726
+ if (options.action === 'show') return printPayload(await fetchOutline(options.contractId), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
727
+ if (!(await requireWriteConfirmation(options, 'GENERATE', `Generate${options.refresh ? ' or refresh' : ''} the bid spec for ${options.contractId}.`))) return console.log('Outline unchanged.');
728
+ return printPayload(await createOutline(options.contractId, options.refresh), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
729
+ }
730
+ if (options.command === 'evidence') {
731
+ if (options.action === 'list') return printPayload(await fetchEvidence(), options.json);
732
+ if (options.action === 'enrich') {
733
+ if (!(await requireWriteConfirmation(options, 'ENRICH', 'Run public evidence enrichment for this account.'))) return console.log('Evidence unchanged.');
734
+ return printPayload(await enrichEvidence(), options.json);
735
+ }
736
+ if (options.action === 'upload') {
737
+ if (!(await requireWriteConfirmation(options, 'UPLOAD', `Upload ${options.files.length} evidence document${options.files.length === 1 ? '' : 's'}.`))) return console.log('Evidence unchanged.');
738
+ return printPayload(await uploadEvidence(options.files, options.documentType), options.json);
739
+ }
740
+ const verb = options.operation === 'update' ? 'UPDATE' : 'REMOVE';
741
+ if (!(await requireWriteConfirmation(options, verb, `${options.operation === 'update' ? 'Update' : 'Remove'} evidence ${options.action} ${options.id}.`))) return console.log('Evidence unchanged.');
742
+ if (options.action === 'record' && options.operation === 'update') return printPayload(await updateEvidenceRecord(options.id, options.reviewStatus), options.json);
743
+ if (options.action === 'record') return printPayload(await removeEvidenceRecord(options.id), options.json, () => `Removed evidence record ${options.id}.`);
744
+ return printPayload(await removeEvidenceDocument(options.id), options.json, () => `Removed evidence document ${options.id}.`);
745
+ }
746
+ if (options.command === 'templates') {
747
+ if (options.action === 'list') {
748
+ const payload = await fetchTemplates();
749
+ return printPayload(payload, options.json, value => formatList('TenderLead bid templates', value.templates || [], row => `${line(row.file_name, 'Unnamed')} · ${line(row.id, 'unknown')}`));
750
+ }
751
+ if (options.action === 'upload') {
752
+ if (!(await requireWriteConfirmation(options, 'UPLOAD', `Upload ${options.files.length} bid template${options.files.length === 1 ? '' : 's'}.`))) return console.log('Templates unchanged.');
753
+ return printPayload(await uploadTemplates(options.files, options.role), options.json);
754
+ }
755
+ if (!(await requireWriteConfirmation(options, 'REMOVE', `Remove bid template ${options.id}.`))) return console.log('Templates unchanged.');
756
+ return printPayload(await removeTemplate(options.id), options.json, () => `Removed bid template ${options.id}.`);
757
+ }
758
+ if (options.command === 'drafts') {
759
+ if (options.action === 'list') {
760
+ const payload = await fetchDrafts();
761
+ return printPayload(payload, options.json, value => formatList('TenderLead bid drafts', value.drafts || [], row => `${line(row.title, 'Untitled')} · ${line(row.contract_id, 'unknown')}`));
762
+ }
763
+ if (options.action === 'show') return printPayload(await fetchDraft(options.contractId, options.workspace), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
764
+ if (options.action === 'export') {
765
+ if (fs.existsSync(options.output) && !options.yes) throw new Error('Output file already exists. Re-run with --yes to replace it.');
766
+ const result = await exportDraft(options.contractId, options.format, options.output);
767
+ return console.log(`Saved ${result.bytes} bytes to ${result.output}.`);
768
+ }
769
+ if (!(await requireWriteConfirmation(options, options.action === 'import' ? 'IMPORT' : 'GENERATE', `${options.action === 'import' ? 'Import workspace answers for' : 'Generate or update draft for'} ${options.contractId}.`))) return console.log('Draft unchanged.');
770
+ if (options.action === 'import') return printPayload(await importDraft(options.contractId, options.file), options.json);
771
+ return printPayload(await createDraft(options.contractId, options), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
772
+ }
773
+ if (options.command === 'evidence-packs') {
774
+ if (options.action === 'list') {
775
+ const payload = await fetchEvidencePacks();
776
+ return printPayload(payload, options.json, value => formatList('TenderLead evidence packs', value.packs || [], row => `${line(row.title, 'Untitled')} · ${line(row.contract_id, 'unknown')}`));
777
+ }
778
+ if (options.action === 'show') return printPayload(await fetchEvidencePack(options.contractId), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
779
+ if (!(await requireWriteConfirmation(options, 'GENERATE', `Generate${options.refresh ? ' or refresh' : ''} the evidence pack for ${options.contractId}.`))) return console.log('Evidence pack unchanged.');
780
+ return printPayload(await createEvidencePack(options.contractId, options), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
781
+ }
782
+ if (options.action === 'template') {
783
+ console.log(JSON.stringify(profileTemplate(), null, 2));
784
+ return;
785
+ }
786
+ if (options.action === 'validate') {
787
+ readProfileFile(options.file);
788
+ console.log('Profile JSON is valid.');
76
789
  return;
77
790
  }
78
- const brief = await fetchBrief(options);
79
- console.log(options.json ? JSON.stringify(brief, null, 2) : formatBrief(brief));
791
+ if (options.action === 'show') {
792
+ const profile = await fetchProfile();
793
+ console.log(options.json ? JSON.stringify(profile, null, 2) : formatProfile(profile));
794
+ return;
795
+ }
796
+
797
+ const proposed = readProfileFile(options.file);
798
+ const current = await fetchProfile();
799
+ if ('size_min' in proposed || 'size_max' in proposed) {
800
+ validateProfile({
801
+ size_min: 'size_min' in proposed ? proposed.size_min : current.size_min,
802
+ size_max: 'size_max' in proposed ? proposed.size_max : current.size_max,
803
+ });
804
+ }
805
+ const changes = profileDiff(current, proposed);
806
+ console.log(formatProfileDiff(changes));
807
+ if (!changes.length) return;
808
+ if (!options.yes) {
809
+ if (!process.stdin.isTTY) throw new Error('Interactive confirmation requires a terminal. Re-run with --yes after reviewing the diff.');
810
+ if (!(await confirmApply())) {
811
+ console.log('Profile unchanged.');
812
+ return;
813
+ }
814
+ }
815
+ await updateProfile(proposed);
816
+ console.log(`Saved ${changes.length} profile change${changes.length === 1 ? '' : 's'}.`);
80
817
  }
81
818
 
82
819
  if (require.main === module) {
83
820
  main().catch((error) => { console.error(error.message); process.exitCode = 1; });
84
821
  }
85
822
 
86
- module.exports = { parseArgs, openLogin, formatBrief, fetchBrief };
823
+ module.exports = {
824
+ parseArgs, openLogin, formatBrief, formatTenders, formatScore, fetchBrief, searchTenders, scoreTender, fetchProfile, updateProfile,
825
+ validateProfile, readProfileFile, profileTemplate, profileDiff, formatProfile,
826
+ formatProfileDiff, confirmApply, confirmWord, readJsonFile, contentTypeForFile, appendFiles,
827
+ fetchPipeline, addPipelineItem, updatePipelineItem, removePipelineItem, fetchInsights, sendTenderFeedback, sendProductFeedback,
828
+ fetchBidProfile, updateBidProfile, fetchOutline, createOutline, fetchEvidence, enrichEvidence, uploadEvidence,
829
+ updateEvidenceRecord, removeEvidenceRecord, removeEvidenceDocument, fetchTemplates, uploadTemplates, removeTemplate,
830
+ fetchDrafts, fetchDraft, createDraft, exportDraft, importDraft, fetchEvidencePacks, fetchEvidencePack, createEvidencePack,
831
+ };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@tenderlead/cli",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "private": false,
5
- "description": "Read-only TenderLead daily briefing CLI",
5
+ "description": "Run TenderLead tender, profile, pipeline, insight and bid-workspace workflows from the terminal",
6
6
  "homepage": "https://tenderlead.co.uk",
7
7
  "files": [
8
8
  "bin",