incremnt 0.6.0 → 0.7.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/src/remote.js CHANGED
@@ -110,17 +110,17 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
110
110
  return sessionsUrl;
111
111
  }
112
112
  case 'session-show':
113
- return resolveServiceUrl(baseUrl, `/cli/sessions/${options.id}`);
113
+ return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options.id)}`);
114
114
  case 'planned-vs-actual':
115
- return resolveServiceUrl(baseUrl, `/cli/sessions/${options['session-id']}/compare`);
115
+ return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/compare`);
116
116
  case 'why-did-this-change':
117
- return resolveServiceUrl(baseUrl, `/cli/sessions/${options['session-id']}/explain`);
117
+ return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/explain`);
118
118
  case 'program-list':
119
119
  return resolveServiceUrl(baseUrl, '/cli/programs');
120
120
  case 'program-summary':
121
121
  return resolveServiceUrl(baseUrl, '/cli/programs/current');
122
122
  case 'program-detail':
123
- return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${options.id}` : '/cli/programs/active');
123
+ return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${encodeURIComponent(options.id)}` : '/cli/programs/active');
124
124
  case 'cycle-summary-list': {
125
125
  const cyclesUrl = resolveServiceUrl(baseUrl, '/cli/cycles');
126
126
  if (options['program-id']) {
@@ -129,7 +129,7 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
129
129
  return cyclesUrl;
130
130
  }
131
131
  case 'cycle-summary-show':
132
- return resolveServiceUrl(baseUrl, `/cli/cycles/${options.id}`);
132
+ return resolveServiceUrl(baseUrl, `/cli/cycles/${encodeURIComponent(options.id)}`);
133
133
  case 'exercise-history': {
134
134
  const historyUrl = resolveServiceUrl(baseUrl, '/cli/exercises/history');
135
135
  historyUrl.searchParams.set('name', options.name ?? options.exercise);
@@ -140,7 +140,7 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
140
140
  case 'goals-list':
141
141
  return resolveServiceUrl(baseUrl, '/cli/goals');
142
142
  case 'goals-show':
143
- return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${options.id}` : '/cli/goals');
143
+ return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${encodeURIComponent(options.id)}` : '/cli/goals');
144
144
  case 'health-summary': {
145
145
  const healthUrl = resolveServiceUrl(baseUrl, '/cli/health/summary');
146
146
  if (options.days) {
@@ -160,9 +160,9 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
160
160
  return askUrl;
161
161
  }
162
162
  case 'ask-show':
163
- return resolveServiceUrl(baseUrl, `/cli/ask/history/${options.id}`);
163
+ return resolveServiceUrl(baseUrl, `/cli/ask/history/${encodeURIComponent(options.id)}`);
164
164
  case 'program-share-fetch':
165
- return resolveServiceUrl(baseUrl, `/program-share/${options.token}`);
165
+ return resolveServiceUrl(baseUrl, `/program-share/${encodeURIComponent(options.token)}`);
166
166
  case 'increment-score-current': {
167
167
  const url = resolveServiceUrl(baseUrl, '/cli/increment-score/current');
168
168
  if (options.historyDays) url.searchParams.set('historyDays', options.historyDays);
@@ -238,6 +238,97 @@ async function executeRemoteCoachReadTool(toolName, input, sessionState) {
238
238
  return executeLocalCoachReadTool(snapshot, toolName, input);
239
239
  }
240
240
 
241
+ // Build the shape of a write request without executing it.
242
+ // Returns { method, url, body }. body is a parsed JS object or null.
243
+ // Used by the --dry-run path. Keep endpoint and body changes in sync with the
244
+ // real write handlers below; the drift test in dry-run.test.js asserts both
245
+ // paths emit the same { method, url, body }.
246
+ export async function buildWriteRequest(commandId, options, sessionState) {
247
+ const baseUrl = sessionState.session?.transport?.baseUrl;
248
+ if (!baseUrl) {
249
+ const error = new Error('--dry-run requires a remote session. Run `incremnt login` first.');
250
+ error.code = 'REMOTE_NOT_IMPLEMENTED';
251
+ throw error;
252
+ }
253
+
254
+ switch (commandId) {
255
+ case 'programs-propose': {
256
+ if (!options.file) {
257
+ const error = new Error('--file is required for programs propose.');
258
+ error.code = 'MISSING_OPTION';
259
+ throw error;
260
+ }
261
+ const raw = await fs.readFile(options.file, 'utf8');
262
+ const proposal = JSON.parse(raw);
263
+ return {
264
+ method: 'POST',
265
+ url: resolveServiceUrl(baseUrl, '/cli/programs/proposals').toString(),
266
+ body: proposal
267
+ };
268
+ }
269
+ case 'proposal-dismiss': {
270
+ if (!options.id) {
271
+ const error = new Error('--id is required for proposal dismiss.');
272
+ error.code = 'MISSING_OPTION';
273
+ throw error;
274
+ }
275
+ return {
276
+ method: 'PATCH',
277
+ url: resolveServiceUrl(baseUrl, `/cli/programs/proposals/${encodeURIComponent(options.id)}`).toString(),
278
+ body: { status: 'dismissed' }
279
+ };
280
+ }
281
+ case 'program-share-create': {
282
+ if (!options['program-id']) {
283
+ const error = new Error('--program-id is required for programs share create.');
284
+ error.code = 'MISSING_OPTION';
285
+ throw error;
286
+ }
287
+ return {
288
+ method: 'POST',
289
+ url: resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/share`).toString(),
290
+ body: null
291
+ };
292
+ }
293
+ case 'program-share-revoke': {
294
+ if (!options['share-id']) {
295
+ const error = new Error('--share-id is required for programs share revoke.');
296
+ error.code = 'MISSING_OPTION';
297
+ throw error;
298
+ }
299
+ return {
300
+ method: 'POST',
301
+ url: resolveServiceUrl(baseUrl, `/cli/program-share/${encodeURIComponent(options['share-id'])}/revoke`).toString(),
302
+ body: null
303
+ };
304
+ }
305
+ case 'increment-score-upload': {
306
+ if (!options.file) {
307
+ const error = new Error('--file is required for increment-score upload.');
308
+ error.code = 'MISSING_OPTION';
309
+ throw error;
310
+ }
311
+ const raw = await fs.readFile(options.file, 'utf8');
312
+ const body = JSON.parse(raw);
313
+ if (!body || !Array.isArray(body.snapshots)) {
314
+ const error = new Error('Invalid file: expected an object with a snapshots array.');
315
+ error.code = 'INVALID_PAYLOAD';
316
+ throw error;
317
+ }
318
+ return {
319
+ method: 'POST',
320
+ url: resolveServiceUrl(baseUrl, '/mobile/score-snapshots').toString(),
321
+ body
322
+ };
323
+ }
324
+ default: {
325
+ const error = new Error(`Command ${commandId} does not support --dry-run.`);
326
+ error.code = 'UNSUPPORTED_DRY_RUN';
327
+ throw error;
328
+ }
329
+ }
330
+ }
331
+
241
332
  const remoteWriteCommandHandlers = {
242
333
  'programs-propose': async (options, sessionState) => {
243
334
  const baseUrl = sessionState.session?.transport?.baseUrl;
@@ -310,7 +401,7 @@ const remoteWriteCommandHandlers = {
310
401
  throw error;
311
402
  }
312
403
 
313
- const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/proposals/${options.id}`);
404
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/proposals/${encodeURIComponent(options.id)}`);
314
405
  const response = await fetch(endpoint, {
315
406
  method: 'PATCH',
316
407
  headers: {
@@ -345,7 +436,7 @@ const remoteWriteCommandHandlers = {
345
436
  throw error;
346
437
  }
347
438
 
348
- const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${options['program-id']}/share`);
439
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/share`);
349
440
  const response = await fetch(endpoint, {
350
441
  method: 'POST',
351
442
  headers: {
@@ -377,7 +468,7 @@ const remoteWriteCommandHandlers = {
377
468
  throw error;
378
469
  }
379
470
 
380
- const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${options['program-id']}/shares`);
471
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/shares`);
381
472
  const response = await fetch(endpoint, {
382
473
  headers: {
383
474
  Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
@@ -441,7 +532,7 @@ const remoteWriteCommandHandlers = {
441
532
  throw error;
442
533
  }
443
534
 
444
- const endpoint = resolveServiceUrl(baseUrl, `/cli/program-share/${options['share-id']}/revoke`);
535
+ const endpoint = resolveServiceUrl(baseUrl, `/cli/program-share/${encodeURIComponent(options['share-id'])}/revoke`);
445
536
  const response = await fetch(endpoint, {
446
537
  method: 'POST',
447
538
  headers: {
@@ -733,6 +733,45 @@ function hasFatigueLanguage(output) {
733
733
  return /\b(fatigue|fatigued|underrecovered|recovery debt|fatigue ceiling|limited by recovery|limited by fatigue|accumulated fatigue)\b/i.test(output);
734
734
  }
735
735
 
736
+ function hasAskFatigueRecoveryLanguage(output) {
737
+ return hasFatigueLanguage(output)
738
+ || /\b(?:poor|low|bad|incomplete)\s+recovery\b/i.test(output)
739
+ || /\bunder[-\s]?recovery\b/i.test(output)
740
+ || /\brecovery\s+(?:limited|held back|caused|explains|drove|deficit|issue|problem)\b/i.test(output);
741
+ }
742
+
743
+ function hasAskFatigueRecoveryUncertaintyLanguage(output) {
744
+ const missingRecoveryData = /\b(?:no|not enough|without|missing|lack(?:ing)?|insufficient)\s+(?:\w+\s+){0,4}?(?:recovery|readiness|vitals?|sleep|hrv|heart rate|data|info|signals?|metrics?)\b/i.test(output);
745
+ const refusesInference = /\b(?:cannot|can't|do not|don't|does not|doesn't|would not|wouldn't|not enough|isn't enough|is not enough|no basis to|hard to)\s+(?:\w+\s+){0,12}?(?:infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\s+(?:\w+\s+){0,12}?(?:fatigue|recovery|readiness|why)\b/i.test(output);
746
+ const recoveryDoesNotExplain = /\b(?:fatigue|recovery|readiness)\b\s+(?:\w+\s+){0,10}?(?:cannot|can't|does not|doesn't|would not|wouldn't|isn't|is not)\s+(?:\w+\s+){0,10}?(?:explain|prove|show|tell|account for)\b/i.test(output);
747
+ return missingRecoveryData || refusesInference || recoveryDoesNotExplain;
748
+ }
749
+
750
+ function hasAskPositiveFatigueRecoveryAttribution(output) {
751
+ const concept = String.raw`(?:fatigue|fatigued|under[-\s]?recovered|under[-\s]?recovery|poor recovery|low recovery|incomplete recovery|recovery debt|fatigue ceiling|accumulated fatigue)`;
752
+ const causeVerb = String.raw`(?:because|due to|caused by|from|reflects?|suggests?|indicates?|points? to|explains?|limited|held back|drove|contributed to|tied to|tie\s+\w+\s+to)`;
753
+ const patterns = [
754
+ new RegExp(String.raw`\b${causeVerb}\b.{0,80}\b${concept}\b`, 'gi'),
755
+ new RegExp(String.raw`\b${concept}\b.{0,80}\b(?:caused|limited|held back|explains?|drove|led to|contributed to|accounts? for)\b`, 'gi')
756
+ ];
757
+ for (const pattern of patterns) {
758
+ for (const match of output.matchAll(pattern)) {
759
+ const start = Math.max(0, (match.index ?? 0) - 40);
760
+ const window = output.slice(start, (match.index ?? 0) + match[0].length);
761
+ if (!/\b(?:not|no|cannot|can't|doesn't|does not|would not|wouldn't|isn't|is not)\b/i.test(window)) {
762
+ return true;
763
+ }
764
+ }
765
+ }
766
+ return false;
767
+ }
768
+
769
+ function hasUnsupportedAskFatigueRecoveryClaim(output) {
770
+ if (!hasAskFatigueRecoveryLanguage(output)) return false;
771
+ if (hasAskPositiveFatigueRecoveryAttribution(output)) return true;
772
+ return !hasAskFatigueRecoveryUncertaintyLanguage(output);
773
+ }
774
+
736
775
  function matchesHistoricalFamilyName(claimName, actualName) {
737
776
  const claimVariants = new Set(historicalExerciseVariants(claimName));
738
777
  const actualVariants = new Set(historicalExerciseVariants(actualName));
@@ -1182,7 +1221,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
1182
1221
  }
1183
1222
  }
1184
1223
 
1185
- if (hasFatigueLanguage(normalized) && !hasAskFatigueSupport(snapshot)) {
1224
+ if (hasUnsupportedAskFatigueRecoveryClaim(normalized) && !hasAskFatigueSupport(snapshot)) {
1186
1225
  failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
1187
1226
  }
1188
1227
 
@@ -21,6 +21,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
21
21
  'weekly-checkin-current': 30,
22
22
  'weekly-checkin-ack': 30,
23
23
  'weekly-checkin-start': 10,
24
+ 'weekly-digest-current': 30,
24
25
  'dev-login': 10,
25
26
  'device-start': 20,
26
27
  'device-poll': 300,
@@ -1050,6 +1051,10 @@ function routeRequest(url, method) {
1050
1051
  return { command: 'weekly-checkin-start', options: {} };
1051
1052
  }
1052
1053
 
1054
+ if (pathname === '/cli/weekly-digest/current') {
1055
+ return { command: 'weekly-digest-current', options: {} };
1056
+ }
1057
+
1053
1058
  if (pathname === '/cli/health/ai') {
1054
1059
  return {
1055
1060
  command: 'health-ai',
@@ -1434,41 +1439,6 @@ function routeRequest(url, method) {
1434
1439
  return null;
1435
1440
  }
1436
1441
 
1437
- /// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
1438
- /// a short text prelude prepended to the AI context. Without this the model
1439
- /// would have to infer "is this a deload week / was last week deload?" from
1440
- /// session prose; with it the structured phase facts are explicit.
1441
- function formatProgramPhasePrelude(programPhase) {
1442
- if (!programPhase || typeof programPhase !== 'object') return null;
1443
- const current = programPhase.current;
1444
- const previous = programPhase.previousWeek;
1445
- const next = programPhase.nextWeek;
1446
- if (!current?.phase || typeof current.displayWeek !== 'number') return null;
1447
- const describe = (phase) => {
1448
- if (!phase?.phase) return null;
1449
- const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
1450
- return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
1451
- };
1452
- const describeList = (phases) => {
1453
- if (!Array.isArray(phases) || phases.length === 0) return null;
1454
- return phases.map(describe).filter(Boolean).join(', ');
1455
- };
1456
- const lines = [
1457
- '[Program phase]',
1458
- `- Current: ${describe(current)}`
1459
- ];
1460
- if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
1461
- if (next?.phase) lines.push(`- Next: ${describe(next)}`);
1462
- if (programPhase.isPostDeloadReturn === true) {
1463
- lines.push('- Post-deload return: yes (last week was deload, this week is build)');
1464
- }
1465
- const range = describeList(programPhase.phasesInRange);
1466
- if (range) lines.push(`- Range phases: ${range}`);
1467
- const previousRange = describeList(programPhase.previousRangePhases);
1468
- if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
1469
- return lines.join('\n');
1470
- }
1471
-
1472
1442
  export function formatIncrementScorePrelude(snapshots) {
1473
1443
  if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1474
1444
  const latest = snapshots[0];
@@ -2021,6 +1991,7 @@ export function createSyncServiceRequestHandler({
2021
1991
  pushMobileSyncChangesForAccount = null,
2022
1992
  insertScoreSnapshotsForAccount = null,
2023
1993
  listScoreSnapshotsForAccount = null,
1994
+ getCurrentWeeklyScoreDigestForAccount = null,
2024
1995
  // Social
2025
1996
  social = null,
2026
1997
  onError = null
@@ -3488,6 +3459,43 @@ export function createSyncServiceRequestHandler({
3488
3459
  return;
3489
3460
  }
3490
3461
 
3462
+ if (route.command === 'weekly-digest-current') {
3463
+ if (request.method !== 'GET') {
3464
+ methodNotAllowed(response, 'Use GET for /cli/weekly-digest/current.');
3465
+ return;
3466
+ }
3467
+ if (!getCurrentWeeklyScoreDigestForAccount) {
3468
+ json(response, 503, { error: 'Weekly digest not available' });
3469
+ return;
3470
+ }
3471
+ try {
3472
+ const row = await getCurrentWeeklyScoreDigestForAccount(account);
3473
+ if (!row) {
3474
+ response.writeHead(204);
3475
+ response.end();
3476
+ return;
3477
+ }
3478
+ json(response, 200, {
3479
+ id: row.id,
3480
+ weekStartDate: row.weekStartDate,
3481
+ status: row.status,
3482
+ score: row.score,
3483
+ scoreDelta: row.scoreDelta,
3484
+ components: row.components,
3485
+ componentsDelta: row.componentsDelta,
3486
+ signals: row.signals,
3487
+ observation: row.observation,
3488
+ formulaVersion: row.formulaVersion,
3489
+ generatedAt: row.generatedAt,
3490
+ seenAt: row.seenAt,
3491
+ });
3492
+ } catch (err) {
3493
+ console.error('Weekly digest read error:', err.message);
3494
+ json(response, 500, { error: 'Failed to read weekly digest' });
3495
+ }
3496
+ return;
3497
+ }
3498
+
3491
3499
  let snapshot;
3492
3500
  try {
3493
3501
  snapshot = loadSnapshotForAccount
@@ -3707,7 +3715,10 @@ export function createSyncServiceRequestHandler({
3707
3715
  if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3708
3716
  try {
3709
3717
  const { weeklyCheckinContext } = await import('./queries.js');
3710
- const ctx = weeklyCheckinContext(snapshot, account.id, {});
3718
+ // Anchor to row.weekStartDate so lazy-gen describes the
3719
+ // canonical week, matching cron behaviour (onemore-8oc5).
3720
+ const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
3721
+ const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
3711
3722
  if (ctx) {
3712
3723
  let priorCommitmentRow = null;
3713
3724
  if (listActiveCoachCommitmentsForAccount) {
@@ -4160,13 +4171,9 @@ export function createSyncServiceRequestHandler({
4160
4171
  ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4161
4172
  : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4162
4173
  const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4163
- const serverProgramPhase = persistedKind === 'weekly-checkin'
4164
- ? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
4165
- : null;
4166
- const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
4167
4174
  const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
4168
4175
 
4169
- const preludes = [programPhasePrelude, incrementScorePrelude].filter(Boolean);
4176
+ const preludes = [incrementScorePrelude].filter(Boolean);
4170
4177
  const ctx = preludes.length > 0
4171
4178
  ? `${preludes.join('\n\n')}\n\n${routedContext.context}`
4172
4179
  : routedContext.context;
@@ -0,0 +1,152 @@
1
+ // Per-option input validation. Defends against agent-hallucinated values
2
+ // (path traversals, embedded query params in IDs, control characters).
3
+ //
4
+ // Formats:
5
+ // 'id' — ^[A-Za-z0-9_-]+$ (no ?, &, #, %, /, whitespace, control chars)
6
+ // 'path' — no .. segments, no NUL, no control chars, no URL schemes
7
+ // 'url' — must parse as http(s) URL
8
+ // 'enum' — must be one of opt.enum
9
+ // 'text' — reject ASCII <0x20 except \t \n; length cap (default 4 KB)
10
+ // undefined → 'text'
11
+
12
+ const ID_RE = /^[A-Za-z0-9_-]+$/;
13
+ // Any RFC-3986-style scheme: letter followed by letters/digits/+/-/. then ://.
14
+ // Matches http://, https://, file://, ftp://, FILE://, custom://, etc.
15
+ const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
16
+ // eslint-disable-next-line no-control-regex
17
+ const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/;
18
+ // eslint-disable-next-line no-control-regex
19
+ const ANY_CONTROL_RE = /[\x00-\x1F]/;
20
+ const DEFAULT_TEXT_CAP = 4096;
21
+ const FIELDS_OPTION = {
22
+ name: 'fields',
23
+ type: 'string',
24
+ format: 'text',
25
+ maxLength: 1024
26
+ };
27
+
28
+ export class ValidationError extends Error {
29
+ constructor(message, { option, format, code = 'INVALID_OPTION' } = {}) {
30
+ super(message);
31
+ this.name = 'ValidationError';
32
+ this.code = code;
33
+ this.option = option;
34
+ this.format = format;
35
+ }
36
+ }
37
+
38
+ export function validateOption(opt, rawValue) {
39
+ if (rawValue === undefined) return rawValue;
40
+
41
+ const format = opt.format ?? (opt.type === 'number' ? 'number' : 'text');
42
+
43
+ if (rawValue === null) {
44
+ throw new ValidationError(`--${opt.name} must not be null`, { option: opt.name, format, code: 'MISSING_OPTION' });
45
+ }
46
+
47
+ if (opt.type === 'number' || format === 'number') {
48
+ if (typeof rawValue !== 'string' && typeof rawValue !== 'number') {
49
+ throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
50
+ }
51
+ if (typeof rawValue === 'string' && rawValue.trim() === '') {
52
+ throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
53
+ }
54
+ const num = typeof rawValue === 'number' ? rawValue : Number(rawValue);
55
+ if (!Number.isFinite(num)) {
56
+ throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
57
+ }
58
+ return num;
59
+ }
60
+
61
+ if (typeof rawValue !== 'string') {
62
+ throw new ValidationError(`--${opt.name} must be a string`, { option: opt.name, format });
63
+ }
64
+
65
+ if (format === 'id') {
66
+ if (!ID_RE.test(rawValue)) {
67
+ throw new ValidationError(
68
+ `--${opt.name} contains disallowed characters (allowed: letters, digits, '-', '_')`,
69
+ { option: opt.name, format }
70
+ );
71
+ }
72
+ return rawValue;
73
+ }
74
+
75
+ if (format === 'path') {
76
+ if (ANY_CONTROL_RE.test(rawValue)) {
77
+ throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
78
+ }
79
+ if (URL_SCHEME_RE.test(rawValue)) {
80
+ throw new ValidationError(`--${opt.name} must be a filesystem path, not a URL`, { option: opt.name, format });
81
+ }
82
+ const segments = rawValue.split(/[\\/]/);
83
+ if (segments.some((seg) => seg === '..')) {
84
+ throw new ValidationError(`--${opt.name} must not contain '..' path segments`, { option: opt.name, format });
85
+ }
86
+ return rawValue;
87
+ }
88
+
89
+ if (format === 'url') {
90
+ if (ANY_CONTROL_RE.test(rawValue)) {
91
+ throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = new URL(rawValue);
96
+ } catch {
97
+ throw new ValidationError(`--${opt.name} is not a valid URL`, { option: opt.name, format });
98
+ }
99
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
100
+ throw new ValidationError(`--${opt.name} must be http(s)`, { option: opt.name, format });
101
+ }
102
+ return rawValue;
103
+ }
104
+
105
+ if (format === 'enum') {
106
+ const allowed = opt.enum ?? [];
107
+ if (!allowed.includes(rawValue)) {
108
+ throw new ValidationError(
109
+ `--${opt.name} must be one of: ${allowed.join(', ')}`,
110
+ { option: opt.name, format }
111
+ );
112
+ }
113
+ return rawValue;
114
+ }
115
+
116
+ // 'text' (default)
117
+ if (CONTROL_RE.test(rawValue)) {
118
+ throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
119
+ }
120
+ const cap = opt.maxLength ?? DEFAULT_TEXT_CAP;
121
+ if (rawValue.length > cap) {
122
+ throw new ValidationError(`--${opt.name} exceeds max length (${cap})`, { option: opt.name, format });
123
+ }
124
+ return rawValue;
125
+ }
126
+
127
+ export function validateOptions(commandEntry, options) {
128
+ if (!commandEntry?.options) return options;
129
+ const out = { ...options };
130
+ for (const opt of commandEntry.options) {
131
+ if (out[opt.name] === undefined) continue;
132
+ if (out[opt.name] === true) {
133
+ const format = opt.format ?? (opt.type === 'number' ? 'number' : 'text');
134
+ throw new ValidationError(`--${opt.name} requires a value`, { option: opt.name, format, code: 'MISSING_OPTION' });
135
+ }
136
+ out[opt.name] = validateOption(opt, out[opt.name]);
137
+ }
138
+ if (commandEntry.supportsFields && out.fields !== undefined) {
139
+ if (out.fields === true) {
140
+ throw new ValidationError('--fields requires a value', { option: 'fields', format: 'text', code: 'MISSING_OPTION' });
141
+ }
142
+ out.fields = validateOption(FIELDS_OPTION, out.fields);
143
+ const keys = out.fields
144
+ .split(',')
145
+ .map((s) => s.trim())
146
+ .filter((s) => s.length > 0);
147
+ if (keys.length === 0) {
148
+ throw new ValidationError('--fields requires at least one field name', { option: 'fields', format: 'text' });
149
+ }
150
+ }
151
+ return out;
152
+ }