@inetafrica/open-claudia 1.13.4 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## v1.14.0
4
+ - Claude Code auth management from Telegram: `/auth_status`, `/login`, `/setup_token`, `/use_oauth_token`, and `/clear_oauth_token`
5
+ - Claude subprocesses now receive `CLAUDE_CODE_OAUTH_TOKEN` from config/env/vault when available, avoiding launchd/macOS Keychain auth failures
6
+ - Sensitive Claude tokens are redacted from Telegram output and logs
7
+ - Package lock metadata corrected to match `@inetafrica/open-claudia`
8
+
3
9
  ## v1.12.0
4
10
  - Cursor Agent tool progress: Shell, Read, Edit, Write, Grep, Glob calls now show in real-time Telegram updates
5
11
  - Plan output surfaced to Telegram: when Cursor creates a plan (`--mode plan`), the full plan markdown and task list are sent to the user
package/README.md CHANGED
@@ -55,7 +55,7 @@ agent login # Opens browser to authenticate
55
55
  agent status # Verify: should show your email and plan
56
56
  ```
57
57
 
58
- > **Important**: Both CLIs store authentication locally on the machine. Open Claudia doesn't handle auth itself it shells out to whichever CLI you've authenticated. If you see auth errors in Telegram, SSH into the machine and re-authenticate the relevant CLI.
58
+ > **Important**: Claude Code can use macOS Keychain when you log in interactively, but a launchd/background bot may not be able to read that Keychain session. Open Claudia v1.14.0 adds Telegram auth helpers and supports `CLAUDE_CODE_OAUTH_TOKEN` for non-interactive Claude runs. Prefer `/setup_token` then `/use_oauth_token` if Telegram shows Claude auth/keychain errors.
59
59
 
60
60
  ### 2. Install Open Claudia
61
61
 
@@ -141,6 +141,20 @@ When you select a project, the last conversation is automatically resumed. Tap "
141
141
  | `/vault` | Manage encrypted credentials (password required) |
142
142
  | `/soul` | View/edit assistant identity and personality |
143
143
 
144
+ ### Claude Code Auth
145
+
146
+ | Command | Description |
147
+ |---------|-------------|
148
+ | `/auth_status` | Runs `claude auth status` and reports redacted status plus whether the bot has an OAuth token configured |
149
+ | `/login` | Starts `claude auth login --claudeai --email sumeet@inet.africa`, sends the login URL/code, and accepts paste-back codes |
150
+ | `/setup_token` | Starts `claude setup-token`; if Claude prints an OAuth token, Open Claudia stores it without echoing it |
151
+ | `/use_oauth_token <token>` | Stores `CLAUDE_CODE_OAUTH_TOKEN` in config for launchd/non-interactive Claude runs; the Telegram message is deleted when possible |
152
+ | `/use_oauth_token` | Secure pending mode: send the token as the next message and it will be deleted/stored without echo |
153
+ | `/clear_oauth_token` | Removes the stored OAuth token from config/process, and from vault if it is unlocked |
154
+
155
+ Tokens are redacted from Telegram output and logs. If the encrypted vault is unlocked, `/use_oauth_token` also mirrors the token into the vault, but `.env` storage is what lets launchd pass `CLAUDE_CODE_OAUTH_TOKEN` to Claude without relying on macOS Keychain.
156
+
157
+
144
158
  ### System
145
159
 
146
160
  | Command | Description |
package/bot-agent.js CHANGED
@@ -190,6 +190,9 @@ bot.setMyCommands([
190
190
  { command: "cursor", description: "Switch to Cursor Agent backend" },
191
191
  { command: "claude", description: "Switch to Claude Code backend" },
192
192
  { command: "backend", description: "Show/switch active backend" },
193
+ { command: "auth_status", description: "Check Claude Code auth" },
194
+ { command: "login", description: "Start Claude Code login" },
195
+ { command: "setup_token", description: "Create Claude OAuth token" },
193
196
  { command: "stop", description: "Cancel running task" },
194
197
  { command: "end", description: "End current session" },
195
198
  { command: "version", description: "Show current version" },
@@ -298,7 +301,10 @@ let messageQueue = []; // Fallback queue (only used if chat process a
298
301
  let activeCrons = new Map();
299
302
  let pendingVaultUnlock = false;
300
303
  let pendingVaultAction = null;
304
+ let pendingClaudeAuthProcess = null;
305
+ let pendingClaudeAuthLabel = null;
301
306
  let isFirstMessage = !lastSessionId;
307
+ let lastInputWasVoice = false;
302
308
 
303
309
  let settings = savedState.settings || {
304
310
  model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude",
@@ -661,11 +667,305 @@ function transcribeAudio(oggPath) {
661
667
  .join(" ").trim();
662
668
  }
663
669
 
670
+ // ── Text-to-Speech ────────────────────────────────────────────────
671
+
672
+ const TTS_CMD = process.platform === "darwin" ? "say" : null;
673
+
674
+ function textToVoice(text) {
675
+ if (!TTS_CMD || !FFMPEG) return null;
676
+ try {
677
+ const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
678
+ if (!clean) return null;
679
+ const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
680
+ const oggPath = aiffPath.replace(".aiff", ".ogg");
681
+ execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
682
+ execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
683
+ try { fs.unlinkSync(aiffPath); } catch (e) {}
684
+ return oggPath;
685
+ } catch (e) {
686
+ console.error("TTS error:", e.message);
687
+ return null;
688
+ }
689
+ }
690
+
691
+ async function sendVoice(oggPath) {
692
+ try {
693
+ await bot.sendVoice(CHAT_ID, oggPath);
694
+ try { fs.unlinkSync(oggPath); } catch (e) {}
695
+ return true;
696
+ } catch (e) {
697
+ console.error("Send voice error:", e.message);
698
+ try { fs.unlinkSync(oggPath); } catch (e2) {}
699
+ return false;
700
+ }
701
+ }
702
+
664
703
  // Delete a message (used for vault password cleanup)
665
704
  async function deleteMessage(msgId) {
666
705
  try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
667
706
  }
668
707
 
708
+
709
+ // ── Claude Auth Helpers ─────────────────────────────────────────────
710
+
711
+ const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
712
+ const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
713
+
714
+ function redactSensitive(value) {
715
+ return String(value || "")
716
+ .replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
717
+ .replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
718
+ .replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
719
+ .replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
720
+ }
721
+
722
+ function looksLikeClaudeToken(value) {
723
+ const text = String(value || "").trim();
724
+ return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
725
+ }
726
+
727
+ function getClaudeOAuthToken() {
728
+ if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
729
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
730
+ if (vault.isUnlocked()) {
731
+ const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
732
+ if (value) return { value, source: "vault" };
733
+ }
734
+ return { value: null, source: null };
735
+ }
736
+
737
+ function claudeSubprocessEnv() {
738
+ const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
739
+ const token = getClaudeOAuthToken().value;
740
+ if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
741
+ return env;
742
+ }
743
+
744
+ function saveClaudeOAuthToken(token) {
745
+ const clean = String(token || "").trim();
746
+ if (!clean) return false;
747
+ saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
748
+ config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
749
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
750
+ if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
751
+ return true;
752
+ }
753
+
754
+ function clearClaudeOAuthToken() {
755
+ saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
756
+ config[CLAUDE_OAUTH_TOKEN_KEY] = "";
757
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
758
+ if (vault.isUnlocked()) {
759
+ vault.remove(CLAUDE_OAUTH_VAULT_KEY);
760
+ vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
761
+ }
762
+ }
763
+
764
+ function extractClaudeToken(text) {
765
+ const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
766
+ return match ? match[0] : null;
767
+ }
768
+
769
+ function extractUrls(text) {
770
+ return [...String(text || "").matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
771
+ }
772
+
773
+
774
+ function isClaudeAuthErrorText(text) {
775
+ const lower = String(text || "").toLowerCase();
776
+ return lower.includes("unauthorized") ||
777
+ lower.includes("not logged in") ||
778
+ lower.includes("login required") ||
779
+ lower.includes("reauthenticate") ||
780
+ lower.includes("re-authenticate") ||
781
+ lower.includes("authentication failed") ||
782
+ lower.includes("auth failed") ||
783
+ lower.includes("invalid api key") ||
784
+ lower.includes("api key") && lower.includes("invalid") ||
785
+ lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
786
+ lower.includes("security unlock-keychain");
787
+ }
788
+
789
+ function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
790
+ const tokenInfo = getClaudeOAuthToken();
791
+ const tokenLine = tokenInfo.value
792
+ ? `Stored OAuth token: yes (${tokenInfo.source})`
793
+ : "Stored OAuth token: no";
794
+ return [
795
+ `Claude auth needs attention: ${redactSensitive(reason)}`,
796
+ "",
797
+ tokenLine,
798
+ "",
799
+ "Try one of these from Telegram:",
800
+ "1. /auth_status — check what Claude sees",
801
+ "2. /setup_token — create a long-lived Claude Code token, then store it",
802
+ "3. /use_oauth_token — paste the token securely if you already generated one",
803
+ "4. /login — interactive Claude.ai browser login fallback",
804
+ "",
805
+ "If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
806
+ "security unlock-keychain",
807
+ "",
808
+ "Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
809
+ ].join("\n");
810
+ }
811
+
812
+ function preflightClaudeAuthMessage() {
813
+ if (settings.backend === "cursor") return null;
814
+ if (getClaudeOAuthToken().value) return null;
815
+ try {
816
+ const output = execSync(`"${CLAUDE_PATH}" auth status`, {
817
+ cwd: process.env.HOME || require("os").homedir(),
818
+ env: claudeSubprocessEnv(),
819
+ encoding: "utf8",
820
+ timeout: 10000,
821
+ stdio: ["ignore", "pipe", "pipe"],
822
+ });
823
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
824
+ return null;
825
+ } catch (e) {
826
+ const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
827
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
828
+ return null;
829
+ }
830
+ }
831
+
832
+
833
+ function isClaudeUsageLimitText(text) {
834
+ const lower = String(text || "").toLowerCase();
835
+ return lower.includes("usage limit") ||
836
+ lower.includes("you've hit your usage limit") ||
837
+ lower.includes("you have hit your usage limit") ||
838
+ lower.includes("spend limit") ||
839
+ lower.includes("monthly cycle") ||
840
+ lower.includes("rate limit") && lower.includes("model");
841
+ }
842
+
843
+ function claudeUsageLimitMessage(details = "") {
844
+ return [
845
+ "Claude ran, but the selected model is unavailable/limited right now.",
846
+ details ? `\nDetails:\n${redactSensitive(details)}` : "",
847
+ "",
848
+ "Try from Telegram:",
849
+ "1. /model sonnet",
850
+ "2. Then send your message again",
851
+ "",
852
+ "If you specifically need Opus, wait for the usage window to reset or increase the spend/usage limit."
853
+ ].filter(Boolean).join("\n");
854
+ }
855
+
856
+ function runClaudeAuthStatusDiagnostic() {
857
+ try {
858
+ const output = execSync(`"${CLAUDE_PATH}" auth status`, {
859
+ cwd: process.env.HOME || require("os").homedir(),
860
+ env: claudeSubprocessEnv(),
861
+ encoding: "utf8",
862
+ timeout: 10000,
863
+ stdio: ["ignore", "pipe", "pipe"],
864
+ });
865
+ return output.trim();
866
+ } catch (e) {
867
+ return `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim();
868
+ }
869
+ }
870
+
871
+ function claudeEmptyFailureMessage(code, stderrText = "") {
872
+ const stderr = redactSensitive(String(stderrText || "").trim());
873
+ if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
874
+ if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
875
+
876
+ const authStatus = runClaudeAuthStatusDiagnostic();
877
+ if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
878
+ if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
879
+
880
+ return [
881
+ `Claude exited with code ${code} but produced no assistant output.`,
882
+ stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
883
+ authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
884
+ "",
885
+ "Useful next steps:",
886
+ "• /auth_status — verify Claude auth",
887
+ "• /model sonnet — switch away from Opus if usage-limited",
888
+ "• /setup_token — create a launchd-safe OAuth token if Keychain is the issue"
889
+ ].filter(Boolean).join("\n");
890
+ }
891
+
892
+
893
+ function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
894
+ const text = String(output || "");
895
+ const lower = text.toLowerCase();
896
+ const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
897
+ const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
898
+ const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
899
+ const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
900
+ let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
901
+ if (/api key|apikey/.test(lower)) method = "API key";
902
+ else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
903
+ return [
904
+ `Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
905
+ `Auth method: ${method}`,
906
+ `Email: ${email || "unknown"}`,
907
+ `Provider: ${provider}`,
908
+ ];
909
+ }
910
+
911
+ async function runClaudeAuthCommand(args, label, opts = {}) {
912
+ if (pendingClaudeAuthProcess) {
913
+ await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
914
+ return;
915
+ }
916
+ await send(`${label} started...`);
917
+ const proc = spawn(CLAUDE_PATH, args, {
918
+ cwd: process.env.HOME || require("os").homedir(),
919
+ env: claudeSubprocessEnv(),
920
+ stdio: ["pipe", "pipe", "pipe"],
921
+ });
922
+ pendingClaudeAuthProcess = proc;
923
+ pendingClaudeAuthLabel = label;
924
+ let output = "";
925
+ let sentUrls = new Set();
926
+ let tokenStored = false;
927
+ let lastSnippetAt = 0;
928
+
929
+ const handleChunk = async (chunk) => {
930
+ output += chunk;
931
+ const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
932
+ if (token && !tokenStored) {
933
+ tokenStored = saveClaudeOAuthToken(token);
934
+ await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
935
+ }
936
+ for (const url of extractUrls(chunk)) {
937
+ if (!sentUrls.has(url)) {
938
+ sentUrls.add(url);
939
+ await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
940
+ }
941
+ }
942
+ const redacted = redactSensitive(chunk).trim();
943
+ const now = Date.now();
944
+ if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
945
+ lastSnippetAt = now;
946
+ await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
947
+ }
948
+ };
949
+
950
+ proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
951
+ proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
952
+ proc.on("close", async (code) => {
953
+ pendingClaudeAuthProcess = null;
954
+ pendingClaudeAuthLabel = null;
955
+ const token = opts.captureToken ? extractClaudeToken(output) : null;
956
+ if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
957
+ const final = redactSensitive(output.trim());
958
+ if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
959
+ else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
960
+ else await send(`${label} finished (exit ${code}).`);
961
+ });
962
+ proc.on("error", async (err) => {
963
+ pendingClaudeAuthProcess = null;
964
+ pendingClaudeAuthLabel = null;
965
+ await send(`${label} failed: ${redactSensitive(err.message)}`);
966
+ });
967
+ }
968
+
669
969
  // ── Claude Runner ───────────────────────────────────────────────────
670
970
 
671
971
  function parseStreamEvents(data) {
@@ -734,7 +1034,7 @@ async function runClaudeChat(prompt, cwd, replyToMsgId) {
734
1034
 
735
1035
  const proc = spawn(CLAUDE_PATH, args, {
736
1036
  cwd,
737
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1037
+ env: claudeSubprocessEnv(),
738
1038
  stdio: ["ignore", "pipe", "pipe"],
739
1039
  });
740
1040
 
@@ -779,6 +1079,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
779
1079
  return;
780
1080
  }
781
1081
 
1082
+ const authPreflight = preflightClaudeAuthMessage();
1083
+ if (authPreflight) {
1084
+ await send(authPreflight, { replyTo: replyToMsgId });
1085
+ return;
1086
+ }
1087
+
782
1088
  bot.sendChatAction(CHAT_ID, "typing");
783
1089
  statusMessageId = null;
784
1090
  streamBuffer = "";
@@ -791,7 +1097,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
791
1097
  const binaryPath = getActiveBinary();
792
1098
  const proc = spawn(binaryPath, args, {
793
1099
  cwd,
794
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1100
+ env: claudeSubprocessEnv(),
795
1101
  stdio: ["ignore", "pipe", "pipe"],
796
1102
  detached: process.platform !== "win32", // Create process group so /stop kills children too
797
1103
  });
@@ -907,13 +1213,31 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
907
1213
  }
908
1214
  });
909
1215
 
910
- proc.stderr.on("data", (d) => console.error("STDERR:", d.toString()));
1216
+ let stderrBuffer = "";
1217
+ proc.stderr.on("data", (d) => {
1218
+ const chunk = d.toString();
1219
+ stderrBuffer += chunk;
1220
+ console.error("STDERR:", redactSensitive(chunk));
1221
+ });
911
1222
 
912
1223
  proc.on("close", async (code) => {
913
1224
  runningProcess = null; runningProcessPrompt = null;
914
1225
  clearTimeout(streamInterval); streamInterval = null;
1226
+ if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1227
+ await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
1228
+ return;
1229
+ }
1230
+ if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1231
+ await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
1232
+ return;
1233
+ }
915
1234
  try {
916
- const finalText = assistantText || "(no output)";
1235
+ if (code !== 0 && code !== null && !assistantText.trim()) {
1236
+ await send(claudeEmptyFailureMessage(code, stderrBuffer), { replyTo: replyToMsgId });
1237
+ return;
1238
+ }
1239
+
1240
+ const finalText = redactSensitive(assistantText || "(no output)");
917
1241
  const chunks = splitMessage(finalText);
918
1242
  const firstChunk = chunks[0];
919
1243
 
@@ -927,6 +1251,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
927
1251
  }
928
1252
  }
929
1253
  if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
1254
+
1255
+ // Send voice reply if input was a voice note
1256
+ if (lastInputWasVoice && TTS_CMD) {
1257
+ lastInputWasVoice = false;
1258
+ const voicePath = textToVoice(finalText);
1259
+ if (voicePath) await sendVoice(voicePath);
1260
+ }
930
1261
  } catch (e) {
931
1262
  console.error("Final message delivery failed:", e.message);
932
1263
  await send("Task completed but failed to deliver the response. Send /continue to see the result.");
@@ -958,13 +1289,13 @@ async function runClaudeSilent(prompt, cwd, label) {
958
1289
  "--append-system-prompt", buildSystemPrompt(),
959
1290
  "--dangerously-skip-permissions", prompt];
960
1291
  const proc = spawn(CLAUDE_PATH, args, {
961
- cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1292
+ cwd, env: claudeSubprocessEnv(),
962
1293
  stdio: ["ignore", "pipe", "pipe"],
963
1294
  });
964
1295
  let output = "";
965
1296
  proc.stdout.on("data", (d) => { output += d.toString(); });
966
1297
  proc.on("close", async () => {
967
- const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
1298
+ const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
968
1299
  for (const c of chunks) await send(c);
969
1300
  resolve();
970
1301
  });
@@ -1064,6 +1395,7 @@ bot.onText(/\/help/, (msg) => {
1064
1395
  "Session: /session /sessions /projects /continue /status /stop /end",
1065
1396
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
1066
1397
  "Automation: /cron /vault /soul",
1398
+ "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
1067
1399
  "System: /restart /upgrade",
1068
1400
  "",
1069
1401
  "Send text, voice, photos, or files.",
@@ -1312,6 +1644,64 @@ bot.onText(/\/soul$/, async (msg) => {
1312
1644
  await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
1313
1645
  });
1314
1646
 
1647
+ // ── Claude Auth Commands ────────────────────────────────────────────
1648
+
1649
+ bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
1650
+ if (!isAuthorized(msg)) return;
1651
+ const tokenInfo = getClaudeOAuthToken();
1652
+ const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
1653
+ cwd: process.env.HOME || require("os").homedir(),
1654
+ env: claudeSubprocessEnv(),
1655
+ stdio: ["ignore", "pipe", "pipe"],
1656
+ });
1657
+ let output = "";
1658
+ proc.stdout.on("data", (d) => { output += d.toString(); });
1659
+ proc.stderr.on("data", (d) => { output += d.toString(); });
1660
+ proc.on("close", async (code) => {
1661
+ const clean = redactSensitive(output.trim()) || "(no output)";
1662
+ await send([
1663
+ `Claude auth status: exit ${code}`,
1664
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1665
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1666
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1667
+ "",
1668
+ clean.slice(-2500),
1669
+ ].join("\n"));
1670
+ });
1671
+ proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
1672
+ });
1673
+
1674
+ bot.onText(/\/login$/, async (msg) => {
1675
+ if (!isAuthorized(msg)) return;
1676
+ await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
1677
+ });
1678
+
1679
+ bot.onText(/\/setup_token$/, async (msg) => {
1680
+ if (!isAuthorized(msg)) return;
1681
+ await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
1682
+ });
1683
+
1684
+ bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
1685
+ if (!isAuthorized(msg)) return;
1686
+ const token = (match[1] || "").trim();
1687
+ await deleteMessage(msg.message_id);
1688
+ if (!token) {
1689
+ pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
1690
+ pendingClaudeAuthLabel = "manual OAuth token save";
1691
+ await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
1692
+ return;
1693
+ }
1694
+ if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
1695
+ saveClaudeOAuthToken(token);
1696
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1697
+ });
1698
+
1699
+ bot.onText(/\/clear_oauth_token$/, async (msg) => {
1700
+ if (!isAuthorized(msg)) return;
1701
+ clearClaudeOAuthToken();
1702
+ await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
1703
+ });
1704
+
1315
1705
  // ── /vault with password protection ─────────────────────────────────
1316
1706
 
1317
1707
  bot.onText(/\/vault$/, async (msg) => {
@@ -1481,6 +1871,7 @@ bot.on("voice", async (msg) => {
1481
1871
  try { fs.unlinkSync(oggPath); } catch (e) {}
1482
1872
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
1483
1873
  await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
1874
+ lastInputWasVoice = true;
1484
1875
  await runClaude(transcript, currentSession.dir, msg.message_id);
1485
1876
  } catch (err) { await send(`Voice failed: ${err.message}`); }
1486
1877
  });
@@ -1547,6 +1938,29 @@ bot.on("message", async (msg) => {
1547
1938
  if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
1548
1939
  if (isDuplicate(msg.message_id)) return;
1549
1940
 
1941
+ // Handle pending Claude auth/token paste-back
1942
+ if (pendingClaudeAuthProcess) {
1943
+ const text = msg.text.trim();
1944
+ await deleteMessage(msg.message_id);
1945
+ if (pendingClaudeAuthLabel === "manual OAuth token save") {
1946
+ pendingClaudeAuthProcess = null;
1947
+ pendingClaudeAuthLabel = null;
1948
+ if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
1949
+ saveClaudeOAuthToken(text);
1950
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1951
+ return;
1952
+ }
1953
+ try {
1954
+ pendingClaudeAuthProcess.stdin.write(text + "\n");
1955
+ await send("Sent to Claude auth process.");
1956
+ } catch (e) {
1957
+ pendingClaudeAuthProcess = null;
1958
+ pendingClaudeAuthLabel = null;
1959
+ await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
1960
+ }
1961
+ return;
1962
+ }
1963
+
1550
1964
  // Handle onboarding
1551
1965
  if (!isOnboarded() && onboardingStep) {
1552
1966
  await handleOnboarding(msg);
package/bot.js CHANGED
@@ -248,6 +248,9 @@ bot.setMyCommands([
248
248
  { command: "cursor", description: "Switch to Cursor Agent backend" },
249
249
  { command: "claude", description: "Switch to Claude Code backend" },
250
250
  { command: "backend", description: "Show/switch active backend" },
251
+ { command: "auth_status", description: "Check Claude Code auth" },
252
+ { command: "login", description: "Start Claude Code login" },
253
+ { command: "setup_token", description: "Create Claude OAuth token" },
251
254
  { command: "stop", description: "Cancel running task" },
252
255
  { command: "end", description: "End current session" },
253
256
  { command: "version", description: "Show current version" },
@@ -359,7 +362,10 @@ let messageQueue = [];
359
362
  let activeCrons = new Map();
360
363
  let pendingVaultUnlock = false; // Waiting for password
361
364
  let pendingVaultAction = null; // What to do after unlock
365
+ let pendingClaudeAuthProcess = null;
366
+ let pendingClaudeAuthLabel = null;
362
367
  let isFirstMessage = !lastSessionId; // Track if this is first message in session
368
+ let lastInputWasVoice = false; // Reply with voice when input was voice
363
369
 
364
370
  let settings = savedState.settings || {
365
371
  model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude",
@@ -722,11 +728,306 @@ function transcribeAudio(oggPath) {
722
728
  .join(" ").trim();
723
729
  }
724
730
 
731
+ // ── Text-to-Speech ────────────────────────────────────────────────
732
+
733
+ const TTS_CMD = process.platform === "darwin" ? "say" : null;
734
+
735
+ function textToVoice(text) {
736
+ if (!TTS_CMD || !FFMPEG) return null;
737
+ try {
738
+ // Strip markdown formatting for cleaner speech
739
+ const clean = text.replace(/[*_`#>\[\]()]/g, "").replace(/\n{2,}/g, ". ").replace(/\n/g, " ").trim();
740
+ if (!clean) return null;
741
+ const aiffPath = path.join(TEMP_DIR, `tts-${Date.now()}.aiff`);
742
+ const oggPath = aiffPath.replace(".aiff", ".ogg");
743
+ execSync(`${TTS_CMD} ${JSON.stringify(clean)} -o "${aiffPath}"`, { timeout: 30000 });
744
+ execSync(`"${FFMPEG}" -i "${aiffPath}" -c:a libopus -y "${oggPath}" 2>/dev/null`, { timeout: 30000 });
745
+ try { fs.unlinkSync(aiffPath); } catch (e) {}
746
+ return oggPath;
747
+ } catch (e) {
748
+ console.error("TTS error:", e.message);
749
+ return null;
750
+ }
751
+ }
752
+
753
+ async function sendVoice(oggPath) {
754
+ try {
755
+ await bot.sendVoice(CHAT_ID, oggPath);
756
+ try { fs.unlinkSync(oggPath); } catch (e) {}
757
+ return true;
758
+ } catch (e) {
759
+ console.error("Send voice error:", e.message);
760
+ try { fs.unlinkSync(oggPath); } catch (e2) {}
761
+ return false;
762
+ }
763
+ }
764
+
725
765
  // Delete a message (used for vault password cleanup)
726
766
  async function deleteMessage(msgId) {
727
767
  try { await bot.deleteMessage(CHAT_ID, msgId); } catch (e) { /* ignore */ }
728
768
  }
729
769
 
770
+
771
+ // ── Claude Auth Helpers ─────────────────────────────────────────────
772
+
773
+ const CLAUDE_OAUTH_TOKEN_KEY = "CLAUDE_CODE_OAUTH_TOKEN";
774
+ const CLAUDE_OAUTH_VAULT_KEY = "claude_oauth_token";
775
+
776
+ function redactSensitive(value) {
777
+ return String(value || "")
778
+ .replace(/sk-ant-[A-Za-z0-9._-]+/g, "[REDACTED_TOKEN]")
779
+ .replace(/(Bearer\s+)[A-Za-z0-9._=-]+/gi, "$1[REDACTED_TOKEN]")
780
+ .replace(/(CLAUDE_CODE_OAUTH_TOKEN\s*=\s*)\S+/gi, "$1[REDACTED_TOKEN]")
781
+ .replace(/([?&](?:token|access_token|refresh_token)=)[^\s&]+/gi, "$1[REDACTED]");
782
+ }
783
+
784
+ function looksLikeClaudeToken(value) {
785
+ const text = String(value || "").trim();
786
+ return /^sk-ant-[A-Za-z0-9._-]+$/.test(text) || text.length >= 80 && /^[A-Za-z0-9._=-]+$/.test(text);
787
+ }
788
+
789
+ function getClaudeOAuthToken() {
790
+ if (config[CLAUDE_OAUTH_TOKEN_KEY]) return { value: config[CLAUDE_OAUTH_TOKEN_KEY], source: ".env" };
791
+ if (process.env.CLAUDE_CODE_OAUTH_TOKEN) return { value: process.env.CLAUDE_CODE_OAUTH_TOKEN, source: "process env" };
792
+ if (vault.isUnlocked()) {
793
+ const value = vault.get(CLAUDE_OAUTH_VAULT_KEY) || vault.get(CLAUDE_OAUTH_TOKEN_KEY);
794
+ if (value) return { value, source: "vault" };
795
+ }
796
+ return { value: null, source: null };
797
+ }
798
+
799
+ function claudeSubprocessEnv() {
800
+ const env = { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME };
801
+ const token = getClaudeOAuthToken().value;
802
+ if (token) env.CLAUDE_CODE_OAUTH_TOKEN = token;
803
+ return env;
804
+ }
805
+
806
+ function saveClaudeOAuthToken(token) {
807
+ const clean = String(token || "").trim();
808
+ if (!clean) return false;
809
+ saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, clean);
810
+ config[CLAUDE_OAUTH_TOKEN_KEY] = clean;
811
+ process.env.CLAUDE_CODE_OAUTH_TOKEN = clean;
812
+ if (vault.isUnlocked()) vault.set(CLAUDE_OAUTH_VAULT_KEY, clean);
813
+ return true;
814
+ }
815
+
816
+ function clearClaudeOAuthToken() {
817
+ saveEnvKey(CLAUDE_OAUTH_TOKEN_KEY, "");
818
+ config[CLAUDE_OAUTH_TOKEN_KEY] = "";
819
+ delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
820
+ if (vault.isUnlocked()) {
821
+ vault.remove(CLAUDE_OAUTH_VAULT_KEY);
822
+ vault.remove(CLAUDE_OAUTH_TOKEN_KEY);
823
+ }
824
+ }
825
+
826
+ function extractClaudeToken(text) {
827
+ const match = String(text || "").match(/sk-ant-[A-Za-z0-9._-]+/);
828
+ return match ? match[0] : null;
829
+ }
830
+
831
+ function extractUrls(text) {
832
+ return [...String(text || "").matchAll(/https?:\/\/[^\s)]+/g)].map((m) => m[0]);
833
+ }
834
+
835
+
836
+ function isClaudeAuthErrorText(text) {
837
+ const lower = String(text || "").toLowerCase();
838
+ return lower.includes("unauthorized") ||
839
+ lower.includes("not logged in") ||
840
+ lower.includes("login required") ||
841
+ lower.includes("reauthenticate") ||
842
+ lower.includes("re-authenticate") ||
843
+ lower.includes("authentication failed") ||
844
+ lower.includes("auth failed") ||
845
+ lower.includes("invalid api key") ||
846
+ lower.includes("api key") && lower.includes("invalid") ||
847
+ lower.includes("keychain") && (lower.includes("unlock") || lower.includes("could not") || lower.includes("failed")) ||
848
+ lower.includes("security unlock-keychain");
849
+ }
850
+
851
+ function claudeAuthRecoveryMessage(reason = "Claude Code authentication failed") {
852
+ const tokenInfo = getClaudeOAuthToken();
853
+ const tokenLine = tokenInfo.value
854
+ ? `Stored OAuth token: yes (${tokenInfo.source})`
855
+ : "Stored OAuth token: no";
856
+ return [
857
+ `Claude auth needs attention: ${redactSensitive(reason)}`,
858
+ "",
859
+ tokenLine,
860
+ "",
861
+ "Try one of these from Telegram:",
862
+ "1. /auth_status — check what Claude sees",
863
+ "2. /setup_token — create a long-lived Claude Code token, then store it",
864
+ "3. /use_oauth_token — paste the token securely if you already generated one",
865
+ "4. /login — interactive Claude.ai browser login fallback",
866
+ "",
867
+ "If this is a macOS Keychain problem after SSH/reboot, run on the Mac:",
868
+ "security unlock-keychain",
869
+ "",
870
+ "Recommended for this bot: /setup_token + /use_oauth_token so launchd does not depend on Keychain."
871
+ ].join("\n");
872
+ }
873
+
874
+ function preflightClaudeAuthMessage() {
875
+ if (settings.backend === "cursor") return null;
876
+ if (getClaudeOAuthToken().value) return null;
877
+ try {
878
+ const output = execSync(`"${CLAUDE_PATH}" auth status`, {
879
+ cwd: process.env.HOME || require("os").homedir(),
880
+ env: claudeSubprocessEnv(),
881
+ encoding: "utf8",
882
+ timeout: 10000,
883
+ stdio: ["ignore", "pipe", "pipe"],
884
+ });
885
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
886
+ return null;
887
+ } catch (e) {
888
+ const output = `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`;
889
+ if (isClaudeAuthErrorText(output)) return claudeAuthRecoveryMessage(output.trim().slice(-500));
890
+ return null;
891
+ }
892
+ }
893
+
894
+
895
+ function isClaudeUsageLimitText(text) {
896
+ const lower = String(text || "").toLowerCase();
897
+ return lower.includes("usage limit") ||
898
+ lower.includes("you've hit your usage limit") ||
899
+ lower.includes("you have hit your usage limit") ||
900
+ lower.includes("spend limit") ||
901
+ lower.includes("monthly cycle") ||
902
+ lower.includes("rate limit") && lower.includes("model");
903
+ }
904
+
905
+ function claudeUsageLimitMessage(details = "") {
906
+ return [
907
+ "Claude ran, but the selected model is unavailable/limited right now.",
908
+ details ? `\nDetails:\n${redactSensitive(details)}` : "",
909
+ "",
910
+ "Try from Telegram:",
911
+ "1. /model sonnet",
912
+ "2. Then send your message again",
913
+ "",
914
+ "If you specifically need Opus, wait for the usage window to reset or increase the spend/usage limit."
915
+ ].filter(Boolean).join("\n");
916
+ }
917
+
918
+ function runClaudeAuthStatusDiagnostic() {
919
+ try {
920
+ const output = execSync(`"${CLAUDE_PATH}" auth status`, {
921
+ cwd: process.env.HOME || require("os").homedir(),
922
+ env: claudeSubprocessEnv(),
923
+ encoding: "utf8",
924
+ timeout: 10000,
925
+ stdio: ["ignore", "pipe", "pipe"],
926
+ });
927
+ return output.trim();
928
+ } catch (e) {
929
+ return `${e.stdout || ""}\n${e.stderr || ""}\n${e.message || ""}`.trim();
930
+ }
931
+ }
932
+
933
+ function claudeEmptyFailureMessage(code, stderrText = "") {
934
+ const stderr = redactSensitive(String(stderrText || "").trim());
935
+ if (isClaudeUsageLimitText(stderr)) return claudeUsageLimitMessage(stderr.slice(-1200));
936
+ if (isClaudeAuthErrorText(stderr)) return claudeAuthRecoveryMessage(stderr.slice(-1200));
937
+
938
+ const authStatus = runClaudeAuthStatusDiagnostic();
939
+ if (isClaudeAuthErrorText(authStatus)) return claudeAuthRecoveryMessage(authStatus.slice(-1200));
940
+ if (isClaudeUsageLimitText(authStatus)) return claudeUsageLimitMessage(authStatus.slice(-1200));
941
+
942
+ return [
943
+ `Claude exited with code ${code} but produced no assistant output.`,
944
+ stderr ? `\nStderr:\n${stderr.slice(-1200)}` : "\nStderr: (empty)",
945
+ authStatus ? `\nAuth status:\n${redactSensitive(authStatus).slice(-1200)}` : "",
946
+ "",
947
+ "Useful next steps:",
948
+ "• /auth_status — verify Claude auth",
949
+ "• /model sonnet — switch away from Opus if usage-limited",
950
+ "• /setup_token — create a launchd-safe OAuth token if Keychain is the issue"
951
+ ].filter(Boolean).join("\n");
952
+ }
953
+
954
+
955
+ function summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
956
+ const text = String(output || "");
957
+ const lower = text.toLowerCase();
958
+ const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
959
+ const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
960
+ const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
961
+ const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
962
+ let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
963
+ if (/api key|apikey/.test(lower)) method = "API key";
964
+ else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
965
+ return [
966
+ `Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
967
+ `Auth method: ${method}`,
968
+ `Email: ${email || "unknown"}`,
969
+ `Provider: ${provider}`,
970
+ ];
971
+ }
972
+
973
+ async function runClaudeAuthCommand(args, label, opts = {}) {
974
+ if (pendingClaudeAuthProcess) {
975
+ await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
976
+ return;
977
+ }
978
+ await send(`${label} started...`);
979
+ const proc = spawn(CLAUDE_PATH, args, {
980
+ cwd: process.env.HOME || require("os").homedir(),
981
+ env: claudeSubprocessEnv(),
982
+ stdio: ["pipe", "pipe", "pipe"],
983
+ });
984
+ pendingClaudeAuthProcess = proc;
985
+ pendingClaudeAuthLabel = label;
986
+ let output = "";
987
+ let sentUrls = new Set();
988
+ let tokenStored = false;
989
+ let lastSnippetAt = 0;
990
+
991
+ const handleChunk = async (chunk) => {
992
+ output += chunk;
993
+ const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
994
+ if (token && !tokenStored) {
995
+ tokenStored = saveClaudeOAuthToken(token);
996
+ await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
997
+ }
998
+ for (const url of extractUrls(chunk)) {
999
+ if (!sentUrls.has(url)) {
1000
+ sentUrls.add(url);
1001
+ await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
1002
+ }
1003
+ }
1004
+ const redacted = redactSensitive(chunk).trim();
1005
+ const now = Date.now();
1006
+ if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
1007
+ lastSnippetAt = now;
1008
+ await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
1009
+ }
1010
+ };
1011
+
1012
+ proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
1013
+ proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
1014
+ proc.on("close", async (code) => {
1015
+ pendingClaudeAuthProcess = null;
1016
+ pendingClaudeAuthLabel = null;
1017
+ const token = opts.captureToken ? extractClaudeToken(output) : null;
1018
+ if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
1019
+ const final = redactSensitive(output.trim());
1020
+ if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
1021
+ else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
1022
+ else await send(`${label} finished (exit ${code}).`);
1023
+ });
1024
+ proc.on("error", async (err) => {
1025
+ pendingClaudeAuthProcess = null;
1026
+ pendingClaudeAuthLabel = null;
1027
+ await send(`${label} failed: ${redactSensitive(err.message)}`);
1028
+ });
1029
+ }
1030
+
730
1031
  // ── Claude Runner ───────────────────────────────────────────────────
731
1032
 
732
1033
  function parseStreamEvents(data) {
@@ -781,6 +1082,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
781
1082
  return;
782
1083
  }
783
1084
 
1085
+ const authPreflight = preflightClaudeAuthMessage();
1086
+ if (authPreflight) {
1087
+ await send(authPreflight, { replyTo: replyToMsgId });
1088
+ return;
1089
+ }
1090
+
784
1091
  bot.sendChatAction(CHAT_ID, "typing");
785
1092
  statusMessageId = null;
786
1093
  streamBuffer = "";
@@ -793,7 +1100,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
793
1100
  const binaryPath = getActiveBinary();
794
1101
  const proc = spawn(binaryPath, args, {
795
1102
  cwd,
796
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1103
+ env: claudeSubprocessEnv(),
797
1104
  stdio: ["ignore", "pipe", "pipe"],
798
1105
  detached: process.platform !== "win32",
799
1106
  });
@@ -927,7 +1234,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
927
1234
  proc.stderr.on("data", (d) => {
928
1235
  const chunk = d.toString();
929
1236
  stderrBuffer += chunk;
930
- console.error("STDERR:", chunk);
1237
+ console.error("STDERR:", redactSensitive(chunk));
931
1238
  });
932
1239
 
933
1240
  proc.on("close", async (code) => {
@@ -935,17 +1242,23 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
935
1242
  clearTimeout(streamInterval); streamInterval = null;
936
1243
  clearTimeout(processTimeout);
937
1244
 
938
- // Check for auth errors in stderr
939
- const stderrLower = stderrBuffer.toLowerCase();
940
- if (stderrLower.includes("unauthorized") || stderrLower.includes("auth") && stderrLower.includes("fail") ||
941
- stderrLower.includes("api key") || stderrLower.includes("not logged in")) {
942
- const hint = settings.backend === "cursor" ? "Run `agent login` to authenticate." : "Run `claude auth` to re-authenticate.";
943
- await send(`Authentication error. ${hint}`);
1245
+ // Check for auth errors in stderr and give actionable Telegram recovery steps.
1246
+ if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1247
+ await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
1248
+ return;
1249
+ }
1250
+ if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1251
+ await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
944
1252
  return;
945
1253
  }
946
1254
 
947
1255
  try {
948
- const finalText = assistantText || "(no output)";
1256
+ if (code !== 0 && code !== null && !assistantText.trim()) {
1257
+ await send(claudeEmptyFailureMessage(code, stderrBuffer), { replyTo: replyToMsgId });
1258
+ return;
1259
+ }
1260
+
1261
+ const finalText = redactSensitive(assistantText || "(no output)");
949
1262
  const chunks = splitMessage(finalText);
950
1263
  const firstChunk = chunks[0];
951
1264
 
@@ -964,6 +1277,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
964
1277
  }
965
1278
  }
966
1279
  if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
1280
+
1281
+ // Send voice reply if input was a voice note
1282
+ if (lastInputWasVoice && TTS_CMD) {
1283
+ lastInputWasVoice = false;
1284
+ const voicePath = textToVoice(finalText);
1285
+ if (voicePath) await sendVoice(voicePath);
1286
+ }
967
1287
  } catch (e) {
968
1288
  console.error("Final message delivery failed:", e.message);
969
1289
  await send("Task completed but failed to deliver the response. Send /continue to see the result.");
@@ -998,13 +1318,13 @@ async function runClaudeSilent(prompt, cwd, label) {
998
1318
  "--append-system-prompt", buildSystemPrompt(),
999
1319
  "--dangerously-skip-permissions", prompt];
1000
1320
  const proc = spawn(CLAUDE_PATH, args, {
1001
- cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1321
+ cwd, env: claudeSubprocessEnv(),
1002
1322
  stdio: ["ignore", "pipe", "pipe"],
1003
1323
  });
1004
1324
  let output = "";
1005
1325
  proc.stdout.on("data", (d) => { output += d.toString(); });
1006
1326
  proc.on("close", async () => {
1007
- const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
1327
+ const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
1008
1328
  for (const c of chunks) await send(c);
1009
1329
  resolve();
1010
1330
  });
@@ -1104,6 +1424,7 @@ bot.onText(/\/help/, (msg) => {
1104
1424
  "Session: /session /sessions /projects /continue /status /stop /end",
1105
1425
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
1106
1426
  "Automation: /cron /vault /soul",
1427
+ "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
1107
1428
  "System: /restart /upgrade",
1108
1429
  "",
1109
1430
  "Send text, voice, photos, or files.",
@@ -1354,6 +1675,64 @@ bot.onText(/\/soul$/, async (msg) => {
1354
1675
  await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
1355
1676
  });
1356
1677
 
1678
+ // ── Claude Auth Commands ────────────────────────────────────────────
1679
+
1680
+ bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
1681
+ if (!isAuthorized(msg)) return;
1682
+ const tokenInfo = getClaudeOAuthToken();
1683
+ const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
1684
+ cwd: process.env.HOME || require("os").homedir(),
1685
+ env: claudeSubprocessEnv(),
1686
+ stdio: ["ignore", "pipe", "pipe"],
1687
+ });
1688
+ let output = "";
1689
+ proc.stdout.on("data", (d) => { output += d.toString(); });
1690
+ proc.stderr.on("data", (d) => { output += d.toString(); });
1691
+ proc.on("close", async (code) => {
1692
+ const clean = redactSensitive(output.trim()) || "(no output)";
1693
+ await send([
1694
+ `Claude auth status: exit ${code}`,
1695
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1696
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1697
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1698
+ "",
1699
+ clean.slice(-2500),
1700
+ ].join("\n"));
1701
+ });
1702
+ proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
1703
+ });
1704
+
1705
+ bot.onText(/\/login$/, async (msg) => {
1706
+ if (!isAuthorized(msg)) return;
1707
+ await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
1708
+ });
1709
+
1710
+ bot.onText(/\/setup_token$/, async (msg) => {
1711
+ if (!isAuthorized(msg)) return;
1712
+ await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
1713
+ });
1714
+
1715
+ bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
1716
+ if (!isAuthorized(msg)) return;
1717
+ const token = (match[1] || "").trim();
1718
+ await deleteMessage(msg.message_id);
1719
+ if (!token) {
1720
+ pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
1721
+ pendingClaudeAuthLabel = "manual OAuth token save";
1722
+ await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
1723
+ return;
1724
+ }
1725
+ if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
1726
+ saveClaudeOAuthToken(token);
1727
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1728
+ });
1729
+
1730
+ bot.onText(/\/clear_oauth_token$/, async (msg) => {
1731
+ if (!isAuthorized(msg)) return;
1732
+ clearClaudeOAuthToken();
1733
+ await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
1734
+ });
1735
+
1357
1736
  // ── /vault with password protection ─────────────────────────────────
1358
1737
 
1359
1738
  bot.onText(/\/vault$/, async (msg) => {
@@ -1527,6 +1906,7 @@ bot.on("voice", async (msg) => {
1527
1906
  try { fs.unlinkSync(oggPath); } catch (e) {}
1528
1907
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
1529
1908
  await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
1909
+ lastInputWasVoice = true;
1530
1910
  await runClaude(transcript, currentSession.dir, msg.message_id);
1531
1911
  } catch (err) { await send(`Voice failed: ${err.message}`); }
1532
1912
  });
@@ -1597,6 +1977,29 @@ bot.on("message", async (msg) => {
1597
1977
  if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
1598
1978
  if (isDuplicate(msg.message_id)) return;
1599
1979
 
1980
+ // Handle pending Claude auth/token paste-back
1981
+ if (pendingClaudeAuthProcess) {
1982
+ const text = msg.text.trim();
1983
+ await deleteMessage(msg.message_id);
1984
+ if (pendingClaudeAuthLabel === "manual OAuth token save") {
1985
+ pendingClaudeAuthProcess = null;
1986
+ pendingClaudeAuthLabel = null;
1987
+ if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
1988
+ saveClaudeOAuthToken(text);
1989
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1990
+ return;
1991
+ }
1992
+ try {
1993
+ pendingClaudeAuthProcess.stdin.write(text + "\n");
1994
+ await send("Sent to Claude auth process.");
1995
+ } catch (e) {
1996
+ pendingClaudeAuthProcess = null;
1997
+ pendingClaudeAuthLabel = null;
1998
+ await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
1999
+ }
2000
+ return;
2001
+ }
2002
+
1600
2003
  // Handle onboarding
1601
2004
  if (!isOnboarded() && onboardingStep) {
1602
2005
  await handleOnboarding(msg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "1.13.4",
3
+ "version": "1.14.1",
4
4
  "description": "Your always-on AI coding assistant — Claude Code via Telegram",
5
5
  "main": "bot.js",
6
6
  "bin": {