@nanhara/hara 0.89.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,7 @@ import { homedir, hostname, platform } from "node:os";
13
13
  import { join } from "node:path";
14
14
  import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
15
15
  import { orgRolesDir } from "../org/roles.js";
16
+ import { loadActiveProfile } from "../profile/profile.js";
16
17
  const orgPath = () => join(homedir(), ".hara", "org.json");
17
18
  const deviceInfo = () => ({ name: hostname(), os: platform(), hara_version: process.env.HARA_BUILD_VERSION ?? "dev" });
18
19
  /** The effective OpenAI-compatible base URL for an enrollment (explicit, else <gatewayUrl>/v1). */
@@ -20,16 +21,38 @@ export function gatewayBaseURL(e) {
20
21
  return e.baseURL || `${e.gatewayUrl.replace(/\/$/, "")}/v1`;
21
22
  }
22
23
  export function loadEnrollment() {
24
+ // 1) Legacy storage (~/.hara/org.json) for back-compat with pre-profile builds. After the
25
+ // profile migration runs (lazily on any profile.ts read), org.json is renamed to .legacy
26
+ // so this branch only fires for users who never touched the new profile layer yet.
23
27
  const p = orgPath();
24
- if (!existsSync(p))
25
- return null;
28
+ if (existsSync(p)) {
29
+ try {
30
+ const e = JSON.parse(readFileSync(p, "utf8"));
31
+ if (e && typeof e === "object" && e.gatewayUrl && e.deviceToken)
32
+ return e;
33
+ }
34
+ catch {
35
+ /* fall through to profile-derived */
36
+ }
37
+ }
38
+ // 2) Active-profile path. profile.ts doesn't import enroll.ts so this static import is safe.
26
39
  try {
27
- const e = JSON.parse(readFileSync(p, "utf8"));
28
- return e && typeof e === "object" && e.gatewayUrl && e.deviceToken ? e : null;
40
+ const ap = loadActiveProfile();
41
+ if (ap.kind === "gateway" && ap.gatewayUrl && ap.deviceToken) {
42
+ return {
43
+ gatewayUrl: ap.gatewayUrl,
44
+ deviceToken: ap.deviceToken,
45
+ deviceId: ap.deviceId || "",
46
+ model: ap.defaultModel || "",
47
+ baseURL: ap.baseURL,
48
+ enrolledAt: ap.enrolledAt || new Date().toISOString(),
49
+ };
50
+ }
29
51
  }
30
52
  catch {
31
- return null;
53
+ /* not yet migrated */
32
54
  }
55
+ return null;
33
56
  }
34
57
  function saveEnrollment(e) {
35
58
  mkdirSync(join(homedir(), ".hara"), { recursive: true });
@@ -2,7 +2,7 @@
2
2
  // runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
3
3
  // index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
4
4
  // .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
5
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync } from "node:fs";
5
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
6
6
  import { join, resolve, isAbsolute } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { execFileSync } from "node:child_process";
@@ -10,6 +10,52 @@ import { readRawConfig } from "../config.js";
10
10
  export function pluginsDir() {
11
11
  return join(homedir(), ".hara", "plugins");
12
12
  }
13
+ /** Where plugin-contributed CLI commands are symlinked. Add to PATH to use them (e.g. `export PATH="$HOME/.hara/bin:$PATH"`). */
14
+ export function haraBinDir() {
15
+ return join(homedir(), ".hara", "bin");
16
+ }
17
+ /** Symlink a plugin's `bin` entries into ~/.hara/bin (chmod +x the targets). Returns the command names linked. */
18
+ function linkPluginBins(root, manifest) {
19
+ if (!manifest.bin)
20
+ return [];
21
+ const dir = haraBinDir();
22
+ mkdirSync(dir, { recursive: true });
23
+ const linked = [];
24
+ for (const [name, rel] of Object.entries(manifest.bin)) {
25
+ const target = join(root, rel);
26
+ if (!existsSync(target))
27
+ continue;
28
+ try {
29
+ chmodSync(target, 0o755);
30
+ }
31
+ catch {
32
+ /* best-effort */
33
+ }
34
+ const link = join(dir, name);
35
+ try {
36
+ rmSync(link, { force: true });
37
+ symlinkSync(target, link);
38
+ linked.push(name);
39
+ }
40
+ catch {
41
+ /* skip a bin we can't link */
42
+ }
43
+ }
44
+ return linked;
45
+ }
46
+ /** Remove a plugin's linked bins (on uninstall). */
47
+ function unlinkPluginBins(manifest) {
48
+ if (!manifest?.bin)
49
+ return;
50
+ for (const name of Object.keys(manifest.bin)) {
51
+ try {
52
+ rmSync(join(haraBinDir(), name), { force: true });
53
+ }
54
+ catch {
55
+ /* ignore */
56
+ }
57
+ }
58
+ }
13
59
  const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
14
60
  function readManifest(root) {
15
61
  for (const rel of MANIFEST_PATHS) {
@@ -112,6 +158,7 @@ export function installPlugin(source) {
112
158
  // move tmp → dest (rename within the same dir)
113
159
  cpSync(tmp, dest, { recursive: true });
114
160
  rmSync(tmp, { recursive: true, force: true });
161
+ linkPluginBins(dest, manifest); // expose any plugin CLI commands in ~/.hara/bin
115
162
  return { name: manifest.name, version: manifest.version || "0.0.0", root: dest, manifest };
116
163
  }
117
164
  catch (e) {
@@ -123,6 +170,7 @@ export function uninstallPlugin(name) {
123
170
  const dest = join(pluginsDir(), name);
124
171
  if (!existsSync(dest))
125
172
  return false;
173
+ unlinkPluginBins(readManifest(dest)); // remove any linked CLI commands first
126
174
  rmSync(dest, { recursive: true, force: true });
127
175
  return true;
128
176
  }
@@ -0,0 +1,436 @@
1
+ // ────────────────────────────────────────────────────────────────────────────────
2
+ // Profile = identity layer for hara (Personal ↔ Org A ↔ Org B). Switching a profile
3
+ // connects through to *every* downstream decision: provider (BYOK direct vs gateway),
4
+ // API key / device token, base URL, **default model** the gateway / setup chose, and
5
+ // the user's model override within that profile. Plus presentation: a `kind` badge
6
+ // ("ORG" vs "PERSONAL"), a label, a routing display.
7
+ //
8
+ // Single source of truth at runtime. `~/.hara/profiles.json` (0600) stores the *list*
9
+ // of profiles + which one is `active`. The legacy `~/.hara/config.json` keeps acting
10
+ // as the storage for the "personal" profile so existing users with only a config.json
11
+ // don't have to migrate anything (their config.json IS their personal profile).
12
+ //
13
+ // Migration (run lazily on first read):
14
+ // • config.json exists, no profiles.json → personal profile is config.json itself,
15
+ // profiles.json is created with active=personal.
16
+ // • org.json exists (legacy enrolled) → injected as a `default-org` gateway profile,
17
+ // active is set to it (the user IS using a gateway
18
+ // right now — preserve that), org.json renamed
19
+ // `.legacy` so we never re-migrate.
20
+ // • Both exist → both become profiles; active = default-org (the gateway, since that's the
21
+ // live routing today).
22
+ //
23
+ // Idempotent: re-running the migration after it's done is a no-op.
24
+ //
25
+ // Provider resolution (in src/index.ts buildProvider):
26
+ // profile.kind === 'gateway' → OpenAI-compatible w/ deviceToken + (baseURL || gatewayUrl+'/v1')
27
+ // profile.kind === 'byok' → existing anthropic / qwen / openai / qwen-oauth dispatch
28
+ //
29
+ // The `hara-gateway` ProviderId enum value is retired from new writes — buildProvider still
30
+ // tolerates reading it from a legacy config.json (it just maps to the migrated gateway profile).
31
+ // ────────────────────────────────────────────────────────────────────────────────
32
+ import { homedir } from "node:os";
33
+ import { join, dirname, parse as parsePath, resolve as resolvePath } from "node:path";
34
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, renameSync, unlinkSync } from "node:fs";
35
+ const PERSONAL_ID = "personal";
36
+ const DEFAULT_ORG_ID = "default-org";
37
+ function haraDir() {
38
+ return join(homedir(), ".hara");
39
+ }
40
+ function profilesPath() {
41
+ return join(haraDir(), "profiles.json");
42
+ }
43
+ function configPath() {
44
+ return join(haraDir(), "config.json");
45
+ }
46
+ function orgPath() {
47
+ return join(haraDir(), "org.json");
48
+ }
49
+ function readJSON(p) {
50
+ if (!existsSync(p))
51
+ return null;
52
+ try {
53
+ return JSON.parse(readFileSync(p, "utf8"));
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ }
59
+ /** Write the profiles file 0600 (it can hold device tokens / api keys). */
60
+ function persistProfilesFile(f) {
61
+ const p = profilesPath();
62
+ mkdirSync(dirname(p), { recursive: true });
63
+ writeFileSync(p, JSON.stringify(f, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
64
+ try {
65
+ chmodSync(p, 0o600);
66
+ }
67
+ catch {
68
+ /* best-effort */
69
+ }
70
+ }
71
+ /** Synthesize the "personal" profile view from the legacy config.json. The config.json itself
72
+ * stays the *storage* — this just presents it as a Profile object. */
73
+ function readPersonalFromConfig() {
74
+ const cfg = readJSON(configPath()) ?? {};
75
+ // A legacy user that ran `hara enroll` had their provider written as "hara-gateway" in config.json.
76
+ // After migration that case is handled separately (default-org profile), so when synthesizing the
77
+ // personal profile we coerce a stray "hara-gateway" provider to anthropic (the BYOK default) — the
78
+ // user can always fix it with `hara setup`.
79
+ const rawProvider = cfg.provider;
80
+ const provider = rawProvider && rawProvider !== "hara-gateway" ? rawProvider : "anthropic";
81
+ return {
82
+ id: PERSONAL_ID,
83
+ kind: "byok",
84
+ label: "Personal",
85
+ provider,
86
+ apiKey: cfg.apiKey,
87
+ baseURL: cfg.baseURL,
88
+ defaultModel: cfg.model,
89
+ // No per-profile override yet for the personal slot — `model` (override) and `defaultModel`
90
+ // come from the same field in config.json. `hara model use X` writes `model` to config.json,
91
+ // `hara model reset` clears it. Conceptually one slot, but the rest of the codebase only ever
92
+ // reads "effective model" so this is fine.
93
+ };
94
+ }
95
+ /** Synthesize a `default-org` profile from the legacy org.json (Enrollment). */
96
+ function readDefaultOrgFromOrgJson() {
97
+ const e = readJSON(orgPath());
98
+ if (!e || !e.gatewayUrl || !e.deviceToken)
99
+ return null;
100
+ const defaultModel = e.model || "";
101
+ return {
102
+ id: DEFAULT_ORG_ID,
103
+ kind: "gateway",
104
+ label: "Default Org",
105
+ gatewayUrl: e.gatewayUrl,
106
+ deviceId: e.deviceId || "",
107
+ deviceToken: e.deviceToken,
108
+ baseURL: e.baseURL,
109
+ defaultModel,
110
+ availableModels: defaultModel ? [defaultModel] : [],
111
+ enrolledAt: e.enrolledAt || new Date().toISOString(),
112
+ };
113
+ }
114
+ /** First-time migration. Idempotent — running again is a no-op (profiles.json already present). */
115
+ function maybeMigrate() {
116
+ const existing = readJSON(profilesPath());
117
+ if (existing && Array.isArray(existing.profiles) && existing.profiles.length > 0)
118
+ return existing;
119
+ const personal = readPersonalFromConfig();
120
+ const org = readDefaultOrgFromOrgJson();
121
+ const profiles = [personal];
122
+ let active = PERSONAL_ID;
123
+ if (org) {
124
+ profiles.push(org);
125
+ active = DEFAULT_ORG_ID; // legacy enrolled user IS using the gateway right now — preserve
126
+ }
127
+ const f = { active, profiles };
128
+ persistProfilesFile(f);
129
+ // Park org.json so we never re-migrate. We keep the file (don't delete data), just rename.
130
+ if (org && existsSync(orgPath())) {
131
+ try {
132
+ renameSync(orgPath(), orgPath() + ".legacy");
133
+ }
134
+ catch {
135
+ /* best-effort */
136
+ }
137
+ }
138
+ return f;
139
+ }
140
+ export function listProfiles() {
141
+ return maybeMigrate().profiles;
142
+ }
143
+ // ────────────────────────────────────────────────────────────────────────────────
144
+ // `.hara-profile` project pin — like .nvmrc, but personal identity rather than
145
+ // runtime version, so we keep it out of repos by default (the printed hint nudges
146
+ // the user to add it to their *global* gitignore — see `profile pin`). Lookup is
147
+ // "walk up from startDir until we hit a `.hara-profile`, fs root, or home". The
148
+ // walk stops at $HOME to prevent a stray ~/.hara-profile from silently overriding
149
+ // the global default (~/.hara/profiles.json `active`).
150
+ // ────────────────────────────────────────────────────────────────────────────────
151
+ const PIN_FILE = ".hara-profile";
152
+ export function pinFilePath(dir) {
153
+ return join(dir, PIN_FILE);
154
+ }
155
+ /** Walk up from `startDir` looking for `.hara-profile`; return the first hit whose
156
+ * contents name a real profile. Returns `{ id, file }` (absolute file path) or null.
157
+ * If a pin file exists but names an unknown profile, we emit a one-line stderr warn
158
+ * and return null (non-fatal — the active resolution falls through to the next layer). */
159
+ export function findPinnedProfile(startDir) {
160
+ const home = homedir();
161
+ const { root } = parsePath(startDir);
162
+ let dir = resolvePath(startDir);
163
+ // Track visited to defend against pathological symlink loops (best-effort).
164
+ const seen = new Set();
165
+ while (!seen.has(dir)) {
166
+ seen.add(dir);
167
+ const file = pinFilePath(dir);
168
+ if (existsSync(file)) {
169
+ try {
170
+ const id = readFileSync(file, "utf8").split(/\r?\n/)[0].trim();
171
+ if (id && getProfile(id))
172
+ return { id, file };
173
+ if (id) {
174
+ // pin points to a profile that no longer exists — warn once, fall through.
175
+ try {
176
+ process.stderr.write(`hara: ${file} pins profile '${id}', but it doesn't exist — falling back. Run \`hara profile pin <id>\` or \`hara profile unpin\` to fix.\n`);
177
+ }
178
+ catch {
179
+ /* ignore */
180
+ }
181
+ }
182
+ }
183
+ catch {
184
+ /* unreadable pin — skip */
185
+ }
186
+ }
187
+ // Stop walking once we're at $HOME or the fs root (don't escape into shared parent dirs).
188
+ if (dir === home || dir === root)
189
+ return null;
190
+ const parent = dirname(dir);
191
+ if (!parent || parent === dir)
192
+ return null;
193
+ dir = parent;
194
+ }
195
+ return null;
196
+ }
197
+ /** Write `.hara-profile` in the given dir with `id` as the only line. */
198
+ export function writePin(dir, id) {
199
+ if (!getProfile(id))
200
+ throw new Error(`no profile '${id}' — list with \`hara profile list\``);
201
+ const file = pinFilePath(dir);
202
+ writeFileSync(file, id + "\n", "utf8");
203
+ return { file };
204
+ }
205
+ /** Remove `.hara-profile` from the given dir. Returns true if it was there. */
206
+ export function removePin(dir) {
207
+ const file = pinFilePath(dir);
208
+ if (!existsSync(file))
209
+ return false;
210
+ try {
211
+ unlinkSync(file);
212
+ return true;
213
+ }
214
+ catch {
215
+ return false;
216
+ }
217
+ }
218
+ let _flagProfile = null;
219
+ /** Set by the top-level `--profile <id>` flag handler. Cleared between processes
220
+ * (we never persist this — it's a one-shot override). Pass null to clear. */
221
+ export function setFlagOverride(id) {
222
+ _flagProfile = id && id.trim() ? id.trim() : null;
223
+ }
224
+ export function getFlagOverride() {
225
+ return _flagProfile;
226
+ }
227
+ export function resolveActive(cwd = process.cwd()) {
228
+ // 1. CLI flag (one-shot).
229
+ if (_flagProfile && getProfile(_flagProfile))
230
+ return { id: _flagProfile, source: "flag" };
231
+ // 2. env (also one-shot — scripts / cron).
232
+ const env = process.env.HARA_PROFILE;
233
+ if (env && getProfile(env))
234
+ return { id: env, source: "env" };
235
+ // 3. project pin — walk up from cwd to home/root.
236
+ const pin = findPinnedProfile(cwd);
237
+ if (pin)
238
+ return { id: pin.id, source: "pin", pinFile: pin.file };
239
+ // 4. global default.
240
+ const f = maybeMigrate();
241
+ if (f.active && getProfile(f.active))
242
+ return { id: f.active, source: "default" };
243
+ // 5. ultimate fallback — personal always exists (migration guarantees it).
244
+ return { id: PERSONAL_ID, source: "fallback" };
245
+ }
246
+ /** Thin wrapper for the (many) call sites that just need "which profile am I as". */
247
+ export function activeId() {
248
+ return resolveActive().id;
249
+ }
250
+ export function getProfile(id) {
251
+ return maybeMigrate().profiles.find((p) => p.id === id);
252
+ }
253
+ /** The effective, runtime view of the active profile. For id==='personal' we re-read from
254
+ * config.json on each call so external edits (config set / setup) are picked up live. */
255
+ export function loadActiveProfile() {
256
+ const id = activeId();
257
+ const f = maybeMigrate();
258
+ if (id === PERSONAL_ID) {
259
+ // Always re-sync personal from config.json (the storage of record). Other profile fields in
260
+ // profiles.json for "personal" are presentation only (label).
261
+ const p = readPersonalFromConfig();
262
+ const stored = f.profiles.find((x) => x.id === PERSONAL_ID);
263
+ if (stored?.label)
264
+ p.label = stored.label;
265
+ return p;
266
+ }
267
+ const p = f.profiles.find((x) => x.id === id);
268
+ if (p)
269
+ return p;
270
+ // Active points to nothing → degrade to personal silently and persist.
271
+ const personal = readPersonalFromConfig();
272
+ return personal;
273
+ }
274
+ export function useProfile(id) {
275
+ const f = maybeMigrate();
276
+ const p = f.profiles.find((x) => x.id === id);
277
+ if (!p)
278
+ return { ok: false, reason: `no profile '${id}' — try \`hara profile list\`` };
279
+ f.active = id;
280
+ persistProfilesFile(f);
281
+ return { ok: true, profile: p };
282
+ }
283
+ export function addProfile(p) {
284
+ if (!p.id || /[\s/]/.test(p.id))
285
+ return { ok: false, reason: "profile id must be non-empty and contain no whitespace or '/'" };
286
+ const f = maybeMigrate();
287
+ if (f.profiles.some((x) => x.id === p.id))
288
+ return { ok: false, reason: `profile '${p.id}' already exists` };
289
+ if (p.kind === "gateway" && (!p.gatewayUrl || !p.deviceToken))
290
+ return { ok: false, reason: "gateway profile needs gatewayUrl + deviceToken" };
291
+ if (p.kind === "byok" && !p.provider)
292
+ return { ok: false, reason: "byok profile needs a provider" };
293
+ f.profiles.push(p);
294
+ persistProfilesFile(f);
295
+ return { ok: true };
296
+ }
297
+ /** Replace an existing profile (same id) — used by `hara enroll <url> --code` when the
298
+ * default-org profile already exists (re-enrollment / token rotation). */
299
+ export function upsertProfile(p) {
300
+ const f = maybeMigrate();
301
+ const i = f.profiles.findIndex((x) => x.id === p.id);
302
+ if (i >= 0)
303
+ f.profiles[i] = p;
304
+ else
305
+ f.profiles.push(p);
306
+ persistProfilesFile(f);
307
+ }
308
+ export function removeProfile(id) {
309
+ if (id === PERSONAL_ID)
310
+ return { ok: false, reason: "personal is your base profile — switch away with `hara profile use <other>`; it stays." };
311
+ const f = maybeMigrate();
312
+ const i = f.profiles.findIndex((x) => x.id === id);
313
+ if (i < 0)
314
+ return { ok: false, reason: `no profile '${id}' — list with \`hara profile list\`` };
315
+ const removed = f.profiles[i];
316
+ f.profiles.splice(i, 1);
317
+ let activeChanged = false;
318
+ if (f.active === id) {
319
+ f.active = PERSONAL_ID;
320
+ activeChanged = true;
321
+ }
322
+ persistProfilesFile(f);
323
+ return { ok: true, activeChanged, removedKind: removed.kind, removed };
324
+ }
325
+ /** Override the effective model within a profile. For "personal" this writes to config.json
326
+ * (the storage of record). For others it writes to profiles.json. P0: when availableModels
327
+ * is non-empty on a gateway profile we validate the choice is in the set. */
328
+ export function setModel(id, model) {
329
+ if (id === PERSONAL_ID) {
330
+ // delegate to config.ts so this stays single-storage for personal
331
+ return setModelOnPersonal(model);
332
+ }
333
+ const f = maybeMigrate();
334
+ const i = f.profiles.findIndex((x) => x.id === id);
335
+ if (i < 0)
336
+ return { ok: false, reason: `no profile '${id}'` };
337
+ const p = f.profiles[i];
338
+ if (p.kind === "gateway" && p.availableModels && p.availableModels.length > 0 && !p.availableModels.includes(model)) {
339
+ return { ok: false, reason: `'${model}' not in this profile's availableModels (${p.availableModels.join(", ")})` };
340
+ }
341
+ f.profiles[i] = { ...p, model };
342
+ persistProfilesFile(f);
343
+ return { ok: true };
344
+ }
345
+ /** Clear a per-profile model override (revert to defaultModel). */
346
+ export function resetModel(id) {
347
+ if (id === PERSONAL_ID) {
348
+ // For personal we don't have a distinct override slot — `model` IS the value. Reset means
349
+ // remove the model line from config.json so the provider default kicks in.
350
+ return clearModelOnPersonal();
351
+ }
352
+ const f = maybeMigrate();
353
+ const i = f.profiles.findIndex((x) => x.id === id);
354
+ if (i < 0)
355
+ return { ok: false, reason: `no profile '${id}'` };
356
+ const { model: _drop, ...rest } = f.profiles[i];
357
+ f.profiles[i] = rest;
358
+ persistProfilesFile(f);
359
+ return { ok: true };
360
+ }
361
+ /** The effective model for a profile = override (model) || defaultModel || "" (caller decides default). */
362
+ export function effectiveModel(p) {
363
+ return process.env.HARA_MODEL || p.model || p.defaultModel || "";
364
+ }
365
+ /** Routing display string — the user-visible "where this profile sends requests".
366
+ * Used by `whoami` / `profile list`; the TUI header uses `routeHost` for a tighter,
367
+ * host-only render. */
368
+ export function routingLabel(p) {
369
+ if (p.kind === "gateway") {
370
+ try {
371
+ const host = new URL(p.gatewayUrl || "").host;
372
+ return `${host}${p.deviceId ? " · device " + p.deviceId.slice(-8) : ""}`;
373
+ }
374
+ catch {
375
+ return p.gatewayUrl || "gateway";
376
+ }
377
+ }
378
+ return `${p.provider}${p.baseURL ? " · " + p.baseURL : ""}`;
379
+ }
380
+ /** Host-only routing for the TUI header. Returns the URL host (no scheme, no path) plus
381
+ * `isCustom`: true when the profile carries a non-default baseURL (BYOK) or always-true
382
+ * for gateway profiles. View layer decides whether to show `→ host` (always for org;
383
+ * only when `isCustom` for personal). Returns `null` if there's nothing to display
384
+ * (BYOK on the provider's official endpoint). */
385
+ export function routeHost(p) {
386
+ if (p.kind === "gateway") {
387
+ try {
388
+ return { host: new URL(p.gatewayUrl || "").host, isCustom: true };
389
+ }
390
+ catch {
391
+ return p.gatewayUrl ? { host: p.gatewayUrl, isCustom: true } : null;
392
+ }
393
+ }
394
+ // BYOK: only surface a host when the user pointed at a non-default endpoint.
395
+ if (!p.baseURL)
396
+ return null;
397
+ try {
398
+ return { host: new URL(p.baseURL).host, isCustom: true };
399
+ }
400
+ catch {
401
+ return { host: p.baseURL, isCustom: true };
402
+ }
403
+ }
404
+ // ────────────────────────────────────────────────────────────────────────────────
405
+ // Personal-profile model storage helpers — split out so they can be re-exported via
406
+ // config.ts without circular imports. Implemented inline to avoid pulling config.ts.
407
+ // ────────────────────────────────────────────────────────────────────────────────
408
+ function setModelOnPersonal(model) {
409
+ const p = configPath();
410
+ const cfg = readJSON(p) ?? {};
411
+ cfg.model = model;
412
+ mkdirSync(dirname(p), { recursive: true });
413
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
414
+ try {
415
+ chmodSync(p, 0o600);
416
+ }
417
+ catch {
418
+ /* best-effort */
419
+ }
420
+ return { ok: true };
421
+ }
422
+ function clearModelOnPersonal() {
423
+ const p = configPath();
424
+ const cfg = readJSON(p) ?? {};
425
+ delete cfg.model;
426
+ mkdirSync(dirname(p), { recursive: true });
427
+ writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
428
+ try {
429
+ chmodSync(p, 0o600);
430
+ }
431
+ catch {
432
+ /* best-effort */
433
+ }
434
+ return { ok: true };
435
+ }
436
+ export { PERSONAL_ID, DEFAULT_ORG_ID };
@@ -51,16 +51,43 @@ export function toAnthropic(history) {
51
51
  }
52
52
  return msgs;
53
53
  }
54
+ /** Anthropic models whose only valid `thinking` setting is `{type: "adaptive"}` — they reject any
55
+ * explicit `budget_tokens`. We detect them by id family so "off"/low/high still degrade gracefully
56
+ * (we just omit the field or stay on adaptive instead of sending a 400-triggering body). */
57
+ function isAdaptiveOnly(model) {
58
+ // Known adaptive-only families per Anthropic docs: opus-4-7 / opus-4-8 / claude-fable.
59
+ // Conservative: regex on the family slug so future micro-versions (opus-4-8-20260101) match.
60
+ return /^(claude-)?(opus-4-7|opus-4-8|fable)/i.test(model) || /(opus-4-7|opus-4-8|fable)/i.test(model);
61
+ }
62
+ /** Map hara's reasoningEffort dial to Anthropic's `thinking` parameter (or omit it).
63
+ * Exported for unit testing. */
64
+ export function buildThinkingParam(model, effort) {
65
+ // Unset = preserve hara's prior behavior (adaptive thinking on by default).
66
+ if (effort === undefined)
67
+ return { type: "adaptive" };
68
+ if (effort === "off")
69
+ return undefined; // omit thinking entirely (works on every model, incl. adaptive-only)
70
+ const adaptiveOnly = isAdaptiveOnly(model);
71
+ if (adaptiveOnly)
72
+ return { type: "adaptive" }; // can't honor budget on these — fall back to adaptive instead of 400'ing
73
+ if (effort === "low")
74
+ return { type: "enabled", budget_tokens: 4096 };
75
+ if (effort === "medium")
76
+ return { type: "adaptive" };
77
+ // high
78
+ return { type: "enabled", budget_tokens: 24000 };
79
+ }
54
80
  export function createAnthropicProvider(opts) {
55
81
  const client = new Anthropic({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
56
82
  return {
57
83
  id: "anthropic",
58
84
  model: opts.model,
59
85
  async turn({ system, history, tools, onText, onReasoning, signal }) {
86
+ const thinking = buildThinkingParam(opts.model, opts.reasoningEffort);
60
87
  const stream = client.messages.stream({
61
88
  model: opts.model,
62
89
  max_tokens: 32000,
63
- thinking: { type: "adaptive" },
90
+ ...(thinking ? { thinking } : {}),
64
91
  system,
65
92
  tools: tools,
66
93
  messages: toAnthropic(history),
@@ -45,6 +45,12 @@ export function toOpenAI(system, history) {
45
45
  }
46
46
  return msgs;
47
47
  }
48
+ /** Reasoning models on OpenAI (o-series + gpt-5) accept `reasoning_effort` on chat-completions.
49
+ * Non-reasoning models reject it. We only attach the param when the model id matches a known
50
+ * reasoning family — keeps DeepSeek/GLM/Qwen requests clean. Exported for tests. */
51
+ export function isReasoningModel(model) {
52
+ return /^(o1|o3|o4|gpt-5)/i.test(model);
53
+ }
48
54
  /** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
49
55
  export function createOpenAIProvider(opts) {
50
56
  const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
@@ -65,6 +71,13 @@ export function createOpenAIProvider(opts) {
65
71
  };
66
72
  if (oaiTools.length)
67
73
  params.tools = oaiTools;
74
+ // reasoning_effort: only attach for OpenAI reasoning models, and only when the user picked a
75
+ // non-default level. "off" means "don't ask the model to reason" — for reasoning models we
76
+ // pass "minimal" (gpt-5 / o-series accept it); on chat-style models that put reasoning in
77
+ // the stream (DeepSeek/GLM) we can't silence it server-side, so the UI just won't render.
78
+ if (opts.reasoningEffort && opts.reasoningEffort !== undefined && isReasoningModel(opts.model)) {
79
+ params.reasoning_effort = opts.reasoningEffort === "off" ? "minimal" : opts.reasoningEffort;
80
+ }
68
81
  // Stream: emit text deltas live; accumulate tool-call args by index; grab usage from the tail chunk.
69
82
  let text = "";
70
83
  const acc = new Map();
@@ -80,7 +93,9 @@ export function createOpenAIProvider(opts) {
80
93
  onText(delta.content);
81
94
  }
82
95
  const rc = delta?.reasoning_content ?? delta?.reasoning; // GLM-5 / DeepSeek
83
- if (rc)
96
+ // reasoningEffort="off" + a stream-reasoning model: server can't be silenced, so we just
97
+ // don't surface it. Anything else (incl. undefined) shows the reasoning live.
98
+ if (rc && opts.reasoningEffort !== "off")
84
99
  onReasoning?.(rc);
85
100
  if (delta?.tool_calls) {
86
101
  for (const tc of delta.tool_calls) {