@inetafrica/open-claudia 1.13.4 → 1.14.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.
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,245 @@ 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 summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
834
+ const text = String(output || "");
835
+ const lower = text.toLowerCase();
836
+ const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
837
+ const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
838
+ const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
839
+ const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
840
+ let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
841
+ if (/api key|apikey/.test(lower)) method = "API key";
842
+ else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
843
+ return [
844
+ `Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
845
+ `Auth method: ${method}`,
846
+ `Email: ${email || "unknown"}`,
847
+ `Provider: ${provider}`,
848
+ ];
849
+ }
850
+
851
+ async function runClaudeAuthCommand(args, label, opts = {}) {
852
+ if (pendingClaudeAuthProcess) {
853
+ await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
854
+ return;
855
+ }
856
+ await send(`${label} started...`);
857
+ const proc = spawn(CLAUDE_PATH, args, {
858
+ cwd: process.env.HOME || require("os").homedir(),
859
+ env: claudeSubprocessEnv(),
860
+ stdio: ["pipe", "pipe", "pipe"],
861
+ });
862
+ pendingClaudeAuthProcess = proc;
863
+ pendingClaudeAuthLabel = label;
864
+ let output = "";
865
+ let sentUrls = new Set();
866
+ let tokenStored = false;
867
+ let lastSnippetAt = 0;
868
+
869
+ const handleChunk = async (chunk) => {
870
+ output += chunk;
871
+ const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
872
+ if (token && !tokenStored) {
873
+ tokenStored = saveClaudeOAuthToken(token);
874
+ await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
875
+ }
876
+ for (const url of extractUrls(chunk)) {
877
+ if (!sentUrls.has(url)) {
878
+ sentUrls.add(url);
879
+ await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
880
+ }
881
+ }
882
+ const redacted = redactSensitive(chunk).trim();
883
+ const now = Date.now();
884
+ if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
885
+ lastSnippetAt = now;
886
+ await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
887
+ }
888
+ };
889
+
890
+ proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
891
+ proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
892
+ proc.on("close", async (code) => {
893
+ pendingClaudeAuthProcess = null;
894
+ pendingClaudeAuthLabel = null;
895
+ const token = opts.captureToken ? extractClaudeToken(output) : null;
896
+ if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
897
+ const final = redactSensitive(output.trim());
898
+ if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
899
+ else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
900
+ else await send(`${label} finished (exit ${code}).`);
901
+ });
902
+ proc.on("error", async (err) => {
903
+ pendingClaudeAuthProcess = null;
904
+ pendingClaudeAuthLabel = null;
905
+ await send(`${label} failed: ${redactSensitive(err.message)}`);
906
+ });
907
+ }
908
+
669
909
  // ── Claude Runner ───────────────────────────────────────────────────
670
910
 
671
911
  function parseStreamEvents(data) {
@@ -734,7 +974,7 @@ async function runClaudeChat(prompt, cwd, replyToMsgId) {
734
974
 
735
975
  const proc = spawn(CLAUDE_PATH, args, {
736
976
  cwd,
737
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
977
+ env: claudeSubprocessEnv(),
738
978
  stdio: ["ignore", "pipe", "pipe"],
739
979
  });
740
980
 
@@ -779,6 +1019,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
779
1019
  return;
780
1020
  }
781
1021
 
1022
+ const authPreflight = preflightClaudeAuthMessage();
1023
+ if (authPreflight) {
1024
+ await send(authPreflight, { replyTo: replyToMsgId });
1025
+ return;
1026
+ }
1027
+
782
1028
  bot.sendChatAction(CHAT_ID, "typing");
783
1029
  statusMessageId = null;
784
1030
  streamBuffer = "";
@@ -791,7 +1037,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
791
1037
  const binaryPath = getActiveBinary();
792
1038
  const proc = spawn(binaryPath, args, {
793
1039
  cwd,
794
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1040
+ env: claudeSubprocessEnv(),
795
1041
  stdio: ["ignore", "pipe", "pipe"],
796
1042
  detached: process.platform !== "win32", // Create process group so /stop kills children too
797
1043
  });
@@ -907,13 +1153,26 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
907
1153
  }
908
1154
  });
909
1155
 
910
- proc.stderr.on("data", (d) => console.error("STDERR:", d.toString()));
1156
+ let stderrBuffer = "";
1157
+ proc.stderr.on("data", (d) => {
1158
+ const chunk = d.toString();
1159
+ stderrBuffer += chunk;
1160
+ console.error("STDERR:", redactSensitive(chunk));
1161
+ });
911
1162
 
912
1163
  proc.on("close", async (code) => {
913
1164
  runningProcess = null; runningProcessPrompt = null;
914
1165
  clearTimeout(streamInterval); streamInterval = null;
1166
+ if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1167
+ await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
1168
+ return;
1169
+ }
1170
+ if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1171
+ await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
1172
+ return;
1173
+ }
915
1174
  try {
916
- const finalText = assistantText || "(no output)";
1175
+ const finalText = redactSensitive(assistantText || "(no output)");
917
1176
  const chunks = splitMessage(finalText);
918
1177
  const firstChunk = chunks[0];
919
1178
 
@@ -927,6 +1186,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
927
1186
  }
928
1187
  }
929
1188
  if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
1189
+
1190
+ // Send voice reply if input was a voice note
1191
+ if (lastInputWasVoice && TTS_CMD) {
1192
+ lastInputWasVoice = false;
1193
+ const voicePath = textToVoice(finalText);
1194
+ if (voicePath) await sendVoice(voicePath);
1195
+ }
930
1196
  } catch (e) {
931
1197
  console.error("Final message delivery failed:", e.message);
932
1198
  await send("Task completed but failed to deliver the response. Send /continue to see the result.");
@@ -958,13 +1224,13 @@ async function runClaudeSilent(prompt, cwd, label) {
958
1224
  "--append-system-prompt", buildSystemPrompt(),
959
1225
  "--dangerously-skip-permissions", prompt];
960
1226
  const proc = spawn(CLAUDE_PATH, args, {
961
- cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1227
+ cwd, env: claudeSubprocessEnv(),
962
1228
  stdio: ["ignore", "pipe", "pipe"],
963
1229
  });
964
1230
  let output = "";
965
1231
  proc.stdout.on("data", (d) => { output += d.toString(); });
966
1232
  proc.on("close", async () => {
967
- const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
1233
+ const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
968
1234
  for (const c of chunks) await send(c);
969
1235
  resolve();
970
1236
  });
@@ -1064,6 +1330,7 @@ bot.onText(/\/help/, (msg) => {
1064
1330
  "Session: /session /sessions /projects /continue /status /stop /end",
1065
1331
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
1066
1332
  "Automation: /cron /vault /soul",
1333
+ "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
1067
1334
  "System: /restart /upgrade",
1068
1335
  "",
1069
1336
  "Send text, voice, photos, or files.",
@@ -1312,6 +1579,64 @@ bot.onText(/\/soul$/, async (msg) => {
1312
1579
  await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
1313
1580
  });
1314
1581
 
1582
+ // ── Claude Auth Commands ────────────────────────────────────────────
1583
+
1584
+ bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
1585
+ if (!isAuthorized(msg)) return;
1586
+ const tokenInfo = getClaudeOAuthToken();
1587
+ const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
1588
+ cwd: process.env.HOME || require("os").homedir(),
1589
+ env: claudeSubprocessEnv(),
1590
+ stdio: ["ignore", "pipe", "pipe"],
1591
+ });
1592
+ let output = "";
1593
+ proc.stdout.on("data", (d) => { output += d.toString(); });
1594
+ proc.stderr.on("data", (d) => { output += d.toString(); });
1595
+ proc.on("close", async (code) => {
1596
+ const clean = redactSensitive(output.trim()) || "(no output)";
1597
+ await send([
1598
+ `Claude auth status: exit ${code}`,
1599
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1600
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1601
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1602
+ "",
1603
+ clean.slice(-2500),
1604
+ ].join("\n"));
1605
+ });
1606
+ proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
1607
+ });
1608
+
1609
+ bot.onText(/\/login$/, async (msg) => {
1610
+ if (!isAuthorized(msg)) return;
1611
+ await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
1612
+ });
1613
+
1614
+ bot.onText(/\/setup_token$/, async (msg) => {
1615
+ if (!isAuthorized(msg)) return;
1616
+ await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
1617
+ });
1618
+
1619
+ bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
1620
+ if (!isAuthorized(msg)) return;
1621
+ const token = (match[1] || "").trim();
1622
+ await deleteMessage(msg.message_id);
1623
+ if (!token) {
1624
+ pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
1625
+ pendingClaudeAuthLabel = "manual OAuth token save";
1626
+ await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
1627
+ return;
1628
+ }
1629
+ if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
1630
+ saveClaudeOAuthToken(token);
1631
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1632
+ });
1633
+
1634
+ bot.onText(/\/clear_oauth_token$/, async (msg) => {
1635
+ if (!isAuthorized(msg)) return;
1636
+ clearClaudeOAuthToken();
1637
+ await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
1638
+ });
1639
+
1315
1640
  // ── /vault with password protection ─────────────────────────────────
1316
1641
 
1317
1642
  bot.onText(/\/vault$/, async (msg) => {
@@ -1481,6 +1806,7 @@ bot.on("voice", async (msg) => {
1481
1806
  try { fs.unlinkSync(oggPath); } catch (e) {}
1482
1807
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
1483
1808
  await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
1809
+ lastInputWasVoice = true;
1484
1810
  await runClaude(transcript, currentSession.dir, msg.message_id);
1485
1811
  } catch (err) { await send(`Voice failed: ${err.message}`); }
1486
1812
  });
@@ -1547,6 +1873,29 @@ bot.on("message", async (msg) => {
1547
1873
  if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
1548
1874
  if (isDuplicate(msg.message_id)) return;
1549
1875
 
1876
+ // Handle pending Claude auth/token paste-back
1877
+ if (pendingClaudeAuthProcess) {
1878
+ const text = msg.text.trim();
1879
+ await deleteMessage(msg.message_id);
1880
+ if (pendingClaudeAuthLabel === "manual OAuth token save") {
1881
+ pendingClaudeAuthProcess = null;
1882
+ pendingClaudeAuthLabel = null;
1883
+ if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
1884
+ saveClaudeOAuthToken(text);
1885
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1886
+ return;
1887
+ }
1888
+ try {
1889
+ pendingClaudeAuthProcess.stdin.write(text + "\n");
1890
+ await send("Sent to Claude auth process.");
1891
+ } catch (e) {
1892
+ pendingClaudeAuthProcess = null;
1893
+ pendingClaudeAuthLabel = null;
1894
+ await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
1895
+ }
1896
+ return;
1897
+ }
1898
+
1550
1899
  // Handle onboarding
1551
1900
  if (!isOnboarded() && onboardingStep) {
1552
1901
  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,246 @@ 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 summarizeClaudeAuthStatus(output, exitCode, tokenInfo) {
896
+ const text = String(output || "");
897
+ const lower = text.toLowerCase();
898
+ const loggedOut = /not (logged in|authenticated)|unauthenticated|no auth|login required/.test(lower);
899
+ const loggedIn = !loggedOut && (exitCode === 0 || /logged in|authenticated|claude\.ai|anthropic/.test(lower));
900
+ const email = (text.match(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/i) || [null])[0];
901
+ const provider = /claude\.ai|claudeai/.test(lower) ? "Claude.ai" : (/anthropic/.test(lower) ? "Anthropic" : "unknown");
902
+ let method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "unknown";
903
+ if (/api key|apikey/.test(lower)) method = "API key";
904
+ else if (/oauth|claude\.ai|claudeai/.test(lower)) method = tokenInfo.value ? `OAuth token (${tokenInfo.source})` : "OAuth/Claude.ai";
905
+ return [
906
+ `Logged in: ${loggedIn ? "yes" : (loggedOut ? "no" : "unknown")}`,
907
+ `Auth method: ${method}`,
908
+ `Email: ${email || "unknown"}`,
909
+ `Provider: ${provider}`,
910
+ ];
911
+ }
912
+
913
+ async function runClaudeAuthCommand(args, label, opts = {}) {
914
+ if (pendingClaudeAuthProcess) {
915
+ await send(`Another Claude auth flow is already running (${pendingClaudeAuthLabel}). Send the requested code/token or wait for it to finish.`);
916
+ return;
917
+ }
918
+ await send(`${label} started...`);
919
+ const proc = spawn(CLAUDE_PATH, args, {
920
+ cwd: process.env.HOME || require("os").homedir(),
921
+ env: claudeSubprocessEnv(),
922
+ stdio: ["pipe", "pipe", "pipe"],
923
+ });
924
+ pendingClaudeAuthProcess = proc;
925
+ pendingClaudeAuthLabel = label;
926
+ let output = "";
927
+ let sentUrls = new Set();
928
+ let tokenStored = false;
929
+ let lastSnippetAt = 0;
930
+
931
+ const handleChunk = async (chunk) => {
932
+ output += chunk;
933
+ const token = opts.captureToken ? extractClaudeToken(chunk) || extractClaudeToken(output) : null;
934
+ if (token && !tokenStored) {
935
+ tokenStored = saveClaudeOAuthToken(token);
936
+ await send(tokenStored ? "Claude OAuth token captured and stored. I did not print it." : "Claude OAuth token appeared, but I could not store it.");
937
+ }
938
+ for (const url of extractUrls(chunk)) {
939
+ if (!sentUrls.has(url)) {
940
+ sentUrls.add(url);
941
+ await send(`${label} URL:\n${redactSensitive(url)}\n\nOpen it, complete the flow, then paste any code Claude asks for here.`);
942
+ }
943
+ }
944
+ const redacted = redactSensitive(chunk).trim();
945
+ const now = Date.now();
946
+ if (redacted && now - lastSnippetAt > 3000 && !looksLikeClaudeToken(redacted)) {
947
+ lastSnippetAt = now;
948
+ await send(redacted.length > 1200 ? redacted.slice(-1200) : redacted);
949
+ }
950
+ };
951
+
952
+ proc.stdout.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth output error:", e.message)));
953
+ proc.stderr.on("data", (d) => handleChunk(d.toString()).catch((e) => console.error("Auth stderr error:", e.message)));
954
+ proc.on("close", async (code) => {
955
+ pendingClaudeAuthProcess = null;
956
+ pendingClaudeAuthLabel = null;
957
+ const token = opts.captureToken ? extractClaudeToken(output) : null;
958
+ if (token && !tokenStored) tokenStored = saveClaudeOAuthToken(token);
959
+ const final = redactSensitive(output.trim());
960
+ if (tokenStored) await send(`${label} finished. OAuth token stored for launchd/non-interactive Claude runs.`);
961
+ else if (final) await send(`${label} finished (exit ${code}).\n\n${final.slice(-2500)}`);
962
+ else await send(`${label} finished (exit ${code}).`);
963
+ });
964
+ proc.on("error", async (err) => {
965
+ pendingClaudeAuthProcess = null;
966
+ pendingClaudeAuthLabel = null;
967
+ await send(`${label} failed: ${redactSensitive(err.message)}`);
968
+ });
969
+ }
970
+
730
971
  // ── Claude Runner ───────────────────────────────────────────────────
731
972
 
732
973
  function parseStreamEvents(data) {
@@ -781,6 +1022,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
781
1022
  return;
782
1023
  }
783
1024
 
1025
+ const authPreflight = preflightClaudeAuthMessage();
1026
+ if (authPreflight) {
1027
+ await send(authPreflight, { replyTo: replyToMsgId });
1028
+ return;
1029
+ }
1030
+
784
1031
  bot.sendChatAction(CHAT_ID, "typing");
785
1032
  statusMessageId = null;
786
1033
  streamBuffer = "";
@@ -793,7 +1040,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
793
1040
  const binaryPath = getActiveBinary();
794
1041
  const proc = spawn(binaryPath, args, {
795
1042
  cwd,
796
- env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1043
+ env: claudeSubprocessEnv(),
797
1044
  stdio: ["ignore", "pipe", "pipe"],
798
1045
  detached: process.platform !== "win32",
799
1046
  });
@@ -927,7 +1174,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
927
1174
  proc.stderr.on("data", (d) => {
928
1175
  const chunk = d.toString();
929
1176
  stderrBuffer += chunk;
930
- console.error("STDERR:", chunk);
1177
+ console.error("STDERR:", redactSensitive(chunk));
931
1178
  });
932
1179
 
933
1180
  proc.on("close", async (code) => {
@@ -935,17 +1182,18 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
935
1182
  clearTimeout(streamInterval); streamInterval = null;
936
1183
  clearTimeout(processTimeout);
937
1184
 
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}`);
1185
+ // Check for auth errors in stderr and give actionable Telegram recovery steps.
1186
+ if (settings.backend !== "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1187
+ await send(claudeAuthRecoveryMessage(stderrBuffer.trim().slice(-800)), { replyTo: replyToMsgId });
1188
+ return;
1189
+ }
1190
+ if (settings.backend === "cursor" && isClaudeAuthErrorText(stderrBuffer)) {
1191
+ await send("Cursor authentication error. Run `agent login` on this machine, then retry.", { replyTo: replyToMsgId });
944
1192
  return;
945
1193
  }
946
1194
 
947
1195
  try {
948
- const finalText = assistantText || "(no output)";
1196
+ const finalText = redactSensitive(assistantText || "(no output)");
949
1197
  const chunks = splitMessage(finalText);
950
1198
  const firstChunk = chunks[0];
951
1199
 
@@ -964,6 +1212,13 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
964
1212
  }
965
1213
  }
966
1214
  if (code !== 0 && code !== null) await send(`Exit code: ${code}`);
1215
+
1216
+ // Send voice reply if input was a voice note
1217
+ if (lastInputWasVoice && TTS_CMD) {
1218
+ lastInputWasVoice = false;
1219
+ const voicePath = textToVoice(finalText);
1220
+ if (voicePath) await sendVoice(voicePath);
1221
+ }
967
1222
  } catch (e) {
968
1223
  console.error("Final message delivery failed:", e.message);
969
1224
  await send("Task completed but failed to deliver the response. Send /continue to see the result.");
@@ -998,13 +1253,13 @@ async function runClaudeSilent(prompt, cwd, label) {
998
1253
  "--append-system-prompt", buildSystemPrompt(),
999
1254
  "--dangerously-skip-permissions", prompt];
1000
1255
  const proc = spawn(CLAUDE_PATH, args, {
1001
- cwd, env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME },
1256
+ cwd, env: claudeSubprocessEnv(),
1002
1257
  stdio: ["ignore", "pipe", "pipe"],
1003
1258
  });
1004
1259
  let output = "";
1005
1260
  proc.stdout.on("data", (d) => { output += d.toString(); });
1006
1261
  proc.on("close", async () => {
1007
- const chunks = splitMessage(`Cron: ${label}\n\n${output.trim() || "(no output)"}`);
1262
+ const chunks = splitMessage(`Cron: ${label}\n\n${redactSensitive(output.trim() || "(no output)")}`);
1008
1263
  for (const c of chunks) await send(c);
1009
1264
  resolve();
1010
1265
  });
@@ -1104,6 +1359,7 @@ bot.onText(/\/help/, (msg) => {
1104
1359
  "Session: /session /sessions /projects /continue /status /stop /end",
1105
1360
  "Settings: /model /effort /budget /plan /compact /worktree /mode",
1106
1361
  "Automation: /cron /vault /soul",
1362
+ "Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
1107
1363
  "System: /restart /upgrade",
1108
1364
  "",
1109
1365
  "Send text, voice, photos, or files.",
@@ -1354,6 +1610,64 @@ bot.onText(/\/soul$/, async (msg) => {
1354
1610
  await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
1355
1611
  });
1356
1612
 
1613
+ // ── Claude Auth Commands ────────────────────────────────────────────
1614
+
1615
+ bot.onText(/\/(?:auth_status|auth status)$/, async (msg) => {
1616
+ if (!isAuthorized(msg)) return;
1617
+ const tokenInfo = getClaudeOAuthToken();
1618
+ const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
1619
+ cwd: process.env.HOME || require("os").homedir(),
1620
+ env: claudeSubprocessEnv(),
1621
+ stdio: ["ignore", "pipe", "pipe"],
1622
+ });
1623
+ let output = "";
1624
+ proc.stdout.on("data", (d) => { output += d.toString(); });
1625
+ proc.stderr.on("data", (d) => { output += d.toString(); });
1626
+ proc.on("close", async (code) => {
1627
+ const clean = redactSensitive(output.trim()) || "(no output)";
1628
+ await send([
1629
+ `Claude auth status: exit ${code}`,
1630
+ ...summarizeClaudeAuthStatus(output, code, tokenInfo),
1631
+ `Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
1632
+ `Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
1633
+ "",
1634
+ clean.slice(-2500),
1635
+ ].join("\n"));
1636
+ });
1637
+ proc.on("error", async (err) => send(`Claude auth status failed: ${redactSensitive(err.message)}`));
1638
+ });
1639
+
1640
+ bot.onText(/\/login$/, async (msg) => {
1641
+ if (!isAuthorized(msg)) return;
1642
+ await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
1643
+ });
1644
+
1645
+ bot.onText(/\/setup_token$/, async (msg) => {
1646
+ if (!isAuthorized(msg)) return;
1647
+ await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
1648
+ });
1649
+
1650
+ bot.onText(/\/use_oauth_token(?:\s+(.+))?$/, async (msg, match) => {
1651
+ if (!isAuthorized(msg)) return;
1652
+ const token = (match[1] || "").trim();
1653
+ await deleteMessage(msg.message_id);
1654
+ if (!token) {
1655
+ pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
1656
+ pendingClaudeAuthLabel = "manual OAuth token save";
1657
+ await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
1658
+ return;
1659
+ }
1660
+ if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
1661
+ saveClaudeOAuthToken(token);
1662
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1663
+ });
1664
+
1665
+ bot.onText(/\/clear_oauth_token$/, async (msg) => {
1666
+ if (!isAuthorized(msg)) return;
1667
+ clearClaudeOAuthToken();
1668
+ await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
1669
+ });
1670
+
1357
1671
  // ── /vault with password protection ─────────────────────────────────
1358
1672
 
1359
1673
  bot.onText(/\/vault$/, async (msg) => {
@@ -1527,6 +1841,7 @@ bot.on("voice", async (msg) => {
1527
1841
  try { fs.unlinkSync(oggPath); } catch (e) {}
1528
1842
  if (!transcript) return send("Couldn't transcribe. Try typing it.");
1529
1843
  await send(`Heard: "${transcript}"`, { replyTo: msg.message_id });
1844
+ lastInputWasVoice = true;
1530
1845
  await runClaude(transcript, currentSession.dir, msg.message_id);
1531
1846
  } catch (err) { await send(`Voice failed: ${err.message}`); }
1532
1847
  });
@@ -1597,6 +1912,29 @@ bot.on("message", async (msg) => {
1597
1912
  if (msg.voice || msg.audio || msg.photo || msg.document || msg.video || msg.sticker) return;
1598
1913
  if (isDuplicate(msg.message_id)) return;
1599
1914
 
1915
+ // Handle pending Claude auth/token paste-back
1916
+ if (pendingClaudeAuthProcess) {
1917
+ const text = msg.text.trim();
1918
+ await deleteMessage(msg.message_id);
1919
+ if (pendingClaudeAuthLabel === "manual OAuth token save") {
1920
+ pendingClaudeAuthProcess = null;
1921
+ pendingClaudeAuthLabel = null;
1922
+ if (!looksLikeClaudeToken(text)) { await send("That doesn't look like a Claude OAuth token. Not saved."); return; }
1923
+ saveClaudeOAuthToken(text);
1924
+ await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
1925
+ return;
1926
+ }
1927
+ try {
1928
+ pendingClaudeAuthProcess.stdin.write(text + "\n");
1929
+ await send("Sent to Claude auth process.");
1930
+ } catch (e) {
1931
+ pendingClaudeAuthProcess = null;
1932
+ pendingClaudeAuthLabel = null;
1933
+ await send(`Could not send to Claude auth process: ${redactSensitive(e.message)}`);
1934
+ }
1935
+ return;
1936
+ }
1937
+
1600
1938
  // Handle onboarding
1601
1939
  if (!isOnboarded() && onboardingStep) {
1602
1940
  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.0",
4
4
  "description": "Your always-on AI coding assistant — Claude Code via Telegram",
5
5
  "main": "bot.js",
6
6
  "bin": {