@tenderlead/cli 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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
+ 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.
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,50 @@ 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
+ Tokens are customer-scoped, revocable, expire after 90 days, and are subject to
73
+ the account's current TenderLead plan. Brief and tender-result reads are
74
+ 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.
37
77
 
38
78
  Provider acceptance and API responses do not prove email inbox delivery.
39
79
 
package/bin/tenderlead.js CHANGED
@@ -1,30 +1,107 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ const fs = require('node:fs');
4
+ const readline = require('node:readline/promises');
5
+
3
6
  const DEFAULT_BASE_URL = 'https://tenderlead.co.uk';
4
7
  const LOGIN_PATH = '/app/settings#api-access';
8
+ const PROFILE_FIELDS = [
9
+ 'company_name', 'legal_name', 'company_number', 'title', 'website', 'team_size', 'specialisms',
10
+ 'past_contracts', 'sectors', 'keywords', 'size_min', 'size_max', 'frameworks',
11
+ 'clearance_capability', 'region', 'market_watch_enabled', 'market_watch_terms',
12
+ 'market_watch_buyers', 'market_watch_note', 'us_readiness_profile', 'no_bid_rules',
13
+ ];
5
14
 
6
15
  function usage() {
7
16
  return [
8
17
  'Usage:',
9
18
  ' tenderlead login',
10
19
  ' tenderlead brief [--json]',
20
+ ' tenderlead tenders search [--query <text>] [--recommendation bid|skip] [--all] [--limit <1-50>] [--json]',
21
+ ' tenderlead tender score <contract-id> [--json]',
22
+ ' tenderlead profile show [--json]',
23
+ ' tenderlead profile template',
24
+ ' tenderlead profile validate --file <path>',
25
+ ' tenderlead profile apply --file <path> [--yes]',
11
26
  '',
12
- 'Login opens TenderLead Settings so you can create a customer-scoped token.',
13
- 'Set TENDERLEAD_API_TOKEN to that token before running brief.',
27
+ 'Login opens TenderLead Settings so you can create a scoped CLI token.',
28
+ 'Set TENDERLEAD_API_TOKEN to that token before reading or updating account data.',
14
29
  ].join('\n');
15
30
  }
16
31
 
17
32
  function parseArgs(argv) {
18
33
  const command = argv[2];
19
34
  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) {
35
+ if (command === 'brief') {
36
+ const options = { command: 'brief', json: false };
37
+ for (let index = 3; index < argv.length; index += 1) {
38
+ const arg = argv[index];
39
+ if (arg === '--json') options.json = true;
40
+ else if (arg === '--help' || arg === '-h') options.help = true;
41
+ else throw new Error(`Unknown argument: ${arg}`);
42
+ }
43
+ return options;
44
+ }
45
+ if (command === 'tenders') {
46
+ if (argv[3] !== 'search') throw new Error('Use tenders search.');
47
+ const options = { command: 'tenders', action: 'search', query: '', recommendation: '', all: false, limit: 20, json: false };
48
+ for (let index = 4; index < argv.length; index += 1) {
49
+ const arg = argv[index];
50
+ if (arg === '--json') options.json = true;
51
+ else if (arg === '--all') options.all = true;
52
+ else if (arg === '--query' || arg === '--recommendation' || arg === '--limit') {
53
+ const value = argv[index + 1];
54
+ if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value`);
55
+ if (arg === '--query') options.query = value;
56
+ if (arg === '--recommendation') {
57
+ if (!['bid', 'skip'].includes(value)) throw new Error('--recommendation must be bid or skip');
58
+ options.recommendation = value;
59
+ }
60
+ if (arg === '--limit') {
61
+ const limit = Number(value);
62
+ if (!Number.isInteger(limit) || limit < 1 || limit > 50) throw new Error('--limit must be an integer from 1 to 50');
63
+ options.limit = limit;
64
+ }
65
+ index += 1;
66
+ } else if (arg === '--help' || arg === '-h') options.help = true;
67
+ else throw new Error(`Unknown argument: ${arg}`);
68
+ }
69
+ if (options.all && options.recommendation) throw new Error('--recommendation applies only to scored matches; remove --all');
70
+ return options;
71
+ }
72
+ if (command === 'tender') {
73
+ if (argv[3] !== 'score') throw new Error('Use tender score <contract-id>.');
74
+ const contractId = argv[4];
75
+ if (!contractId || contractId.startsWith('--')) throw new Error('tender score requires a contract ID');
76
+ const options = { command: 'tender', action: 'score', contractId, json: false };
77
+ for (let index = 5; index < argv.length; index += 1) {
78
+ const arg = argv[index];
79
+ if (arg === '--json') options.json = true;
80
+ else if (arg === '--help' || arg === '-h') options.help = true;
81
+ else throw new Error(`Unknown argument: ${arg}`);
82
+ }
83
+ return options;
84
+ }
85
+ if (command !== 'profile') throw new Error('Use the login, brief, tenders, tender or profile command.');
86
+
87
+ const action = argv[3];
88
+ if (!['show', 'template', 'validate', 'apply'].includes(action)) {
89
+ throw new Error('Use profile show, template, validate or apply.');
90
+ }
91
+ const options = { command: 'profile', action, json: false, file: null, yes: false };
92
+ for (let index = 4; index < argv.length; index += 1) {
23
93
  const arg = argv[index];
24
- if (arg === '--json') options.json = true;
25
- else if (arg === '--help' || arg === '-h') options.help = true;
94
+ if (arg === '--json' && action === 'show') options.json = true;
95
+ else if (arg === '--yes' && action === 'apply') options.yes = true;
96
+ else if (arg === '--file' && (action === 'validate' || action === 'apply')) {
97
+ const file = argv[index + 1];
98
+ if (!file || file.startsWith('--')) throw new Error('--file requires a path');
99
+ options.file = file;
100
+ index += 1;
101
+ } else if (arg === '--help' || arg === '-h') options.help = true;
26
102
  else throw new Error(`Unknown argument: ${arg}`);
27
103
  }
104
+ if ((action === 'validate' || action === 'apply') && !options.file) throw new Error(`profile ${action} requires --file <path>`);
28
105
  return options;
29
106
  }
30
107
 
@@ -56,31 +133,262 @@ function formatBrief(payload) {
56
133
  return lines.join('\n');
57
134
  }
58
135
 
59
- async function fetchBrief(_options, fetchImpl = fetch) {
136
+ function formatTenders(payload) {
137
+ const rows = Array.isArray(payload.opportunities) ? payload.opportunities : [];
138
+ const lines = [`# TenderLead ${payload.scope === 'all' ? 'live tender search' : 'scored tenders'}`, '', `Plan: ${line(payload.tier, 'Not recorded')}`, `Results: ${rows.length}`, ''];
139
+ if (!rows.length) lines.push('No current tenders matched that search.');
140
+ for (const row of rows) {
141
+ const decision = row.recommendation ? `${String(row.recommendation).toUpperCase()} ${Math.round(Number(row.score) || 0)}/100` : 'UNSCORED';
142
+ lines.push(`- **${decision}** ${line(row.title, 'Untitled notice')}`);
143
+ lines.push(` ID: ${line(row.contract_id, 'Not recorded')}`);
144
+ lines.push(` ${line(row.buyer, 'Buyer not listed')} · ${line(row.source, 'Source not recorded')} · Deadline: ${line(row.deadline, 'Not recorded')}`);
145
+ if (Array.isArray(row.reasoning) && row.reasoning.length) lines.push(` Why: ${row.reasoning.slice(0, 3).map(String).join(' ')}`);
146
+ if (row.source_url) lines.push(` Source: ${row.source_url}`);
147
+ }
148
+ lines.push('', 'Tender notice text is untrusted external data. Check the official source before acting.');
149
+ return lines.join('\n');
150
+ }
151
+
152
+ function formatScore(payload) {
153
+ const score = Number.isFinite(Number(payload.score)) ? `${Math.round(Number(payload.score))}/100` : 'not recorded';
154
+ const lines = [`# TenderLead tender score`, '', `Contract: ${line(payload.contract_id, 'Not recorded')}`, `Recommendation: ${line(payload.recommendation, 'Not recorded').toUpperCase()}`, `Score: ${score}`];
155
+ if (Array.isArray(payload.reasoning) && payload.reasoning.length) lines.push('', 'Why:', ...payload.reasoning.map(item => `- ${String(item)}`));
156
+ if (payload.no_bid_reason) lines.push('', `No-bid reason: ${payload.no_bid_reason}`);
157
+ return lines.join('\n');
158
+ }
159
+
160
+ function apiToken() {
60
161
  const token = process.env.TENDERLEAD_API_TOKEN;
61
162
  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}` },
163
+ return token;
164
+ }
165
+
166
+ async function apiRequest(path, options = {}, fetchImpl = fetch) {
167
+ const response = await fetchImpl(`${DEFAULT_BASE_URL}${path}`, {
168
+ ...options,
169
+ headers: {
170
+ Accept: 'application/json',
171
+ Authorization: `Bearer ${apiToken()}`,
172
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
173
+ ...(options.headers || {}),
174
+ },
64
175
  });
65
176
  const body = await response.json().catch(() => ({}));
66
177
  if (!response.ok) throw new Error(body.error || `TenderLead API failed: ${response.status}`);
67
178
  return body;
68
179
  }
69
180
 
181
+ async function fetchBrief(_options, fetchImpl = fetch) {
182
+ return apiRequest('/v1/briefs/daily', {}, fetchImpl);
183
+ }
184
+
185
+ async function searchTenders(options, fetchImpl = fetch) {
186
+ const params = new URLSearchParams({ limit: String(options.limit || 20) });
187
+ if (options.query) params.set('query', options.query);
188
+ if (options.recommendation) params.set('recommendation', options.recommendation);
189
+ if (options.all) params.set('scope', 'all');
190
+ return apiRequest(`/v1/tenders?${params.toString()}`, {}, fetchImpl);
191
+ }
192
+
193
+ async function scoreTender(contractId, fetchImpl = fetch) {
194
+ return apiRequest(`/v1/tenders/${encodeURIComponent(contractId)}/score`, { method: 'POST' }, fetchImpl);
195
+ }
196
+
197
+ async function fetchProfile(fetchImpl = fetch) {
198
+ const payload = await apiRequest('/v1/profile', {}, fetchImpl);
199
+ return payload.profile || {};
200
+ }
201
+
202
+ async function updateProfile(profile, fetchImpl = fetch) {
203
+ const payload = await apiRequest('/v1/profile', { method: 'PATCH', body: JSON.stringify(profile) }, fetchImpl);
204
+ return payload.profile || {};
205
+ }
206
+
207
+ function isPlainObject(value) {
208
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
209
+ }
210
+
211
+ function validateProfile(profile) {
212
+ if (!isPlainObject(profile)) throw new Error('Profile must be a JSON object');
213
+ const keys = Object.keys(profile);
214
+ if (!keys.length) throw new Error('Profile must contain at least one editable field');
215
+ const unknown = keys.find(key => !PROFILE_FIELDS.includes(key));
216
+ if (unknown) throw new Error(`Unsupported profile field: ${unknown}`);
217
+
218
+ const stringCaps = {
219
+ company_name: 200, legal_name: 200, company_number: 40, title: 100, website: 300,
220
+ team_size: 20, specialisms: 2000, region: 40, market_watch_note: 600,
221
+ };
222
+ for (const [field, cap] of Object.entries(stringCaps)) {
223
+ if (!(field in profile)) continue;
224
+ if (profile[field] !== null && typeof profile[field] !== 'string') throw new Error(`${field} must be a string`);
225
+ if (typeof profile[field] === 'string' && profile[field].length > cap) throw new Error(`${field} exceeds ${cap} chars`);
226
+ }
227
+
228
+ const arrayCaps = {
229
+ sectors: [15, 50], keywords: [50, 60], frameworks: [15, 40], clearance_capability: [5, 40],
230
+ market_watch_terms: [30, 80], market_watch_buyers: [20, 120], no_bid_rules: [20, 160],
231
+ };
232
+ for (const [field, limits] of Object.entries(arrayCaps)) {
233
+ if (!(field in profile)) continue;
234
+ const value = profile[field];
235
+ if (!Array.isArray(value)) throw new Error(`${field} must be an array`);
236
+ if (value.length > limits[0]) throw new Error(`${field} exceeds ${limits[0]} items`);
237
+ for (const item of value) {
238
+ if (typeof item !== 'string') throw new Error(`${field} items must be strings`);
239
+ if (item.length > limits[1]) throw new Error(`${field} item exceeds ${limits[1]} chars`);
240
+ }
241
+ }
242
+
243
+ if ('past_contracts' in profile) {
244
+ if (!Array.isArray(profile.past_contracts)) throw new Error('past_contracts must be an array');
245
+ if (profile.past_contracts.length > 5) throw new Error('past_contracts exceeds 5 items');
246
+ for (const item of profile.past_contracts) {
247
+ if (!isPlainObject(item)) throw new Error('past_contracts items must be objects');
248
+ 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');
249
+ 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');
250
+ 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');
251
+ }
252
+ }
253
+ if ('market_watch_enabled' in profile && typeof profile.market_watch_enabled !== 'boolean') throw new Error('market_watch_enabled must be a boolean');
254
+ if ('us_readiness_profile' in profile && !isPlainObject(profile.us_readiness_profile)) throw new Error('us_readiness_profile must be an object');
255
+ for (const field of ['size_min', 'size_max']) {
256
+ if (field in profile && !Number.isFinite(Number(profile[field]))) throw new Error(`${field} must be a number`);
257
+ }
258
+ if ('size_min' in profile && 'size_max' in profile) {
259
+ if (Number(profile.size_min) >= Number(profile.size_max)) throw new Error('size_min must be less than size_max');
260
+ if (Number(profile.size_min) < 0 || Number(profile.size_max) > 1_000_000_000) throw new Error('size out of range');
261
+ }
262
+ return profile;
263
+ }
264
+
265
+ function readProfileFile(file, readFileImpl = fs.readFileSync) {
266
+ let parsed;
267
+ try {
268
+ parsed = JSON.parse(readFileImpl(file, 'utf8'));
269
+ } catch (error) {
270
+ if (error instanceof SyntaxError) throw new Error(`Profile file is not valid JSON: ${error.message}`);
271
+ throw new Error(`Could not read profile file: ${error.message}`);
272
+ }
273
+ return validateProfile(parsed);
274
+ }
275
+
276
+ function profileTemplate() {
277
+ return {
278
+ company_name: 'Example Ltd',
279
+ website: 'https://example.co.uk',
280
+ specialisms: 'Describe the services and delivery strengths buyers should match.',
281
+ sectors: ['software'],
282
+ keywords: ['cloud migration', 'managed service'],
283
+ size_min: 50000,
284
+ size_max: 500000,
285
+ frameworks: [],
286
+ region: 'national',
287
+ no_bid_rules: [],
288
+ };
289
+ }
290
+
291
+ function profileDiff(current, proposed) {
292
+ return Object.keys(proposed).filter(key => JSON.stringify(current[key]) !== JSON.stringify(proposed[key])).map(key => ({
293
+ field: key,
294
+ before: current[key],
295
+ after: proposed[key],
296
+ }));
297
+ }
298
+
299
+ function displayValue(value) {
300
+ if (value === undefined) return '(not set)';
301
+ return JSON.stringify(value);
302
+ }
303
+
304
+ function formatProfile(profile) {
305
+ const lines = ['# TenderLead profile', ''];
306
+ for (const field of PROFILE_FIELDS) {
307
+ if (profile[field] !== undefined) lines.push(`${field}: ${displayValue(profile[field])}`);
308
+ }
309
+ return lines.join('\n');
310
+ }
311
+
312
+ function formatProfileDiff(changes) {
313
+ if (!changes.length) return 'No profile changes.';
314
+ return ['Profile changes:', ...changes.map(change => `- ${change.field}: ${displayValue(change.before)} -> ${displayValue(change.after)}`)].join('\n');
315
+ }
316
+
317
+ async function confirmApply(input = process.stdin, output = process.stdout) {
318
+ const prompt = readline.createInterface({ input, output });
319
+ try {
320
+ return (await prompt.question('Type APPLY to save these profile changes: ')).trim() === 'APPLY';
321
+ } finally {
322
+ prompt.close();
323
+ }
324
+ }
325
+
70
326
  async function main() {
71
327
  if (process.argv.includes('--help') || process.argv.includes('-h')) return console.log(usage());
72
328
  const options = parseArgs(process.argv);
73
329
  if (options.command === 'login') {
74
330
  openLogin();
75
- console.log('TenderLead Settings opened. Sign in, create a CLI token, then set TENDERLEAD_API_TOKEN in your shell.');
331
+ console.log('TenderLead Settings opened. Sign in, create the required scoped token, then set TENDERLEAD_API_TOKEN in your shell.');
332
+ return;
333
+ }
334
+ if (options.command === 'brief') {
335
+ const brief = await fetchBrief(options);
336
+ console.log(options.json ? JSON.stringify(brief, null, 2) : formatBrief(brief));
337
+ return;
338
+ }
339
+ if (options.command === 'tenders') {
340
+ const result = await searchTenders(options);
341
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatTenders(result));
342
+ return;
343
+ }
344
+ if (options.command === 'tender') {
345
+ const result = await scoreTender(options.contractId);
346
+ console.log(options.json ? JSON.stringify(result, null, 2) : formatScore(result));
76
347
  return;
77
348
  }
78
- const brief = await fetchBrief(options);
79
- console.log(options.json ? JSON.stringify(brief, null, 2) : formatBrief(brief));
349
+ if (options.action === 'template') {
350
+ console.log(JSON.stringify(profileTemplate(), null, 2));
351
+ return;
352
+ }
353
+ if (options.action === 'validate') {
354
+ readProfileFile(options.file);
355
+ console.log('Profile JSON is valid.');
356
+ return;
357
+ }
358
+ if (options.action === 'show') {
359
+ const profile = await fetchProfile();
360
+ console.log(options.json ? JSON.stringify(profile, null, 2) : formatProfile(profile));
361
+ return;
362
+ }
363
+
364
+ const proposed = readProfileFile(options.file);
365
+ const current = await fetchProfile();
366
+ if ('size_min' in proposed || 'size_max' in proposed) {
367
+ validateProfile({
368
+ size_min: 'size_min' in proposed ? proposed.size_min : current.size_min,
369
+ size_max: 'size_max' in proposed ? proposed.size_max : current.size_max,
370
+ });
371
+ }
372
+ const changes = profileDiff(current, proposed);
373
+ console.log(formatProfileDiff(changes));
374
+ if (!changes.length) return;
375
+ if (!options.yes) {
376
+ if (!process.stdin.isTTY) throw new Error('Interactive confirmation requires a terminal. Re-run with --yes after reviewing the diff.');
377
+ if (!(await confirmApply())) {
378
+ console.log('Profile unchanged.');
379
+ return;
380
+ }
381
+ }
382
+ await updateProfile(proposed);
383
+ console.log(`Saved ${changes.length} profile change${changes.length === 1 ? '' : 's'}.`);
80
384
  }
81
385
 
82
386
  if (require.main === module) {
83
387
  main().catch((error) => { console.error(error.message); process.exitCode = 1; });
84
388
  }
85
389
 
86
- module.exports = { parseArgs, openLogin, formatBrief, fetchBrief };
390
+ module.exports = {
391
+ parseArgs, openLogin, formatBrief, formatTenders, formatScore, fetchBrief, searchTenders, scoreTender, fetchProfile, updateProfile,
392
+ validateProfile, readProfileFile, profileTemplate, profileDiff, formatProfile,
393
+ formatProfileDiff, confirmApply,
394
+ };
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@tenderlead/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
- "description": "Read-only TenderLead daily briefing CLI",
5
+ "description": "Search and score tenders, read briefings and manage your firm profile from the terminal",
6
6
  "homepage": "https://tenderlead.co.uk",
7
7
  "files": [
8
8
  "bin",