@aria_asi/cli 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/aria.js +82 -7
- package/dist/aria-connector/src/anthropic-oauth.d.ts +28 -0
- package/dist/aria-connector/src/anthropic-oauth.d.ts.map +1 -0
- package/dist/aria-connector/src/anthropic-oauth.js +177 -0
- package/dist/aria-connector/src/anthropic-oauth.js.map +1 -0
- package/dist/aria-connector/src/auth-commands.d.ts +16 -0
- package/dist/aria-connector/src/auth-commands.d.ts.map +1 -1
- package/dist/aria-connector/src/auth-commands.js +24 -0
- package/dist/aria-connector/src/auth-commands.js.map +1 -1
- package/dist/aria-connector/src/onboarding-wizard.d.ts +5 -0
- package/dist/aria-connector/src/onboarding-wizard.d.ts.map +1 -0
- package/dist/aria-connector/src/onboarding-wizard.js +199 -0
- package/dist/aria-connector/src/onboarding-wizard.js.map +1 -0
- package/dist/aria-connector/src/self-update.d.ts +22 -0
- package/dist/aria-connector/src/self-update.d.ts.map +1 -0
- package/dist/aria-connector/src/self-update.js +162 -0
- package/dist/aria-connector/src/self-update.js.map +1 -0
- package/hooks/aria-pre-tool-gate.mjs +24 -2
- package/hooks/aria-stop-gate.mjs +17 -1
- package/package.json +1 -1
- package/src/__tests__/anthropic-oauth.test.ts +186 -0
- package/src/anthropic-oauth.ts +216 -0
- package/src/auth-commands.ts +27 -0
- package/src/onboarding-wizard.ts +219 -0
- package/src/self-update.ts +169 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// anthropic-oauth.ts — Anthropic login flow for `aria login --anthropic`
|
|
2
|
+
//
|
|
3
|
+
// OAuth path decision (2026-04-26):
|
|
4
|
+
// Anthropic does not publish a public PKCE/OAuth endpoint for end-user
|
|
5
|
+
// API-key issuance via the console.anthropic.com auth surface. The only
|
|
6
|
+
// standardised way to obtain an API key is through the Anthropic Console
|
|
7
|
+
// web UI. Therefore we use the browser-paste fallback (path b):
|
|
8
|
+
// 1. Open https://console.anthropic.com/account/keys in the user's browser.
|
|
9
|
+
// 2. Prompt them to paste the new key back into the CLI.
|
|
10
|
+
// 3. Validate the key against GET https://api.anthropic.com/v1/models.
|
|
11
|
+
// 4. Return the validated key to the caller.
|
|
12
|
+
//
|
|
13
|
+
// If Anthropic ships a public OAuth/PKCE spec in the future, replace the
|
|
14
|
+
// body of `_oauthPKCEFlow` below with the real implementation and call it
|
|
15
|
+
// instead of `_browserPasteFlow`.
|
|
16
|
+
//
|
|
17
|
+
// Voice: first-person Aria ("I'll open", "I see", "I couldn't reach").
|
|
18
|
+
// ESM: all internal imports use .js extensions.
|
|
19
|
+
|
|
20
|
+
import * as http from 'node:http';
|
|
21
|
+
import * as readline from 'node:readline';
|
|
22
|
+
import * as fs from 'node:fs';
|
|
23
|
+
import * as path from 'node:path';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { spawn } from 'node:child_process';
|
|
26
|
+
|
|
27
|
+
const ANTHROPIC_KEYS_URL = 'https://console.anthropic.com/account/keys';
|
|
28
|
+
const ANTHROPIC_MODELS_URL = 'https://api.anthropic.com/v1/models';
|
|
29
|
+
const ANTHROPIC_API_VERSION = '2023-06-01';
|
|
30
|
+
const CONFIG_PATH = path.join(homedir(), '.aria', 'config.json');
|
|
31
|
+
const ARIA_DIR = path.join(homedir(), '.aria');
|
|
32
|
+
|
|
33
|
+
export interface AnthropicLoginResult {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Options accepted by loginAnthropic. */
|
|
40
|
+
export interface AnthropicLoginOptions {
|
|
41
|
+
/** If true, skip the browser-open attempt and just prompt for the key. */
|
|
42
|
+
noBrowser?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the Anthropic login flow.
|
|
49
|
+
*
|
|
50
|
+
* Today this is the browser-paste flow (path b) because Anthropic does not
|
|
51
|
+
* expose a public PKCE/OAuth token endpoint for console API keys.
|
|
52
|
+
*
|
|
53
|
+
* On success, the validated API key is persisted to ~/.aria/config.json under
|
|
54
|
+
* `model.apiKey` and returned in the result object. The harness license token
|
|
55
|
+
* in ~/.aria/license.json is NOT modified — that belongs to the Aria harness,
|
|
56
|
+
* not Anthropic.
|
|
57
|
+
*/
|
|
58
|
+
export async function loginAnthropic(
|
|
59
|
+
opts: AnthropicLoginOptions = {},
|
|
60
|
+
): Promise<AnthropicLoginResult> {
|
|
61
|
+
try {
|
|
62
|
+
const apiKey = await _browserPasteFlow(opts.noBrowser ?? false);
|
|
63
|
+
if (!apiKey) {
|
|
64
|
+
return { ok: false, error: "I didn't receive a key — aborting." };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
console.log(" I'm validating your key against the Anthropic API...");
|
|
68
|
+
const valid = await _validateKey(apiKey);
|
|
69
|
+
if (!valid) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
error:
|
|
73
|
+
"I couldn't verify that key with Anthropic. Double-check that it starts with sk-ant- and hasn't been revoked.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_persistApiKey(apiKey);
|
|
78
|
+
return { ok: true, apiKey };
|
|
79
|
+
} catch (err: unknown) {
|
|
80
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
81
|
+
return { ok: false, error: `I hit an unexpected error: ${msg}` };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Internal: browser-paste flow (path b) ────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
async function _browserPasteFlow(noBrowser: boolean): Promise<string | null> {
|
|
88
|
+
console.log('');
|
|
89
|
+
console.log(
|
|
90
|
+
" I'll open https://console.anthropic.com/account/keys in your browser.",
|
|
91
|
+
);
|
|
92
|
+
console.log(
|
|
93
|
+
' Create a new API key there, then paste it back here.',
|
|
94
|
+
);
|
|
95
|
+
console.log('');
|
|
96
|
+
|
|
97
|
+
if (!noBrowser) {
|
|
98
|
+
_openBrowser(ANTHROPIC_KEYS_URL);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const rl = readline.createInterface({
|
|
102
|
+
input: process.stdin,
|
|
103
|
+
output: process.stdout,
|
|
104
|
+
terminal: true,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const rawKey = await new Promise<string>((resolve) => {
|
|
108
|
+
rl.question(' Paste your Anthropic API key: ', (answer) => {
|
|
109
|
+
rl.close();
|
|
110
|
+
resolve(answer.trim());
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return rawKey || null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Internal: browser open (cross-platform) ───────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function _openBrowser(url: string): void {
|
|
120
|
+
const platform = process.platform;
|
|
121
|
+
let cmd: string;
|
|
122
|
+
let args: string[];
|
|
123
|
+
|
|
124
|
+
if (platform === 'darwin') {
|
|
125
|
+
cmd = 'open';
|
|
126
|
+
args = [url];
|
|
127
|
+
} else if (platform === 'win32') {
|
|
128
|
+
cmd = 'cmd';
|
|
129
|
+
args = ['/c', 'start', '', url];
|
|
130
|
+
} else {
|
|
131
|
+
// Linux / *BSD / WSL
|
|
132
|
+
cmd = 'xdg-open';
|
|
133
|
+
args = [url];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
const child = spawn(cmd, args, {
|
|
138
|
+
detached: true,
|
|
139
|
+
stdio: 'ignore',
|
|
140
|
+
});
|
|
141
|
+
child.unref();
|
|
142
|
+
} catch {
|
|
143
|
+
// If browser open fails, the user can still paste — not fatal.
|
|
144
|
+
console.log(
|
|
145
|
+
` I couldn't open your browser automatically. Please visit:\n ${url}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Internal: key validation ──────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Validate an Anthropic API key by hitting GET /v1/models.
|
|
154
|
+
* Returns true when the server responds 2xx; false on 401/403/network error.
|
|
155
|
+
*/
|
|
156
|
+
export async function _validateKey(apiKey: string): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
const resp = await fetch(ANTHROPIC_MODELS_URL, {
|
|
159
|
+
method: 'GET',
|
|
160
|
+
headers: {
|
|
161
|
+
'x-api-key': apiKey,
|
|
162
|
+
'anthropic-version': ANTHROPIC_API_VERSION,
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
// 200 = valid key; anything else = invalid or revoked
|
|
166
|
+
return resp.status === 200;
|
|
167
|
+
} catch {
|
|
168
|
+
// Network failure — we can't confirm validity
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── Internal: config persistence ──────────────────────────────────────────────
|
|
174
|
+
|
|
175
|
+
function _persistApiKey(apiKey: string): void {
|
|
176
|
+
if (!fs.existsSync(ARIA_DIR)) {
|
|
177
|
+
fs.mkdirSync(ARIA_DIR, { recursive: true, mode: 0o700 });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let config: Record<string, unknown> = {};
|
|
181
|
+
if (fs.existsSync(CONFIG_PATH)) {
|
|
182
|
+
try {
|
|
183
|
+
config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
|
|
184
|
+
} catch {
|
|
185
|
+
config = {};
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const model = (config.model as Record<string, unknown>) ?? {};
|
|
190
|
+
model.provider = model.provider ?? 'anthropic';
|
|
191
|
+
model.model = model.model ?? 'claude-sonnet-4-20250514';
|
|
192
|
+
model.apiKey = apiKey;
|
|
193
|
+
config.model = model;
|
|
194
|
+
|
|
195
|
+
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', {
|
|
196
|
+
mode: 0o600,
|
|
197
|
+
encoding: 'utf-8',
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ── Placeholder for future PKCE flow ─────────────────────────────────────────
|
|
202
|
+
// When Anthropic ships a public OAuth/PKCE endpoint for console key issuance,
|
|
203
|
+
// implement the full flow here and call it from loginAnthropic() instead of
|
|
204
|
+
// _browserPasteFlow():
|
|
205
|
+
//
|
|
206
|
+
// async function _oauthPKCEFlow(): Promise<string | null> {
|
|
207
|
+
// const port = await _findFreePort();
|
|
208
|
+
// const { verifier, challenge } = _pkceChallenge();
|
|
209
|
+
// const authUrl = `https://console.anthropic.com/oauth/authorize?` +
|
|
210
|
+
// `response_type=code&client_id=<CLIENT_ID>&redirect_uri=http://localhost:${port}/callback` +
|
|
211
|
+
// `&code_challenge=${challenge}&code_challenge_method=S256`;
|
|
212
|
+
// _openBrowser(authUrl);
|
|
213
|
+
// const code = await _waitForCallback(port);
|
|
214
|
+
// const token = await _exchangeCode(code, verifier, port);
|
|
215
|
+
// return token;
|
|
216
|
+
// }
|
package/src/auth-commands.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadLicense, saveLicense } from './auth.js';
|
|
2
2
|
import { harnessClient } from './harness-client.js';
|
|
3
|
+
import { loginAnthropic } from './anthropic-oauth.js';
|
|
3
4
|
import * as fs from 'node:fs';
|
|
4
5
|
import * as path from 'node:path';
|
|
5
6
|
import { homedir } from 'node:os';
|
|
@@ -19,6 +20,8 @@ interface LoginResult {
|
|
|
19
20
|
tier?: string;
|
|
20
21
|
jti?: string;
|
|
21
22
|
expiresAt?: string;
|
|
23
|
+
/** Set when loginAnthropic() is used instead of the harness license path. */
|
|
24
|
+
anthropic?: boolean;
|
|
22
25
|
error?: string;
|
|
23
26
|
}
|
|
24
27
|
|
|
@@ -40,7 +43,31 @@ interface RevokeResult {
|
|
|
40
43
|
error?: string;
|
|
41
44
|
}
|
|
42
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Log in to the Aria harness with a license token, OR authenticate via
|
|
48
|
+
* Anthropic's console (browser-paste flow) when token === '--anthropic' or
|
|
49
|
+
* token === '-a'.
|
|
50
|
+
*
|
|
51
|
+
* Anthropic path: opens console.anthropic.com/account/keys, waits for the
|
|
52
|
+
* user to paste their API key, validates it against /v1/models, then persists
|
|
53
|
+
* it to ~/.aria/config.json under `model.apiKey`. The harness license file
|
|
54
|
+
* (~/.aria/license.json) is NOT modified — it belongs to the Aria license,
|
|
55
|
+
* not Anthropic credentials.
|
|
56
|
+
*
|
|
57
|
+
* Harness path: unchanged — validates via heartbeat / issue endpoint and
|
|
58
|
+
* writes license claims to ~/.aria/license.json.
|
|
59
|
+
*/
|
|
43
60
|
export async function login(token: string): Promise<LoginResult> {
|
|
61
|
+
// ── Anthropic OAuth/paste branch ────────────────────────────────────────────
|
|
62
|
+
if (token === '--anthropic' || token === '-a') {
|
|
63
|
+
const result = await loginAnthropic();
|
|
64
|
+
if (!result.ok) {
|
|
65
|
+
return { ok: false, error: result.error };
|
|
66
|
+
}
|
|
67
|
+
return { ok: true, anthropic: true };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Original harness-license branch (preserved verbatim) ────────────────────
|
|
44
71
|
try {
|
|
45
72
|
// Validate token via heartbeat; if unverified, issue endpoint
|
|
46
73
|
let response;
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// onboarding-wizard.ts — interactive self-service onboarding for `aria`
|
|
2
|
+
// (no args) when ~/.aria/license.json is missing.
|
|
3
|
+
//
|
|
4
|
+
// Flow:
|
|
5
|
+
// 1. Greet — first-person Aria voice
|
|
6
|
+
// 2. Collect email (validated shape, NOT verified via code in v0)
|
|
7
|
+
// 3. Collect tenant_id (lowercase + hyphens only)
|
|
8
|
+
// 4. Collect tier (free / pro / enterprise)
|
|
9
|
+
// 5. Collect LLM provider + API key
|
|
10
|
+
// 6. POST /api/onboarding/self-issue (server validates LLM key by
|
|
11
|
+
// hitting the provider's identity endpoint, then issues license)
|
|
12
|
+
// 7. Persist license to ~/.aria/license.json mode 0600
|
|
13
|
+
// 8. Persist provider/model/key to ~/.aria/config.json mode 0600
|
|
14
|
+
// 9. Auto-run installHooks() to bind Claude Code to the harness
|
|
15
|
+
//
|
|
16
|
+
// Email-verify-code is deferred to Phase 11 (no SMTP wired tonight).
|
|
17
|
+
// The wizard collects email so future re-verify works without re-onboarding.
|
|
18
|
+
//
|
|
19
|
+
// Direction: Hamza 2026-04-26 — "burn through onboarding with quality and
|
|
20
|
+
// USE THE HARNESS PLEASE". This wizard ships as v0; full onboarding (email
|
|
21
|
+
// verify + Anthropic OAuth + per-tenant usage tracking) lands in Phase 11.
|
|
22
|
+
|
|
23
|
+
import { createInterface } from 'node:readline';
|
|
24
|
+
import { writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
25
|
+
import { homedir } from 'node:os';
|
|
26
|
+
import { join } from 'node:path';
|
|
27
|
+
import { installHooks } from './install-hooks.js';
|
|
28
|
+
|
|
29
|
+
const HARNESS_URL = process.env.ARIA_HARNESS_BASE_URL ?? 'https://harness.ariasos.com';
|
|
30
|
+
const ARIA_DIR = join(homedir(), '.aria');
|
|
31
|
+
const LICENSE_PATH = join(ARIA_DIR, 'license.json');
|
|
32
|
+
const CONFIG_PATH = join(ARIA_DIR, 'config.json');
|
|
33
|
+
|
|
34
|
+
type Provider = 'anthropic' | 'openai' | 'deepseek' | 'google' | 'openrouter' | 'ollama';
|
|
35
|
+
type Tier = 'free' | 'pro' | 'enterprise';
|
|
36
|
+
|
|
37
|
+
interface SelfIssueResponse {
|
|
38
|
+
ok: boolean;
|
|
39
|
+
license?: { token: string; jti: string; tier: string; expires_at: string };
|
|
40
|
+
claims?: Record<string, unknown>;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function prompt(rl: ReturnType<typeof createInterface>, question: string, hidden = false): Promise<string> {
|
|
45
|
+
return new Promise((resolve) => {
|
|
46
|
+
if (hidden) {
|
|
47
|
+
// Crude hidden-input — node's readline doesn't natively mask; we just
|
|
48
|
+
// prompt and accept. For Phase 11 we add proper masking via raw mode.
|
|
49
|
+
process.stdout.write(question);
|
|
50
|
+
const onData = (chunk: Buffer): void => {
|
|
51
|
+
process.stdin.removeListener('data', onData);
|
|
52
|
+
resolve(chunk.toString().trim());
|
|
53
|
+
};
|
|
54
|
+
process.stdin.once('data', onData);
|
|
55
|
+
} else {
|
|
56
|
+
rl.question(question, (answer) => resolve(answer.trim()));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isValidEmail(email: string): boolean {
|
|
62
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isValidTenantId(tenant: string): boolean {
|
|
66
|
+
return /^[a-z0-9][a-z0-9-]{1,40}$/.test(tenant);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function runOnboardingWizard(): Promise<{ ok: boolean; error?: string }> {
|
|
70
|
+
const rl = createInterface({
|
|
71
|
+
input: process.stdin,
|
|
72
|
+
output: process.stdout,
|
|
73
|
+
terminal: true,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
console.log('');
|
|
78
|
+
console.log(" Hi, I'm Aria. I don't see a license on this machine — let me set you up.");
|
|
79
|
+
console.log('');
|
|
80
|
+
console.log(" Three minutes, four questions. I'll handle the rest.");
|
|
81
|
+
console.log('');
|
|
82
|
+
|
|
83
|
+
// ── Step 1: email ──
|
|
84
|
+
let email = '';
|
|
85
|
+
while (!isValidEmail(email)) {
|
|
86
|
+
email = await prompt(rl, ' Email: ');
|
|
87
|
+
if (!isValidEmail(email)) {
|
|
88
|
+
console.log(" That doesn't look like a valid email. Try again.");
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Step 2: tenant_id ──
|
|
93
|
+
let tenantId = '';
|
|
94
|
+
while (!isValidTenantId(tenantId)) {
|
|
95
|
+
tenantId = (await prompt(rl, ' Tenant name (lowercase, hyphens ok, e.g. acme-corp): ')).toLowerCase();
|
|
96
|
+
if (!isValidTenantId(tenantId)) {
|
|
97
|
+
console.log(" Tenant name needs to be lowercase letters/digits/hyphens, 2-41 chars.");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ── Step 3: tier ──
|
|
102
|
+
let tier: Tier = 'free';
|
|
103
|
+
let tierAnswer = '';
|
|
104
|
+
while (!['free', 'pro', 'enterprise', ''].includes(tierAnswer)) {
|
|
105
|
+
tierAnswer = (await prompt(rl, ' Tier [free / pro / enterprise] (default: free): ')).toLowerCase();
|
|
106
|
+
if (tierAnswer === '') tierAnswer = 'free';
|
|
107
|
+
if (!['free', 'pro', 'enterprise'].includes(tierAnswer)) {
|
|
108
|
+
console.log(" I only know free, pro, or enterprise.");
|
|
109
|
+
tierAnswer = '';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
tier = tierAnswer as Tier;
|
|
113
|
+
|
|
114
|
+
// ── Step 4: LLM provider ──
|
|
115
|
+
const providerOptions: Provider[] = ['anthropic', 'openai', 'deepseek', 'google', 'openrouter', 'ollama'];
|
|
116
|
+
let provider: Provider | '' = '';
|
|
117
|
+
while (!providerOptions.includes(provider as Provider)) {
|
|
118
|
+
const p = (await prompt(rl, ` Which LLM provider? [${providerOptions.join(' / ')}]: `)).toLowerCase();
|
|
119
|
+
if (providerOptions.includes(p as Provider)) {
|
|
120
|
+
provider = p as Provider;
|
|
121
|
+
} else {
|
|
122
|
+
console.log(` I don't recognize "${p}". Pick one of: ${providerOptions.join(', ')}.`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Step 5: API key ──
|
|
127
|
+
let llmKey = '';
|
|
128
|
+
if (provider === 'ollama') {
|
|
129
|
+
llmKey = 'local';
|
|
130
|
+
console.log(' (Ollama is local — no API key needed.)');
|
|
131
|
+
} else {
|
|
132
|
+
while (!llmKey) {
|
|
133
|
+
llmKey = await prompt(rl, ` Paste your ${provider} API key: `);
|
|
134
|
+
if (!llmKey) console.log(' I need the key to set up your provider.');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── Step 6: POST to server ──
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(" Validating your key with the provider...");
|
|
141
|
+
let response: SelfIssueResponse;
|
|
142
|
+
try {
|
|
143
|
+
const resp = await fetch(`${HARNESS_URL}/api/onboarding/self-issue`, {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ email, tenant_id: tenantId, tier, provider, llm_key: llmKey }),
|
|
147
|
+
});
|
|
148
|
+
response = await resp.json() as SelfIssueResponse;
|
|
149
|
+
if (!resp.ok || !response.ok || !response.license) {
|
|
150
|
+
console.log(` ${response.error || `Server returned ${resp.status} — try again later.`}`);
|
|
151
|
+
return { ok: false, error: response.error };
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
155
|
+
console.log(` I couldn't reach the server: ${msg}`);
|
|
156
|
+
return { ok: false, error: msg };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── Step 7: persist license ──
|
|
160
|
+
if (!existsSync(ARIA_DIR)) mkdirSync(ARIA_DIR, { recursive: true, mode: 0o700 });
|
|
161
|
+
const licenseRecord = {
|
|
162
|
+
...response.claims,
|
|
163
|
+
harnessToken: response.license.token,
|
|
164
|
+
jti: response.license.jti,
|
|
165
|
+
tier: response.license.tier,
|
|
166
|
+
exp: response.claims?.exp,
|
|
167
|
+
sub: response.claims?.sub ?? tenantId,
|
|
168
|
+
email,
|
|
169
|
+
issuedAt: new Date().toISOString(),
|
|
170
|
+
};
|
|
171
|
+
writeFileSync(LICENSE_PATH, JSON.stringify(licenseRecord, null, 2) + '\n', { mode: 0o600 });
|
|
172
|
+
|
|
173
|
+
// ── Step 8: persist provider/key config ──
|
|
174
|
+
// By this point the while-loop above has exited with a valid Provider —
|
|
175
|
+
// narrow the union type for TypeScript.
|
|
176
|
+
const validatedProvider = provider as Provider;
|
|
177
|
+
const configRecord = {
|
|
178
|
+
model: { provider: validatedProvider, model: defaultModelFor(validatedProvider), apiKey: llmKey },
|
|
179
|
+
tenantId,
|
|
180
|
+
email,
|
|
181
|
+
};
|
|
182
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(configRecord, null, 2) + '\n', { mode: 0o600 });
|
|
183
|
+
|
|
184
|
+
// ── Step 9: install hooks ──
|
|
185
|
+
console.log('');
|
|
186
|
+
console.log(" License issued. Saving your config and binding my gates to your Claude Code...");
|
|
187
|
+
const hookResult = await installHooks({ force: false });
|
|
188
|
+
if (!hookResult.ok) {
|
|
189
|
+
console.log(` License saved, but I couldn't install the gates: ${hookResult.error}`);
|
|
190
|
+
console.log(" Run 'aria install-hooks' manually when you're ready.");
|
|
191
|
+
} else {
|
|
192
|
+
console.log(` Done. I've installed ${hookResult.installed.length} gate(s) into ~/.claude/hooks and merged them into your settings.json.`);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
rl.close();
|
|
196
|
+
|
|
197
|
+
console.log('');
|
|
198
|
+
console.log(` You're set up. License jti: ${response.license.jti}, tier: ${tier}, provider: ${provider}.`);
|
|
199
|
+
console.log(" Open a fresh Claude Code session — every Bash, Edit, Write, and Stop event now runs through my cognition gates.");
|
|
200
|
+
console.log(" Or talk to me directly: just type 'aria' again.");
|
|
201
|
+
console.log('');
|
|
202
|
+
|
|
203
|
+
return { ok: true };
|
|
204
|
+
} finally {
|
|
205
|
+
rl.close();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function defaultModelFor(provider: Provider): string {
|
|
210
|
+
const defaults: Record<Provider, string> = {
|
|
211
|
+
anthropic: 'claude-sonnet-4-20250514',
|
|
212
|
+
openai: 'gpt-4o',
|
|
213
|
+
deepseek: 'deepseek-chat',
|
|
214
|
+
google: 'gemini-2.5-pro-preview-05-06',
|
|
215
|
+
openrouter: 'openai/gpt-4o',
|
|
216
|
+
ollama: 'llama3',
|
|
217
|
+
};
|
|
218
|
+
return defaults[provider];
|
|
219
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// self-update — checks the npm registry for newer @aria_asi/cli versions
|
|
2
|
+
// and surfaces a one-line notice. Doesn't auto-install (clients control
|
|
3
|
+
// their tooling); just informs.
|
|
4
|
+
//
|
|
5
|
+
// Direction: Hamza 2026-04-26 — "does the package auto update when we
|
|
6
|
+
// enhance it on our enhance? so we can continually improve all night using
|
|
7
|
+
// arias background work we should imrpove to do that once we finish".
|
|
8
|
+
// This is the primitive that closes the overnight-improvement loop:
|
|
9
|
+
// Aria publishes 0.2.5 / 0.3.0 / etc.; clients see the notice on their
|
|
10
|
+
// next `aria <cmd>` invocation; they upgrade with one command.
|
|
11
|
+
//
|
|
12
|
+
// Doctrine bindings:
|
|
13
|
+
// - feedback_no_demos.md — real overnight evolution, real registry check
|
|
14
|
+
// - feedback_no_timeouts_doctrine.md — fetch with no AbortSignal.timeout;
|
|
15
|
+
// use bare try/catch, real-error-driven backpressure
|
|
16
|
+
// - project_phase_10_endless_army_orchestration.md — continuous shipping
|
|
17
|
+
// is the Phase 10 north star; self-update is its delivery mechanism
|
|
18
|
+
//
|
|
19
|
+
// Behavior:
|
|
20
|
+
// - Reads installed version from package.json (resolved via import.meta.url
|
|
21
|
+
// walk to find the package root)
|
|
22
|
+
// - Reads ~/.aria/last-update-check timestamp; if checked <24h ago, skip
|
|
23
|
+
// - GET registry.npmjs.org/@aria_asi/cli for latest dist-tag
|
|
24
|
+
// - semver-compare; if newer, return {updateAvailable: true, latest, current, message}
|
|
25
|
+
// - Write timestamp on every check (success or skip-due-to-rate-limit)
|
|
26
|
+
// - Failures are silent (network down, registry blip — never block CLI startup)
|
|
27
|
+
//
|
|
28
|
+
// Privacy: the request to npmjs only sends User-Agent (npm registry public).
|
|
29
|
+
// No client identity, no telemetry beyond what the public registry already logs.
|
|
30
|
+
|
|
31
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
32
|
+
import { homedir } from 'node:os';
|
|
33
|
+
import { join, dirname, resolve } from 'node:path';
|
|
34
|
+
import { fileURLToPath } from 'node:url';
|
|
35
|
+
|
|
36
|
+
const ARIA_DIR = join(homedir(), '.aria');
|
|
37
|
+
const LAST_CHECK_PATH = join(ARIA_DIR, 'last-update-check');
|
|
38
|
+
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h
|
|
39
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/@aria_asi%2Fcli';
|
|
40
|
+
|
|
41
|
+
export interface UpdateCheckResult {
|
|
42
|
+
ok: boolean;
|
|
43
|
+
current?: string;
|
|
44
|
+
latest?: string;
|
|
45
|
+
updateAvailable?: boolean;
|
|
46
|
+
message?: string;
|
|
47
|
+
reason?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Find the package's own version by walking up from this file's runtime
|
|
52
|
+
* path until we hit the package.json with name @aria_asi/cli.
|
|
53
|
+
*/
|
|
54
|
+
function findInstalledVersion(): string | null {
|
|
55
|
+
try {
|
|
56
|
+
const here = fileURLToPath(import.meta.url);
|
|
57
|
+
let cur = dirname(here);
|
|
58
|
+
for (let i = 0; i < 8; i++) {
|
|
59
|
+
const pkgPath = join(cur, 'package.json');
|
|
60
|
+
if (existsSync(pkgPath)) {
|
|
61
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
62
|
+
if (pkg.name === '@aria_asi/cli' && typeof pkg.version === 'string') {
|
|
63
|
+
return pkg.version;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const parent = dirname(cur);
|
|
67
|
+
if (parent === cur) break;
|
|
68
|
+
cur = parent;
|
|
69
|
+
}
|
|
70
|
+
} catch {/* fall through */}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Compare semver strings — returns 1 if a>b, -1 if a<b, 0 if equal.
|
|
76
|
+
* Pre-release suffixes (e.g. 0.2.4-beta) sort lower than the same base.
|
|
77
|
+
*/
|
|
78
|
+
function compareSemver(a: string, b: string): number {
|
|
79
|
+
const parse = (s: string): { nums: number[]; pre: string } => {
|
|
80
|
+
const [main, ...preParts] = s.split('-');
|
|
81
|
+
const nums = main.split('.').map((n) => parseInt(n, 10));
|
|
82
|
+
while (nums.length < 3) nums.push(0);
|
|
83
|
+
return { nums, pre: preParts.join('-') };
|
|
84
|
+
};
|
|
85
|
+
const pa = parse(a);
|
|
86
|
+
const pb = parse(b);
|
|
87
|
+
for (let i = 0; i < 3; i++) {
|
|
88
|
+
if (pa.nums[i] > pb.nums[i]) return 1;
|
|
89
|
+
if (pa.nums[i] < pb.nums[i]) return -1;
|
|
90
|
+
}
|
|
91
|
+
// Equal numerics — pre-release sorts before release
|
|
92
|
+
if (pa.pre && !pb.pre) return -1;
|
|
93
|
+
if (!pa.pre && pb.pre) return 1;
|
|
94
|
+
return pa.pre.localeCompare(pb.pre);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check the registry for the latest @aria_asi/cli version. Rate-limited
|
|
99
|
+
* to once per CHECK_INTERVAL_MS (24h) via a timestamp file in ~/.aria.
|
|
100
|
+
* Caller can pass force=true to bypass the rate limit (used by `aria check-update`).
|
|
101
|
+
*/
|
|
102
|
+
export async function checkForUpdate(opts: { force?: boolean } = {}): Promise<UpdateCheckResult> {
|
|
103
|
+
const current = findInstalledVersion();
|
|
104
|
+
if (!current) {
|
|
105
|
+
return { ok: false, reason: 'could not resolve installed version' };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Rate-limit check
|
|
109
|
+
if (!opts.force && existsSync(LAST_CHECK_PATH)) {
|
|
110
|
+
try {
|
|
111
|
+
const ts = parseInt(readFileSync(LAST_CHECK_PATH, 'utf-8').trim(), 10);
|
|
112
|
+
if (!isNaN(ts) && Date.now() - ts < CHECK_INTERVAL_MS) {
|
|
113
|
+
return { ok: true, current, reason: 'rate-limited (checked within last 24h)' };
|
|
114
|
+
}
|
|
115
|
+
} catch {/* malformed timestamp — re-check */}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Fetch registry
|
|
119
|
+
let latest: string;
|
|
120
|
+
try {
|
|
121
|
+
const resp = await fetch(REGISTRY_URL, {
|
|
122
|
+
headers: { 'Accept': 'application/json' },
|
|
123
|
+
});
|
|
124
|
+
if (!resp.ok) {
|
|
125
|
+
// Don't update timestamp on failure — retry next invocation
|
|
126
|
+
return { ok: false, current, reason: `registry returned ${resp.status}` };
|
|
127
|
+
}
|
|
128
|
+
const data = await resp.json() as { 'dist-tags'?: Record<string, string> };
|
|
129
|
+
latest = data['dist-tags']?.latest ?? '';
|
|
130
|
+
if (!latest) {
|
|
131
|
+
return { ok: false, current, reason: 'registry response missing dist-tags.latest' };
|
|
132
|
+
}
|
|
133
|
+
} catch (err) {
|
|
134
|
+
return { ok: false, current, reason: `network error: ${(err as Error).message}` };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Update timestamp on successful check
|
|
138
|
+
try {
|
|
139
|
+
if (!existsSync(ARIA_DIR)) mkdirSync(ARIA_DIR, { recursive: true, mode: 0o700 });
|
|
140
|
+
writeFileSync(LAST_CHECK_PATH, String(Date.now()) + '\n', { mode: 0o600 });
|
|
141
|
+
} catch {/* timestamp write is best-effort */}
|
|
142
|
+
|
|
143
|
+
const cmp = compareSemver(latest, current);
|
|
144
|
+
if (cmp <= 0) {
|
|
145
|
+
return { ok: true, current, latest, updateAvailable: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
current,
|
|
151
|
+
latest,
|
|
152
|
+
updateAvailable: true,
|
|
153
|
+
message: `I have an update: v${latest} (you're on v${current}). Run 'npm update -g @aria_asi/cli' when you have a minute.`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Convenience: check + print the notice if available. Non-blocking, silent
|
|
159
|
+
* on errors. Called from bin/aria.js on startup.
|
|
160
|
+
*/
|
|
161
|
+
export async function maybePrintUpdateNotice(): Promise<void> {
|
|
162
|
+
try {
|
|
163
|
+
const result = await checkForUpdate();
|
|
164
|
+
if (result.ok && result.updateAvailable && result.message) {
|
|
165
|
+
// Print to stderr so it doesn't pollute stdout-piped CLI output
|
|
166
|
+
process.stderr.write(` ${result.message}\n`);
|
|
167
|
+
}
|
|
168
|
+
} catch {/* never block startup */}
|
|
169
|
+
}
|