@thirdfy/agent-cli 0.4.3 → 0.5.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.
@@ -0,0 +1,117 @@
1
+ import { DEFAULT_API_BASE } from '../constants.mjs';
2
+ import { createCliError } from '../envelope.mjs';
3
+ import { loadProfileConfig, persistProfileConfig } from '../context.mjs';
4
+ import { refreshTokens } from './authServer.mjs';
5
+
6
+ /**
7
+ * The OAuth sign-in `thirdfy-agent login` stores in `~/.thirdfy/config.json` (`auth.oauth`, mode
8
+ * 0600): one agent's access and refresh tokens from auth.thirdfy.com. It is the agent credential
9
+ * when no `--agent-api-key` or `THIRDFY_AGENT_API_KEY` is set. The HTTP layer sends it as
10
+ * `Authorization: Bearer` and refreshes it before it expires (rotation: each refresh returns a new
11
+ * refresh token).
12
+ */
13
+
14
+ /** auth.thirdfy.com access tokens: `{userId}:{grantId}:{secret}`. Agent API keys and JWTs never match. */
15
+ const OAUTH_TOKEN_SHAPE = /^[^\s:]+:[^\s:]+:[^\s:]+$/;
16
+
17
+ /** Refresh when fewer than this many seconds are left, so a command never starts with a dying token. */
18
+ const REFRESH_MARGIN_SECONDS = 120;
19
+
20
+ export function isOAuthAccessToken(value) {
21
+ return OAUTH_TOKEN_SHAPE.test(String(value || '').trim());
22
+ }
23
+
24
+ export function readOAuthSession(config = loadProfileConfig()) {
25
+ const session = config?.auth?.oauth;
26
+ if (!session || typeof session !== 'object') return null;
27
+ if (!isOAuthAccessToken(session.accessToken)) return null;
28
+ return session;
29
+ }
30
+
31
+ const trimBase = (value) => String(value || '').trim().replace(/\/+$/, '');
32
+
33
+ /** The API base a command talks to: --api-base, then THIRDFY_API_BASE, then the default (as runtime/context). */
34
+ export function apiBaseFor(flags = {}, env = process.env) {
35
+ return trimBase(flags.apiBase || env.THIRDFY_API_BASE || DEFAULT_API_BASE);
36
+ }
37
+
38
+ /**
39
+ * The sign-in for this API. A sign-in is issued for one API (its `resource`): one for api-test is
40
+ * never sent to production, or the reverse.
41
+ */
42
+ export function oauthSessionForApi(apiBase, config = loadProfileConfig()) {
43
+ const session = readOAuthSession(config);
44
+ return session && trimBase(session.resource) === trimBase(apiBase) ? session : null;
45
+ }
46
+
47
+ /**
48
+ * The OAuth sign-in acts as the agent unless an agent API key is passed or set in the environment,
49
+ * and only on the API it was issued for.
50
+ */
51
+ export function oauthSessionIsAgentCredential(flags = {}, env = process.env, config = loadProfileConfig()) {
52
+ if (String(flags.agentApiKey || '').trim()) return false;
53
+ if (String(env.THIRDFY_AGENT_API_KEY || '').trim()) return false;
54
+ return Boolean(oauthSessionForApi(apiBaseFor(flags, env), config));
55
+ }
56
+
57
+ export function writeOAuthSession(session) {
58
+ const config = loadProfileConfig();
59
+ const auth = config.auth && typeof config.auth === 'object' ? config.auth : {};
60
+ persistProfileConfig({ ...config, auth: { ...auth, oauth: session } });
61
+ }
62
+
63
+ export function clearOAuthSession() {
64
+ const config = loadProfileConfig();
65
+ if (!config.auth?.oauth) return;
66
+ const { oauth: _removed, ...auth } = config.auth;
67
+ persistProfileConfig({ ...config, auth });
68
+ }
69
+
70
+ /** The stored session after a token response (code exchange or refresh). */
71
+ export function sessionFromTokens(base, tokens, nowSeconds = Math.floor(Date.now() / 1000)) {
72
+ const expiresIn = Number(tokens.expires_in);
73
+ return {
74
+ ...base,
75
+ accessToken: String(tokens.access_token),
76
+ refreshToken: tokens.refresh_token ? String(tokens.refresh_token) : base.refreshToken || null,
77
+ expiresAt: Number.isFinite(expiresIn) && expiresIn > 0 ? nowSeconds + expiresIn : null,
78
+ scope: tokens.scope ? String(tokens.scope) : base.scope || '',
79
+ obtainedAt: nowSeconds,
80
+ };
81
+ }
82
+
83
+ let refreshInFlight = null;
84
+
85
+ /**
86
+ * The access token to send, refreshed first when it is about to expire (or when `force`, after the
87
+ * API answered 401). A refresh the auth server refuses (`invalid_grant`: revoked, or the owner no
88
+ * longer owns the agent) is `OAUTH_SESSION_EXPIRED`: sign in again.
89
+ */
90
+
91
+ export async function freshOAuthAccessToken({ timeoutMs, force = false } = {}) {
92
+ // One refresh at a time: the refresh token rotates, so a second refresh with the same token
93
+ // would be refused. Concurrent callers share the first one's result.
94
+ if (refreshInFlight) return refreshInFlight;
95
+ const session = readOAuthSession();
96
+ if (!session) return null;
97
+ const now = Math.floor(Date.now() / 1000);
98
+ const expiring = Number.isFinite(Number(session.expiresAt)) && Number(session.expiresAt) - now < REFRESH_MARGIN_SECONDS;
99
+ if (!force && !expiring) return session.accessToken;
100
+ if (!session.refreshToken || !session.tokenEndpoint || !session.clientId) {
101
+ throw createCliError('OAUTH_SESSION_EXPIRED', 'The sign-in expired. Sign in again: thirdfy-agent login', { blockedStage: 'auth' });
102
+ }
103
+ refreshInFlight = (async () => {
104
+ const tokens = await refreshTokens(
105
+ { tokenEndpoint: session.tokenEndpoint, clientId: session.clientId, refreshToken: session.refreshToken, resource: session.resource },
106
+ timeoutMs || 30_000,
107
+ );
108
+ const next = sessionFromTokens(session, tokens);
109
+ writeOAuthSession(next);
110
+ return next.accessToken;
111
+ })();
112
+ try {
113
+ return await refreshInFlight;
114
+ } finally {
115
+ refreshInFlight = null;
116
+ }
117
+ }
@@ -1,54 +1,153 @@
1
1
  /**
2
- * Credits balances and action costs are shown in credits, never as a dollar price per credit or per
3
- * request (owner decision 2026-10-14; thirdfy-mcp #252 `stripUsdFields`). Buying credits keeps its USD
4
- * amount (`credits buy --usd`): that is a purchase, not a request price.
2
+ * Credits are Thirdfy's internal ledger unit; people see a USD balance. The public docs say so
3
+ * (thirdfy-docs credits page: "Your balance shows in USD; credits are the internal unit") and never
4
+ * state what an action costs (DOCUMENTATION_GUIDE: no credit costs, no USD-per-credit rate). Owner
5
+ * rule 2026-10-15; same rules as thirdfy-mcp `usdFirstCredits.ts` (thirdfy/thirdfy-mcp#279).
6
+ *
7
+ * `credits balance` and `pricing quote` keep `availableUsd`, free request counts, `wouldBePaidBy` and
8
+ * `displayCurrency`. Every credit count, per-credit rate and per-action price is dropped, so a reader
9
+ * can say "$4.20 left" but not "286 credits" or "$0.18 a request". The owner's fallback balance, which
10
+ * the API sends in credits only, gets an `availableUsd` at the API's own `retailUsdPerCredit` (the rate
11
+ * the API uses for the agent's `availableUsd`). Buying keeps its USD amount (`credits buy --usd`).
5
12
  */
13
+
14
+ const KEEP_USD = new Set(['availableUsd']);
15
+ /** Credit counts the API names without "credit" (`totalAvailable`: the owner route's balance plus free requests). */
16
+ const CREDIT_COUNTS = new Set(['balance', 'totalEarned', 'totalSpent', 'totalAvailable']);
17
+ const CREDIT_KEY = /credit/i;
6
18
  const USD_KEY = /usd/i;
7
19
 
8
- /** The value with every key that names USD (and displayCurrency / pricingCurrency) removed, at any depth. */
9
- export function stripUsdFields(value) {
10
- if (Array.isArray(value)) return value.map(stripUsdFields);
11
- if (!value || typeof value !== 'object') return value;
20
+ const isRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
21
+
22
+ const finite = (value) => {
23
+ const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN;
24
+ return Number.isFinite(n) ? n : undefined;
25
+ };
26
+
27
+ export const roundUsd = (usd) => Math.round(usd * 100) / 100;
28
+
29
+ const formatUsd = (amount) => `$${roundUsd(amount).toFixed(2)}`;
30
+
31
+ /** The API's display rate wherever the payload carries it (top level or `data`). */
32
+ export function retailUsdPerCreditOf(value) {
33
+ if (!isRecord(value)) return undefined;
34
+ const own = finite(value.retailUsdPerCredit);
35
+ if (own !== undefined && own > 0) return own;
36
+ return isRecord(value.data) ? retailUsdPerCreditOf(value.data) : undefined;
37
+ }
38
+
39
+ /** A credits amount in USD at the API's rate, or undefined when either is missing. */
40
+ export function usdFromCredits(credits, rate) {
41
+ const amount = finite(credits);
42
+ return amount === undefined || rate === undefined ? undefined : roundUsd(amount * rate);
43
+ }
44
+
45
+ /** The value with balances in USD and no credit count, per-credit rate or per-action price, at any depth. */
46
+ export function toUsdFirst(value, rate = retailUsdPerCreditOf(value)) {
47
+ if (Array.isArray(value)) return value.map((item) => toUsdFirst(item, rate));
48
+ if (!isRecord(value)) return value;
12
49
  const out = {};
13
50
  for (const [key, inner] of Object.entries(value)) {
14
- if (USD_KEY.test(key) || key === 'displayCurrency' || key === 'pricingCurrency') continue;
15
- out[key] = stripUsdFields(inner);
51
+ const scalar = !isRecord(inner) && !Array.isArray(inner);
52
+ if (CREDIT_KEY.test(key) && scalar) continue;
53
+ if (USD_KEY.test(key) && !KEEP_USD.has(key)) continue;
54
+ if (CREDIT_COUNTS.has(key) && typeof inner === 'number') continue;
55
+ // The owner route's ledger unit (`currency: 'credits'`): the balance is shown in USD.
56
+ if (key === 'currency' && typeof inner === 'string' && CREDIT_KEY.test(inner)) continue;
57
+ out[key] = toUsdFirst(inner, rate);
58
+ }
59
+ // A balance the API sends only in credits (the owner's fallback) is shown in USD.
60
+ if (out.availableUsd === undefined && finite(value.balanceCredits) !== undefined) {
61
+ const usd = usdFromCredits(value.balanceCredits, rate);
62
+ if (usd !== undefined) out.availableUsd = usd;
16
63
  }
17
64
  return out;
18
65
  }
19
66
 
20
67
  const isCount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
21
68
 
69
+ /** The balance route's fields: in `data` (`{ success, data }`) or at the top. */
70
+ function balanceFields(payload) {
71
+ if (!isRecord(payload)) return undefined;
72
+ const inner = payload.data;
73
+ if (isRecord(inner) && ('freeDailyLimit' in inner || 'freeAllowance' in inner || 'availableUsd' in inner || 'balanceCredits' in inner)) return inner;
74
+ return payload;
75
+ }
76
+
77
+ /** The balance in USD: `availableUsd`, else the credits balance at the API's rate. */
78
+ function balanceUsd(payload) {
79
+ const fields = balanceFields(payload);
80
+ if (!fields) return undefined;
81
+ if (isCount(fields.availableUsd)) return fields.availableUsd;
82
+ return usdFromCredits(fields.balanceCredits ?? fields.balance, retailUsdPerCreditOf(payload));
83
+ }
84
+
85
+ /** Who pays next: the agent's part (or null) and the rest, in billing order. Undefined for an older API. */
86
+ function freeQuotaParts(payload) {
87
+ const fields = balanceFields(payload);
88
+ if (!fields) return undefined;
89
+ const { ownerCreditsFallback, balanceCredits } = fields;
90
+ // The agent route says freeDailyLimit / freeRequestsRemainingToday; the owner route freeAllowance / freeRemainingToday.
91
+ const freeDailyLimit = fields.freeDailyLimit ?? fields.freeAllowance;
92
+ const freeRequestsRemainingToday = fields.freeRequestsRemainingToday ?? fields.freeRemainingToday;
93
+ if (!isCount(freeDailyLimit) || !isCount(freeRequestsRemainingToday)) return undefined;
94
+ const rate = retailUsdPerCreditOf(payload);
95
+ const agentUsd = isCount(fields.availableUsd) ? fields.availableUsd : usdFromCredits(balanceCredits, rate);
96
+ let agent = null;
97
+ if (isCount(agentUsd) && agentUsd > 0) agent = `The agent's balance (${formatUsd(agentUsd)}) pays first`;
98
+ else if (isCount(balanceCredits) && balanceCredits > 0) agent = "The agent's balance pays first";
99
+ const rest = [`${freeRequestsRemainingToday} of ${freeDailyLimit} free requests left today`];
100
+ if (isRecord(ownerCreditsFallback) && ownerCreditsFallback.enabled === true) {
101
+ const ownerUsd = usdFromCredits(ownerCreditsFallback.balanceCredits, rate);
102
+ rest.push(isCount(ownerUsd) ? `then the owner's balance (${formatUsd(ownerUsd)}) pays` : "then the owner's balance pays");
103
+ }
104
+ rest.push('then top up the balance');
105
+ return { agent, rest };
106
+ }
107
+
22
108
  /**
23
109
  * Who pays next for the agent's actions, from the agent credits route's fields (thirdfy-api-v2#884):
24
110
  * `freeRequestsRemainingToday` of `freeDailyLimit` (free requests per UTC day) and
25
- * `ownerCreditsFallback` (the owner's credits pay once the agent's credits and free requests are
26
- * used). Billing order: agent's credits, free requests, owner's credits, refusal. Same wording as
27
- * thirdfy-mcp `creditsFreeQuotaNote` (#253). Undefined when an older API omits the fields.
111
+ * `ownerCreditsFallback` (the owner's balance pays once the agent's balance and free requests are
112
+ * used). Billing order: agent's balance, free requests, owner's balance, refusal. Amounts in USD,
113
+ * never credits; same wording as thirdfy-mcp `creditsFreeQuotaNote` (#279). Undefined when an older
114
+ * API omits the fields.
28
115
  */
29
116
  export function creditsFreeQuotaNote(payload) {
30
- const fields = payload?.data && typeof payload.data === 'object' && 'freeDailyLimit' in payload.data ? payload.data : payload;
31
- if (!fields || typeof fields !== 'object') return undefined;
32
- const { freeDailyLimit, freeRequestsRemainingToday, ownerCreditsFallback, balanceCredits } = fields;
33
- if (!isCount(freeDailyLimit) || !isCount(freeRequestsRemainingToday)) return undefined;
34
- const parts = [];
35
- if (isCount(balanceCredits) && balanceCredits > 0) parts.push(`The agent's credits (${balanceCredits}) pay first`);
36
- parts.push(`${freeRequestsRemainingToday} of ${freeDailyLimit} free requests left today`);
37
- if (ownerCreditsFallback && typeof ownerCreditsFallback === 'object' && ownerCreditsFallback.enabled === true) {
38
- const owner = ownerCreditsFallback.balanceCredits;
39
- parts.push(isCount(owner) ? `then the owner's credits (${owner}) pay` : "then the owner's credits pay");
40
- }
41
- parts.push('then buy credits');
42
- return `${parts.join('; ')}.`;
117
+ const parts = freeQuotaParts(payload);
118
+ if (!parts) return undefined;
119
+ return `${[...(parts.agent ? [parts.agent] : []), ...parts.rest].join('; ')}.`;
43
120
  }
44
121
 
45
- /** The balance in credits only, with who pays next when the API says. */
122
+ /**
123
+ * The balance in dollars, never credits, with who pays next when the API says, for example
124
+ * "$4.20 left; 8 of 20 free requests left today; then the owner's balance ($86.22) pays; then top up the balance."
125
+ */
46
126
  export function formatCreditsBalanceSummary(response) {
47
- const credits = `${response?.balance ?? response?.balanceCredits ?? 0} credits`;
48
- const note = creditsFreeQuotaNote(response);
49
- return note ? `${credits}. ${note}` : credits;
127
+ const usd = balanceUsd(response);
128
+ const lead = usd === undefined ? 'balance in USD not reported by this API' : `${formatUsd(usd)} left`;
129
+ const parts = freeQuotaParts(response);
130
+ return `${[lead, ...(parts ? parts.rest : [])].join('; ')}.`;
131
+ }
132
+
133
+ const PAYER_LABELS = {
134
+ agent_credits: "the agent's balance",
135
+ free_request: 'a free request',
136
+ owner_credits: "the owner's balance",
137
+ none: 'nobody: it would be refused until the balance is topped up',
138
+ };
139
+
140
+ /** `wouldBePaidBy` in words, with the API's value in parentheses. Never a price. */
141
+ export function describeWouldBePaidBy(wouldBePaidBy) {
142
+ const raw = String(wouldBePaidBy || '').trim();
143
+ if (!raw) return undefined;
144
+ return PAYER_LABELS[raw] ? `${PAYER_LABELS[raw]} (${raw})` : raw;
50
145
  }
51
146
 
147
+ /**
148
+ * `models list` / `models estimate` are owner model-pricing reports (owner auth only) and are left as
149
+ * they were by the USD-first rule, as thirdfy-mcp left its owner-token credits tools.
150
+ */
52
151
  export function formatCreditsModelsSummary(response) {
53
152
  const firstModel = response?.models?.[0];
54
153
  if (firstModel?.estimatedUsdPerTurn != null) {
@@ -227,6 +227,7 @@ export function buildExecutionCertificationReport({
227
227
  runMode = null,
228
228
  expectedSchemaDigest = null,
229
229
  requiredActions = [],
230
+ catalogPolicyScoped = false,
230
231
  } = {}) {
231
232
  const version = String(cliVersion || PACKAGE_VERSION).trim();
232
233
  const normalizedRequiredActions = parseRequiredActions(requiredActions);
@@ -259,10 +260,25 @@ export function buildExecutionCertificationReport({
259
260
  }
260
261
  }
261
262
 
263
+ // A keyed catalog lists only the agent's allowlist. Bare certification then checks the venue
264
+ // writes this agent may call; the rest are reported as skipped, not as missing schemas.
265
+ // An unscoped (public) catalog that lacks a venue action still fails closed.
266
+ const scopeToAllowlist = bareCertification && catalogFetched && catalogPolicyScoped === true;
267
+ const liveActionKeys = new Set();
268
+ for (const row of liveActions || []) {
269
+ for (const name of [row?.action, ...(Array.isArray(row?.aliases) ? row.aliases : [])]) {
270
+ if (name) liveActionKeys.add(normalizeActionKey(String(name)));
271
+ }
272
+ }
273
+ const skippedVenueActions = [];
262
274
  const venueActionNames =
263
275
  normalizedRequiredActions.length > 0
264
276
  ? normalizedRequiredActions.filter((action) => isWriteAction(action))
265
- : VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action);
277
+ : VENUE_WRITE_ACTION_FIXTURES.map((fixture) => fixture.action).filter((action) => {
278
+ if (!scopeToAllowlist || liveActionKeys.has(normalizeActionKey(action))) return true;
279
+ skippedVenueActions.push({ action, reason: 'NOT_ON_AGENT_ALLOWLIST' });
280
+ return false;
281
+ });
266
282
 
267
283
  const venueActions = runSchemaCompatibilityChecks(catalogByAction, venueActionNames, {
268
284
  requireLiveCatalog: bareCertification,
@@ -380,6 +396,7 @@ export function buildExecutionCertificationReport({
380
396
  commandAvailability,
381
397
  authorityInputs,
382
398
  venueActions,
399
+ skippedVenueActions,
383
400
  checks,
384
401
  };
385
402
  }
@@ -3,6 +3,7 @@ import { wrapWriteIdempotencyKeyForHostedRuntime } from '../../core/execution/ho
3
3
  import { resolveExecutionContextFromEnv } from '../../core/execution/executionContext.mjs';
4
4
  import { requireFlag, parseJsonFlag } from '../../core/args.mjs';
5
5
  import { findAgentApiKey, loadProfileConfig } from '../../core/context.mjs';
6
+ import { apiBaseFor, oauthSessionForApi, oauthSessionIsAgentCredential } from '../../core/oauth/session.mjs';
6
7
  import { createCliError } from '../../core/envelope.mjs';
7
8
  import { toLegacyVenueActionKey } from '../../core/venueBrand.mjs';
8
9
  import { normalizeRunMode, normalizeManagedRunMode, normalizeHybridWalletMode } from '../../core/runMode.mjs';
@@ -62,6 +63,11 @@ function buildBuildTxPayload(flags, resolvedAction, apiAction) {
62
63
  function resolveAgentKey(flags) {
63
64
  const explicit = String(flags.agentKey || '').trim();
64
65
  if (explicit) return explicit.toLowerCase();
66
+ // With an OAuth sign-in as the agent credential, the agent is the one the owner picked for it.
67
+ if (oauthSessionIsAgentCredential(flags)) {
68
+ const oauthAgent = String(oauthSessionForApi(apiBaseFor(flags))?.agentKey || '').trim();
69
+ if (oauthAgent) return oauthAgent.toLowerCase();
70
+ }
65
71
  const envKey = String(process.env.THIRDFY_AGENT_KEY || process.env.AGENT_KEY || '').trim();
66
72
  if (envKey) return envKey.toLowerCase();
67
73
  const config = loadProfileConfig();
@@ -218,6 +218,7 @@ export function createRuntimeHandlers(deps) {
218
218
  resolveEffectiveUserDid,
219
219
  negotiateCapabilities,
220
220
  commandAgentRegister: __agent.commandAgentRegister,
221
+ CLI_VERSION,
221
222
  });
222
223
 
223
224
  return {