@phnx-labs/agents-cli 1.22.44 → 1.22.46
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/CHANGELOG.md +32 -0
- package/README.md +24 -0
- package/dist/bootstrap.js +1 -6
- package/dist/cli/command-registry.d.ts +0 -1
- package/dist/cli/command-registry.js +1 -3
- package/dist/commands/accounts.d.ts +1 -9
- package/dist/commands/accounts.js +12 -90
- package/dist/commands/auth.d.ts +0 -7
- package/dist/commands/auth.js +198 -83
- package/dist/commands/insights.js +82 -154
- package/dist/commands/view.js +1 -1
- package/dist/lib/accounting/usage.d.ts +22 -3
- package/dist/lib/accounting/usage.js +94 -12
- package/dist/lib/agent-spec/agents.js +6 -1
- package/dist/lib/cli-resources.js +17 -15
- package/dist/lib/devices/harness-inventory.js +20 -3
- package/dist/lib/exec.d.ts +20 -0
- package/dist/lib/exec.js +43 -6
- package/dist/lib/identity/client.d.ts +53 -0
- package/dist/lib/identity/client.js +106 -0
- package/dist/lib/identity/index.d.ts +115 -0
- package/dist/lib/identity/index.js +82 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +5 -1
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +15 -2
- package/dist/lib/probe.d.ts +8 -0
- package/dist/lib/probe.js +105 -0
- package/dist/lib/startup/command-registry.d.ts +3 -1
- package/dist/lib/startup/command-registry.js +5 -2
- package/dist/lib/view-types.d.ts +2 -2
- package/package.json +1 -1
- package/dist/commands/org.d.ts +0 -11
- package/dist/commands/org.js +0 -228
- package/dist/lib/entitlement.d.ts +0 -31
- package/dist/lib/entitlement.js +0 -137
- package/dist/lib/prix-account.d.ts +0 -159
- package/dist/lib/prix-account.js +0 -215
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
/** How long a cached tier is trusted before the next call re-fetches. */
|
|
2
|
-
export declare const ENTITLEMENT_CACHE_TTL_MS: number;
|
|
3
|
-
export type EntitlementSource = 'live' | 'cache' | 'offline' | 'no-session';
|
|
4
|
-
export interface EntitlementTier {
|
|
5
|
-
/** The raw tier name from the backend ('free', 'admin', or a paid tier name). */
|
|
6
|
-
tierName: string;
|
|
7
|
-
/** true for 'admin' or any tier name other than 'free'. */
|
|
8
|
-
isPaid: boolean;
|
|
9
|
-
/** Where this value came from — mainly for diagnostics/tests. */
|
|
10
|
-
source: EntitlementSource;
|
|
11
|
-
}
|
|
12
|
-
export declare function setEntitlementUserYamlPathForTest(value: string | null): void;
|
|
13
|
-
export declare function setEntitlementCachePathForTest(value: string | null): void;
|
|
14
|
-
export declare function setEntitlementFetchForTest(fn: typeof globalThis.fetch): void;
|
|
15
|
-
export declare function entitlementCachePath(): string;
|
|
16
|
-
/**
|
|
17
|
-
* The live plan tier, cache-first with a TTL.
|
|
18
|
-
*
|
|
19
|
-
* - No `~/.rush/user.yaml` at all → free, no network call.
|
|
20
|
-
* - A fresh cache entry (within {@link ENTITLEMENT_CACHE_TTL_MS}) is returned
|
|
21
|
-
* with no network call.
|
|
22
|
-
* - A stale or missing cache triggers a live fetch; on success the result is
|
|
23
|
-
* cached and returned.
|
|
24
|
-
* - A failed fetch (offline, timeout, non-2xx) falls back to a stale cache if
|
|
25
|
-
* one exists — never silently drops a known-paid account to free just
|
|
26
|
-
* because the network hiccuped — and only falls back to free when there is
|
|
27
|
-
* no cache at all to fall back to.
|
|
28
|
-
*/
|
|
29
|
-
export declare function getTier(): Promise<EntitlementTier>;
|
|
30
|
-
/** Per-harness account cap for a tier: 3 on free, 10 on paid/admin. */
|
|
31
|
-
export declare function accountCapForTier(tier: Pick<EntitlementTier, 'isPaid'>): number;
|
package/dist/lib/entitlement.js
DELETED
|
@@ -1,137 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Plan-tier entitlement — the one place agents-cli reads a user's live billing
|
|
3
|
-
* tier for plan gates (the `agents accounts` per-harness cap, the `agents
|
|
4
|
-
* insights` paid split). Fetches `GET /api/v1/billing/subscription?agent=agi-cli`
|
|
5
|
-
* using the Rush session token from `~/.rush/user.yaml` (same read pattern as
|
|
6
|
-
* `lib/cloud/rush.ts` / `lib/secrets/drivers/rush.ts` — this module does not
|
|
7
|
-
* import either, since neither exports its token reader), caches the result on
|
|
8
|
-
* disk with a TTL, and stays offline-tolerant: a stale cache is honored over a
|
|
9
|
-
* failed network call rather than silently dropping a paid account to free, and
|
|
10
|
-
* no session file at all resolves straight to the free tier with no request.
|
|
11
|
-
*/
|
|
12
|
-
import * as fs from 'node:fs';
|
|
13
|
-
import * as os from 'node:os';
|
|
14
|
-
import * as path from 'node:path';
|
|
15
|
-
import * as yaml from 'yaml';
|
|
16
|
-
import { getCacheDir } from './state.js';
|
|
17
|
-
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
18
|
-
const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
|
|
19
|
-
const SUBSCRIPTION_PATH = '/api/v1/billing/subscription?agent=agi-cli';
|
|
20
|
-
/** How long a cached tier is trusted before the next call re-fetches. */
|
|
21
|
-
export const ENTITLEMENT_CACHE_TTL_MS = 15 * 60_000;
|
|
22
|
-
/** Same shape as bootstrap.ts's fetchNpmPackageMetadata — a hung connection
|
|
23
|
-
* (dead peer, a firewall dropping packets silently) must not block every
|
|
24
|
-
* `accounts add`/`insights` call indefinitely once the cache goes stale. */
|
|
25
|
-
const FETCH_TIMEOUT_MS = 5000;
|
|
26
|
-
const FREE_TIER = { tierName: 'free', isPaid: false };
|
|
27
|
-
// ─── Injectable seams (tests only — the lib's own real read/write paths) ────
|
|
28
|
-
let userYamlPathOverride = null;
|
|
29
|
-
let cachePathOverride = null;
|
|
30
|
-
let fetchImpl = globalThis.fetch;
|
|
31
|
-
export function setEntitlementUserYamlPathForTest(value) {
|
|
32
|
-
userYamlPathOverride = value;
|
|
33
|
-
}
|
|
34
|
-
export function setEntitlementCachePathForTest(value) {
|
|
35
|
-
cachePathOverride = value;
|
|
36
|
-
}
|
|
37
|
-
export function setEntitlementFetchForTest(fn) {
|
|
38
|
-
fetchImpl = fn;
|
|
39
|
-
}
|
|
40
|
-
function userYamlPath() {
|
|
41
|
-
return userYamlPathOverride ?? path.join(os.homedir(), '.rush', 'user.yaml');
|
|
42
|
-
}
|
|
43
|
-
export function entitlementCachePath() {
|
|
44
|
-
return cachePathOverride ?? path.join(getCacheDir(), '.entitlement-cache.json');
|
|
45
|
-
}
|
|
46
|
-
function readRushToken() {
|
|
47
|
-
const file = userYamlPath();
|
|
48
|
-
if (!fs.existsSync(file))
|
|
49
|
-
return null;
|
|
50
|
-
try {
|
|
51
|
-
const data = yaml.parse(fs.readFileSync(file, 'utf-8'));
|
|
52
|
-
return data?.session?.access_token || null;
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
function readCache() {
|
|
59
|
-
try {
|
|
60
|
-
const parsed = JSON.parse(fs.readFileSync(entitlementCachePath(), 'utf-8'));
|
|
61
|
-
if (parsed?.version === 1 && typeof parsed.tierName === 'string' && typeof parsed.fetchedAt === 'number')
|
|
62
|
-
return parsed;
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// missing or corrupt — treat as no cache
|
|
66
|
-
}
|
|
67
|
-
return null;
|
|
68
|
-
}
|
|
69
|
-
/** Best-effort write; a failed cache write just means the next call re-fetches. */
|
|
70
|
-
function writeCache(tierName, isPaid) {
|
|
71
|
-
try {
|
|
72
|
-
const target = entitlementCachePath();
|
|
73
|
-
ensureLockTarget(target);
|
|
74
|
-
withFileLock(target, () => {
|
|
75
|
-
const entry = { version: 1, tierName, isPaid, fetchedAt: Date.now() };
|
|
76
|
-
atomicWriteFileSync(target, JSON.stringify(entry, null, 2));
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
// best-effort
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Only `tierName: "free"` (or an absent tier name) reads as free — every other
|
|
85
|
-
* name (`admin`, or a real paid-tier name once agi-cli has a pricing manifest,
|
|
86
|
-
* see the integration spec §4.4 G3) is treated as paid. Backend tier config for
|
|
87
|
-
* agi-cli doesn't exist yet, so this deliberately does not lean on
|
|
88
|
-
* `hasSubscription`/`needsUpgrade` nuance the backend isn't populating meaningfully
|
|
89
|
-
* for this agent today.
|
|
90
|
-
*/
|
|
91
|
-
function classifySubscription(sub) {
|
|
92
|
-
const tierName = typeof sub.tierName === 'string' && sub.tierName ? sub.tierName : 'free';
|
|
93
|
-
return { tierName, isPaid: tierName !== 'free' };
|
|
94
|
-
}
|
|
95
|
-
/**
|
|
96
|
-
* The live plan tier, cache-first with a TTL.
|
|
97
|
-
*
|
|
98
|
-
* - No `~/.rush/user.yaml` at all → free, no network call.
|
|
99
|
-
* - A fresh cache entry (within {@link ENTITLEMENT_CACHE_TTL_MS}) is returned
|
|
100
|
-
* with no network call.
|
|
101
|
-
* - A stale or missing cache triggers a live fetch; on success the result is
|
|
102
|
-
* cached and returned.
|
|
103
|
-
* - A failed fetch (offline, timeout, non-2xx) falls back to a stale cache if
|
|
104
|
-
* one exists — never silently drops a known-paid account to free just
|
|
105
|
-
* because the network hiccuped — and only falls back to free when there is
|
|
106
|
-
* no cache at all to fall back to.
|
|
107
|
-
*/
|
|
108
|
-
export async function getTier() {
|
|
109
|
-
const token = readRushToken();
|
|
110
|
-
if (!token)
|
|
111
|
-
return { ...FREE_TIER, source: 'no-session' };
|
|
112
|
-
const cached = readCache();
|
|
113
|
-
if (cached && Date.now() - cached.fetchedAt < ENTITLEMENT_CACHE_TTL_MS) {
|
|
114
|
-
return { tierName: cached.tierName, isPaid: cached.isPaid, source: 'cache' };
|
|
115
|
-
}
|
|
116
|
-
try {
|
|
117
|
-
const res = await fetchImpl(`${PROXY_BASE}${SUBSCRIPTION_PATH}`, {
|
|
118
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
119
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
120
|
-
});
|
|
121
|
-
if (!res.ok)
|
|
122
|
-
throw new Error(`billing/subscription responded ${res.status}`);
|
|
123
|
-
const sub = (await res.json());
|
|
124
|
-
const { tierName, isPaid } = classifySubscription(sub);
|
|
125
|
-
writeCache(tierName, isPaid);
|
|
126
|
-
return { tierName, isPaid, source: 'live' };
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
if (cached)
|
|
130
|
-
return { tierName: cached.tierName, isPaid: cached.isPaid, source: 'offline' };
|
|
131
|
-
return { ...FREE_TIER, source: 'offline' };
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
/** Per-harness account cap for a tier: 3 on free, 10 on paid/admin. */
|
|
135
|
-
export function accountCapForTier(tier) {
|
|
136
|
-
return tier.isPaid ? 10 : 3;
|
|
137
|
-
}
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client for the Rush account layer (`api.prix.dev`) — backs `agents auth` and
|
|
3
|
-
* `agents auth space` (canonical) / `agents org` (deprecated alias). `agents auth space`
|
|
4
|
-
* maps to `/api/v1/spaces`, not `/api/v1/orgs`: spaces
|
|
5
|
-
* already carry the free-tier caps (1 owned space, 3 members) and can exist
|
|
6
|
-
* standalone with no parent organization, matching a "just want a team" CLI flow
|
|
7
|
-
* better than the heavier enterprise-tenancy `orgs` routes (domain, SSO). See
|
|
8
|
-
* `.agents/artifacts/2026-08-20/agi-cli-client-integration-spec.md` §4.2 in the
|
|
9
|
-
* agi-cli-web repo for the full reasoning and the live-confirmed route/shape audit.
|
|
10
|
-
*
|
|
11
|
-
* Token custody: `agents auth login` writes its own session file
|
|
12
|
-
* (`getPrixSessionFile()`), separate from `~/.rush/user.yaml`, so `agents auth
|
|
13
|
-
* logout` never signs the user out of `rush` (the two CLIs share a backend
|
|
14
|
-
* account, not a credential store). Reads fall back to `~/.rush/user.yaml`
|
|
15
|
-
* (the pattern `readRushToken` in `lib/secrets/drivers/rush.ts` already uses)
|
|
16
|
-
* so a user who is only signed in via `rush login` still gets a working
|
|
17
|
-
* `agents auth whoami` / `agents org` with zero extra login step.
|
|
18
|
-
*/
|
|
19
|
-
export declare const PRIX_API_BASE = "https://api.prix.dev";
|
|
20
|
-
/** The session `agents auth login` owns. */
|
|
21
|
-
export interface PrixSession {
|
|
22
|
-
access_token: string;
|
|
23
|
-
refresh_token?: string;
|
|
24
|
-
/** Unix ms. */
|
|
25
|
-
expires_at?: number;
|
|
26
|
-
email?: string;
|
|
27
|
-
userId?: string;
|
|
28
|
-
}
|
|
29
|
-
export declare function getPrixSessionFile(): string;
|
|
30
|
-
export declare function readPrixSession(): PrixSession | null;
|
|
31
|
-
export declare function writePrixSession(session: PrixSession): void;
|
|
32
|
-
export declare function clearPrixSession(): boolean;
|
|
33
|
-
/** Where the caller's Bearer token came from — surfaced by `whoami` so the user knows which login is live. */
|
|
34
|
-
export type PrixTokenSource = 'agents' | 'rush';
|
|
35
|
-
export declare function resolvePrixToken(): {
|
|
36
|
-
token: string;
|
|
37
|
-
source: PrixTokenSource;
|
|
38
|
-
} | null;
|
|
39
|
-
export declare class PrixApiError extends Error {
|
|
40
|
-
status: number;
|
|
41
|
-
constructor(status: number, message: string);
|
|
42
|
-
}
|
|
43
|
-
export interface WhoAmI {
|
|
44
|
-
userId: string;
|
|
45
|
-
email: string;
|
|
46
|
-
valid: true;
|
|
47
|
-
}
|
|
48
|
-
/** `GET /api/v1/auth/me` — live-confirmed shape: `{email, userId, valid}`. */
|
|
49
|
-
export declare function fetchWhoAmI(token?: string): Promise<WhoAmI>;
|
|
50
|
-
export interface DeviceAuthorization {
|
|
51
|
-
device_code: string;
|
|
52
|
-
user_code: string;
|
|
53
|
-
verification_uri: string;
|
|
54
|
-
verification_uri_complete: string;
|
|
55
|
-
expires_in: number;
|
|
56
|
-
interval: number;
|
|
57
|
-
}
|
|
58
|
-
/** `POST /api/v1/auth/device/authorization` — public, no token. */
|
|
59
|
-
export declare function startDeviceAuthorization(): Promise<DeviceAuthorization>;
|
|
60
|
-
export type DeviceTokenPoll = {
|
|
61
|
-
status: 'authorized';
|
|
62
|
-
access_token: string;
|
|
63
|
-
refresh_token?: string;
|
|
64
|
-
expires_in?: number;
|
|
65
|
-
user: {
|
|
66
|
-
email: string;
|
|
67
|
-
id: string;
|
|
68
|
-
};
|
|
69
|
-
} | {
|
|
70
|
-
status: 'pending';
|
|
71
|
-
} | {
|
|
72
|
-
status: 'slow_down';
|
|
73
|
-
} | {
|
|
74
|
-
status: 'expired';
|
|
75
|
-
} | {
|
|
76
|
-
status: 'denied';
|
|
77
|
-
};
|
|
78
|
-
/** `POST /api/v1/auth/device/token` — one poll attempt. Callers own the interval loop. */
|
|
79
|
-
export declare function pollDeviceToken(deviceCode: string): Promise<DeviceTokenPoll>;
|
|
80
|
-
export interface SpaceSummary {
|
|
81
|
-
id: string;
|
|
82
|
-
slug: string;
|
|
83
|
-
name: string;
|
|
84
|
-
organization_id: string | null;
|
|
85
|
-
owner_user_id: string;
|
|
86
|
-
invite_code?: string;
|
|
87
|
-
user_role: 'owner' | 'admin' | 'member';
|
|
88
|
-
created_at: string;
|
|
89
|
-
}
|
|
90
|
-
export interface SpaceMember {
|
|
91
|
-
user_id: string;
|
|
92
|
-
email: string;
|
|
93
|
-
name?: string;
|
|
94
|
-
avatar_url?: string;
|
|
95
|
-
role: 'owner' | 'admin' | 'member';
|
|
96
|
-
joined_at: string;
|
|
97
|
-
}
|
|
98
|
-
/** `GET /api/v1/spaces` — live-confirmed: array of `SpaceSummary`. */
|
|
99
|
-
export declare function listSpaces(): Promise<SpaceSummary[]>;
|
|
100
|
-
/** `POST /api/v1/spaces` — 403 if the caller already owns a space (free tier: 1). */
|
|
101
|
-
export declare function createSpace(input: {
|
|
102
|
-
name: string;
|
|
103
|
-
slug: string;
|
|
104
|
-
description?: string;
|
|
105
|
-
}): Promise<SpaceSummary>;
|
|
106
|
-
/** `GET /api/v1/spaces/:id` — requires membership. */
|
|
107
|
-
export declare function getSpace(spaceId: string): Promise<SpaceSummary>;
|
|
108
|
-
/** `GET /api/v1/spaces/:id/members`. */
|
|
109
|
-
export declare function listSpaceMembers(spaceId: string): Promise<SpaceMember[]>;
|
|
110
|
-
export type CreateSpaceInviteResult = {
|
|
111
|
-
invited: true;
|
|
112
|
-
email: string;
|
|
113
|
-
role: string;
|
|
114
|
-
member_added: true;
|
|
115
|
-
} | {
|
|
116
|
-
invited: true;
|
|
117
|
-
email: string;
|
|
118
|
-
role: string;
|
|
119
|
-
invite_code: string;
|
|
120
|
-
member_added?: false;
|
|
121
|
-
};
|
|
122
|
-
/** `POST /api/v1/spaces/:id/invites` — sends a real email for the pending-invite path. */
|
|
123
|
-
export declare function createSpaceInvite(spaceId: string, email: string, role?: 'admin' | 'member'): Promise<CreateSpaceInviteResult>;
|
|
124
|
-
export interface SpaceInvite {
|
|
125
|
-
id: string;
|
|
126
|
-
space_id: string;
|
|
127
|
-
email: string;
|
|
128
|
-
role: 'admin' | 'member';
|
|
129
|
-
invite_code: string;
|
|
130
|
-
created_at: string;
|
|
131
|
-
}
|
|
132
|
-
/** `GET /api/v1/spaces/:id/invites`. */
|
|
133
|
-
export declare function listSpaceInvites(spaceId: string): Promise<SpaceInvite[]>;
|
|
134
|
-
/** `DELETE /api/v1/spaces/:id/invites/:inviteId`. */
|
|
135
|
-
export declare function revokeSpaceInvite(spaceId: string, inviteId: string): Promise<{
|
|
136
|
-
revoked: true;
|
|
137
|
-
}>;
|
|
138
|
-
/** `PATCH /api/v1/spaces/:id/members/:userId` — owner-only for admin changes. Route takes userId, not email. */
|
|
139
|
-
export declare function updateSpaceMemberRole(spaceId: string, userId: string, role: 'admin' | 'member'): Promise<{
|
|
140
|
-
user_id: string;
|
|
141
|
-
role: string;
|
|
142
|
-
updated: true;
|
|
143
|
-
}>;
|
|
144
|
-
/** `DELETE /api/v1/spaces/:id/members/:userId` — owner, admin, or the member themself (leave). */
|
|
145
|
-
export declare function removeSpaceMember(spaceId: string, userId: string): Promise<void>;
|
|
146
|
-
/** `DELETE /api/v1/spaces/:id` — soft delete, 30-day restore window. */
|
|
147
|
-
export declare function deleteSpace(spaceId: string): Promise<void>;
|
|
148
|
-
/** `agents-cli-space-name` -> `agi-cli-space-name`; lowercase, hyphenated, matches the backend's `^[a-z0-9-]+$` slug rule. */
|
|
149
|
-
export declare function slugify(name: string): string;
|
|
150
|
-
/**
|
|
151
|
-
* Resolve the `--space` a command should act on: an explicit id/slug match
|
|
152
|
-
* against the caller's own space list, or — with nothing passed — the
|
|
153
|
-
* caller's sole space (free tier caps ownership at one, so this is almost
|
|
154
|
-
* always unambiguous). Pure over an already-fetched list so it's cheaply
|
|
155
|
-
* unit-testable with a fixture.
|
|
156
|
-
*/
|
|
157
|
-
export declare function resolveSpaceFromList(spaces: SpaceSummary[], explicit?: string): SpaceSummary;
|
|
158
|
-
/** Resolve a member's email to their `user_id` from an already-fetched member list. */
|
|
159
|
-
export declare function resolveMemberFromList(members: SpaceMember[], email: string): SpaceMember;
|
package/dist/lib/prix-account.js
DELETED
|
@@ -1,215 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Client for the Rush account layer (`api.prix.dev`) — backs `agents auth` and
|
|
3
|
-
* `agents auth space` (canonical) / `agents org` (deprecated alias). `agents auth space`
|
|
4
|
-
* maps to `/api/v1/spaces`, not `/api/v1/orgs`: spaces
|
|
5
|
-
* already carry the free-tier caps (1 owned space, 3 members) and can exist
|
|
6
|
-
* standalone with no parent organization, matching a "just want a team" CLI flow
|
|
7
|
-
* better than the heavier enterprise-tenancy `orgs` routes (domain, SSO). See
|
|
8
|
-
* `.agents/artifacts/2026-08-20/agi-cli-client-integration-spec.md` §4.2 in the
|
|
9
|
-
* agi-cli-web repo for the full reasoning and the live-confirmed route/shape audit.
|
|
10
|
-
*
|
|
11
|
-
* Token custody: `agents auth login` writes its own session file
|
|
12
|
-
* (`getPrixSessionFile()`), separate from `~/.rush/user.yaml`, so `agents auth
|
|
13
|
-
* logout` never signs the user out of `rush` (the two CLIs share a backend
|
|
14
|
-
* account, not a credential store). Reads fall back to `~/.rush/user.yaml`
|
|
15
|
-
* (the pattern `readRushToken` in `lib/secrets/drivers/rush.ts` already uses)
|
|
16
|
-
* so a user who is only signed in via `rush login` still gets a working
|
|
17
|
-
* `agents auth whoami` / `agents org` with zero extra login step.
|
|
18
|
-
*/
|
|
19
|
-
import * as fs from 'fs';
|
|
20
|
-
import * as os from 'os';
|
|
21
|
-
import * as path from 'path';
|
|
22
|
-
import * as yaml from 'yaml';
|
|
23
|
-
import { getRuntimeStateDir } from './state.js';
|
|
24
|
-
export const PRIX_API_BASE = 'https://api.prix.dev';
|
|
25
|
-
const RUSH_USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
|
|
26
|
-
/** Computed per-call (not cached at module load) so `AGENTS_STATE_DIR` overrides in tests take effect. */
|
|
27
|
-
function prixSessionFile() {
|
|
28
|
-
return path.join(getRuntimeStateDir(), 'prix-account.json');
|
|
29
|
-
}
|
|
30
|
-
/** Read the token `rush login` wrote, with no expiry check — the same shape `readRushToken` reads. */
|
|
31
|
-
function readRushSessionToken() {
|
|
32
|
-
if (!fs.existsSync(RUSH_USER_YAML))
|
|
33
|
-
return null;
|
|
34
|
-
try {
|
|
35
|
-
const data = yaml.parse(fs.readFileSync(RUSH_USER_YAML, 'utf-8'));
|
|
36
|
-
return data?.session?.access_token ?? null;
|
|
37
|
-
}
|
|
38
|
-
catch {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
export function getPrixSessionFile() {
|
|
43
|
-
return prixSessionFile();
|
|
44
|
-
}
|
|
45
|
-
export function readPrixSession() {
|
|
46
|
-
const file = prixSessionFile();
|
|
47
|
-
if (!fs.existsSync(file))
|
|
48
|
-
return null;
|
|
49
|
-
try {
|
|
50
|
-
const parsed = JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
51
|
-
if (!parsed || typeof parsed.access_token !== 'string')
|
|
52
|
-
return null;
|
|
53
|
-
return parsed;
|
|
54
|
-
}
|
|
55
|
-
catch {
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
export function writePrixSession(session) {
|
|
60
|
-
const file = prixSessionFile();
|
|
61
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
62
|
-
const tmp = `${file}.${process.pid}.tmp`;
|
|
63
|
-
fs.writeFileSync(tmp, JSON.stringify(session, null, 2), { mode: 0o600 });
|
|
64
|
-
fs.renameSync(tmp, file);
|
|
65
|
-
}
|
|
66
|
-
export function clearPrixSession() {
|
|
67
|
-
const file = prixSessionFile();
|
|
68
|
-
if (!fs.existsSync(file))
|
|
69
|
-
return false;
|
|
70
|
-
fs.rmSync(file);
|
|
71
|
-
return true;
|
|
72
|
-
}
|
|
73
|
-
export function resolvePrixToken() {
|
|
74
|
-
const own = readPrixSession();
|
|
75
|
-
if (own?.access_token)
|
|
76
|
-
return { token: own.access_token, source: 'agents' };
|
|
77
|
-
const rushToken = readRushSessionToken();
|
|
78
|
-
if (rushToken)
|
|
79
|
-
return { token: rushToken, source: 'rush' };
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
export class PrixApiError extends Error {
|
|
83
|
-
status;
|
|
84
|
-
constructor(status, message) {
|
|
85
|
-
super(message);
|
|
86
|
-
this.status = status;
|
|
87
|
-
this.name = 'PrixApiError';
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
async function prixRequest(method, endpoint, opts = {}) {
|
|
91
|
-
const auth = opts.auth !== false;
|
|
92
|
-
let token = opts.token;
|
|
93
|
-
if (auth && !token) {
|
|
94
|
-
const resolved = resolvePrixToken();
|
|
95
|
-
if (!resolved)
|
|
96
|
-
throw new Error("Not signed in. Run 'agents auth login' first.");
|
|
97
|
-
token = resolved.token;
|
|
98
|
-
}
|
|
99
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
100
|
-
if (token)
|
|
101
|
-
headers.Authorization = `Bearer ${token}`;
|
|
102
|
-
const res = await fetch(`${PRIX_API_BASE}${endpoint}`, {
|
|
103
|
-
method,
|
|
104
|
-
headers,
|
|
105
|
-
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
106
|
-
});
|
|
107
|
-
const text = await res.text();
|
|
108
|
-
const data = text ? JSON.parse(text) : undefined;
|
|
109
|
-
if (!res.ok) {
|
|
110
|
-
const message = (data && typeof data === 'object' && 'error' in data) ? String(data.error) : `${res.status} ${res.statusText}`;
|
|
111
|
-
throw new PrixApiError(res.status, message);
|
|
112
|
-
}
|
|
113
|
-
return data;
|
|
114
|
-
}
|
|
115
|
-
/** `GET /api/v1/auth/me` — live-confirmed shape: `{email, userId, valid}`. */
|
|
116
|
-
export async function fetchWhoAmI(token) {
|
|
117
|
-
return prixRequest('GET', '/api/v1/auth/me', { token });
|
|
118
|
-
}
|
|
119
|
-
/** `POST /api/v1/auth/device/authorization` — public, no token. */
|
|
120
|
-
export async function startDeviceAuthorization() {
|
|
121
|
-
return prixRequest('POST', '/api/v1/auth/device/authorization', { auth: false, body: {} });
|
|
122
|
-
}
|
|
123
|
-
/** `POST /api/v1/auth/device/token` — one poll attempt. Callers own the interval loop. */
|
|
124
|
-
export async function pollDeviceToken(deviceCode) {
|
|
125
|
-
try {
|
|
126
|
-
const data = await prixRequest('POST', '/api/v1/auth/device/token', { auth: false, body: { grant_type: 'urn:ietf:params:oauth:grant-type:device_code', device_code: deviceCode } });
|
|
127
|
-
return { status: 'authorized', ...data };
|
|
128
|
-
}
|
|
129
|
-
catch (err) {
|
|
130
|
-
if (err instanceof PrixApiError) {
|
|
131
|
-
if (err.message.includes('authorization_pending'))
|
|
132
|
-
return { status: 'pending' };
|
|
133
|
-
if (err.message.includes('slow_down'))
|
|
134
|
-
return { status: 'slow_down' };
|
|
135
|
-
if (err.message.includes('expired_token'))
|
|
136
|
-
return { status: 'expired' };
|
|
137
|
-
if (err.message.includes('access_denied'))
|
|
138
|
-
return { status: 'denied' };
|
|
139
|
-
}
|
|
140
|
-
throw err;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
/** `GET /api/v1/spaces` — live-confirmed: array of `SpaceSummary`. */
|
|
144
|
-
export async function listSpaces() {
|
|
145
|
-
return prixRequest('GET', '/api/v1/spaces');
|
|
146
|
-
}
|
|
147
|
-
/** `POST /api/v1/spaces` — 403 if the caller already owns a space (free tier: 1). */
|
|
148
|
-
export async function createSpace(input) {
|
|
149
|
-
return prixRequest('POST', '/api/v1/spaces', { body: input });
|
|
150
|
-
}
|
|
151
|
-
/** `GET /api/v1/spaces/:id` — requires membership. */
|
|
152
|
-
export async function getSpace(spaceId) {
|
|
153
|
-
return prixRequest('GET', `/api/v1/spaces/${encodeURIComponent(spaceId)}`);
|
|
154
|
-
}
|
|
155
|
-
/** `GET /api/v1/spaces/:id/members`. */
|
|
156
|
-
export async function listSpaceMembers(spaceId) {
|
|
157
|
-
return prixRequest('GET', `/api/v1/spaces/${encodeURIComponent(spaceId)}/members`);
|
|
158
|
-
}
|
|
159
|
-
/** `POST /api/v1/spaces/:id/invites` — sends a real email for the pending-invite path. */
|
|
160
|
-
export async function createSpaceInvite(spaceId, email, role = 'member') {
|
|
161
|
-
return prixRequest('POST', `/api/v1/spaces/${encodeURIComponent(spaceId)}/invites`, { body: { email, role } });
|
|
162
|
-
}
|
|
163
|
-
/** `GET /api/v1/spaces/:id/invites`. */
|
|
164
|
-
export async function listSpaceInvites(spaceId) {
|
|
165
|
-
return prixRequest('GET', `/api/v1/spaces/${encodeURIComponent(spaceId)}/invites`);
|
|
166
|
-
}
|
|
167
|
-
/** `DELETE /api/v1/spaces/:id/invites/:inviteId`. */
|
|
168
|
-
export async function revokeSpaceInvite(spaceId, inviteId) {
|
|
169
|
-
return prixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(spaceId)}/invites/${encodeURIComponent(inviteId)}`);
|
|
170
|
-
}
|
|
171
|
-
/** `PATCH /api/v1/spaces/:id/members/:userId` — owner-only for admin changes. Route takes userId, not email. */
|
|
172
|
-
export async function updateSpaceMemberRole(spaceId, userId, role) {
|
|
173
|
-
return prixRequest('PATCH', `/api/v1/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`, { body: { role } });
|
|
174
|
-
}
|
|
175
|
-
/** `DELETE /api/v1/spaces/:id/members/:userId` — owner, admin, or the member themself (leave). */
|
|
176
|
-
export async function removeSpaceMember(spaceId, userId) {
|
|
177
|
-
await prixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(spaceId)}/members/${encodeURIComponent(userId)}`);
|
|
178
|
-
}
|
|
179
|
-
/** `DELETE /api/v1/spaces/:id` — soft delete, 30-day restore window. */
|
|
180
|
-
export async function deleteSpace(spaceId) {
|
|
181
|
-
await prixRequest('DELETE', `/api/v1/spaces/${encodeURIComponent(spaceId)}`);
|
|
182
|
-
}
|
|
183
|
-
/** `agents-cli-space-name` -> `agi-cli-space-name`; lowercase, hyphenated, matches the backend's `^[a-z0-9-]+$` slug rule. */
|
|
184
|
-
export function slugify(name) {
|
|
185
|
-
const slug = name.toLowerCase().trim().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
186
|
-
return slug || 'space';
|
|
187
|
-
}
|
|
188
|
-
/**
|
|
189
|
-
* Resolve the `--space` a command should act on: an explicit id/slug match
|
|
190
|
-
* against the caller's own space list, or — with nothing passed — the
|
|
191
|
-
* caller's sole space (free tier caps ownership at one, so this is almost
|
|
192
|
-
* always unambiguous). Pure over an already-fetched list so it's cheaply
|
|
193
|
-
* unit-testable with a fixture.
|
|
194
|
-
*/
|
|
195
|
-
export function resolveSpaceFromList(spaces, explicit) {
|
|
196
|
-
if (explicit) {
|
|
197
|
-
const match = spaces.find(s => s.id === explicit || s.slug === explicit);
|
|
198
|
-
if (!match)
|
|
199
|
-
throw new Error(`No space matching '${explicit}'. Run 'agents org list' to see your spaces.`);
|
|
200
|
-
return match;
|
|
201
|
-
}
|
|
202
|
-
if (spaces.length === 0)
|
|
203
|
-
throw new Error("You have no spaces. Create one with 'agents org create <name>'.");
|
|
204
|
-
if (spaces.length > 1) {
|
|
205
|
-
throw new Error(`You belong to ${spaces.length} spaces — pass --space <id-or-slug>: ${spaces.map(s => s.slug).join(', ')}`);
|
|
206
|
-
}
|
|
207
|
-
return spaces[0];
|
|
208
|
-
}
|
|
209
|
-
/** Resolve a member's email to their `user_id` from an already-fetched member list. */
|
|
210
|
-
export function resolveMemberFromList(members, email) {
|
|
211
|
-
const match = members.find(m => m.email.toLowerCase() === email.toLowerCase());
|
|
212
|
-
if (!match)
|
|
213
|
-
throw new Error(`'${email}' is not a member of this space. Run 'agents org members' to see who is.`);
|
|
214
|
-
return match;
|
|
215
|
-
}
|