niahere 0.5.6 → 0.5.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "niahere",
3
- "version": "0.5.6",
3
+ "version": "0.5.7",
4
4
  "description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
5
5
  "type": "module",
6
6
  "scripts": {
package/src/agent/auth.ts CHANGED
@@ -2,6 +2,8 @@ import { existsSync, readFileSync } from "fs";
2
2
  import { homedir } from "os";
3
3
  import { join } from "path";
4
4
  import type { ProviderName } from "./models";
5
+ import { resolveClaudeCredential } from "./credentials";
6
+ import { getConfig } from "../utils/config";
5
7
 
6
8
  /**
7
9
  * Provider sign-in state, read from whatever each CLI stores on disk.
@@ -43,6 +45,17 @@ const defaultReader: AuthReader = {
43
45
  env: (k) => process.env[k],
44
46
  };
45
47
 
48
+ /** Config is optional here: a health check must never fail because config is
49
+ * unreadable, it must say so. */
50
+ function credentialConfig(): { anthropic_oauth_token: string | null; anthropic_api_key: string | null } {
51
+ try {
52
+ const c = getConfig();
53
+ return { anthropic_oauth_token: c.anthropic_oauth_token, anthropic_api_key: c.anthropic_api_key };
54
+ } catch {
55
+ return { anthropic_oauth_token: null, anthropic_api_key: null };
56
+ }
57
+ }
58
+
46
59
  export function claudeCredentialsPath(): string {
47
60
  return join(homedir(), ".claude", ".credentials.json");
48
61
  }
@@ -62,8 +75,16 @@ function ago(ms: number): string {
62
75
  export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader = defaultReader): AuthStatus {
63
76
  const base = { provider: "claude" as const };
64
77
 
65
- if (reader.env("ANTHROPIC_API_KEY")) {
66
- return { ...base, state: "ok", detail: "API key (no expiry)" };
78
+ // Ask the same resolver the backend uses. Reading the environment here while
79
+ // the backend also reads config meant the check could report one credential
80
+ // while a different one served the turn — the exact ambiguity this exists to
81
+ // remove.
82
+ const credential = resolveClaudeCredential(credentialConfig(), reader.env);
83
+ if (credential.kind === "oauth_token") {
84
+ return { ...base, state: "ok", detail: "configured oauth token (subscription, no refresh needed)" };
85
+ }
86
+ if (credential.kind === "api_key") {
87
+ return { ...base, state: "ok", detail: "configured API key (metered — billed per token)" };
67
88
  }
68
89
 
69
90
  const path = claudeCredentialsPath();
@@ -97,7 +118,11 @@ export function claudeAuthStatus(now: number = Date.now(), reader: AuthReader =
97
118
  return { ...status, state: "stale", detail: `access token lapsed ${ago(now - access)} ago, renewable${plan}` };
98
119
  }
99
120
  if (access !== undefined) {
100
- return { ...status, state: "ok", detail: `valid for ${ago(access - now)}${plan}` };
121
+ return {
122
+ ...status,
123
+ state: "ok",
124
+ detail: `Claude Code login, valid for ${ago(access - now)}${plan} — renewed only when the CLI is used here`,
125
+ };
101
126
  }
102
127
  return { ...status, state: "unknown", detail: "credentials file carries no expiry" };
103
128
  }
@@ -90,6 +90,7 @@ function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
90
90
  * stay backend-agnostic.
91
91
  */
92
92
  export class SdkNormalizer implements Normalizer {
93
+ private apiKeySource: string | undefined;
93
94
  private accumulatedThinking = "";
94
95
  private lastThinkingLine = "";
95
96
 
@@ -97,6 +98,10 @@ export class SdkNormalizer implements Normalizer {
97
98
  const msg = message as any;
98
99
 
99
100
  if (msg.type === "system" && msg.subtype === "init") {
101
+ // apiKeySource names which credential served the session ('oauth' is
102
+ // Claude Code's own login). Recording it is what makes a silent switch
103
+ // of credential visible after the fact.
104
+ if (typeof msg.apiKeySource === "string") this.apiKeySource = msg.apiKeySource;
100
105
  return [{ type: "session", backendSessionId: msg.session_id }];
101
106
  }
102
107
 
@@ -211,6 +216,7 @@ export class SdkNormalizer implements Normalizer {
211
216
  terminal_reason: msg.terminal_reason,
212
217
  session_id: msg.session_id,
213
218
  subtype: msg.subtype,
219
+ api_key_source: this.apiKeySource,
214
220
  usage: msg.usage,
215
221
  model_usage: attribute(msg.modelUsage),
216
222
  },
@@ -11,6 +11,7 @@ import { MessageStream } from "../message-stream";
11
11
  import { getSdkSkillsSetting } from "../../core/skills";
12
12
  import { getSdkHooks } from "../../core/sdk-hooks";
13
13
  import { getConfig } from "../../utils/config";
14
+ import { resolveClaudeCredential, credentialEnv } from "../credentials";
14
15
  import { sleep } from "../../utils/retry";
15
16
 
16
17
  /** The shape of the SDK `query()` handle the session consumes. Injected so the
@@ -106,6 +107,14 @@ class ClaudeSession implements AgentSession {
106
107
  // same cwd; jobs always run with a unique id and never auto-continued.
107
108
  if (this.ctx.interactive) options.continue = false;
108
109
  }
110
+ // Hand the CLI a credential Nia owns when one is configured. Without this
111
+ // it inherits ~/.claude/.credentials.json, which only refreshes when a
112
+ // human runs `claude` on this machine — the coupling that had Nia
113
+ // answering as codex for sixteen days.
114
+ const credential = resolveClaudeCredential(getConfig());
115
+ if (credential.envVar) {
116
+ options.env = credentialEnv(credential, process.env as Record<string, string>);
117
+ }
109
118
  if (this.ctx.outputSchema) options.outputFormat = { type: "json_schema", schema: this.ctx.outputSchema };
110
119
  if (this.ctx.mcpServers) options.mcpServers = this.ctx.mcpServers;
111
120
  if (this.ctx.subagents && Object.keys(this.ctx.subagents).length > 0) options.agents = this.ctx.subagents;
@@ -0,0 +1,77 @@
1
+ import type { Config } from "../types/config";
2
+
3
+ /**
4
+ * Which Claude credential the daemon should use, and where it came from.
5
+ *
6
+ * Nia used to have no answer to this: it inherited whatever `claude` had last
7
+ * written to `~/.claude/.credentials.json`, refreshed by a human opening a
8
+ * terminal on the same machine. When that stopped, Nia answered as codex for
9
+ * sixteen days and nothing said why. A credential the daemon is handed
10
+ * explicitly is one it can report on, and one that does not lapse because
11
+ * nobody logged in today.
12
+ */
13
+
14
+ export type CredentialKind = "oauth_token" | "api_key" | "claude_code_login";
15
+
16
+ export interface ClaudeCredential {
17
+ kind: CredentialKind;
18
+ /** The variable the CLI reads it from. Absent for the inherited login. */
19
+ envVar?: "CLAUDE_CODE_OAUTH_TOKEN" | "ANTHROPIC_API_KEY";
20
+ value?: string;
21
+ /**
22
+ * `subscription` rides the plan. `metered` bills per token — worth saying out
23
+ * loud, because Nia has run $552 in a week and that is an invoice on the API.
24
+ */
25
+ billing: "subscription" | "metered";
26
+ }
27
+
28
+ /** Every variable a credential could occupy, so switching never leaves a stale one behind. */
29
+ const TOKEN_VARS = ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"] as const;
30
+
31
+ const clean = (v: unknown): string | undefined => {
32
+ const s = typeof v === "string" ? v.trim() : "";
33
+ return s.length > 0 ? s : undefined;
34
+ };
35
+
36
+ export type EnvLookup = (key: string) => string | undefined;
37
+
38
+ /**
39
+ * Config first, then the ambient environment, then Claude Code's own login.
40
+ * Config wins so the daemon is not at the mercy of whatever shell launched it.
41
+ */
42
+ export function resolveClaudeCredential(
43
+ config: Pick<Config, "anthropic_oauth_token" | "anthropic_api_key">,
44
+ env: EnvLookup = (k) => process.env[k],
45
+ ): ClaudeCredential {
46
+ const oauth = clean(config.anthropic_oauth_token) ?? clean(env("CLAUDE_CODE_OAUTH_TOKEN"));
47
+ if (oauth) {
48
+ return { kind: "oauth_token", envVar: "CLAUDE_CODE_OAUTH_TOKEN", value: oauth, billing: "subscription" };
49
+ }
50
+ const key = clean(config.anthropic_api_key) ?? clean(env("ANTHROPIC_API_KEY"));
51
+ if (key) {
52
+ return { kind: "api_key", envVar: "ANTHROPIC_API_KEY", value: key, billing: "metered" };
53
+ }
54
+ return { kind: "claude_code_login", billing: "subscription" };
55
+ }
56
+
57
+ /**
58
+ * The environment for the spawned CLI. The base is passed through — it still
59
+ * needs PATH and HOME — with exactly one credential variable set, and any other
60
+ * cleared so a removed credential stops working immediately rather than at the
61
+ * next restart.
62
+ */
63
+ export function credentialEnv(credential: ClaudeCredential, base: Record<string, string>): Record<string, string> {
64
+ const env: Record<string, string> = {};
65
+ for (const [k, v] of Object.entries(base)) {
66
+ if ((TOKEN_VARS as readonly string[]).includes(k)) continue;
67
+ env[k] = v;
68
+ }
69
+ if (credential.envVar && credential.value) env[credential.envVar] = credential.value;
70
+ return env;
71
+ }
72
+
73
+ export function describeCredential(credential: ClaudeCredential): string {
74
+ if (credential.kind === "oauth_token") return "configured oauth token (subscription, long-lived)";
75
+ if (credential.kind === "api_key") return "configured API key (metered, billed per token)";
76
+ return "Claude Code's own login (refreshed by whoever last used the CLI here)";
77
+ }
@@ -112,6 +112,11 @@ export interface Config {
112
112
  activeHours: { start: string; end: string };
113
113
  database_url: string;
114
114
  log_level: string;
115
+ /** Long-lived token from `claude setup-token`. Preferred: it keeps the
116
+ * subscription and does not depend on anyone logging in on this machine. */
117
+ anthropic_oauth_token: string | null;
118
+ /** Metered per-token billing — a different bill from the subscription. */
119
+ anthropic_api_key: string | null;
115
120
  gemini_api_key: string | null;
116
121
  sessionFinalization: SessionFinalizationConfig;
117
122
  channels: ChannelsConfig;
@@ -15,6 +15,8 @@ const DEFAULTS: Config = {
15
15
  activeHours: { start: "00:00", end: "23:59" },
16
16
  database_url: DEFAULT_DATABASE_URL,
17
17
  log_level: "info",
18
+ anthropic_oauth_token: null,
19
+ anthropic_api_key: null,
18
20
  gemini_api_key: null,
19
21
  sessionFinalization: {
20
22
  enabled: true,
@@ -129,6 +131,11 @@ export function loadConfig(): Config {
129
131
  const log_level = process.env.LOG_LEVEL || (typeof raw.log_level === "string" ? raw.log_level : DEFAULTS.log_level);
130
132
 
131
133
  // Gemini API key — env var overrides config
134
+ const anthropic_oauth_token =
135
+ process.env.CLAUDE_CODE_OAUTH_TOKEN ||
136
+ (typeof raw.anthropic_oauth_token === "string" ? raw.anthropic_oauth_token : null);
137
+ const anthropic_api_key =
138
+ process.env.ANTHROPIC_API_KEY || (typeof raw.anthropic_api_key === "string" ? raw.anthropic_api_key : null);
132
139
  const gemini_api_key =
133
140
  process.env.GEMINI_API_KEY || (typeof raw.gemini_api_key === "string" ? raw.gemini_api_key : null);
134
141
 
@@ -256,6 +263,8 @@ export function loadConfig(): Config {
256
263
  activeHours: { start, end },
257
264
  database_url,
258
265
  log_level,
266
+ anthropic_oauth_token,
267
+ anthropic_api_key,
259
268
  gemini_api_key,
260
269
  sessionFinalization,
261
270
  channels: {