@velaro/cli 1.4.90 → 1.4.91

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/bin/velaro.js CHANGED
@@ -97,6 +97,7 @@ import { supportCommand } from '../lib/commands/support.js';
97
97
  import { routingCommand } from '../lib/commands/routing.js';
98
98
  import { diagnosticsCommand } from '../lib/commands/diagnostics.js';
99
99
  import { aiSpendCommand } from '../lib/commands/ai-spend.js';
100
+ import { aiFederationCommand } from '../lib/commands/ai-federation.js';
100
101
  import { statusPageCommand } from '../lib/commands/status-page.js';
101
102
  import { statusNotifyCommand } from '../lib/commands/status-notify.js';
102
103
  import { statusSlaCommand } from '../lib/commands/status-sla.js';
@@ -229,6 +230,7 @@ await yargs(hideBin(process.argv))
229
230
  .command(routingCommand)
230
231
  .command(diagnosticsCommand)
231
232
  .command(aiSpendCommand)
233
+ .command(aiFederationCommand)
232
234
  .command(statusPageCommand)
233
235
  .command(statusNotifyCommand)
234
236
  .command(statusSlaCommand)
@@ -0,0 +1,16 @@
1
+ // AI follow-up suggestions ("AI mode" on a transferToAI node). Pure helpers, no I/O.
2
+ // Resolution on the server: node value ?? account default (SiteSettings) ?? OFF.
3
+
4
+ export function applyFollowUpOptions(node, { mode, count, instruction } = {}) {
5
+ const updated = { ...node };
6
+ if (mode === 'on') updated.suggestFollowUps = true;
7
+ if (mode === 'off') updated.suggestFollowUps = false;
8
+ // "inherit" CLEARS the node value so the account default decides (never persist false for it).
9
+ if (mode === 'inherit') delete updated.suggestFollowUps;
10
+ if (count != null) updated.suggestFollowUpCount = Math.min(4, Math.max(2, Math.round(count)));
11
+ if (instruction != null) {
12
+ if (instruction === '') delete updated.suggestFollowUpInstruction;
13
+ else updated.suggestFollowUpInstruction = String(instruction).slice(0, 300);
14
+ }
15
+ return updated;
16
+ }
@@ -0,0 +1,32 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import { applyFollowUpOptions } from './ai-followups.js';
5
+
6
+ const node = { id: 1, actionType: 'transferToAI', aiConfigId: 5 };
7
+
8
+ test('on/off set the node value; inherit clears it', () => {
9
+ assert.equal(applyFollowUpOptions(node, { mode: 'on' }).suggestFollowUps, true);
10
+ assert.equal(applyFollowUpOptions(node, { mode: 'off' }).suggestFollowUps, false);
11
+ const cleared = applyFollowUpOptions({ ...node, suggestFollowUps: true }, { mode: 'inherit' });
12
+ assert.equal('suggestFollowUps' in cleared, false);
13
+ });
14
+
15
+ test('count clamps to 2-4 and unrelated fields survive', () => {
16
+ assert.equal(applyFollowUpOptions(node, { count: 0 }).suggestFollowUpCount, 2);
17
+ assert.equal(applyFollowUpOptions(node, { count: 9 }).suggestFollowUpCount, 4);
18
+ assert.equal(applyFollowUpOptions(node, { count: 3 }).aiConfigId, 5);
19
+ });
20
+
21
+ test('instruction is capped at 300 chars and empty string clears it', () => {
22
+ assert.equal(applyFollowUpOptions(node, { instruction: 'x'.repeat(500) }).suggestFollowUpInstruction.length, 300);
23
+ const cleared = applyFollowUpOptions({ ...node, suggestFollowUpInstruction: 'a' }, { instruction: '' });
24
+ assert.equal('suggestFollowUpInstruction' in cleared, false);
25
+ });
26
+
27
+ test('workflow command wires the helper and the site-default route', () => {
28
+ const src = fs.readFileSync(new URL('./commands/workflow.js', import.meta.url), 'utf8');
29
+ assert.match(src, /applyFollowUpOptions/);
30
+ assert.match(src, /\/SiteSettings\/AiFollowUps/);
31
+ assert.match(src, /ai-followups <workflowId> <nodeId>/);
32
+ });
@@ -0,0 +1,55 @@
1
+ import { get, post } from '../api.js';
2
+
3
+ function fmtPT(ts) {
4
+ if (!ts) return '-';
5
+ return new Date(ts).toLocaleString('en-US', { timeZone: 'America/Los_Angeles', dateStyle: 'short', timeStyle: 'short' }) + ' PT';
6
+ }
7
+
8
+ function printStatus(s) {
9
+ console.log(`Anthropic federation: ${s.enabled ? 'ENABLED' : 'disabled'}${s.enabled && !s.configured ? ' (NOT fully configured)' : ''}`);
10
+ console.log(` breaker: ${s.breakerState}${s.breakerOpenUntil ? ' until ' + fmtPT(s.breakerOpenUntil) : ''}`);
11
+ console.log(` last success: ${fmtPT(s.lastSuccessUtc)}`);
12
+ console.log(` last failure: ${fmtPT(s.lastFailureUtc)}${s.lastFailureReason ? ' ' + s.lastFailureReason : ''}`);
13
+ console.log(` calls: federated ${s.federatedCalls}, api-key fallback ${s.fallbackCalls}, breaker opens ${s.breakerOpens}`);
14
+ console.log(` token expires: ${fmtPT(s.tokenExpiresUtc)}`);
15
+ }
16
+
17
+ const statusCommand = {
18
+ command: 'status',
19
+ describe: 'Anthropic federation state: enabled, breaker, last success/failure, federated vs fallback counts (superadmin only)',
20
+ builder: (y) => y.option('json', { type: 'boolean', default: false }),
21
+ handler: async (argv) => {
22
+ const s = await get('AiFederation/status');
23
+ if (argv.json) { console.log(JSON.stringify(s, null, 2)); return; }
24
+ printStatus(s);
25
+ },
26
+ };
27
+
28
+ const testCommand = {
29
+ command: 'test',
30
+ describe: 'Real token exchange plus a 1-token Haiku message; reports which auth path served it',
31
+ builder: (y) => y.option('json', { type: 'boolean', default: false }),
32
+ handler: async (argv) => {
33
+ const r = await post('AiFederation/test', {});
34
+ if (argv.json) { console.log(JSON.stringify(r, null, 2)); return; }
35
+ console.log(`exchange: ${r.exchangeOk ? 'OK' : 'FAILED ' + (r.exchangeError ?? '')}`);
36
+ console.log(`message: HTTP ${r.messageStatus ?? '-'}${r.messageError ? ' ' + r.messageError : ''}`);
37
+ console.log(`path used: ${r.pathUsed ?? 'unknown'}`);
38
+ printStatus(r.status);
39
+ },
40
+ };
41
+
42
+ const resetCommand = {
43
+ command: 'reset-breaker',
44
+ describe: 'Close the federation circuit breaker so the next call retries federated auth',
45
+ builder: (y) => y,
46
+ handler: async () => { printStatus(await post('AiFederation/reset-breaker', {})); },
47
+ };
48
+
49
+ export const aiFederationCommand = {
50
+ command: 'ai-federation <subcommand>',
51
+ describe: 'Anthropic Workload Identity Federation status, test and breaker reset (superadmin only)',
52
+ builder: (y) => y.command(statusCommand).command(testCommand).command(resetCommand)
53
+ .demandCommand(1, 'Specify a subcommand: status, test, reset-breaker').strict(),
54
+ handler: () => {},
55
+ };
@@ -13,6 +13,12 @@ const REPORTS = {
13
13
  'sentiment-breakdown': 'Sentiment by website, team, agent or topic',
14
14
  'topic-radar': 'Topics by volume, growth, transfer rate and handle time',
15
15
  'weekly-brief': 'Preview the Monday brief sections',
16
+ 'repeat-contact': 'Share of closed chats where the same contact came back within --window-days, by agent, topic and program',
17
+ 'demand-coverage': 'Day-of-week by hour demand against Available agent hours over whole local weeks (from/to ignored), top gaps, after-hours, next-week forecast',
18
+ 'bot-containment': 'Claimed vs true bot containment and handoff regret',
19
+ 'effort-index': 'Inferred 0-100 customer effort by topic, program and team (not a survey CES)',
20
+ 'promise-ledger': 'Agent commitments made in chat and whether a follow-up appeared; overdue list',
21
+ 'promise-dismiss': 'Dismiss one promise by --id (managers only)',
16
22
  'weekly-brief-send-test': 'Email this week\'s brief to the signed-in user',
17
23
  };
18
24
 
@@ -29,14 +35,27 @@ export const enterpriseReportsCommand = {
29
35
  .option('threshold', { type: 'number', describe: 'coaching-queue: queue chats scored below this (1-100)' })
30
36
  .option('per-agent', { type: 'number', describe: 'coaching-queue: chats listed per agent' })
31
37
  .option('limit', { type: 'number', describe: 'topic-radar: max topics' })
32
- .option('days', { type: 'number', describe: 'weekly-brief: days to cover' }),
38
+ .option('days', { type: 'number', describe: 'weekly-brief: days to cover' })
39
+ .option('window-days', { type: 'number', describe: 'repeat-contact: days a contact has to come back to count as a repeat (1-30, default 7)' })
40
+ .option('weeks', { type: 'number', describe: 'demand-coverage: whole local weeks of history (1-12, default 6). from/to are IGNORED by this report' })
41
+ .option('handle-minutes', { type: 'number', describe: 'demand-coverage: assumed minutes per chat (1-120, default 10)' })
42
+ .option('max-concurrent', { type: 'number', describe: 'demand-coverage: assumed chats an agent handles at once (1-10, default 3)' })
43
+ .option('time-zone', { type: 'string', describe: 'demand-coverage: IANA time zone (default America/Los_Angeles)' })
44
+ .option('low-score', { type: 'number', describe: 'bot-containment: QA score below which a handed-off chat counts as regret (1-100, default 60)' })
45
+ .option('include-demo', { type: 'boolean', describe: 'Show only the seeded sample (demo) data' })
46
+ .option('id', { type: 'string', describe: 'promise-dismiss: the promise id' }),
33
47
  handler: runCommand(async (argv) => {
34
48
  if (argv.report === 'weekly-brief-send-test') {
35
49
  console.log(JSON.stringify(await messagingPost('/api/enterprise-reports/weekly-brief/send-test', {}), null, 2));
36
50
  return;
37
51
  }
52
+ if (argv.report === 'promise-dismiss') {
53
+ if (!argv.id) throw new Error('--id is required for promise-dismiss');
54
+ console.log(JSON.stringify(await messagingPost(`/api/enterprise-reports/promise-ledger/${encodeURIComponent(String(argv.id))}/dismiss`, {}), null, 2));
55
+ return;
56
+ }
38
57
  const qs = new URLSearchParams();
39
- const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days' };
58
+ const map = { from: 'from', to: 'to', groupBy: 'groupBy', deploymentId: 'deploymentId', teamId: 'teamId', threshold: 'threshold', perAgent: 'perAgent', limit: 'limit', days: 'days', windowDays: 'windowDays', weeks: 'weeks', handleMinutes: 'handleMinutes', maxConcurrent: 'maxConcurrent', timeZone: 'timeZone', lowScore: 'lowScore', includeDemo: 'includeDemo' };
40
59
  for (const [arg, key] of Object.entries(map)) if (argv[arg] != null && argv[arg] !== '') qs.set(key, String(argv[arg]));
41
60
  const q = qs.toString();
42
61
  console.log(JSON.stringify(await messagingGet(`/api/enterprise-reports/${argv.report}${q ? `?${q}` : ''}`), null, 2));
@@ -1,7 +1,7 @@
1
1
  import { writeFileSync } from 'fs';
2
2
  import { messagingGet, messagingPost, messagingPut, messagingDel, getCredentials } from '../api.js';
3
3
  import { runCommand } from '../run.js';
4
- import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from '../recruiting-apply.js';
4
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildRenewBody, jobLifecyclePath } from '../recruiting-apply.js';
5
5
 
6
6
  // -- velaro recruiting --------------------------------------------------------
7
7
  // CLI surface for the Recruiting Bot: job-feed sync (JSON/XML/RSS/Indeed/
@@ -16,8 +16,10 @@ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from
16
16
  // POST Recruiting/Sync -> { success, error, added, updated, total }
17
17
  // GET Recruiting/Jobs?search=&location=&department= -> jobListing[]
18
18
  // DELETE Recruiting/Jobs/{id} -> { success }
19
+ // GET Recruiting/Jobs?includeAll=true -> every status (needs the Job Board lifecycle package)
20
+ // POST Recruiting/Jobs/{id}/close | /renew { days? } ; GET Recruiting/Lifecycle -> { entitled, defaultListingDays } (403 without the package)
19
21
  // GET Recruiting/Applications -> candidateApplication[]
20
- // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId? }; GET Recruiting/Applications/{id}/Resume (binary)
22
+ // GET Recruiting/Applications/AtsFailures; POST Recruiting/Applications/{id}/AtsRetry; PUT Recruiting/ApplySettings { publicApplyEnabled?, autoCreateCaseTypeId?, defaultListingDays? }; GET Recruiting/Applications/{id}/Resume (binary)
21
23
  // GET Recruiting/Applications/ExportEeoc -> text/csv (Professional tier+, EnableRecruitingBotEeoc)
22
24
 
23
25
  const BOLD = '\x1b[1m';
@@ -72,7 +74,8 @@ async function jobsListHandler(argv) {
72
74
  }
73
75
  console.log(`\n${BOLD}Recruiting Bot job listings${RST} (${jobs.length})\n`);
74
76
  for (const j of jobs) {
75
- console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}`);
77
+ const life = j.status ? ` ${DIM}${j.status}${j.expiresAt ? ` until ${j.expiresAt}` : ''}${RST}` : '';
78
+ console.log(` ${DIM}#${j.id}${RST} ${j.title} ${DIM}${j.department || '-'} / ${j.location || '-'}${RST}${life}`);
76
79
  }
77
80
  console.log('');
78
81
  }
@@ -82,6 +85,7 @@ function buildQuery(argv) {
82
85
  if (argv.search) params.set('search', argv.search);
83
86
  if (argv.location) params.set('location', argv.location);
84
87
  if (argv.department) params.set('department', argv.department);
88
+ if (argv.all) params.set('includeAll', 'true');
85
89
  const qs = params.toString();
86
90
  return qs ? `?${qs}` : '';
87
91
  }
@@ -91,6 +95,23 @@ async function jobsDeleteHandler(argv) {
91
95
  console.log(`\n${GRN}OK${RST} Job listing ${argv.jobId} deleted.\n`);
92
96
  }
93
97
 
98
+ async function jobsCloseHandler(argv) {
99
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'close'), {});
100
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} closed (status ${job.status}, closed at ${job.closedAt}).\n`);
101
+ }
102
+
103
+ async function jobsRenewHandler(argv) {
104
+ const job = await messagingPost(jobLifecyclePath(argv.jobId, 'renew'), buildRenewBody(argv.days));
105
+ console.log(`\n${GRN}OK${RST} Job listing ${job.id} renewed until ${job.expiresAt} (renewal #${job.renewalCount}, status ${job.status}).\n`);
106
+ }
107
+
108
+ async function jobsLifecycleHandler() {
109
+ const r = await messagingGet('/Recruiting/Lifecycle');
110
+ console.log(`\n${BOLD}Job Board lifecycle${RST}`);
111
+ console.log(` Package: ${r.entitled ? `${GRN}included${RST}` : 'not included (close, renew and JSON-LD pages return 403)'}`);
112
+ console.log(` Default listing days: ${r.defaultListingDays}\n`);
113
+ }
114
+
94
115
  async function applicationsListHandler() {
95
116
  const apps = await messagingGet('/Recruiting/Applications');
96
117
  if (!Array.isArray(apps) || !apps.length) {
@@ -152,8 +173,8 @@ async function botSettingsHandler(argv) {
152
173
  }
153
174
 
154
175
  async function applySettingsHandler(argv) {
155
- const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId }));
156
- console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}\n`);
176
+ const r = await messagingPut('/Recruiting/ApplySettings', buildApplySettingsBody({ publicApply: argv.publicApply, caseTypeId: argv.caseTypeId, defaultListingDays: argv.defaultListingDays }));
177
+ console.log(`\n${GRN}OK${RST} Apply settings: publicApplyEnabled=${r.publicApplyEnabled}, autoCreateCaseTypeId=${r.autoCreateCaseTypeId ?? '(none)'}, defaultListingDays=${r.defaultListingDays ?? '(unchanged)'}\n`);
157
178
  }
158
179
 
159
180
  // Resume is a binary download; fetch raw (messagingGet JSON-parses) with the same auth/site header.
@@ -216,16 +237,36 @@ const jobsCommand = {
216
237
  builder: (y2) => y2
217
238
  .option('search', { describe: 'Free-text search across title/description', type: 'string' })
218
239
  .option('location', { describe: 'Filter by location', type: 'string' })
219
- .option('department', { describe: 'Filter by department', type: 'string' }),
240
+ .option('department', { describe: 'Filter by department', type: 'string' })
241
+ .option('all', { describe: 'Include closed, paused and expired listings (needs the Job Board lifecycle package)', type: 'boolean' }),
220
242
  handler: runCommand(jobsListHandler),
221
243
  })
244
+ .command({
245
+ command: 'close <jobId>',
246
+ describe: 'Close a listing (gone from search and the public board; its public page and applying answer 410). Needs the Job Board lifecycle package',
247
+ builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
248
+ handler: runCommand(jobsCloseHandler),
249
+ })
250
+ .command({
251
+ command: 'renew <jobId>',
252
+ describe: 'Renew a listing: extend its expiry and republish it. Needs the Job Board lifecycle package',
253
+ builder: (y2) => y2
254
+ .positional('jobId', { describe: 'Job listing ID', type: 'number' })
255
+ .option('days', { describe: 'Days to extend by (1-365; default: the site default listing days)', type: 'number' }),
256
+ handler: runCommand(jobsRenewHandler),
257
+ })
258
+ .command({
259
+ command: 'lifecycle',
260
+ describe: 'Show whether this site has the Job Board lifecycle package and its default listing days',
261
+ handler: runCommand(jobsLifecycleHandler),
262
+ })
222
263
  .command({
223
264
  command: 'delete <jobId>',
224
265
  describe: 'Delete a synced job listing',
225
266
  builder: (y2) => y2.positional('jobId', { describe: 'Job listing ID', type: 'number' }),
226
267
  handler: runCommand(jobsDeleteHandler),
227
268
  })
228
- .demandCommand(1, 'Specify a jobs subcommand: list | delete'),
269
+ .demandCommand(1, 'Specify a jobs subcommand: list | close | renew | lifecycle | delete'),
229
270
  handler: () => {},
230
271
  };
231
272
 
@@ -262,7 +303,8 @@ const applicationsCommand = {
262
303
  describe: 'Set public apply form options (apply-if-present)',
263
304
  builder: (y2) => y2
264
305
  .option('public-apply', { describe: 'Enable/disable the public apply endpoint for this site', type: 'boolean' })
265
- .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' }),
306
+ .option('case-type-id', { describe: 'Case type to auto-create a case per application (0 clears)', type: 'number' })
307
+ .option('default-listing-days', { describe: 'Days a new listing stays live when its feed gives no expiry (1-365; needs the Job Board lifecycle package)', type: 'number' }),
266
308
  handler: runCommand(applySettingsHandler),
267
309
  })
268
310
  .command({
@@ -101,6 +101,61 @@ export const teamsCommand = {
101
101
  else { console.error(`Send failed: ${result?.message || 'unknown error'}`); process.exit(1); }
102
102
  }
103
103
  )
104
+ .command(
105
+ 'send-direct <user> <title>',
106
+ 'Send a 1:1 Teams message to any user in the tenant (UPN or Entra id). Velaro staff only.',
107
+ (y) =>
108
+ y
109
+ .positional('user', { type: 'string', describe: 'alex@velaro.com or Entra object id' })
110
+ .positional('title', { type: 'string', describe: 'Card title' })
111
+ .option('message', { type: 'string', alias: 'm', describe: 'Message text' })
112
+ .option('fact', { type: 'array', describe: 'Repeatable "Label=Value"' })
113
+ .option('button-label', { type: 'string' })
114
+ .option('button-url', { type: 'string' }),
115
+ async (argv) => {
116
+ const facts = (argv.fact || []).map((f) => { const eq = f.indexOf('='); return { label: f.slice(0, eq), value: f.slice(eq + 1) }; });
117
+ const r = await request('POST', '/TeamsMessaging/Direct', {
118
+ user: argv.user, title: argv.title, message: argv.message || '', facts,
119
+ actionUrl: argv.buttonUrl, actionLabel: argv.buttonLabel,
120
+ });
121
+ if (r?.sent) console.log(`Sent to ${argv.user} via ${r.path}.`);
122
+ else { console.error(`Send failed: ${r?.error || 'unknown error'}`); process.exit(1); }
123
+ }
124
+ )
125
+ .command(
126
+ 'card <title>',
127
+ 'Post a card to a named DevOps-team channel with real @mentions (Velaro staff). Destinations: prs, workflows, deploys, alerts, qa, general.',
128
+ (y) =>
129
+ y
130
+ .positional('title', { type: 'string' })
131
+ .option('destination', { type: 'string', alias: 'd', describe: 'Named channel (see: velaro teams destinations)' })
132
+ .option('mention', { type: 'array', describe: 'Repeatable UPN or UPN=Display Name, e.g. --mention andrea@velaro.com=Andrea' })
133
+ .option('message', { type: 'string', alias: 'm', describe: 'Subtitle text' })
134
+ .option('fact', { type: 'array', describe: 'Repeatable "Label=Value"' })
135
+ .option('button-label', { type: 'string' })
136
+ .option('button-url', { type: 'string' }),
137
+ async (argv) => {
138
+ const facts = (argv.fact || []).map((f) => { const eq = f.indexOf('='); return { label: f.slice(0, eq), value: f.slice(eq + 1) }; });
139
+ const mentions = (argv.mention || []).map((m) => { const t = String(m); const eq = t.indexOf('='); return eq < 0 ? { user: t } : { user: t.slice(0, eq), name: t.slice(eq + 1) }; });
140
+ const r = await request('POST', '/TeamsMessaging/Card', {
141
+ title: argv.title, subtitle: argv.message || '', facts, mentions, destination: argv.destination,
142
+ actionUrl: argv.buttonUrl, actionLabel: argv.buttonLabel,
143
+ });
144
+ if (r?.sent) console.log(`Card sent via ${r.path}.${r.warnings?.length ? ' Warnings: ' + r.warnings.join('; ') : ''}`);
145
+ else { console.error(`Send failed: ${r?.error || 'unknown error'}`); process.exit(1); }
146
+ }
147
+ )
148
+ .command(
149
+ 'destinations',
150
+ 'List named Teams destinations and the live channels of the DevOps team (Velaro staff)',
151
+ () => {},
152
+ async () => {
153
+ const r = await request('GET', '/TeamsMessaging/Destinations');
154
+ for (const n of r.named || []) console.log(`${n.name.padEnd(10)} ${n.resolved ? n.channelId : '(not resolved: set app setting TeamsDestinations:' + n.name + ':ChannelId)'}`);
155
+ console.log('\nChannels:');
156
+ for (const c of r.channels || []) console.log(` ${c.displayName} ${c.id}`);
157
+ }
158
+ )
104
159
  .command(
105
160
  'push-settings',
106
161
  'Show or change which alerts the Velaro Bot pushes into Teams (per-alert switches + daily cap). Only flags you pass are changed.',
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import { getCredentials, messagingGet, messagingPost, messagingPut } from '../api.js';
3
3
  import { requireFeature } from '../subscription.js';
4
4
  import { runCommand } from '../run.js';
5
+ import { applyFollowUpOptions } from '../ai-followups.js';
5
6
 
6
7
  export const workflowCommand = {
7
8
  command: 'workflow <subcommand>',
@@ -15,7 +16,9 @@ export const workflowCommand = {
15
16
  .command(workflowDisableCommand)
16
17
  .command(workflowNodeOptionsCommand)
17
18
  .command(workflowIntegrityScanCommand)
18
- .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan'),
19
+ .command(workflowAiFollowupsCommand)
20
+ .command(siteAiFollowupsCommand)
21
+ .demandCommand(1, 'Specify a subcommand: list, get, create, enable, disable, node-options, integrity-scan, ai-followups, ai-followups-site'),
19
22
  handler: () => {},
20
23
  };
21
24
 
@@ -232,6 +235,68 @@ const workflowNodeOptionsCommand = {
232
235
  }),
233
236
  };
234
237
 
238
+ // ── ai-followups (AI follow-up suggestions / "AI mode" on a transferToAI node) ────────
239
+ // After each bot reply the bot offers 2-4 short tappable follow-up replies (web chat; typing stays
240
+ // available). Node value ?? account default ?? OFF. "inherit" CLEARS the node value so the account
241
+ // default decides. Same read-modify-write PUT /Workflows as node-options (no per-node route exists).
242
+
243
+ const workflowAiFollowupsCommand = {
244
+ command: 'ai-followups <workflowId> <nodeId>',
245
+ describe: 'View or set AI follow-up suggestions on an AI Chatbot (transferToAI) node',
246
+ builder: (y) =>
247
+ y
248
+ .positional('workflowId', { type: 'number', describe: 'Workflow ID' })
249
+ .positional('nodeId', { type: 'number', describe: 'Node ID of the transferToAI node' })
250
+ .option('mode', { type: 'string', choices: ['on', 'off', 'inherit'], describe: 'on/off for this node, or inherit = use the account default' })
251
+ .option('count', { type: 'number', describe: 'Number of suggestions, 2-4 (default 3)' })
252
+ .option('instruction', { type: 'string', describe: 'Optional style/focus guidance (max 300 chars); pass "" to clear' }),
253
+ handler: runCommand(async (argv) => {
254
+ await requireFeature('enableAutomation', 'Workflows');
255
+ const w = await messagingGet(`/Workflows/${argv.workflowId}`);
256
+ if (!w) { console.error(`Workflow ${argv.workflowId} not found.`); process.exit(1); }
257
+ const nodes = w.nodes ?? {};
258
+ const nodeKey = String(argv.nodeId);
259
+ const node = nodes[nodeKey];
260
+ if (!node) { console.error(`Node ${argv.nodeId} not found in workflow ${argv.workflowId}.`); process.exit(1); }
261
+ if (node.actionType !== 'transferToAI') {
262
+ console.error(`Node ${argv.nodeId} is not an AI Chatbot (transferToAI) node (found: ${node.actionType}).`);
263
+ process.exit(1);
264
+ }
265
+
266
+ const hasChanges = argv.mode || argv.count != null || argv.instruction != null;
267
+ if (!hasChanges) {
268
+ const mode = node.suggestFollowUps === true ? 'on' : node.suggestFollowUps === false ? 'off' : 'inherit (account default)';
269
+ console.log(`\nNode ${argv.nodeId} — AI follow-up suggestions:`);
270
+ console.log(` Mode: ${mode}`);
271
+ console.log(` Count: ${node.suggestFollowUpCount ?? '3 (default)'}`);
272
+ console.log(` Guidance: ${node.suggestFollowUpInstruction ?? '(none)'}`);
273
+ return;
274
+ }
275
+
276
+ const updated = applyFollowUpOptions(node, { mode: argv.mode, count: argv.count, instruction: argv.instruction });
277
+ await messagingPut('/Workflows', { ...w, nodes: { ...nodes, [nodeKey]: updated } });
278
+ // Read back from the server -- a "saved" message alone is not evidence.
279
+ const check = (await messagingGet(`/Workflows/${argv.workflowId}`))?.nodes?.[nodeKey] ?? {};
280
+ console.log(`Node ${argv.nodeId} updated. Read-back: suggestFollowUps=${check.suggestFollowUps ?? 'unset'} count=${check.suggestFollowUpCount ?? 'unset'}`);
281
+ }),
282
+ };
283
+
284
+ const siteAiFollowupsCommand = {
285
+ command: 'ai-followups-site',
286
+ describe: "View or set this account's default for AI follow-up suggestions (nodes can override)",
287
+ builder: (y) => y.option('mode', { type: 'string', choices: ['on', 'off', 'inherit'], describe: 'on/off, or inherit = clear (platform default: off)' }),
288
+ handler: runCommand(async (argv) => {
289
+ const show = (r) => (r?.enabled === true ? 'on' : r?.enabled === false ? 'off' : 'unset (off)');
290
+ if (!argv.mode) {
291
+ console.log(`Account default: ${show(await messagingGet('/SiteSettings/AiFollowUps'))}`);
292
+ return;
293
+ }
294
+ const enabled = argv.mode === 'on' ? true : argv.mode === 'off' ? false : null;
295
+ await messagingPut('/SiteSettings/AiFollowUps', { enabled });
296
+ console.log(`Account default now: ${show(await messagingGet('/SiteSettings/AiFollowUps'))}`);
297
+ }),
298
+ };
299
+
235
300
  // ── integrity-scan (Velaro staff only) ──────────────────────────────────────────
236
301
 
237
302
  const workflowIntegrityScanCommand = {
@@ -0,0 +1,10 @@
1
+ // The five novel Enterprise Reports (asks 11-15) must stay wired into the CLI with the exact messaging route segments.
2
+ import test from 'node:test';
3
+ import assert from 'node:assert/strict';
4
+ import { enterpriseReportsCommand } from './commands/enterprise-reports.js';
5
+
6
+ test('describe lists the five novel reports and the promise dismiss action', () => {
7
+ for (const r of ['repeat-contact', 'demand-coverage', 'bot-containment', 'effort-index', 'promise-ledger', 'promise-dismiss']) {
8
+ assert.ok(enterpriseReportsCommand.describe.includes(r), `${r} missing from describe`);
9
+ }
10
+ });
@@ -9,7 +9,7 @@ import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
 
11
11
  const dir = path.join(path.dirname(fileURLToPath(import.meta.url)), 'commands');
12
- const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin'];
12
+ const MESSAGING_ONLY = ['/Teams/List', '/Deployment', '/Users/List', '/Workflows/List', '/Rules', '/Ticket/knowledge-overrides', '/UserStatus/admin', '/api/ivr/config', '/WooCommerce/billing', '/WooCommerce/auth', '/diagnostics/test-magento', '/voice/admin', '/api/enterprise-reports'];
13
13
 
14
14
  test('messaging-only routes are never called through the admin api helpers', () => {
15
15
  const offenders = [];
@@ -8,8 +8,25 @@ export function applicationIdPath(id, suffix) {
8
8
  return `/Recruiting/Applications/${n}/${suffix}`;
9
9
  }
10
10
 
11
+ // Job Board listing lifecycle (package job-board-lifecycle): POST Recruiting/Jobs/{id}/close | renew.
12
+ export function jobLifecyclePath(id, action) {
13
+ const n = Number(id);
14
+ if (!Number.isInteger(n) || n <= 0) throw new Error('jobId must be a positive integer.');
15
+ if (!['close', 'renew'].includes(action)) throw new Error('action must be close or renew.');
16
+ return `/Recruiting/Jobs/${n}/${action}`;
17
+ }
18
+
19
+ // Body for POST Recruiting/Jobs/{id}/renew. Omit days to use the site default (RecruitingConfig.DefaultListingDays).
20
+ export function buildRenewBody(days) {
21
+ if (days === undefined) return {};
22
+ const n = Number(days);
23
+ if (!Number.isInteger(n) || n < 1 || n > 365) throw new Error('days must be an integer from 1 to 365.');
24
+ return { days: n };
25
+ }
26
+
11
27
  // Apply-if-present body for PUT /Recruiting/ApplySettings. caseTypeId 0 clears the auto-case link.
12
- export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
28
+ // defaultListingDays (1-365) needs the Job Board lifecycle package (the server answers 403 otherwise).
29
+ export function buildApplySettingsBody({ publicApply, caseTypeId, defaultListingDays } = {}) {
13
30
  const body = {};
14
31
  if (publicApply !== undefined) body.publicApplyEnabled = Boolean(publicApply);
15
32
  if (caseTypeId !== undefined) {
@@ -17,7 +34,12 @@ export function buildApplySettingsBody({ publicApply, caseTypeId } = {}) {
17
34
  if (!Number.isInteger(n) || n < 0) throw new Error('caseTypeId must be an integer >= 0 (0 clears).');
18
35
  body.autoCreateCaseTypeId = n;
19
36
  }
20
- if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId.');
37
+ if (defaultListingDays !== undefined) {
38
+ const d = Number(defaultListingDays);
39
+ if (!Number.isInteger(d) || d < 1 || d > 365) throw new Error('defaultListingDays must be an integer from 1 to 365.');
40
+ body.defaultListingDays = d;
41
+ }
42
+ if (!Object.keys(body).length) throw new Error('Specify at least one of publicApply, caseTypeId, defaultListingDays.');
21
43
  return body;
22
44
  }
23
45
 
@@ -1,6 +1,29 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody } from './recruiting-apply.js';
3
+ import { applicationIdPath, buildApplySettingsBody, buildBotSettingsBody, buildRenewBody, jobLifecyclePath } from './recruiting-apply.js';
4
+
5
+ test('job lifecycle paths validate id and action', () => {
6
+ assert.equal(jobLifecyclePath(7, 'close'), '/Recruiting/Jobs/7/close');
7
+ assert.equal(jobLifecyclePath('9', 'renew'), '/Recruiting/Jobs/9/renew');
8
+ assert.throws(() => jobLifecyclePath('x', 'close'));
9
+ assert.throws(() => jobLifecyclePath(0, 'close'));
10
+ assert.throws(() => jobLifecyclePath(1, 'delete'));
11
+ });
12
+
13
+ test('renew body: omitted days uses the site default, supplied days are range-checked', () => {
14
+ assert.deepEqual(buildRenewBody(undefined), {});
15
+ assert.deepEqual(buildRenewBody(30), { days: 30 });
16
+ assert.deepEqual(buildRenewBody('7'), { days: 7 });
17
+ assert.throws(() => buildRenewBody(0));
18
+ assert.throws(() => buildRenewBody(366));
19
+ assert.throws(() => buildRenewBody(2.5));
20
+ });
21
+
22
+ test('apply settings carries defaultListingDays (1-365)', () => {
23
+ assert.deepEqual(buildApplySettingsBody({ defaultListingDays: 45 }), { defaultListingDays: 45 });
24
+ assert.throws(() => buildApplySettingsBody({ defaultListingDays: 0 }));
25
+ assert.throws(() => buildApplySettingsBody({ defaultListingDays: 400 }));
26
+ });
4
27
 
5
28
  test('application paths validate id', () => {
6
29
  assert.equal(applicationIdPath(12, 'AtsRetry'), '/Recruiting/Applications/12/AtsRetry');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@velaro/cli",
3
- "version": "1.4.90",
3
+ "version": "1.4.91",
4
4
  "description": "Velaro Workspace v20 — command-line interface for managing bots, knowledge base ingestion, MCP API keys, search indexes, and ops health.",
5
5
  "type": "module",
6
6
  "bin": {