@tenderlead/cli 0.2.0 → 0.4.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
- Search and score tenders, read your TenderLead daily briefing and manage the firm profile used for matching 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
 
@@ -21,9 +21,13 @@ Sign in with Google or an emailed sign-in link, then create a scoped token under
21
21
 
22
22
  ```bash
23
23
  export TENDERLEAD_API_TOKEN="paste-token-here"
24
+ tenderlead whoami
24
25
  tenderlead brief
25
26
  ```
26
27
 
28
+ `whoami` confirms which account and token the CLI is using (masked email, plan,
29
+ token label, scopes and expiry). It works with any valid token.
30
+
27
31
  Use JSON output for scripts and agents:
28
32
 
29
33
  ```bash
@@ -69,11 +73,56 @@ tenderlead profile apply --file tenderlead-profile.json
69
73
  The apply command shows the field-level changes and asks you to type `APPLY`
70
74
  before saving. Trusted automation can use `--yes` after reviewing the same diff.
71
75
 
76
+ ## Manage the pipeline and feedback loop
77
+
78
+ ```bash
79
+ tenderlead pipeline list
80
+ tenderlead pipeline add CONTRACT_ID
81
+ tenderlead pipeline update PIPELINE_ITEM_ID --file pipeline-change.json
82
+ tenderlead pipeline remove PIPELINE_ITEM_ID
83
+ tenderlead insights --json
84
+ tenderlead feedback tender CONTRACT_ID positive --note "Fits our current delivery model"
85
+ tenderlead feedback product --area pipeline --note "Keep the buyer visible when I change stage"
86
+ ```
87
+
88
+ Pipeline updates accept the same JSON fields as the product: `stage`, `notes`,
89
+ `outcome_summary`, `outcome_reason`, `outcome_competitor` and `outcome_value`.
90
+
91
+ ## Use the bid workspace
92
+
93
+ Team accounts can use their bid profile, specs, evidence, templates, drafts and
94
+ evidence packs from the CLI:
95
+
96
+ ```bash
97
+ tenderlead bid-profile show --json
98
+ tenderlead bid-profile apply --file bid-profile.json
99
+ tenderlead outlines create CONTRACT_ID
100
+ tenderlead evidence list --json
101
+ tenderlead evidence upload --file certificate.pdf --type certificate
102
+ tenderlead templates upload --file house-style.docx --role primary
103
+ tenderlead drafts create CONTRACT_ID --workspace
104
+ tenderlead drafts export CONTRACT_ID --format xlsx --output workspace.xlsx
105
+ tenderlead drafts import CONTRACT_ID --file workspace.xlsx
106
+ tenderlead evidence-packs create CONTRACT_ID
107
+ ```
108
+
109
+ For draft workspace edits or evidence-pack workspace state, pass the existing
110
+ request JSON with `--file`. The CLI sets `contract_id` from the command and sends
111
+ the remaining fields to the same handler used by the signed-in product.
112
+
113
+ Commands that replace, import, update or remove saved data ask for a typed
114
+ confirmation. Reviewed automation can pass `--yes`. Run `tenderlead --help` for
115
+ the full command list.
116
+
72
117
  Tokens are customer-scoped, revocable, expire after 90 days, and are subject to
73
118
  the account's current TenderLead plan. Brief and tender-result reads are
74
119
  read-only. Profile updates require `profile:write`; on-demand scoring requires
75
- `tenders:write`. Do not commit a token to a repository or paste it into a shared
76
- prompt. Revoke exposed tokens in Settings.
120
+ `tenders:write`; pipeline, feedback, insight and workspace routes use their own
121
+ scopes. Do not commit a token to a repository or paste it into a shared prompt.
122
+ Revoke exposed tokens in Settings.
123
+
124
+ Sign-in, token creation and revocation, Stripe checkout and billing, and
125
+ permanent account deletion remain browser tasks.
77
126
 
78
127
  Provider acceptance and API responses do not prove email inbox delivery.
79
128
 
package/bin/tenderlead.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  const fs = require('node:fs');
4
+ const path = require('node:path');
4
5
  const readline = require('node:readline/promises');
5
6
 
6
7
  const DEFAULT_BASE_URL = 'https://tenderlead.co.uk';
@@ -16,6 +17,7 @@ function usage() {
16
17
  return [
17
18
  'Usage:',
18
19
  ' tenderlead login',
20
+ ' tenderlead whoami [--json]',
19
21
  ' tenderlead brief [--json]',
20
22
  ' tenderlead tenders search [--query <text>] [--recommendation bid|skip] [--all] [--limit <1-50>] [--json]',
21
23
  ' tenderlead tender score <contract-id> [--json]',
@@ -23,6 +25,33 @@ function usage() {
23
25
  ' tenderlead profile template',
24
26
  ' tenderlead profile validate --file <path>',
25
27
  ' tenderlead profile apply --file <path> [--yes]',
28
+ ' tenderlead pipeline list [--json]',
29
+ ' tenderlead pipeline add <contract-id> [--json]',
30
+ ' tenderlead pipeline update <item-id> --file <changes.json> [--yes] [--json]',
31
+ ' tenderlead pipeline remove <item-id> [--yes]',
32
+ ' tenderlead insights [--json]',
33
+ ' tenderlead feedback tender <contract-id> positive|negative [--reason <reason>] [--note <text>]',
34
+ ' tenderlead feedback product --note <text> [--type <type>] [--area <area>] [--contract-id <id>] [--section-id <id>]',
35
+ ' tenderlead bid-profile show [--json]',
36
+ ' tenderlead bid-profile apply --file <profile.json> [--yes]',
37
+ ' tenderlead outlines show <contract-id> [--json]',
38
+ ' tenderlead outlines create <contract-id> [--refresh] [--yes] [--json]',
39
+ ' tenderlead evidence list|enrich [--json]',
40
+ ' tenderlead evidence upload --file <path> [--file <path>...] [--type <type>] [--yes] [--json]',
41
+ ' tenderlead evidence record update <record-id> --status approved|rejected|pending [--yes]',
42
+ ' tenderlead evidence record remove <record-id> [--yes]',
43
+ ' tenderlead evidence document remove <document-id> [--yes]',
44
+ ' tenderlead templates list [--json]',
45
+ ' tenderlead templates upload --file <path> [--file <path>...] [--role <role>] [--yes] [--json]',
46
+ ' tenderlead templates remove <template-id> [--yes]',
47
+ ' tenderlead drafts list [--json]',
48
+ ' tenderlead drafts show <contract-id> [--workspace] [--json]',
49
+ ' tenderlead drafts create <contract-id> [--file <request.json>] [--refresh] [--workspace] [--yes] [--json]',
50
+ ' tenderlead drafts export <contract-id> --format text|docx|xlsx --output <path> [--yes]',
51
+ ' tenderlead drafts import <contract-id> --file <workspace.xlsx> [--yes]',
52
+ ' tenderlead evidence-packs list [--json]',
53
+ ' tenderlead evidence-packs show <contract-id> [--json]',
54
+ ' tenderlead evidence-packs create <contract-id> [--file <request.json>] [--refresh] [--yes] [--json]',
26
55
  '',
27
56
  'Login opens TenderLead Settings so you can create a scoped CLI token.',
28
57
  'Set TENDERLEAD_API_TOKEN to that token before reading or updating account data.',
@@ -32,6 +61,7 @@ function usage() {
32
61
  function parseArgs(argv) {
33
62
  const command = argv[2];
34
63
  if (command === 'login') return { command: 'login' };
64
+ if (command === 'whoami') return parseJsonOnlyArgs(argv, 3, { command: 'whoami', json: false });
35
65
  if (command === 'brief') {
36
66
  const options = { command: 'brief', json: false };
37
67
  for (let index = 3; index < argv.length; index += 1) {
@@ -82,7 +112,16 @@ function parseArgs(argv) {
82
112
  }
83
113
  return options;
84
114
  }
85
- if (command !== 'profile') throw new Error('Use the login, brief, tenders, tender or profile command.');
115
+ if (command === 'pipeline') return parsePipelineArgs(argv);
116
+ if (command === 'insights') return parseJsonOnlyArgs(argv, 3, { command: 'insights', json: false });
117
+ if (command === 'feedback') return parseFeedbackArgs(argv);
118
+ if (command === 'bid-profile') return parseFileResourceArgs(argv, 'bid-profile', ['show', 'apply']);
119
+ if (command === 'outlines') return parseContractResourceArgs(argv, 'outlines', ['show', 'create']);
120
+ if (command === 'evidence') return parseEvidenceArgs(argv);
121
+ if (command === 'templates') return parseTemplatesArgs(argv);
122
+ if (command === 'drafts') return parseDraftArgs(argv);
123
+ if (command === 'evidence-packs') return parseContractResourceArgs(argv, 'evidence-packs', ['list', 'show', 'create']);
124
+ if (command !== 'profile') throw new Error('Unknown command. Run tenderlead --help for the full command list.');
86
125
 
87
126
  const action = argv[3];
88
127
  if (!['show', 'template', 'validate', 'apply'].includes(action)) {
@@ -105,6 +144,196 @@ function parseArgs(argv) {
105
144
  return options;
106
145
  }
107
146
 
147
+ function optionValue(argv, index, flag) {
148
+ const value = argv[index + 1];
149
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
150
+ return value;
151
+ }
152
+
153
+ function parseJsonOnlyArgs(argv, start, options) {
154
+ for (let index = start; index < argv.length; index += 1) {
155
+ const arg = argv[index];
156
+ if (arg === '--json') options.json = true;
157
+ else if (arg === '--help' || arg === '-h') options.help = true;
158
+ else throw new Error(`Unknown argument: ${arg}`);
159
+ }
160
+ return options;
161
+ }
162
+
163
+ function parsePipelineArgs(argv) {
164
+ const action = argv[3];
165
+ if (!['list', 'add', 'update', 'remove'].includes(action)) throw new Error('Use pipeline list, add, update or remove.');
166
+ const needsId = action !== 'list';
167
+ const id = needsId ? argv[4] : null;
168
+ if (needsId && (!id || id.startsWith('--'))) throw new Error(`pipeline ${action} requires an ID`);
169
+ const options = { command: 'pipeline', action, id, file: null, yes: false, json: false };
170
+ for (let index = needsId ? 5 : 4; index < argv.length; index += 1) {
171
+ const arg = argv[index];
172
+ if (arg === '--json') options.json = true;
173
+ else if (arg === '--yes') options.yes = true;
174
+ else if (arg === '--file' && action === 'update') { options.file = optionValue(argv, index, arg); index += 1; }
175
+ else if (arg === '--help' || arg === '-h') options.help = true;
176
+ else throw new Error(`Unknown argument: ${arg}`);
177
+ }
178
+ if (action === 'update' && !options.file) throw new Error('pipeline update requires --file <changes.json>');
179
+ return options;
180
+ }
181
+
182
+ function parseFeedbackArgs(argv) {
183
+ const action = argv[3];
184
+ if (action === 'tender') {
185
+ const contractId = argv[4];
186
+ const response = argv[5];
187
+ if (!contractId || !['positive', 'negative'].includes(response)) throw new Error('Use feedback tender <contract-id> positive|negative.');
188
+ const options = { command: 'feedback', action, contractId, response, reason: null, note: null, json: false };
189
+ for (let index = 6; index < argv.length; index += 1) {
190
+ const arg = argv[index];
191
+ if (arg === '--json') options.json = true;
192
+ else if (arg === '--reason' || arg === '--note') { options[arg.slice(2)] = optionValue(argv, index, arg); index += 1; }
193
+ else throw new Error(`Unknown argument: ${arg}`);
194
+ }
195
+ return options;
196
+ }
197
+ if (action !== 'product') throw new Error('Use feedback tender or feedback product.');
198
+ const options = { command: 'feedback', action, note: null, feedbackType: 'general', area: null, contextPath: '/app#insights', contractId: null, sectionId: null, json: false };
199
+ const flags = { '--note': 'note', '--type': 'feedbackType', '--area': 'area', '--context': 'contextPath', '--contract-id': 'contractId', '--section-id': 'sectionId' };
200
+ for (let index = 4; index < argv.length; index += 1) {
201
+ const arg = argv[index];
202
+ if (arg === '--json') options.json = true;
203
+ else if (flags[arg]) { options[flags[arg]] = optionValue(argv, index, arg); index += 1; }
204
+ else throw new Error(`Unknown argument: ${arg}`);
205
+ }
206
+ if (!options.note) throw new Error('feedback product requires --note <text>');
207
+ return options;
208
+ }
209
+
210
+ function parseFileResourceArgs(argv, command, actions) {
211
+ const action = argv[3];
212
+ if (!actions.includes(action)) throw new Error(`Use ${command} ${actions.join(' or ')}.`);
213
+ const options = { command, action, file: null, yes: false, json: false };
214
+ for (let index = 4; index < argv.length; index += 1) {
215
+ const arg = argv[index];
216
+ if (arg === '--json') options.json = true;
217
+ else if (arg === '--yes') options.yes = true;
218
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
219
+ else throw new Error(`Unknown argument: ${arg}`);
220
+ }
221
+ if (action === 'apply' && !options.file) throw new Error(`${command} apply requires --file <path>`);
222
+ return options;
223
+ }
224
+
225
+ function parseContractResourceArgs(argv, command, actions) {
226
+ const action = argv[3];
227
+ if (!actions.includes(action)) throw new Error(`Use ${command} ${actions.join(', ')}.`);
228
+ const needsId = action !== 'list';
229
+ const contractId = needsId ? argv[4] : null;
230
+ if (needsId && (!contractId || contractId.startsWith('--'))) throw new Error(`${command} ${action} requires a contract ID`);
231
+ const options = { command, action, contractId, file: null, refresh: false, yes: false, json: false };
232
+ for (let index = needsId ? 5 : 4; index < argv.length; index += 1) {
233
+ const arg = argv[index];
234
+ if (arg === '--json') options.json = true;
235
+ else if (arg === '--refresh') options.refresh = true;
236
+ else if (arg === '--yes') options.yes = true;
237
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
238
+ else throw new Error(`Unknown argument: ${arg}`);
239
+ }
240
+ return options;
241
+ }
242
+
243
+ function parseEvidenceArgs(argv) {
244
+ const action = argv[3];
245
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'evidence', action, json: false });
246
+ if (action === 'enrich') {
247
+ const options = { command: 'evidence', action, yes: false, json: false };
248
+ for (let index = 4; index < argv.length; index += 1) {
249
+ const arg = argv[index];
250
+ if (arg === '--json') options.json = true;
251
+ else if (arg === '--yes') options.yes = true;
252
+ else throw new Error(`Unknown argument: ${arg}`);
253
+ }
254
+ return options;
255
+ }
256
+ if (action === 'upload') {
257
+ const options = { command: 'evidence', action, files: [], documentType: null, yes: false, json: false };
258
+ for (let index = 4; index < argv.length; index += 1) {
259
+ const arg = argv[index];
260
+ if (arg === '--json') options.json = true;
261
+ else if (arg === '--yes') options.yes = true;
262
+ else if (arg === '--file') { options.files.push(optionValue(argv, index, arg)); index += 1; }
263
+ else if (arg === '--type') { options.documentType = optionValue(argv, index, arg); index += 1; }
264
+ else throw new Error(`Unknown argument: ${arg}`);
265
+ }
266
+ if (!options.files.length) throw new Error('evidence upload requires at least one --file <path>');
267
+ return options;
268
+ }
269
+ if (!['record', 'document'].includes(action)) throw new Error('Use evidence list, enrich, upload, record or document.');
270
+ const operation = argv[4];
271
+ const id = argv[5];
272
+ if (!id || !['update', 'remove'].includes(operation) || (action === 'document' && operation !== 'remove')) throw new Error(`Use evidence ${action} ${action === 'record' ? 'update|remove' : 'remove'} <id>.`);
273
+ const options = { command: 'evidence', action, operation, id, reviewStatus: null, yes: false, json: false };
274
+ for (let index = 6; index < argv.length; index += 1) {
275
+ const arg = argv[index];
276
+ if (arg === '--json') options.json = true;
277
+ else if (arg === '--yes') options.yes = true;
278
+ else if (arg === '--status' && operation === 'update') { options.reviewStatus = optionValue(argv, index, arg); index += 1; }
279
+ else throw new Error(`Unknown argument: ${arg}`);
280
+ }
281
+ if (operation === 'update' && !['approved', 'rejected', 'pending'].includes(options.reviewStatus)) throw new Error('--status must be approved, rejected or pending');
282
+ return options;
283
+ }
284
+
285
+ function parseTemplatesArgs(argv) {
286
+ const action = argv[3];
287
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'templates', action, json: false });
288
+ if (action === 'remove') {
289
+ const id = argv[4];
290
+ if (!id || id.startsWith('--')) throw new Error('templates remove requires a template ID');
291
+ const options = { command: 'templates', action, id, yes: false, json: false };
292
+ for (let index = 5; index < argv.length; index += 1) {
293
+ const arg = argv[index];
294
+ if (arg === '--json') options.json = true;
295
+ else if (arg === '--yes') options.yes = true;
296
+ else throw new Error(`Unknown argument: ${arg}`);
297
+ }
298
+ return options;
299
+ }
300
+ if (action !== 'upload') throw new Error('Use templates list, upload or remove.');
301
+ const options = { command: 'templates', action, files: [], role: null, yes: false, json: false };
302
+ for (let index = 4; index < argv.length; index += 1) {
303
+ const arg = argv[index];
304
+ if (arg === '--json') options.json = true;
305
+ else if (arg === '--yes') options.yes = true;
306
+ else if (arg === '--file') { options.files.push(optionValue(argv, index, arg)); index += 1; }
307
+ else if (arg === '--role') { options.role = optionValue(argv, index, arg); index += 1; }
308
+ else throw new Error(`Unknown argument: ${arg}`);
309
+ }
310
+ if (!options.files.length) throw new Error('templates upload requires at least one --file <path>');
311
+ return options;
312
+ }
313
+
314
+ function parseDraftArgs(argv) {
315
+ const action = argv[3];
316
+ if (action === 'list') return parseJsonOnlyArgs(argv, 4, { command: 'drafts', action, json: false });
317
+ if (!['show', 'create', 'export', 'import'].includes(action)) throw new Error('Use drafts list, show, create, export or import.');
318
+ const contractId = argv[4];
319
+ if (!contractId || contractId.startsWith('--')) throw new Error(`drafts ${action} requires a contract ID`);
320
+ const options = { command: 'drafts', action, contractId, file: null, refresh: false, workspace: false, format: null, output: null, yes: false, json: false };
321
+ for (let index = 5; index < argv.length; index += 1) {
322
+ const arg = argv[index];
323
+ if (arg === '--json') options.json = true;
324
+ else if (arg === '--yes') options.yes = true;
325
+ else if (arg === '--refresh') options.refresh = true;
326
+ else if (arg === '--workspace') options.workspace = true;
327
+ else if (arg === '--file') { options.file = optionValue(argv, index, arg); index += 1; }
328
+ else if (arg === '--format') { options.format = optionValue(argv, index, arg); index += 1; }
329
+ else if (arg === '--output') { options.output = optionValue(argv, index, arg); index += 1; }
330
+ else throw new Error(`Unknown argument: ${arg}`);
331
+ }
332
+ if (action === 'export' && (!['text', 'docx', 'xlsx'].includes(options.format) || !options.output)) throw new Error('drafts export requires --format text|docx|xlsx and --output <path>');
333
+ if (action === 'import' && !options.file) throw new Error('drafts import requires --file <workspace.xlsx>');
334
+ return options;
335
+ }
336
+
108
337
  function openLogin(spawnImpl = require('node:child_process').spawnSync) {
109
338
  const url = `${DEFAULT_BASE_URL}${LOGIN_PATH}`;
110
339
  const command = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
@@ -119,6 +348,19 @@ function line(value, fallback) {
119
348
  return text || fallback;
120
349
  }
121
350
 
351
+ function formatWhoami(payload) {
352
+ const token = payload.token || {};
353
+ const rows = [
354
+ ['Account', line(payload.email, 'unknown')],
355
+ ['Plan', `${line(payload.plan_tier, 'unknown')} (${line(payload.plan_status, 'unknown')})`],
356
+ ['Token', line(token.label, 'unnamed')],
357
+ ['Scopes', Array.isArray(token.scopes) && token.scopes.length ? token.scopes.join(', ') : 'none'],
358
+ ['Expires', token.expires_at ? String(token.expires_at).slice(0, 10) : 'unknown'],
359
+ ];
360
+ const width = Math.max(...rows.map(([label]) => label.length));
361
+ return rows.map(([label, value]) => `${label.padEnd(width)} ${value}`).join('\n');
362
+ }
363
+
122
364
  function formatBrief(payload) {
123
365
  const rows = Array.isArray(payload.opportunities) ? payload.opportunities : [];
124
366
  const lines = ['# TenderLead daily brief', '', `Generated: ${line(payload.generated_at, 'Not recorded')}`, `Plan: ${line(payload.tier, 'Not recorded')}`, '', '## Opportunities', ''];
@@ -164,12 +406,13 @@ function apiToken() {
164
406
  }
165
407
 
166
408
  async function apiRequest(path, options = {}, fetchImpl = fetch) {
409
+ const isForm = typeof FormData !== 'undefined' && options.body instanceof FormData;
167
410
  const response = await fetchImpl(`${DEFAULT_BASE_URL}${path}`, {
168
411
  ...options,
169
412
  headers: {
170
413
  Accept: 'application/json',
171
414
  Authorization: `Bearer ${apiToken()}`,
172
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
415
+ ...(options.body && !isForm ? { 'Content-Type': 'application/json' } : {}),
173
416
  ...(options.headers || {}),
174
417
  },
175
418
  });
@@ -178,6 +421,8 @@ async function apiRequest(path, options = {}, fetchImpl = fetch) {
178
421
  return body;
179
422
  }
180
423
 
424
+ async function fetchWhoami(fetchImpl = fetch) { return apiRequest('/api/cli/whoami', {}, fetchImpl); }
425
+
181
426
  async function fetchBrief(_options, fetchImpl = fetch) {
182
427
  return apiRequest('/v1/briefs/daily', {}, fetchImpl);
183
428
  }
@@ -204,6 +449,96 @@ async function updateProfile(profile, fetchImpl = fetch) {
204
449
  return payload.profile || {};
205
450
  }
206
451
 
452
+ function readJsonFile(file, readFileImpl = fs.readFileSync) {
453
+ try {
454
+ const value = JSON.parse(readFileImpl(file, 'utf8'));
455
+ if (!isPlainObject(value)) throw new Error('JSON root must be an object');
456
+ return value;
457
+ } catch (error) {
458
+ if (error instanceof SyntaxError) throw new Error(`File is not valid JSON: ${error.message}`);
459
+ throw new Error(`Could not read JSON file: ${error.message}`);
460
+ }
461
+ }
462
+
463
+ function contentTypeForFile(file) {
464
+ const extension = path.extname(file).toLowerCase();
465
+ return ({
466
+ '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
467
+ '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
468
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.md': 'text/markdown', '.html': 'text/html',
469
+ })[extension] || 'application/octet-stream';
470
+ }
471
+
472
+ function appendFiles(form, files, field = 'files', readFileImpl = fs.readFileSync) {
473
+ for (const file of files) {
474
+ const bytes = readFileImpl(file);
475
+ form.append(field, new Blob([bytes], { type: contentTypeForFile(file) }), path.basename(file));
476
+ }
477
+ return form;
478
+ }
479
+
480
+ async function fetchPipeline(fetchImpl = fetch) { return apiRequest('/v1/pipeline', {}, fetchImpl); }
481
+ async function addPipelineItem(contractId, fetchImpl = fetch) { return apiRequest('/v1/pipeline', { method: 'POST', body: JSON.stringify({ contract_id: contractId }) }, fetchImpl); }
482
+ async function updatePipelineItem(id, changes, fetchImpl = fetch) { return apiRequest(`/v1/pipeline/${encodeURIComponent(id)}`, { method: 'PATCH', body: JSON.stringify(changes) }, fetchImpl); }
483
+ async function removePipelineItem(id, fetchImpl = fetch) { return apiRequest(`/v1/pipeline/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
484
+ async function fetchInsights(fetchImpl = fetch) { return apiRequest('/v1/insights', {}, fetchImpl); }
485
+ async function sendTenderFeedback(options, fetchImpl = fetch) {
486
+ return apiRequest('/v1/feedback/tender', { method: 'POST', body: JSON.stringify({ contract_id: options.contractId, response: options.response, reason: options.reason, note: options.note }) }, fetchImpl);
487
+ }
488
+ async function sendProductFeedback(options, fetchImpl = fetch) {
489
+ 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);
490
+ }
491
+ async function fetchBidProfile(fetchImpl = fetch) { const payload = await apiRequest('/v1/bid-profile', {}, fetchImpl); return payload.profile; }
492
+ async function updateBidProfile(profile, fetchImpl = fetch) { const payload = await apiRequest('/v1/bid-profile', { method: 'PUT', body: JSON.stringify(profile) }, fetchImpl); return payload.profile; }
493
+ async function fetchOutline(contractId, fetchImpl = fetch) { return apiRequest(`/v1/outlines/${encodeURIComponent(contractId)}`, {}, fetchImpl); }
494
+ async function createOutline(contractId, refresh, fetchImpl = fetch) { return apiRequest('/v1/outlines', { method: 'POST', body: JSON.stringify({ contract_id: contractId, refresh }) }, fetchImpl); }
495
+ async function fetchEvidence(fetchImpl = fetch) { return apiRequest('/v1/evidence', {}, fetchImpl); }
496
+ async function enrichEvidence(fetchImpl = fetch) { return apiRequest('/v1/evidence/enrich', { method: 'POST' }, fetchImpl); }
497
+ async function uploadEvidence(files, documentType, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
498
+ const form = appendFiles(new FormData(), files, 'files', readFileImpl);
499
+ if (documentType) form.set('document_type', documentType);
500
+ return apiRequest('/v1/evidence/documents', { method: 'POST', body: form }, fetchImpl);
501
+ }
502
+ async function updateEvidenceRecord(id, reviewStatus, fetchImpl = fetch) { return apiRequest(`/v1/evidence/records/${encodeURIComponent(id)}`, { method: 'PUT', body: JSON.stringify({ review_status: reviewStatus }) }, fetchImpl); }
503
+ async function removeEvidenceRecord(id, fetchImpl = fetch) { return apiRequest(`/v1/evidence/records/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
504
+ async function removeEvidenceDocument(id, fetchImpl = fetch) { return apiRequest(`/v1/evidence/documents/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
505
+ async function fetchTemplates(fetchImpl = fetch) { return apiRequest('/v1/templates', {}, fetchImpl); }
506
+ async function uploadTemplates(files, role, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
507
+ const form = appendFiles(new FormData(), files, 'files', readFileImpl);
508
+ if (role) form.set('template_role', role);
509
+ return apiRequest('/v1/templates', { method: 'POST', body: form }, fetchImpl);
510
+ }
511
+ async function removeTemplate(id, fetchImpl = fetch) { return apiRequest(`/v1/templates/${encodeURIComponent(id)}`, { method: 'DELETE' }, fetchImpl); }
512
+ async function fetchDrafts(fetchImpl = fetch) { return apiRequest('/v1/drafts', {}, fetchImpl); }
513
+ async function fetchDraft(contractId, workspace, fetchImpl = fetch) { return apiRequest(`/v1/drafts/${encodeURIComponent(contractId)}${workspace ? '?workspace=1' : ''}`, {}, fetchImpl); }
514
+ async function createDraft(contractId, options, fetchImpl = fetch) {
515
+ const body = { ...(options.file ? readJsonFile(options.file) : {}), contract_id: contractId };
516
+ if (options.refresh) body.refresh = true;
517
+ if (options.workspace) body.workspace = true;
518
+ return apiRequest('/v1/drafts', { method: 'POST', body: JSON.stringify(body) }, fetchImpl);
519
+ }
520
+ async function exportDraft(contractId, format, output, fetchImpl = fetch, writeFileImpl = fs.writeFileSync) {
521
+ const response = await fetchImpl(`${DEFAULT_BASE_URL}/v1/drafts/${encodeURIComponent(contractId)}?export=${encodeURIComponent(format)}`, { headers: { Authorization: `Bearer ${apiToken()}` } });
522
+ if (!response.ok) {
523
+ const body = await response.json().catch(() => ({}));
524
+ throw new Error(body.error || `TenderLead API failed: ${response.status}`);
525
+ }
526
+ const bytes = Buffer.from(await response.arrayBuffer());
527
+ writeFileImpl(output, bytes);
528
+ return { output, bytes: bytes.length };
529
+ }
530
+ async function importDraft(contractId, file, fetchImpl = fetch, readFileImpl = fs.readFileSync) {
531
+ const form = appendFiles(new FormData(), [file], 'file', readFileImpl);
532
+ return apiRequest(`/v1/drafts/${encodeURIComponent(contractId)}/import`, { method: 'POST', body: form }, fetchImpl);
533
+ }
534
+ async function fetchEvidencePacks(fetchImpl = fetch) { return apiRequest('/v1/evidence-packs', {}, fetchImpl); }
535
+ async function fetchEvidencePack(contractId, fetchImpl = fetch) { return apiRequest(`/v1/evidence-packs/${encodeURIComponent(contractId)}`, {}, fetchImpl); }
536
+ async function createEvidencePack(contractId, options, fetchImpl = fetch) {
537
+ const body = { ...(options.file ? readJsonFile(options.file) : {}), contract_id: contractId };
538
+ if (options.refresh) body.refresh = true;
539
+ return apiRequest('/v1/evidence-packs', { method: 'POST', body: JSON.stringify(body) }, fetchImpl);
540
+ }
541
+
207
542
  function isPlainObject(value) {
208
543
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
209
544
  }
@@ -323,6 +658,46 @@ async function confirmApply(input = process.stdin, output = process.stdout) {
323
658
  }
324
659
  }
325
660
 
661
+ async function confirmWord(word, message, input = process.stdin, output = process.stdout) {
662
+ const prompt = readline.createInterface({ input, output });
663
+ try {
664
+ return (await prompt.question(`${message} Type ${word} to continue: `)).trim() === word;
665
+ } finally {
666
+ prompt.close();
667
+ }
668
+ }
669
+
670
+ async function requireWriteConfirmation(options, word, message) {
671
+ if (options.yes) return true;
672
+ if (!process.stdin.isTTY) throw new Error(`Interactive confirmation requires a terminal. Re-run with --yes after reviewing the command.`);
673
+ return confirmWord(word, message);
674
+ }
675
+
676
+ function printPayload(payload, json, summary) {
677
+ if (json || !summary) console.log(JSON.stringify(payload, null, 2));
678
+ else console.log(summary(payload));
679
+ }
680
+
681
+ function formatPipeline(payload) {
682
+ const stages = ['watching', 'preparing', 'submitted', 'won', 'lost'];
683
+ const lines = ['# TenderLead pipeline', ''];
684
+ for (const stage of stages) {
685
+ const rows = Array.isArray(payload[stage]) ? payload[stage] : [];
686
+ lines.push(`## ${stage[0].toUpperCase()}${stage.slice(1)} (${rows.length})`);
687
+ for (const row of rows) lines.push(`- ${line(row.title, 'Untitled notice')} · item ${line(row.id, 'unknown')} · contract ${line(row.contract_id, 'unknown')}`);
688
+ if (!rows.length) lines.push('- None');
689
+ lines.push('');
690
+ }
691
+ return lines.join('\n').trim();
692
+ }
693
+
694
+ function formatList(title, rows, rowText) {
695
+ const lines = [`# ${title}`, '', `Results: ${rows.length}`, ''];
696
+ if (!rows.length) lines.push('None.');
697
+ for (const row of rows) lines.push(`- ${rowText(row)}`);
698
+ return lines.join('\n');
699
+ }
700
+
326
701
  async function main() {
327
702
  if (process.argv.includes('--help') || process.argv.includes('-h')) return console.log(usage());
328
703
  const options = parseArgs(process.argv);
@@ -331,6 +706,7 @@ async function main() {
331
706
  console.log('TenderLead Settings opened. Sign in, create the required scoped token, then set TENDERLEAD_API_TOKEN in your shell.');
332
707
  return;
333
708
  }
709
+ if (options.command === 'whoami') return printPayload(await fetchWhoami(), options.json, formatWhoami);
334
710
  if (options.command === 'brief') {
335
711
  const brief = await fetchBrief(options);
336
712
  console.log(options.json ? JSON.stringify(brief, null, 2) : formatBrief(brief));
@@ -346,6 +722,81 @@ async function main() {
346
722
  console.log(options.json ? JSON.stringify(result, null, 2) : formatScore(result));
347
723
  return;
348
724
  }
725
+ if (options.command === 'pipeline') {
726
+ if (options.action === 'list') return printPayload(await fetchPipeline(), options.json, formatPipeline);
727
+ 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')}.`);
728
+ if (!(await requireWriteConfirmation(options, options.action === 'remove' ? 'REMOVE' : 'UPDATE', `${options.action === 'remove' ? 'Remove' : 'Update'} pipeline item ${options.id}.`))) return console.log('Pipeline unchanged.');
729
+ if (options.action === 'update') return printPayload(await updatePipelineItem(options.id, readJsonFile(options.file)), options.json, row => `Updated pipeline item ${line(row.id, options.id)}.`);
730
+ return printPayload(await removePipelineItem(options.id), options.json, () => `Removed pipeline item ${options.id}.`);
731
+ }
732
+ if (options.command === 'insights') return printPayload(await fetchInsights(), options.json);
733
+ if (options.command === 'feedback') {
734
+ const result = options.action === 'tender' ? await sendTenderFeedback(options) : await sendProductFeedback(options);
735
+ return printPayload(result, options.json, () => 'Feedback saved.');
736
+ }
737
+ if (options.command === 'bid-profile') {
738
+ if (options.action === 'show') return printPayload(await fetchBidProfile(), options.json);
739
+ const profile = readJsonFile(options.file);
740
+ if (!(await requireWriteConfirmation(options, 'APPLY', 'Replace the bid workspace profile with the supplied JSON.'))) return console.log('Bid profile unchanged.');
741
+ return printPayload(await updateBidProfile(profile), options.json, () => 'Bid profile saved.');
742
+ }
743
+ if (options.command === 'outlines') {
744
+ if (options.action === 'show') return printPayload(await fetchOutline(options.contractId), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
745
+ if (!(await requireWriteConfirmation(options, 'GENERATE', `Generate${options.refresh ? ' or refresh' : ''} the bid spec for ${options.contractId}.`))) return console.log('Outline unchanged.');
746
+ return printPayload(await createOutline(options.contractId, options.refresh), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
747
+ }
748
+ if (options.command === 'evidence') {
749
+ if (options.action === 'list') return printPayload(await fetchEvidence(), options.json);
750
+ if (options.action === 'enrich') {
751
+ if (!(await requireWriteConfirmation(options, 'ENRICH', 'Run public evidence enrichment for this account.'))) return console.log('Evidence unchanged.');
752
+ return printPayload(await enrichEvidence(), options.json);
753
+ }
754
+ if (options.action === 'upload') {
755
+ if (!(await requireWriteConfirmation(options, 'UPLOAD', `Upload ${options.files.length} evidence document${options.files.length === 1 ? '' : 's'}.`))) return console.log('Evidence unchanged.');
756
+ return printPayload(await uploadEvidence(options.files, options.documentType), options.json);
757
+ }
758
+ const verb = options.operation === 'update' ? 'UPDATE' : 'REMOVE';
759
+ if (!(await requireWriteConfirmation(options, verb, `${options.operation === 'update' ? 'Update' : 'Remove'} evidence ${options.action} ${options.id}.`))) return console.log('Evidence unchanged.');
760
+ if (options.action === 'record' && options.operation === 'update') return printPayload(await updateEvidenceRecord(options.id, options.reviewStatus), options.json);
761
+ if (options.action === 'record') return printPayload(await removeEvidenceRecord(options.id), options.json, () => `Removed evidence record ${options.id}.`);
762
+ return printPayload(await removeEvidenceDocument(options.id), options.json, () => `Removed evidence document ${options.id}.`);
763
+ }
764
+ if (options.command === 'templates') {
765
+ if (options.action === 'list') {
766
+ const payload = await fetchTemplates();
767
+ return printPayload(payload, options.json, value => formatList('TenderLead bid templates', value.templates || [], row => `${line(row.file_name, 'Unnamed')} · ${line(row.id, 'unknown')}`));
768
+ }
769
+ if (options.action === 'upload') {
770
+ if (!(await requireWriteConfirmation(options, 'UPLOAD', `Upload ${options.files.length} bid template${options.files.length === 1 ? '' : 's'}.`))) return console.log('Templates unchanged.');
771
+ return printPayload(await uploadTemplates(options.files, options.role), options.json);
772
+ }
773
+ if (!(await requireWriteConfirmation(options, 'REMOVE', `Remove bid template ${options.id}.`))) return console.log('Templates unchanged.');
774
+ return printPayload(await removeTemplate(options.id), options.json, () => `Removed bid template ${options.id}.`);
775
+ }
776
+ if (options.command === 'drafts') {
777
+ if (options.action === 'list') {
778
+ const payload = await fetchDrafts();
779
+ return printPayload(payload, options.json, value => formatList('TenderLead bid drafts', value.drafts || [], row => `${line(row.title, 'Untitled')} · ${line(row.contract_id, 'unknown')}`));
780
+ }
781
+ if (options.action === 'show') return printPayload(await fetchDraft(options.contractId, options.workspace), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
782
+ if (options.action === 'export') {
783
+ if (fs.existsSync(options.output) && !options.yes) throw new Error('Output file already exists. Re-run with --yes to replace it.');
784
+ const result = await exportDraft(options.contractId, options.format, options.output);
785
+ return console.log(`Saved ${result.bytes} bytes to ${result.output}.`);
786
+ }
787
+ 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.');
788
+ if (options.action === 'import') return printPayload(await importDraft(options.contractId, options.file), options.json);
789
+ return printPayload(await createDraft(options.contractId, options), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
790
+ }
791
+ if (options.command === 'evidence-packs') {
792
+ if (options.action === 'list') {
793
+ const payload = await fetchEvidencePacks();
794
+ return printPayload(payload, options.json, value => formatList('TenderLead evidence packs', value.packs || [], row => `${line(row.title, 'Untitled')} · ${line(row.contract_id, 'unknown')}`));
795
+ }
796
+ if (options.action === 'show') return printPayload(await fetchEvidencePack(options.contractId), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
797
+ if (!(await requireWriteConfirmation(options, 'GENERATE', `Generate${options.refresh ? ' or refresh' : ''} the evidence pack for ${options.contractId}.`))) return console.log('Evidence pack unchanged.');
798
+ return printPayload(await createEvidencePack(options.contractId, options), options.json, payload => payload.content || JSON.stringify(payload, null, 2));
799
+ }
349
800
  if (options.action === 'template') {
350
801
  console.log(JSON.stringify(profileTemplate(), null, 2));
351
802
  return;
@@ -388,7 +839,11 @@ if (require.main === module) {
388
839
  }
389
840
 
390
841
  module.exports = {
391
- parseArgs, openLogin, formatBrief, formatTenders, formatScore, fetchBrief, searchTenders, scoreTender, fetchProfile, updateProfile,
842
+ parseArgs, openLogin, fetchWhoami, formatWhoami, formatBrief, formatTenders, formatScore, fetchBrief, searchTenders, scoreTender, fetchProfile, updateProfile,
392
843
  validateProfile, readProfileFile, profileTemplate, profileDiff, formatProfile,
393
- formatProfileDiff, confirmApply,
844
+ formatProfileDiff, confirmApply, confirmWord, readJsonFile, contentTypeForFile, appendFiles,
845
+ fetchPipeline, addPipelineItem, updatePipelineItem, removePipelineItem, fetchInsights, sendTenderFeedback, sendProductFeedback,
846
+ fetchBidProfile, updateBidProfile, fetchOutline, createOutline, fetchEvidence, enrichEvidence, uploadEvidence,
847
+ updateEvidenceRecord, removeEvidenceRecord, removeEvidenceDocument, fetchTemplates, uploadTemplates, removeTemplate,
848
+ fetchDrafts, fetchDraft, createDraft, exportDraft, importDraft, fetchEvidencePacks, fetchEvidencePack, createEvidencePack,
394
849
  };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@tenderlead/cli",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "private": false,
5
- "description": "Search and score tenders, read briefings and manage your firm profile from the terminal",
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",