@bli-cockpit/cli 0.2.121 → 0.2.122

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.
@@ -1,16 +1,88 @@
1
+ import { readFile } from 'node:fs/promises';
1
2
  import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './agent-door.js';
3
+ /**
4
+ * `cockpit careers` — application review (BLI-3706) and, since BLI-4460, the
5
+ * pipeline: invite, decide, the role's take-home and the four settings.
6
+ *
7
+ * Every verb goes through the SAME Tower door its MCP twin calls; nothing here
8
+ * decides policy. The take-home body arrives from a FILE rather than a flag,
9
+ * because an email body does not belong in an argument vector, a shell history
10
+ * or a process list.
11
+ */
2
12
  export async function runCareers(command, io) {
3
13
  const door = await openAgentDoor('careers', command, io);
4
- const query = new URLSearchParams();
5
- if (command.role)
6
- query.set('role', command.role);
7
- if (command.minScore !== undefined)
8
- query.set('min_score', String(command.minScore));
9
- if (command.since)
10
- query.set('since', command.since);
11
- const path = command.action === 'list' ? `/api/careers/applications?${query}` : `/api/careers/applications/${encodeURIComponent(command.id)}${command.action === 'rescreen' ? '/rescreen' : ''}`;
12
- const answer = await askAgentDoor(door, { path, method: command.action === 'rescreen' ? 'POST' : 'GET', label: `careers ${command.action}`, timeoutMs: 60_000 });
14
+ let plan;
15
+ try {
16
+ plan = await planCareersRequest(command);
17
+ }
18
+ catch (error) {
19
+ return failAgentDoor(door, '[careers]', 'invalid_request', error instanceof Error ? error.message : 'Could not read that request.');
20
+ }
21
+ const answer = await askAgentDoor(door, { ...plan, label: `careers ${command.action}`, timeoutMs: 60_000 });
13
22
  if (!answer.ok)
14
23
  return failAgentDoor(door, '[careers]', answer.reason, answer.detail);
15
24
  return emitAgentDoor(door, answer.body);
25
+ }
26
+ async function planCareersRequest(command) {
27
+ const application = `/api/careers/applications/${encodeURIComponent(command.id ?? '')}`;
28
+ switch (command.action) {
29
+ case 'list': {
30
+ const query = new URLSearchParams();
31
+ if (command.role)
32
+ query.set('role', command.role);
33
+ if (command.minScore !== undefined)
34
+ query.set('min_score', String(command.minScore));
35
+ if (command.since)
36
+ query.set('since', command.since);
37
+ if (command.stage)
38
+ query.set('stage', command.stage);
39
+ return { path: `/api/careers/applications?${query}`, method: 'GET' };
40
+ }
41
+ case 'show': return { path: application, method: 'GET' };
42
+ case 'rescreen': return { path: `${application}/rescreen`, method: 'POST' };
43
+ case 'invite': return { path: `${application}/invite`, method: 'POST' };
44
+ case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
45
+ case 'takehome-show': return { path: takehomePath(command), method: 'GET' };
46
+ case 'takehome-set': return { path: takehomePath(command), method: 'PUT', body: await takehomeBody(command) };
47
+ case 'settings-show': return { path: '/api/careers/settings', method: 'GET' };
48
+ case 'settings-set': return { path: '/api/careers/settings', method: 'PUT', body: settingsBody(command) };
49
+ }
50
+ }
51
+ function takehomePath(command) {
52
+ return `/api/careers/takehomes/${encodeURIComponent(command.takehomeRole ?? '')}`;
53
+ }
54
+ async function takehomeBody(command) {
55
+ const body = {};
56
+ if (command.subject)
57
+ body.subject = command.subject;
58
+ if (command.link)
59
+ body.artifact_link = command.link;
60
+ if (command.passScore !== undefined)
61
+ body.pass_score = command.passScore;
62
+ if (command.bodyFile) {
63
+ const text = await readFile(command.bodyFile, 'utf8');
64
+ if (!text.includes('{first_name}') || !text.includes('{link}')) {
65
+ throw new Error('That body file is missing {first_name} or {link}, so nothing was sent to Tower.');
66
+ }
67
+ body.body_text = text;
68
+ }
69
+ if (Object.keys(body).length === 0) {
70
+ throw new Error('careers takehome set needs at least one of --subject, --body-file, --link or --pass-score.');
71
+ }
72
+ return body;
73
+ }
74
+ function settingsBody(command) {
75
+ const body = {};
76
+ if (command.autoInviteEnabled !== undefined)
77
+ body.auto_invite_enabled = command.autoInviteEnabled;
78
+ if (command.autoInviteMinScore !== undefined)
79
+ body.auto_invite_min_score = command.autoInviteMinScore;
80
+ if (command.attentionMinScore !== undefined)
81
+ body.attention_min_score = command.attentionMinScore;
82
+ if (command.inviteAccountId !== undefined)
83
+ body.invite_account_id = command.inviteAccountId;
84
+ if (Object.keys(body).length === 0) {
85
+ throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score or --account.');
86
+ }
87
+ return body;
16
88
  }
@@ -1,13 +1,31 @@
1
1
  import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
2
+ const ACTIONS = ['list', 'show', 'rescreen', 'invite', 'decide', 'takehome-show', 'takehome-set', 'settings-show', 'settings-set'];
3
+ const DECISIONS = ['passed_on', 'archived', 'reopen'];
4
+ /** Verbs that name an application id, a role slug, or nothing at all. */
5
+ const NEEDS_ID = ['show', 'rescreen', 'invite', 'decide'];
6
+ const NEEDS_ROLE = ['takehome-show', 'takehome-set'];
2
7
  export function parseCareersArgs(args) {
3
- const values = parseNamedArgs(args, { allowedFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--json'], valueFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url'] });
4
- const action = values.positionals[0] ?? 'list';
5
- if (action !== 'list' && action !== 'show' && action !== 'rescreen')
6
- throw new Error('careers takes list, show, or rescreen.');
7
- const id = values.positionals[1];
8
- if (action !== 'list' && !id)
9
- throw new Error(`careers ${action} needs an application id.`);
10
- if (values.positionals.length > (action === 'list' ? 1 : 2))
8
+ const valueFlags = ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--decision', '--stage', '--subject', '--body-file', '--link', '--pass-score', '--account', '--auto-invite-min-score', '--attention-min-score', '--enabled'];
9
+ const values = parseNamedArgs(args, { allowedFlags: [...valueFlags, '--json'], valueFlags });
10
+ const positionals = [...values.positionals];
11
+ // `takehome show` and `settings set` are two words a person types and one
12
+ // action everything downstream reads.
13
+ // A bare `careers takehome` or `careers settings` reads as its `show`, the way
14
+ // `team device` reads as `team device list`: the noun alone is a question.
15
+ const first = positionals[0] ?? 'list';
16
+ const paired = first === 'takehome' || first === 'settings';
17
+ const action = paired
18
+ ? `${first}-${positionals.splice(0, positionals[1] === undefined ? 1 : 2)[1] ?? 'show'}`
19
+ : (positionals.shift() ?? 'list');
20
+ if (!ACTIONS.includes(action))
21
+ throw new Error(`careers takes ${ACTIONS.join(', ')}.`);
22
+ const verb = action;
23
+ const target = positionals.shift();
24
+ if (NEEDS_ID.includes(verb) && !target)
25
+ throw new Error(`careers ${verb} needs an application id.`);
26
+ if (NEEDS_ROLE.includes(verb) && !target)
27
+ throw new Error(`careers ${verb.replace('-', ' ')} needs a role slug.`);
28
+ if (positionals.length > 0)
11
29
  throw new Error('Unexpected careers argument.');
12
30
  const rawScore = values.flags.get('--min-score');
13
31
  const minScore = rawScore === undefined ? undefined : Number(rawScore);
@@ -16,5 +34,34 @@ export function parseCareersArgs(args) {
16
34
  const since = optionalNonEmpty(values.flags.get('--since'));
17
35
  if (since && !Number.isFinite(Date.parse(since)))
18
36
  throw new Error('--since must be a date.');
19
- return { kind: 'careers', action, id, role: optionalNonEmpty(values.flags.get('--role')), minScore, since: since ? new Date(since).toISOString() : undefined, homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json') };
37
+ const decision = optionalNonEmpty(values.flags.get('--decision'));
38
+ if (verb === 'decide' && (!decision || !DECISIONS.includes(decision)))
39
+ throw new Error(`careers decide needs --decision ${DECISIONS.join('|')}.`);
40
+ const passScore = numberFlag(values.flags.get('--pass-score'), '--pass-score', 0, 10);
41
+ const autoInviteMinScore = numberFlag(values.flags.get('--auto-invite-min-score'), '--auto-invite-min-score', 0, 100);
42
+ const attentionMinScore = numberFlag(values.flags.get('--attention-min-score'), '--attention-min-score', 0, 100);
43
+ const enabledRaw = optionalNonEmpty(values.flags.get('--enabled'));
44
+ if (enabledRaw !== undefined && enabledRaw !== 'true' && enabledRaw !== 'false')
45
+ throw new Error('--enabled must be true or false.');
46
+ return {
47
+ kind: 'careers', action: verb,
48
+ id: NEEDS_ID.includes(verb) ? target : undefined,
49
+ takehomeRole: NEEDS_ROLE.includes(verb) ? target : undefined,
50
+ role: optionalNonEmpty(values.flags.get('--role')), minScore, since: since ? new Date(since).toISOString() : undefined,
51
+ stage: optionalNonEmpty(values.flags.get('--stage')), decision,
52
+ subject: optionalNonEmpty(values.flags.get('--subject')), bodyFile: optionalNonEmpty(values.flags.get('--body-file')),
53
+ link: optionalNonEmpty(values.flags.get('--link')), passScore,
54
+ inviteAccountId: optionalNonEmpty(values.flags.get('--account')),
55
+ autoInviteMinScore, attentionMinScore,
56
+ autoInviteEnabled: enabledRaw === undefined ? undefined : enabledRaw === 'true',
57
+ homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json'),
58
+ };
59
+ }
60
+ function numberFlag(raw, flag, min, max) {
61
+ if (raw === undefined)
62
+ return undefined;
63
+ const value = Number(raw);
64
+ if (!Number.isFinite(value) || value < min || value > max)
65
+ throw new Error(`${flag} must be ${min} to ${max}.`);
66
+ return value;
20
67
  }
@@ -22,7 +22,21 @@ const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
22
22
  const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
23
23
  /** One entry per Tower noun, in the order `cockpit --help` lists them. */
24
24
  export const TOWER_COMMAND_HELP = [
25
- ["careers", ["Usage: cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]", "Super-admin application review. Lists at most 100 matches with total and has_more; use filters to narrow."]],
25
+ [
26
+ "careers",
27
+ [
28
+ "Usage: cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <d>|takehome show <role>|takehome set <role>|settings show|settings set] [flags]",
29
+ "",
30
+ "Super-admin application review and pipeline. Lists at most 100 matches with total and has_more; use filters to narrow.",
31
+ "list [--role <slug>] [--min-score <n>] [--since <date>] [--stage <screened|invited|submitted|graded|booking_sent|passed_on|archived>] — the board, newest first, with the two thresholds in force.",
32
+ "invite <id> — sends that role's take-home from the configured mailbox, whatever the screening scored. Only a row still at `screened`.",
33
+ "decide <id> --decision <passed_on|archived|reopen> — your own call on a row; `reopen` puts it back to `screened`.",
34
+ "takehome show <role> — the subject, body, link and the reviewer's rubric for that role.",
35
+ "takehome set <role> [--subject <s>] [--body-file <path>] [--link <url>] [--pass-score <0-10>] — the body comes from a FILE and must keep {first_name} and {link}.",
36
+ "settings show — the bar, the attention threshold, whether automatic sending is on, and which mailbox it goes out from.",
37
+ "settings set [--enabled true|false] [--auto-invite-min-score <0-100>] [--attention-min-score <0-100>] [--account <mailbox uuid>] — each takes effect on the next screening, no deploy.",
38
+ ],
39
+ ],
26
40
  [
27
41
  "docs",
28
42
  [
@@ -104,7 +104,7 @@ export function localCommandHelp(command) {
104
104
  " cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
105
105
  ` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
106
106
  " cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
107
- " cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
107
+ " cockpit careers [list|show <id>|rescreen <id>|invite <id>|decide <id> --decision <passed_on|archived|reopen>|takehome show <role>|takehome set <role> --body-file <path>|settings show|settings set --enabled <true|false>] [--role <slug>] [--min-score <n>] [--since <date>] [--stage <s>] [--json]",
108
108
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
109
109
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
110
110
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
15
15
  }
16
16
 
17
17
  if (command === "--version" || command === "-V" || command === "version") {
18
- writeLine(io?.stdout ?? process.stdout, "0.2.121");
18
+ writeLine(io?.stdout ?? process.stdout, "0.2.122");
19
19
  return 0;
20
20
  }
21
21
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.121",
3
+ "version": "0.2.122",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "dependencies": {
30
30
  "@bli-cockpit/memory-mcp": "0.1.32",
31
- "@bli-cockpit/mcp": "0.1.48",
31
+ "@bli-cockpit/mcp": "0.1.49",
32
32
  "@bli-cockpit/telemetry-core": "0.1.48"
33
33
  }
34
34
  }