incremnt 0.7.2 → 0.8.1

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/contract.js CHANGED
@@ -1,4 +1,4 @@
1
- export const contractVersion = 17;
1
+ export const contractVersion = 21;
2
2
 
3
3
  export const capabilities = {
4
4
  readOnly: false,
@@ -6,7 +6,9 @@ export const capabilities = {
6
6
  remoteReads: true,
7
7
  remoteWrites: true,
8
8
  remoteAuthShell: true,
9
- remoteBootstrap: true
9
+ remoteBootstrap: true,
10
+ agentTokens: true,
11
+ cliSessionRefresh: true
10
12
  };
11
13
 
12
14
  // Single source of truth for all read commands.
@@ -77,6 +79,18 @@ export const commandSchema = [
77
79
  { name: 'id', type: 'string', format: 'id', required: false, description: 'Program ID (defaults to active program)' }
78
80
  ]
79
81
  },
82
+ {
83
+ command: 'programs progress',
84
+ id: 'program-progress',
85
+ description: 'Summarize active program progress using cycle and exercise evidence',
86
+ supportsFields: true,
87
+ agentNotes: 'Use for broad program progress questions. Returns compact first/best/latest exercise evidence plus cycle and training-load context.',
88
+ options: [
89
+ { name: 'program-id', type: 'string', format: 'id', required: false, description: 'Program ID (defaults to active program)' },
90
+ { name: 'since', type: 'string', required: false, description: 'Start date for progress window (YYYY-MM-DD)' },
91
+ { name: 'limitExercises', type: 'number', required: false, description: 'Max exercise rows to return (default 10, max 50)' }
92
+ ]
93
+ },
80
94
  {
81
95
  command: 'exercises history',
82
96
  id: 'exercise-history',
@@ -87,6 +101,19 @@ export const commandSchema = [
87
101
  { name: 'name', type: 'string', required: true, description: 'Exercise name' }
88
102
  ]
89
103
  },
104
+ {
105
+ command: 'exercises progress',
106
+ id: 'exercise-progress-summary',
107
+ description: 'Summarize exercise first/best/latest progress over a date window',
108
+ supportsFields: true,
109
+ agentNotes: 'Use for longitudinal progress questions such as "what improved since January". Omit --name to summarize active-program exercises.',
110
+ options: [
111
+ { name: 'name', type: 'string', required: false, description: 'Exercise name to scope progress' },
112
+ { name: 'since', type: 'string', required: false, description: 'Start date for progress window (YYYY-MM-DD)' },
113
+ { name: 'program-id', type: 'string', format: 'id', required: false, description: 'Program ID used to scope exercise names' },
114
+ { name: 'limit', type: 'number', required: false, description: 'Max exercise rows to return (default 12, max 50)' }
115
+ ]
116
+ },
90
117
  {
91
118
  command: 'records',
92
119
  id: 'records',
@@ -130,6 +157,16 @@ export const commandSchema = [
130
157
  { name: 'id', type: 'string', format: 'id', required: true, description: 'Cycle summary ID' }
131
158
  ]
132
159
  },
160
+ {
161
+ command: 'cycles progress',
162
+ id: 'cycle-progression-summary',
163
+ description: 'Summarize cycle progression counts and adherence',
164
+ supportsFields: true,
165
+ options: [
166
+ { name: 'program-id', type: 'string', format: 'id', required: false, description: 'Filter by program ID' },
167
+ { name: 'limit', type: 'number', required: false, description: 'Max cycles to return (default 8, max 20)' }
168
+ ]
169
+ },
133
170
  {
134
171
  command: 'health summary',
135
172
  id: 'health-summary',
@@ -151,6 +188,15 @@ export const commandSchema = [
151
188
  description: 'Training load analysis (7-day vs 28-day comparison, effort scores, readiness via ATL/CTL/TSB, per-type breakdown)',
152
189
  options: []
153
190
  },
191
+ {
192
+ command: 'training profile',
193
+ id: 'training-profile',
194
+ description: 'Summarize stable lifter profile evidence from logs and current program',
195
+ supportsFields: true,
196
+ options: [
197
+ { name: 'since', type: 'string', required: false, description: 'Start date for profile window (YYYY-MM-DD)' }
198
+ ]
199
+ },
154
200
  {
155
201
  command: 'ask history',
156
202
  id: 'ask-history',
@@ -209,7 +255,7 @@ export const commandSchema = [
209
255
  id: 'coach-observations-current',
210
256
  description: 'List current persisted coach observations',
211
257
  supportsFields: true,
212
- agentNotes: 'Read-only persisted insight surface. Returns generated/seen observations with evidence and lifecycle status. Use IDs from this command for CLI seen/dismiss actions.',
258
+ agentNotes: 'Read-only persisted insight surface. Returns generated/seen observations with evidence, lifecycle status, outcome tracking, and user feedback status. Use IDs from this command for CLI seen/dismiss/outcome/feedback actions.',
213
259
  options: [
214
260
  { name: 'limit', type: 'number', required: false, description: 'Max observations to return (default 5, max 20)' }
215
261
  ]
@@ -232,6 +278,7 @@ export const writeCommandSchema = [
232
278
  command: 'programs proposals',
233
279
  id: 'programs-proposals',
234
280
  description: 'List program proposals',
281
+ requiredAccess: 'read',
235
282
  agentNotes: 'Pass --status to filter. Use this before programs proposal dismiss to look up proposal IDs.',
236
283
  options: [
237
284
  { name: 'status', type: 'string', format: 'enum', enum: ['pending', 'accepted', 'dismissed'], required: false, description: 'Filter by status (pending, accepted, dismissed)' }
@@ -264,6 +311,7 @@ export const writeCommandSchema = [
264
311
  id: 'program-share-list',
265
312
  description: 'List share artifacts for one program',
266
313
  usage: 'programs share list --program-id <program-id>',
314
+ requiredAccess: 'read',
267
315
  agentNotes: 'Returns existing share-ids for the program. Use this to look up share-id before programs share revoke — the public token is NOT the share-id.',
268
316
  options: [
269
317
  { name: 'program-id', type: 'string', format: 'id', required: true, description: 'Program ID' }
@@ -315,11 +363,65 @@ export const writeCommandSchema = [
315
363
  options: [
316
364
  { name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' }
317
365
  ]
366
+ },
367
+ {
368
+ command: 'coach observations outcome',
369
+ id: 'coach-observations-outcome',
370
+ description: 'Record whether a coach observation recommendation worked',
371
+ usage: 'coach observations outcome --id <observation-id> --status <improved|unchanged|regressed|inconclusive>',
372
+ dryRun: true,
373
+ mcp: false,
374
+ agentNotes: 'Mutation. Records recommendation outcome on the original coach_observations row so later Ask Coach turns can compare new sessions against prior advice. Does not change seen/dismissed lifecycle state.',
375
+ options: [
376
+ { name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' },
377
+ { name: 'status', type: 'string', format: 'enum', enum: ['improved', 'unchanged', 'regressed', 'inconclusive'], required: true, description: 'Observed recommendation outcome' },
378
+ { name: 'observedAt', type: 'string', required: false, description: 'Optional ISO timestamp for when the outcome was observed' },
379
+ { name: 'notes', type: 'string', required: false, description: 'Optional outcome notes' },
380
+ { name: 'linkedFollowupObservationId', type: 'string', format: 'id', required: false, description: 'Optional follow-up observation ID connected to the outcome' }
381
+ ]
382
+ },
383
+ {
384
+ command: 'coach observations feedback',
385
+ id: 'coach-observations-feedback',
386
+ description: 'Record whether the user accepted or rejected a coach observation',
387
+ usage: 'coach observations feedback --id <observation-id> --status <accepted|rejected>',
388
+ dryRun: true,
389
+ mcp: false,
390
+ agentNotes: 'Mutation. Records explicit user agreement/disagreement separately from seen/dismissed visibility state.',
391
+ options: [
392
+ { name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' },
393
+ { name: 'status', type: 'string', format: 'enum', enum: ['accepted', 'rejected'], required: true, description: 'User feedback status' },
394
+ { name: 'feedbackAt', type: 'string', required: false, description: 'Optional ISO timestamp for when feedback was recorded' }
395
+ ]
396
+ },
397
+ {
398
+ command: 'ask',
399
+ id: 'ask',
400
+ description: 'Ask the AI coach a question and return the answer',
401
+ usage: 'ask --question "How is my bench progressing?" [--conversation-id <id>] [--tone default|hype|numbers-only] [--exclude <items>] [--history <json>]',
402
+ dryRun: false,
403
+ mcp: true,
404
+ agentNotes: 'Mutation. Calls the remote Ask Coach pipeline; persists the conversation server-side. If --conversation-id is omitted, a new UUID is generated and returned in the response. Pass --history as JSON to continue an existing conversation. Tone defaults to "default".',
405
+ options: [
406
+ { name: 'question', type: 'string', required: true, maxLength: 500, description: 'Question text (1-500 chars)' },
407
+ { name: 'conversation-id', type: 'string', format: 'id', required: false, description: 'Conversation ID (auto-generated if omitted)' },
408
+ { name: 'history', type: 'string', required: false, description: 'Prior conversation turns as JSON array of {role, content}' },
409
+ { name: 'exclude', type: 'string', required: false, description: 'Comma-separated AI privacy exclusions (e.g. coach_observations)' },
410
+ { name: 'tone', type: 'string', format: 'enum', enum: ['default', 'hype', 'numbers-only'], required: false, description: 'Response tone (default: default)' }
411
+ ]
318
412
  }
319
413
  ];
320
414
 
321
415
  export const writeCommands = new Set(writeCommandSchema.map((c) => c.id));
322
416
 
417
+ for (const command of commandSchema) {
418
+ command.requiredAccess ??= 'read';
419
+ }
420
+
421
+ for (const command of writeCommandSchema) {
422
+ command.requiredAccess ??= 'write';
423
+ }
424
+
323
425
  export const proposalSchema = {
324
426
  description: 'JSON structure for programs propose --file. Weights are omitted — iOS calculates from history.',
325
427
  required: ['name', 'equipmentTier', 'days'],
@@ -354,6 +456,56 @@ export const proposalSchema = {
354
456
  }
355
457
  };
356
458
 
459
+ // Describes how token scope gates commands, so an agent can decide before
460
+ // calling rather than discovering limits reactively via a 403.
461
+ export const accessModel = {
462
+ description: 'Agent tokens (incr_agent_*) carry an access scope. A command runs only if the active token satisfies its requiredAccess. Read tokens cannot run write commands.',
463
+ scopes: ['read', 'write'],
464
+ check: 'The active token access (status.auth.credential.access) must satisfy each command\'s requiredAccess (see schema/writeSchema entries).',
465
+ onInsufficientScope: 'A 403 with code INSUFFICIENT_SCOPE includes requiredAccess and requiresHuman:true. Minting a write token needs a human login — escalate rather than retry.'
466
+ };
467
+
468
+ // Structured definitions for the human-only `agents` token-management commands.
469
+ // These are NOT registered as MCP tools and CANNOT be run by an agent token
470
+ // (management requires a human session); they are described here so the command
471
+ // surface is fully discoverable from `incremnt contract`.
472
+ export const agentCommandSchema = [
473
+ {
474
+ id: 'agents-create',
475
+ command: 'agents create',
476
+ usage: 'agents create --name <name> [--access <read|write>] [--expires-days <n>] [--store]',
477
+ description: 'Mint a new headless agent token.',
478
+ requiresHuman: true,
479
+ agentNotes: 'An agent CANNOT run this — minting requires a human (non-agent-token) login. On INSUFFICIENT_SCOPE, escalate to an operator. The plaintext token is returned ONCE in agentToken.token; capture it immediately (tokenIssued:true even if local persistence fails). --store replaces the active CLI session with the new token.',
480
+ options: [
481
+ { name: 'name', type: 'string', required: true, description: 'Human-readable label for the token.' },
482
+ { name: 'access', type: 'string', format: 'enum', enum: ['read', 'write'], required: false, description: 'Token scope (default read). read = read-only; write = can mutate.' },
483
+ { name: 'expires-days', type: 'number', required: false, description: 'Days until expiry (1-3650, default 90).' },
484
+ { name: 'store', type: 'boolean', required: false, description: 'Persist the token as the active CLI session, replacing the current login.' }
485
+ ]
486
+ },
487
+ {
488
+ id: 'agents-list',
489
+ command: 'agents list',
490
+ usage: 'agents list',
491
+ description: 'List agent tokens for the account (never returns the secret).',
492
+ requiresHuman: true,
493
+ agentNotes: 'Returns token ids, hints, scopes and expiry — not the secret. Use the id with agents revoke.',
494
+ options: []
495
+ },
496
+ {
497
+ id: 'agents-revoke',
498
+ command: 'agents revoke',
499
+ usage: 'agents revoke --id <agent-token-id>',
500
+ description: 'Revoke an agent token by id.',
501
+ requiresHuman: true,
502
+ agentNotes: 'Use the id from agents list (the agt_... id, not the public token).',
503
+ options: [
504
+ { name: 'id', type: 'string', format: 'id', required: true, description: 'Agent token id (agt_...) to revoke.' }
505
+ ]
506
+ }
507
+ ];
508
+
357
509
  export const officialCommands = [
358
510
  ...commandSchema.map((c) => c.usage ?? c.command),
359
511
  ...writeCommandSchema.map((c) => c.usage ?? c.command),
@@ -366,7 +518,12 @@ export const officialCommands = [
366
518
  'login --snapshot <snapshot-file>',
367
519
  'login --base-url <base-url> --token <token>',
368
520
  'login --base-url <base-url> --email <email>',
521
+ 'login --agent-token <token>',
369
522
  'login --session-file <session-file>',
523
+ 'agents create --name <name>',
524
+ 'agents create --name <name> --access <read|write>',
525
+ 'agents list',
526
+ 'agents revoke --id <agent-token-id>',
370
527
  'logout'
371
528
  ];
372
529
 
package/src/format.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { commandSchema, writeCommandSchema } from './contract.js';
2
+ import { agentCommandSchema, commandSchema, writeCommandSchema } from './contract.js';
3
3
 
4
4
  const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
5
5
  const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -803,6 +803,15 @@ function formatCoachObservationsCurrent(payload) {
803
803
  lines.push(` ${chalk.bold(observation.title ?? observation.kind ?? 'Observation')}${status}${confidence}`);
804
804
  if (observation.summary) lines.push(` ${observation.summary}`);
805
805
  if (observation.actionText) lines.push(` ${chalk.dim('Action')} ${observation.actionText}`);
806
+ if (observation.outcomeStatus) {
807
+ const outcomeMeta = observation.outcomeObservedAt ? ` (${observation.outcomeObservedAt})` : '';
808
+ const outcomeNotes = observation.outcomeNotes ? ` ${observation.outcomeNotes}` : '';
809
+ lines.push(` ${chalk.dim('Outcome')} ${observation.outcomeStatus}${outcomeMeta}${outcomeNotes}`);
810
+ }
811
+ if (observation.userFeedbackStatus) {
812
+ const feedbackMeta = observation.userFeedbackAt ? ` (${observation.userFeedbackAt})` : '';
813
+ lines.push(` ${chalk.dim('Feedback')} ${observation.userFeedbackStatus}${feedbackMeta}`);
814
+ }
806
815
  if (observation.id) lines.push(` ${chalk.dim(observation.id)}`);
807
816
  lines.push('');
808
817
  }
@@ -817,6 +826,18 @@ function formatCoachObservationMutation(payload) {
817
826
  return ` Coach observation ${chalk.bold(observation.id)} is ${chalk.bold(observation.status)}.`;
818
827
  }
819
828
 
829
+ function formatCoachObservationOutcomeMutation(payload) {
830
+ const observation = payload?.observation;
831
+ if (!observation) return 'Coach observation not found.';
832
+ return ` Coach observation ${chalk.bold(observation.id)} outcome is ${chalk.bold(observation.outcomeStatus ?? 'unknown')}.`;
833
+ }
834
+
835
+ function formatCoachObservationFeedbackMutation(payload) {
836
+ const observation = payload?.observation;
837
+ if (!observation) return 'Coach observation not found.';
838
+ return ` Coach observation ${chalk.bold(observation.id)} feedback is ${chalk.bold(observation.userFeedbackStatus ?? 'unknown')}.`;
839
+ }
840
+
820
841
  // --- Main export ---
821
842
 
822
843
  export function formatHelp(opts = {}) {
@@ -848,6 +869,9 @@ export function formatHelp(opts = {}) {
848
869
  cmd('login --session-file <file>', 'Import session file'),
849
870
  cmd('logout', 'Clear session'),
850
871
  '',
872
+ header('AGENT TOKENS'),
873
+ ...agentCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
874
+ '',
851
875
  header('OTHER'),
852
876
  cmd('browse', 'Interactive Ink browser'),
853
877
  cmd('tui', 'Alias for browse'),
@@ -891,7 +915,9 @@ export function formatPretty(command, payload) {
891
915
  'increment-score-upload': formatIncrementScoreUpload,
892
916
  'coach-observations-current': formatCoachObservationsCurrent,
893
917
  'coach-observations-seen': formatCoachObservationMutation,
894
- 'coach-observations-dismiss': formatCoachObservationMutation
918
+ 'coach-observations-dismiss': formatCoachObservationMutation,
919
+ 'coach-observations-outcome': formatCoachObservationOutcomeMutation,
920
+ 'coach-observations-feedback': formatCoachObservationFeedbackMutation
895
921
  }[command];
896
922
 
897
923
  return formatter ? formatter(payload) : null;
package/src/lib.js CHANGED
@@ -2,14 +2,18 @@ import fs from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import { spawn, execFileSync } from 'node:child_process';
5
- import { capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
5
+ import { accessModel, agentCommandSchema, capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
6
6
  import {
7
7
  bootstrapSessionFromRemoteBaseUrl,
8
+ bootstrapSessionFromRemoteBaseUrlWithAgentToken,
8
9
  bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
9
10
  bootstrapSessionFromRemoteBaseUrlWithEmail,
10
11
  bootstrapSessionFromSnapshot,
12
+ createRemoteAgentToken,
11
13
  fetchRemoteAuthConfig,
12
- importSessionFile
14
+ importSessionFile,
15
+ listRemoteAgentTokens,
16
+ revokeRemoteAgentToken
13
17
  } from './auth.js';
14
18
  import { clearSessionState, isSessionExpired, readSessionState, resolveConfigDir } from './state.js';
15
19
  import { createTransport } from './transport.js';
@@ -19,6 +23,7 @@ import { runBrowseCli } from './browse.js';
19
23
  import { ValidationError, validateOptions } from './validate.js';
20
24
  import { projectFields } from './fields.js';
21
25
  import { buildWriteRequest } from './remote.js';
26
+ import { resolveConfiguredBaseUrl } from './service-url.js';
22
27
 
23
28
  const schemaById = new Map([
24
29
  ...commandSchema.map((c) => [c.id, c]),
@@ -96,7 +101,44 @@ export async function runCli(argv, stdout, stderr) {
96
101
  return 0;
97
102
  }
98
103
 
99
- const transport = await createTransport(options, sessionState);
104
+ let transport;
105
+ try {
106
+ transport = await createTransport(options, sessionState);
107
+ } catch (error) {
108
+ // `status` is a diagnostic command: if the transport can't be built (e.g. the
109
+ // refresh endpoint is unreachable), still report local session state plus the
110
+ // transport error rather than hard-failing. Other commands fail with exit 1.
111
+ if (normalizedCommand === 'status') {
112
+ stdout.write(`${JSON.stringify({
113
+ contractVersion,
114
+ capabilities,
115
+ mode: null,
116
+ config: {
117
+ dir: resolveConfigDir(),
118
+ sessionPath: sessionState.path
119
+ },
120
+ auth: {
121
+ loggedIn: Boolean(sessionState.session),
122
+ sessionExists: sessionState.exists,
123
+ sessionSchemaVersion: sessionState.session?.version ?? null,
124
+ expired: isSessionExpired(sessionState.session),
125
+ error: sessionState.error,
126
+ account: sessionState.session?.account ?? null,
127
+ credential: null,
128
+ authExpired: isSessionExpired(sessionState.session),
129
+ reauthCommand: 'incremnt login'
130
+ },
131
+ transportError: cliErrorPayload(error)
132
+ }, null, 2)}\n`);
133
+ return 0;
134
+ }
135
+ if (options.json) {
136
+ stdout.write(`${JSON.stringify(cliErrorPayload(error), null, 2)}\n`);
137
+ } else {
138
+ stderr.write(`${error.message}\n`);
139
+ }
140
+ return 1;
141
+ }
100
142
 
101
143
  if (normalizedCommand === 'status') {
102
144
  stdout.write(`${JSON.stringify({
@@ -108,12 +150,26 @@ export async function runCli(argv, stdout, stderr) {
108
150
  sessionPath: sessionState.path
109
151
  },
110
152
  auth: {
111
- loggedIn: Boolean(sessionState.session),
153
+ loggedIn: Boolean(sessionState.session || transport.credential?.source === 'env'),
112
154
  sessionExists: sessionState.exists,
113
155
  sessionSchemaVersion: sessionState.session?.version ?? null,
114
- expired: isSessionExpired(sessionState.session),
156
+ expired: transport.expired ?? isSessionExpired(sessionState.session),
115
157
  error: sessionState.error,
116
- account: sessionState.session?.account ?? null
158
+ account: transport.account ?? sessionState.session?.account ?? null,
159
+ credential: transport.kind === 'remote'
160
+ ? {
161
+ source: transport.credential?.source ?? null,
162
+ type: transport.credential?.type ?? null,
163
+ access: transport.credential?.access ?? null,
164
+ expiresAt: transport.credential?.expiresAt ?? null,
165
+ canRefresh: transport.credential?.canRefresh ?? false,
166
+ reauthCommand: transport.credential?.type === 'agent-token'
167
+ ? 'incremnt login --agent-token <token>'
168
+ : 'incremnt login'
169
+ }
170
+ : null,
171
+ authExpired: transport.expired ?? isSessionExpired(sessionState.session),
172
+ reauthCommand: 'incremnt login'
117
173
  },
118
174
  remote: {
119
175
  bootstrap: transport.kind === 'remote' ? transport.bootstrap ?? false : false,
@@ -121,7 +177,8 @@ export async function runCli(argv, stdout, stderr) {
121
177
  baseUrl: transport.kind === 'remote' ? transport.baseUrl ?? null : null,
122
178
  contractVersion: transport.kind === 'remote' ? transport.contractVersion ?? null : null,
123
179
  capabilities: transport.kind === 'remote' ? transport.capabilities ?? null : null,
124
- fixturePath: transport.kind === 'remote' ? transport.fixturePath ?? null : null
180
+ fixturePath: transport.kind === 'remote' ? transport.fixturePath ?? null : null,
181
+ contractUnchecked: transport.kind === 'remote' ? transport.contractUnchecked ?? false : false
125
182
  },
126
183
  snapshot: {
127
184
  source: transport.snapshotSource?.source ?? null,
@@ -139,6 +196,8 @@ export async function runCli(argv, stdout, stderr) {
139
196
  officialCommands,
140
197
  schema: commandSchema,
141
198
  writeSchema: writeCommandSchema,
199
+ agentSchema: agentCommandSchema,
200
+ accessModel,
142
201
  proposalSchema
143
202
  }, null, 2)}\n`);
144
203
  return 0;
@@ -247,6 +306,27 @@ export async function runCli(argv, stdout, stderr) {
247
306
  const loginBaseUrl = resolveLoginBaseUrl(options, sessionState);
248
307
 
249
308
  if (loginBaseUrl) {
309
+ if (options['agent-token']) {
310
+ try {
311
+ const result = await bootstrapSessionFromRemoteBaseUrlWithAgentToken(loginBaseUrl, options['agent-token']);
312
+ stdout.write(`${JSON.stringify({
313
+ ok: true,
314
+ sessionPath: result.path,
315
+ account: result.session.account,
316
+ credential: result.session.auth ? {
317
+ type: result.session.auth.type ?? null,
318
+ access: result.session.auth.access ?? null
319
+ } : null,
320
+ remoteContractVersion: result.session.transport?.contractVersion ?? null,
321
+ transport: result.session.transport
322
+ }, null, 2)}\n`);
323
+ return 0;
324
+ } catch (error) {
325
+ stderr.write(`${error.message}\n`);
326
+ return 1;
327
+ }
328
+ }
329
+
250
330
  if (options.token && options.email) {
251
331
  stderr.write('Pass either --token or --email with --base-url, not both\n');
252
332
  return 1;
@@ -348,6 +428,104 @@ export async function runCli(argv, stdout, stderr) {
348
428
  return 1;
349
429
  }
350
430
 
431
+ if (normalizedCommand === 'agents create' || normalizedCommand === 'agents list' || normalizedCommand === 'agents revoke') {
432
+ const currentSessionState = await readSessionState();
433
+ const currentSession = currentSessionState.session ?? sessionState.session;
434
+ const baseUrl = options['base-url'] ?? currentSession?.transport?.baseUrl ?? null;
435
+ const bearerToken = currentSession?.auth?.type === 'agent-token'
436
+ ? null
437
+ : currentSession?.auth?.accessToken;
438
+
439
+ if (!baseUrl || !bearerToken) {
440
+ const message = 'Agent token management requires a human login. Run incremnt login first.';
441
+ if (options.json) {
442
+ stdout.write(`${JSON.stringify({ error: message, code: 'REMOTE_AUTH_REQUIRED', reauthCommand: 'incremnt login' }, null, 2)}\n`);
443
+ } else {
444
+ stderr.write(`${message}\n`);
445
+ }
446
+ return 1;
447
+ }
448
+
449
+ try {
450
+ if (normalizedCommand === 'agents create') {
451
+ const name = typeof options.name === 'string' ? options.name.trim() : '';
452
+ if (!name) {
453
+ throw Object.assign(new Error('--name is required for agents create.'), { code: 'MISSING_OPTION' });
454
+ }
455
+ const access = options.access ?? 'read';
456
+ const expiresDays = options['expires-days'] ?? 90;
457
+ const payload = await createRemoteAgentToken(baseUrl, bearerToken, {
458
+ name,
459
+ access,
460
+ expiresDays
461
+ });
462
+ let stored = null;
463
+ let warning = null;
464
+ if (options.store) {
465
+ try {
466
+ const storedSession = await bootstrapSessionFromRemoteBaseUrlWithAgentToken(baseUrl, payload.agentToken.token, {
467
+ access: payload.agentToken.access,
468
+ expiresAt: payload.agentToken.expiresAt ?? null
469
+ });
470
+ stored = {
471
+ ok: true,
472
+ sessionPath: storedSession.path,
473
+ credential: {
474
+ type: storedSession.session.auth?.type ?? null,
475
+ access: storedSession.session.auth?.access ?? null
476
+ }
477
+ };
478
+ // --store replaces the active CLI session with this agent token. The
479
+ // human login that authorised this command is now gone; flag it so the
480
+ // caller isn't silently demoted (token management needs a human login).
481
+ warning = 'The active CLI session is now this agent token. Run `incremnt login` to restore your human session (required for agent token management).';
482
+ } catch (error) {
483
+ stored = {
484
+ ok: false,
485
+ error: error?.message ?? String(error),
486
+ code: error?.code ?? null
487
+ };
488
+ // The token was minted server-side and is shown once below. Do NOT fail
489
+ // the command — a non-zero exit invites callers to discard stdout and
490
+ // retry, orphaning a live token. Surface the salvage instruction instead.
491
+ warning = 'Token was created but could not be stored locally. Capture agentToken.token now — it cannot be retrieved again — and set INCREMNT_AGENT_TOKEN manually.';
492
+ }
493
+ }
494
+ stdout.write(`${JSON.stringify({
495
+ ...payload,
496
+ tokenIssued: true,
497
+ ...(options.store ? { persisted: Boolean(stored?.ok) } : {}),
498
+ stored,
499
+ ...(warning ? { warning } : {})
500
+ }, null, 2)}\n`);
501
+ // Exit 0: the primary operation (minting the token) succeeded regardless of
502
+ // local persistence. Persistence failures are reported via `persisted`/`warning`.
503
+ return 0;
504
+ }
505
+
506
+ if (normalizedCommand === 'agents list') {
507
+ const payload = await listRemoteAgentTokens(baseUrl, bearerToken);
508
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
509
+ return 0;
510
+ }
511
+
512
+ const id = typeof options.id === 'string' ? options.id.trim() : '';
513
+ if (!id) {
514
+ throw Object.assign(new Error('--id is required for agents revoke.'), { code: 'MISSING_OPTION' });
515
+ }
516
+ const payload = await revokeRemoteAgentToken(baseUrl, bearerToken, id);
517
+ stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
518
+ return 0;
519
+ } catch (error) {
520
+ if (options.json) {
521
+ stdout.write(`${JSON.stringify(cliErrorPayload(error), null, 2)}\n`);
522
+ } else {
523
+ stderr.write(`${error.message}\n`);
524
+ }
525
+ return 1;
526
+ }
527
+ }
528
+
351
529
  if (normalizedCommand === 'browse') {
352
530
  if (!(stdout.isTTY ?? false) || !(process.stdin.isTTY ?? false)) {
353
531
  stderr.write('browse requires an interactive TTY.\n');
@@ -403,7 +581,7 @@ export async function runCli(argv, stdout, stderr) {
403
581
  return 0;
404
582
  } catch (error) {
405
583
  if (explicitJson) {
406
- stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
584
+ stdout.write(`${JSON.stringify(cliErrorPayload(error), null, 2)}\n`);
407
585
  } else {
408
586
  stderr.write(`${error.message}\n`);
409
587
  }
@@ -423,7 +601,7 @@ export async function runCli(argv, stdout, stderr) {
423
601
  return 0;
424
602
  } catch (error) {
425
603
  if (explicitJson) {
426
- stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
604
+ stdout.write(`${JSON.stringify(cliErrorPayload(error), null, 2)}\n`);
427
605
  } else {
428
606
  stderr.write(`${error.message}\n`);
429
607
  }
@@ -501,7 +679,7 @@ export async function runCli(argv, stdout, stderr) {
501
679
  return 0;
502
680
  } catch (error) {
503
681
  if (explicitJson) {
504
- stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
682
+ stdout.write(`${JSON.stringify(cliErrorPayload(error), null, 2)}\n`);
505
683
  } else {
506
684
  stderr.write(`${error.message}\n`);
507
685
  }
@@ -510,14 +688,24 @@ export async function runCli(argv, stdout, stderr) {
510
688
  }
511
689
  }
512
690
 
513
- const DEFAULT_BASE_URL = 'https://incremnt-sync.onrender.com';
514
-
515
691
  function resolveLoginBaseUrl(options, sessionState) {
516
- if (options['base-url']) return options['base-url'];
517
- // If INCREMNT_BASE_URL is explicitly set (even to empty string), respect it — empty = no URL
518
- if ('INCREMNT_BASE_URL' in process.env) return process.env.INCREMNT_BASE_URL || null;
519
- if (sessionState.session?.transport?.baseUrl) return sessionState.session.transport.baseUrl;
520
- return DEFAULT_BASE_URL;
692
+ return resolveConfiguredBaseUrl(options, sessionState.session);
693
+ }
694
+
695
+ function cliErrorPayload(error) {
696
+ const code = error?.code ?? null;
697
+ return {
698
+ error: error?.message ?? String(error),
699
+ code,
700
+ ...(code === 'SESSION_EXPIRED' ? { authExpired: true, reauthCommand: 'incremnt login' } : {}),
701
+ ...(code === 'REMOTE_AUTH_ERROR' ? { reauthCommand: 'incremnt login' } : {}),
702
+ ...(code === 'SNAPSHOT_NOT_FOUND' ? { reauthCommand: 'incremnt login' } : {}),
703
+ ...(code === 'INSUFFICIENT_SCOPE' ? {
704
+ requiredAccess: error?.requiredAccess ?? 'write',
705
+ requiresHuman: error?.requiresHuman ?? true,
706
+ remedy: error?.remedy ?? 'A write-capable agent token is required. Minting one needs a human login: run `incremnt login`, then `incremnt agents create --access write`.'
707
+ } : {})
708
+ };
521
709
  }
522
710
 
523
711
  function isHeadlessLoginEnvironment(options) {