@inetafrica/open-claudia 1.20.0 → 2.0.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/.env.example +15 -1
- package/CHANGELOG.md +15 -0
- package/Dockerfile +3 -0
- package/README.md +2 -1
- package/bot.js +109 -3440
- package/channels/kazee/adapter.js +375 -0
- package/channels/kazee/format.js +9 -0
- package/channels/kazee/socket.js +28 -0
- package/channels/telegram/adapter.js +296 -0
- package/channels/telegram/format.js +12 -0
- package/channels/types.js +91 -0
- package/core/access.js +147 -0
- package/core/actions.js +180 -0
- package/core/adapter-registry.js +113 -0
- package/core/auth-flow.js +433 -0
- package/core/channel-wizard.js +174 -0
- package/core/commands.js +73 -0
- package/core/config.js +197 -0
- package/core/context.js +44 -0
- package/core/cron.js +77 -0
- package/core/doctor.js +126 -0
- package/core/handlers.js +995 -0
- package/core/identity.js +87 -0
- package/core/io.js +59 -0
- package/core/kazee-probe.js +48 -0
- package/core/media.js +39 -0
- package/core/onboarding.js +97 -0
- package/core/process-tree.js +40 -0
- package/core/projects.js +43 -0
- package/core/redact.js +26 -0
- package/core/router.js +309 -0
- package/core/runner.js +683 -0
- package/core/state.js +261 -0
- package/core/system-prompt.js +66 -0
- package/core/transcripts.js +71 -0
- package/core/vault-store.js +9 -0
- package/docker-entrypoint.sh +21 -4
- package/package.json +6 -3
- package/project-transcripts.js +17 -12
- package/setup.js +44 -3
package/core/handlers.js
ADDED
|
@@ -0,0 +1,995 @@
|
|
|
1
|
+
// All slash-command handlers. Each registers itself into the registry so
|
|
2
|
+
// adapters can advertise the slash menu and the router can dispatch
|
|
3
|
+
// inbound text uniformly across channels.
|
|
4
|
+
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const path = require("path");
|
|
7
|
+
const cronLib = require("node-cron");
|
|
8
|
+
const { execSync } = require("child_process");
|
|
9
|
+
const {
|
|
10
|
+
WORKSPACE, FULL_PATH, AUTO_COMPACT_TOKENS, CONFIG_DIR,
|
|
11
|
+
config, saveEnvKey,
|
|
12
|
+
CHAT_ID, resolvedCursorPath, resolvedCodexPath, SOUL_FILE,
|
|
13
|
+
} = require("./config");
|
|
14
|
+
const { register } = require("./commands");
|
|
15
|
+
const { send, deleteMessage } = require("./io");
|
|
16
|
+
const { currentState, resetSettings, resetSessionUsage, saveState, getProjectSessions, getLastProjectSession, recordSession, linkIdentity } = require("./state");
|
|
17
|
+
const { canonicalForChannel, normalizeCanonicalUserId, channelKey, identities } = require("./identity");
|
|
18
|
+
const { isChatAuthorized, isChatOwner, recordPendingAuthRequest, authRequestLabel, hasOwner, bootstrapOwner } = require("./access");
|
|
19
|
+
const { isOnboarded, startOnboarding } = require("./onboarding");
|
|
20
|
+
const { listProjects, findProject, projectKeyboard, workspacePath } = require("./projects");
|
|
21
|
+
const { vault } = require("./vault-store");
|
|
22
|
+
const { redactSensitive } = require("./redact");
|
|
23
|
+
const { runDoctorChecks, formatDoctorReport } = require("./doctor");
|
|
24
|
+
const {
|
|
25
|
+
loadCrons, saveCrons, scheduleCron, activeCrons,
|
|
26
|
+
} = require("./cron");
|
|
27
|
+
const {
|
|
28
|
+
runClaude, compactActiveSession, getActiveSessionId,
|
|
29
|
+
} = require("./runner");
|
|
30
|
+
const {
|
|
31
|
+
getClaudeOAuthToken, claudeSubprocessEnv, saveClaudeOAuthToken,
|
|
32
|
+
clearClaudeOAuthToken, looksLikeClaudeToken, looksLikeClaudeAuthReply,
|
|
33
|
+
looksLikeOpenAIKey,
|
|
34
|
+
isClaudeAuthErrorText,
|
|
35
|
+
summarizeClaudeAuthStatus,
|
|
36
|
+
sendClaudeAuthStatusSummary,
|
|
37
|
+
runClaudeAuthCommand, clearPendingClaudeAuth,
|
|
38
|
+
sendCodexAuthStatusSummary, runCodexDeviceLogin, clearPendingCodexAuth,
|
|
39
|
+
saveCodexApiKeyWithCli,
|
|
40
|
+
} = require("./auth-flow");
|
|
41
|
+
|
|
42
|
+
const CURRENT_VERSION = require(path.join(__dirname, "..", "package.json")).version;
|
|
43
|
+
|
|
44
|
+
function authorized(envelope) {
|
|
45
|
+
return isChatAuthorized(envelope.channelId);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function ownerEnv(envelope) {
|
|
49
|
+
return isChatOwner(envelope.channelId);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function requireSession() {
|
|
53
|
+
if (!currentState().currentSession) {
|
|
54
|
+
send("Pick a project first:", { keyboard: projectKeyboard() });
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function startSession(name, resumeSessionId) {
|
|
61
|
+
const state = currentState();
|
|
62
|
+
let projectName, projectDir;
|
|
63
|
+
if (name === "__workspace__") {
|
|
64
|
+
projectName = "Workspace";
|
|
65
|
+
projectDir = WORKSPACE;
|
|
66
|
+
} else {
|
|
67
|
+
const result = findProject(name);
|
|
68
|
+
if (!result) return send(`No match for "${name}".`, { keyboard: projectKeyboard() });
|
|
69
|
+
if (Array.isArray(result)) return send("Multiple matches:", { keyboard: { inline_keyboard: result.map((p) => [{ text: p, callback_data: `s:${p}` }]) } });
|
|
70
|
+
projectName = result;
|
|
71
|
+
projectDir = path.join(WORKSPACE, result);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
state.currentSession = { name: projectName, dir: projectDir };
|
|
75
|
+
state.messageQueue = []; resetSettings(state);
|
|
76
|
+
|
|
77
|
+
if (resumeSessionId) {
|
|
78
|
+
state.lastSessionId = resumeSessionId;
|
|
79
|
+
const sessions = getProjectSessions(state.userId, projectName);
|
|
80
|
+
const s = sessions.find((x) => x.id === resumeSessionId);
|
|
81
|
+
const title = s ? s.title : "";
|
|
82
|
+
state.isFirstMessage = false;
|
|
83
|
+
saveState();
|
|
84
|
+
send(`Session: ${projectName}\nResumed: ${title || resumeSessionId.slice(0, 8)}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
85
|
+
} else {
|
|
86
|
+
const last = getLastProjectSession(state.userId, projectName);
|
|
87
|
+
if (last) {
|
|
88
|
+
state.lastSessionId = last.id;
|
|
89
|
+
state.isFirstMessage = false;
|
|
90
|
+
saveState();
|
|
91
|
+
send(`Session: ${projectName}\nResumed: ${last.title}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`, {
|
|
92
|
+
keyboard: { inline_keyboard: [[{ text: "New conversation", callback_data: `new:${projectName}` }]] },
|
|
93
|
+
});
|
|
94
|
+
} else {
|
|
95
|
+
state.lastSessionId = null;
|
|
96
|
+
state.isFirstMessage = true;
|
|
97
|
+
saveState();
|
|
98
|
+
send(`Session: ${projectName}\n\nSend text, voice, or images.\n\n/sessions — switch conversation\n/session — switch project`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Make startSession reachable for the action router.
|
|
104
|
+
module.exports.startSession = startSession;
|
|
105
|
+
|
|
106
|
+
// ── Registry ────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
register({
|
|
109
|
+
name: "start", description: "Start the bot",
|
|
110
|
+
handler: async (env) => {
|
|
111
|
+
if (!authorized(env)) return;
|
|
112
|
+
if (!isOnboarded()) return startOnboarding();
|
|
113
|
+
send("Pick a project to start:", { keyboard: { inline_keyboard: [[{ text: "Pick a project", callback_data: "show:projects" }]] } });
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
register({
|
|
118
|
+
name: "help", description: "Show all commands",
|
|
119
|
+
handler: async (env) => {
|
|
120
|
+
if (!authorized(env)) return;
|
|
121
|
+
send([
|
|
122
|
+
"Session: /session /sessions /projects /continue /status /stop /end",
|
|
123
|
+
"Settings: /model /effort /budget /plan /compact /worktree /mode",
|
|
124
|
+
"Identity: /whoami /link",
|
|
125
|
+
"Automation: /cron /vault /soul",
|
|
126
|
+
"Claude auth: /auth_status /login /setup_token /use_oauth_token /clear_oauth_token",
|
|
127
|
+
"Codex auth: /codex_auth_status /codex_login /codex_setup_token",
|
|
128
|
+
"System: /doctor /requirements /restart /upgrade",
|
|
129
|
+
"",
|
|
130
|
+
"Send text, voice, photos, or files.",
|
|
131
|
+
"Reply to any message for context.",
|
|
132
|
+
].join("\n"));
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
register({
|
|
137
|
+
name: "auth", description: "Request access to this bot",
|
|
138
|
+
authRequired: false,
|
|
139
|
+
handler: async (env) => {
|
|
140
|
+
if (authorized(env)) {
|
|
141
|
+
send("You're already authorized.");
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!hasOwner()) {
|
|
145
|
+
bootstrapOwner({
|
|
146
|
+
chatId: env.channelId,
|
|
147
|
+
name: env.from?.name || "",
|
|
148
|
+
username: env.from?.username || "",
|
|
149
|
+
});
|
|
150
|
+
send("Owner registered — you now have full access.\n\nSend /start to begin.");
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
const result = recordPendingAuthRequest({
|
|
154
|
+
chatId: env.channelId,
|
|
155
|
+
name: env.from?.name || "",
|
|
156
|
+
username: env.from?.username || "",
|
|
157
|
+
});
|
|
158
|
+
if (result.status === "already_pending") {
|
|
159
|
+
send("Your request is already pending. The bot owner will review it.");
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
send("Access requested! The bot owner will review your request.");
|
|
163
|
+
// Notify the owner via their preferred channel.
|
|
164
|
+
const ownerNote = `New auth request from ${authRequestLabel({
|
|
165
|
+
chatId: env.channelId, name: env.from?.name, username: env.from?.username,
|
|
166
|
+
})} (${env.channelId}).`;
|
|
167
|
+
// Only send the inline-keyboard prompt back through the same adapter so
|
|
168
|
+
// the buttons resolve correctly. Cross-channel notifications would need
|
|
169
|
+
// identities lookup — this stays simple for the v1 cutover.
|
|
170
|
+
if (String(env.channelId) !== String(CHAT_ID)) {
|
|
171
|
+
send(ownerNote, {
|
|
172
|
+
keyboard: { inline_keyboard: [[
|
|
173
|
+
{ text: "Approve", callback_data: `auth:approve:${env.channelId}` },
|
|
174
|
+
{ text: "Deny", callback_data: `auth:deny:${env.channelId}` },
|
|
175
|
+
]] },
|
|
176
|
+
chatId: CHAT_ID, // owner; same adapter
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
register({
|
|
183
|
+
name: "whoami", description: "Show your canonical user id",
|
|
184
|
+
handler: async (env) => {
|
|
185
|
+
if (!authorized(env)) return;
|
|
186
|
+
const transport = env.adapter?.type || "telegram";
|
|
187
|
+
const key = channelKey(transport, env.channelId);
|
|
188
|
+
const userId = canonicalForChannel(transport, env.channelId);
|
|
189
|
+
const preferred = identities.preferred[userId];
|
|
190
|
+
send([
|
|
191
|
+
`Channel: ${key}`,
|
|
192
|
+
`User: ${userId}`,
|
|
193
|
+
preferred ? `Preferred: ${preferred.transport}:${preferred.channelId}` : "Preferred: this channel",
|
|
194
|
+
].join("\n"));
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
register({
|
|
199
|
+
name: "links", description: "List identity links (owner)",
|
|
200
|
+
ownerOnly: true,
|
|
201
|
+
handler: async (env) => {
|
|
202
|
+
if (!ownerEnv(env)) return;
|
|
203
|
+
const rows = Object.entries(identities.channels)
|
|
204
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
205
|
+
.map(([channel, userId]) => `${channel} -> ${userId}`);
|
|
206
|
+
send(rows.length ? rows.join("\n") : "No explicit identity links. Unlinked chats use <transport>:<channelId>.");
|
|
207
|
+
},
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
register({
|
|
211
|
+
name: "link", description: "Link this chat to a canonical user id", args: "[<chat-id>] <email-or-id>",
|
|
212
|
+
handler: async (env, { tail }) => {
|
|
213
|
+
if (!authorized(env)) return;
|
|
214
|
+
if (!tail) {
|
|
215
|
+
send(ownerEnv(env)
|
|
216
|
+
? "Usage:\n/link <email-or-user-id>\n/link <chat-id> <email-or-user-id>\n/link <transport>:<chat-id> <email-or-user-id>\n\nOwner can link any chat; other users can link only their current chat."
|
|
217
|
+
: "Usage:\n/link <email-or-user-id>\n\nThis links your chat to a canonical user id.");
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const parts = tail.split(/\s+/).filter(Boolean);
|
|
221
|
+
if (parts.length === 0 || parts.length > 2) return send("Usage: /link <email-or-user-id>");
|
|
222
|
+
|
|
223
|
+
let targetTransport = env.adapter?.type || "telegram";
|
|
224
|
+
let targetChatId = String(env.channelId);
|
|
225
|
+
let userId = parts[0];
|
|
226
|
+
if (parts.length === 2) {
|
|
227
|
+
if (!ownerEnv(env)) return send("Only the owner can link another chat.");
|
|
228
|
+
const channel = parts[0];
|
|
229
|
+
if (channel.includes(":")) {
|
|
230
|
+
const [t, id] = channel.split(":");
|
|
231
|
+
targetTransport = t || targetTransport;
|
|
232
|
+
targetChatId = id;
|
|
233
|
+
} else {
|
|
234
|
+
targetChatId = channel;
|
|
235
|
+
}
|
|
236
|
+
userId = parts[1];
|
|
237
|
+
}
|
|
238
|
+
const normalizedUserId = normalizeCanonicalUserId(userId);
|
|
239
|
+
if (!normalizedUserId || /\s/.test(normalizedUserId)) return send("Canonical user id cannot be empty or contain spaces.");
|
|
240
|
+
const result = linkIdentity(targetTransport, targetChatId, normalizedUserId);
|
|
241
|
+
send([
|
|
242
|
+
`Linked ${result.key} -> ${result.userId}`,
|
|
243
|
+
result.migrated ? `Migrated state from ${result.previousUserId}.` : "Existing canonical state was left in place.",
|
|
244
|
+
].join("\n"));
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
register({
|
|
249
|
+
name: "version", description: "Show current version",
|
|
250
|
+
handler: async (env) => {
|
|
251
|
+
if (!authorized(env)) return;
|
|
252
|
+
send(`Open Claudia v${CURRENT_VERSION}`);
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
register({
|
|
257
|
+
name: "restart", description: "Restart the bot", ownerOnly: true,
|
|
258
|
+
handler: async (env) => {
|
|
259
|
+
if (!ownerEnv(env)) return;
|
|
260
|
+
await send("Going offline for a quick restart — back in a moment.");
|
|
261
|
+
setTimeout(() => process.exit(0), 1000);
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
register({
|
|
266
|
+
name: "upgrade", description: "Upgrade and restart", ownerOnly: true,
|
|
267
|
+
handler: async (env) => {
|
|
268
|
+
if (!ownerEnv(env)) return;
|
|
269
|
+
try { process.chdir(process.env.HOME || require("os").homedir()); } catch (e) {}
|
|
270
|
+
let latest = null;
|
|
271
|
+
try {
|
|
272
|
+
latest = execSync("npm view @inetafrica/open-claudia version", {
|
|
273
|
+
encoding: "utf-8", timeout: 15000,
|
|
274
|
+
env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
|
|
275
|
+
}).trim();
|
|
276
|
+
if (latest === CURRENT_VERSION) {
|
|
277
|
+
await send(`Already on the latest version (v${CURRENT_VERSION}).`);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
await send(`Upgrading v${CURRENT_VERSION} → v${latest}...`);
|
|
281
|
+
} catch (e) {
|
|
282
|
+
await send("Upgrading...");
|
|
283
|
+
}
|
|
284
|
+
const installTarget = latest || "latest";
|
|
285
|
+
try {
|
|
286
|
+
execSync(`npm install -g @inetafrica/open-claudia@${installTarget} 2>&1`, {
|
|
287
|
+
encoding: "utf-8", timeout: 120000,
|
|
288
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
289
|
+
env: { ...process.env, PATH: FULL_PATH, HOME: process.env.HOME || require("os").homedir() },
|
|
290
|
+
});
|
|
291
|
+
try {
|
|
292
|
+
const { ensureSetup: postUpgradeSetup } = require("../health");
|
|
293
|
+
postUpgradeSetup();
|
|
294
|
+
} catch (e) {}
|
|
295
|
+
const root = execSync("npm root -g", { encoding: "utf-8", cwd: process.env.HOME || require("os").homedir(), env: { ...process.env, PATH: FULL_PATH } }).trim();
|
|
296
|
+
const newPkg = JSON.parse(fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "package.json"), "utf-8"));
|
|
297
|
+
let whatsNew = "";
|
|
298
|
+
try {
|
|
299
|
+
const changelog = fs.readFileSync(path.join(root, "@inetafrica", "open-claudia", "CHANGELOG.md"), "utf-8");
|
|
300
|
+
let versionHeader = `## v${newPkg.version}`;
|
|
301
|
+
let start = changelog.indexOf(versionHeader);
|
|
302
|
+
if (start < 0) { versionHeader = `## ${newPkg.version}`; start = changelog.indexOf(versionHeader); }
|
|
303
|
+
if (start >= 0) {
|
|
304
|
+
const afterHeader = changelog.slice(start + versionHeader.length);
|
|
305
|
+
const nextVersion = afterHeader.indexOf("\n## ");
|
|
306
|
+
const section = nextVersion >= 0 ? afterHeader.slice(0, nextVersion) : afterHeader;
|
|
307
|
+
whatsNew = section.trim();
|
|
308
|
+
}
|
|
309
|
+
} catch (e) {}
|
|
310
|
+
const doctorReport = formatDoctorReport(runDoctorChecks());
|
|
311
|
+
const msg = `Installed v${newPkg.version}.${whatsNew ? `\n\nWhat's new:\n${whatsNew}` : ""}\n\nPost-upgrade requirements check:\n${doctorReport}\n\nGoing offline to restart...`;
|
|
312
|
+
await send(msg.length > 3900 ? msg.slice(0, 3900) : msg);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
const errOutput = (e.stdout || e.stderr || e.message || "").slice(-500);
|
|
315
|
+
await send(`Upgrade failed:\n${errOutput}`);
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
setTimeout(() => process.exit(0), 2000);
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
register({
|
|
323
|
+
name: "projects", description: "Browse all workspace projects",
|
|
324
|
+
handler: async (env) => { if (authorized(env)) send("Pick:", { keyboard: projectKeyboard() }); },
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
register({
|
|
328
|
+
name: "session", description: "Pick a project to work on", args: "[<project>]",
|
|
329
|
+
handler: async (env, { tail }) => {
|
|
330
|
+
if (!authorized(env)) return;
|
|
331
|
+
if (tail) return startSession(tail);
|
|
332
|
+
const cs = currentState().currentSession;
|
|
333
|
+
if (cs) send(`Active: ${cs.name}\n\nSwitch?`, { keyboard: projectKeyboard() });
|
|
334
|
+
else send("Pick:", { keyboard: projectKeyboard() });
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
register({
|
|
339
|
+
name: "sessions", description: "List conversations for this project",
|
|
340
|
+
handler: async (env) => {
|
|
341
|
+
if (!authorized(env)) return;
|
|
342
|
+
if (!requireSession()) return;
|
|
343
|
+
const state = currentState();
|
|
344
|
+
const sessions = getProjectSessions(state.userId, state.currentSession.name);
|
|
345
|
+
if (sessions.length === 0) return send("No past conversations for this project.");
|
|
346
|
+
const rows = sessions.slice(0, 10).map((s) => {
|
|
347
|
+
const date = new Date(s.lastUsed).toLocaleDateString();
|
|
348
|
+
const active = state.lastSessionId === s.id ? " (active)" : "";
|
|
349
|
+
return [{ text: `${s.title}${active} — ${date}`, callback_data: `ss:${s.id}` }];
|
|
350
|
+
});
|
|
351
|
+
rows.push([{ text: "New conversation", callback_data: `new:${state.currentSession.name}` }]);
|
|
352
|
+
send(`Conversations in ${state.currentSession.name}:`, { keyboard: { inline_keyboard: rows } });
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
register({
|
|
357
|
+
name: "model", description: "Switch model (opus/sonnet/haiku)", args: "[<model>]",
|
|
358
|
+
handler: async (env, { tail }) => {
|
|
359
|
+
if (!authorized(env)) return;
|
|
360
|
+
if (tail) {
|
|
361
|
+
const { settings } = currentState();
|
|
362
|
+
settings.model = tail.toLowerCase();
|
|
363
|
+
if (settings.model === "default") settings.model = null;
|
|
364
|
+
return send(`Model: ${settings.model || "default"}`);
|
|
365
|
+
}
|
|
366
|
+
const { settings } = currentState();
|
|
367
|
+
const rows = [];
|
|
368
|
+
rows.push([{ text: "── Claude ──", callback_data: "noop" }]);
|
|
369
|
+
rows.push([
|
|
370
|
+
{ text: "Opus 4.7", callback_data: "mb:claude:claude-opus-4-7" },
|
|
371
|
+
{ text: "Opus 4.6", callback_data: "mb:claude:claude-opus-4-6" },
|
|
372
|
+
{ text: "Sonnet 4.6", callback_data: "mb:claude:claude-sonnet-4-6" },
|
|
373
|
+
{ text: "Haiku", callback_data: "mb:claude:claude-haiku-4-5-20251001" },
|
|
374
|
+
]);
|
|
375
|
+
if (resolvedCursorPath) {
|
|
376
|
+
rows.push([{ text: "── Cursor ──", callback_data: "noop" }]);
|
|
377
|
+
rows.push([
|
|
378
|
+
{ text: "Composer 2", callback_data: "mb:cursor:composer-2" },
|
|
379
|
+
{ text: "Composer 2 Fast", callback_data: "mb:cursor:composer-2-fast" },
|
|
380
|
+
{ text: "Auto", callback_data: "mb:cursor:auto" },
|
|
381
|
+
]);
|
|
382
|
+
rows.push([
|
|
383
|
+
{ text: "Opus 4.6 Thinking", callback_data: "mb:cursor:claude-4.6-opus-high-thinking" },
|
|
384
|
+
{ text: "GPT-5.4", callback_data: "mb:cursor:gpt-5.4-medium" },
|
|
385
|
+
]);
|
|
386
|
+
}
|
|
387
|
+
if (resolvedCodexPath) {
|
|
388
|
+
rows.push([{ text: "── Codex ──", callback_data: "noop" }]);
|
|
389
|
+
rows.push([
|
|
390
|
+
{ text: "gpt-5", callback_data: "mb:codex:gpt-5" },
|
|
391
|
+
{ text: "gpt-5-codex", callback_data: "mb:codex:gpt-5-codex" },
|
|
392
|
+
]);
|
|
393
|
+
rows.push([
|
|
394
|
+
{ text: "o3", callback_data: "mb:codex:o3" },
|
|
395
|
+
{ text: "o4-mini", callback_data: "mb:codex:o4-mini" },
|
|
396
|
+
]);
|
|
397
|
+
}
|
|
398
|
+
rows.push([{ text: "Default (current backend)", callback_data: "m:default" }]);
|
|
399
|
+
const beLabel = settings.backend === "cursor" ? "Cursor" : settings.backend === "codex" ? "Codex" : "Claude";
|
|
400
|
+
send(`Current: ${beLabel} · ${settings.model || "default"}\n\nPick a model — backend switches automatically.\nOr type /model <name>.`, { keyboard: { inline_keyboard: rows } });
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
register({
|
|
405
|
+
name: "effort", description: "Set effort level", args: "[low|medium|high|max]",
|
|
406
|
+
handler: async (env, { tail }) => {
|
|
407
|
+
if (!authorized(env)) return;
|
|
408
|
+
const { settings } = currentState();
|
|
409
|
+
if (settings.backend === "cursor") return send("Effort levels are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
|
|
410
|
+
if (settings.backend === "codex") return send("Effort levels are not exposed as a flag on Codex.\nSet `model_reasoning_effort` in ~/.codex/config.toml, or switch to /claude.");
|
|
411
|
+
if (tail) {
|
|
412
|
+
const e = tail.toLowerCase();
|
|
413
|
+
settings.effort = ["low", "medium", "high", "max"].includes(e) ? e : null;
|
|
414
|
+
return send(`Effort: ${settings.effort || "default"}`);
|
|
415
|
+
}
|
|
416
|
+
send(`Effort: ${settings.effort || "default"}`, {
|
|
417
|
+
keyboard: { inline_keyboard: [
|
|
418
|
+
[{ text: "Low", callback_data: "e:low" }, { text: "Med", callback_data: "e:medium" }, { text: "High", callback_data: "e:high" }, { text: "Max", callback_data: "e:max" }],
|
|
419
|
+
[{ text: "Default", callback_data: "e:default" }],
|
|
420
|
+
] },
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
register({
|
|
426
|
+
name: "budget", description: "Set max spend for next task", args: "[$N]",
|
|
427
|
+
handler: async (env, { tail }) => {
|
|
428
|
+
if (!authorized(env)) return;
|
|
429
|
+
const { settings } = currentState();
|
|
430
|
+
if (settings.backend === "cursor") return send("Budget limits are not supported on Cursor Agent.\nSwitch to Claude with /claude to use this.");
|
|
431
|
+
if (settings.backend === "codex") return send("Budget limits are not supported on Codex.\nSwitch to Claude with /claude to use this.");
|
|
432
|
+
if (tail) {
|
|
433
|
+
const v = parseFloat(tail.replace("$", ""));
|
|
434
|
+
settings.budget = v > 0 ? v : null;
|
|
435
|
+
return send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`);
|
|
436
|
+
}
|
|
437
|
+
send(`Budget: ${settings.budget ? "$" + settings.budget : "none"}`, {
|
|
438
|
+
keyboard: { inline_keyboard: [
|
|
439
|
+
[{ text: "$1", callback_data: "b:1" }, { text: "$5", callback_data: "b:5" }, { text: "$10", callback_data: "b:10" }, { text: "$25", callback_data: "b:25" }],
|
|
440
|
+
[{ text: "No limit", callback_data: "b:none" }],
|
|
441
|
+
] },
|
|
442
|
+
});
|
|
443
|
+
},
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
register({
|
|
447
|
+
name: "plan", description: "Toggle plan mode",
|
|
448
|
+
handler: async (env) => {
|
|
449
|
+
if (!authorized(env)) return;
|
|
450
|
+
const state = currentState();
|
|
451
|
+
const { settings } = state;
|
|
452
|
+
const p = settings.permissionMode === "plan";
|
|
453
|
+
settings.permissionMode = p ? null : "plan";
|
|
454
|
+
const label = settings.backend === "cursor" ? "read-only planning, no edits"
|
|
455
|
+
: settings.backend === "codex" ? "read-only sandbox, no edits"
|
|
456
|
+
: "plan permission mode";
|
|
457
|
+
if (p) {
|
|
458
|
+
if (settings.backend === "cursor") state.cursorSessionId = null;
|
|
459
|
+
else if (settings.backend === "codex") state.codexSessionId = null;
|
|
460
|
+
}
|
|
461
|
+
send(p ? "Plan mode off (session reset)." : `Plan mode on (${label}).`);
|
|
462
|
+
},
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
register({
|
|
466
|
+
name: "ask", description: "Toggle ask mode (Cursor only)",
|
|
467
|
+
handler: async (env) => {
|
|
468
|
+
if (!authorized(env)) return;
|
|
469
|
+
const { settings } = currentState();
|
|
470
|
+
if (settings.backend !== "cursor") return send("Ask mode is only available on Cursor Agent.\nUse /cursor to switch.");
|
|
471
|
+
const a = settings.permissionMode === "ask";
|
|
472
|
+
settings.permissionMode = a ? null : "ask";
|
|
473
|
+
send(a ? "Ask mode off." : "Ask mode on (read-only Q&A, no edits).");
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
register({
|
|
478
|
+
name: "compact", description: "Summarize conversation context",
|
|
479
|
+
handler: async (env) => {
|
|
480
|
+
if (!authorized(env)) return;
|
|
481
|
+
if (!requireSession()) return;
|
|
482
|
+
if (!getActiveSessionId()) return send("No conversation.");
|
|
483
|
+
try {
|
|
484
|
+
const result = await compactActiveSession(currentState().currentSession.dir, {
|
|
485
|
+
notify: true,
|
|
486
|
+
message: "Compacting this conversation into a fresh session…",
|
|
487
|
+
});
|
|
488
|
+
if (result.compacted) await send(`Compacted into a fresh session${result.newSessionId ? ` (${result.newSessionId.slice(0, 8)}…)` : ""}. Continue normally.`, { replyTo: env.messageId });
|
|
489
|
+
else await send(result.reason || "Could not compact.", { replyTo: env.messageId });
|
|
490
|
+
} catch (e) {
|
|
491
|
+
await send(`Compaction failed: ${redactSensitive(e.message)}`, { replyTo: env.messageId });
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
|
|
496
|
+
register({
|
|
497
|
+
name: "continue", description: "Resume last conversation",
|
|
498
|
+
handler: async (env) => {
|
|
499
|
+
if (!authorized(env)) return;
|
|
500
|
+
if (!requireSession()) return;
|
|
501
|
+
if (!getActiveSessionId()) return send("No conversation to continue.");
|
|
502
|
+
await runClaude("continue where we left off", currentState().currentSession.dir, env.messageId);
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
register({
|
|
507
|
+
name: "worktree", description: "Toggle isolated git branch",
|
|
508
|
+
handler: async (env) => {
|
|
509
|
+
if (!authorized(env)) return;
|
|
510
|
+
const { settings } = currentState();
|
|
511
|
+
settings.worktree = !settings.worktree;
|
|
512
|
+
send(settings.worktree ? "Worktree on." : "Worktree off.");
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
register({
|
|
517
|
+
name: "cursor", description: "Switch to Cursor Agent backend",
|
|
518
|
+
handler: async (env) => {
|
|
519
|
+
if (!authorized(env)) return;
|
|
520
|
+
if (!resolvedCursorPath) return send("Cursor Agent CLI not found.\nSet CURSOR_PATH in .env or install: https://docs.cursor.com/agent");
|
|
521
|
+
const state = currentState();
|
|
522
|
+
state.settings.backend = "cursor";
|
|
523
|
+
state.settings.model = null;
|
|
524
|
+
saveState();
|
|
525
|
+
const sid = state.cursorSessionId ? `\nSession: ${state.cursorSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
526
|
+
send(`Switched to Cursor Agent.${sid}\n\n/claude — switch back · /codex — Codex`);
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
register({
|
|
531
|
+
name: "claude", description: "Switch to Claude Code backend",
|
|
532
|
+
handler: async (env) => {
|
|
533
|
+
if (!authorized(env)) return;
|
|
534
|
+
const state = currentState();
|
|
535
|
+
state.settings.backend = "claude";
|
|
536
|
+
state.settings.model = null;
|
|
537
|
+
saveState();
|
|
538
|
+
const sid = state.lastSessionId ? `\nSession: ${state.lastSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
539
|
+
send(`Switched to Claude Code.${sid}\n\n/cursor — Cursor · /codex — Codex`);
|
|
540
|
+
},
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
register({
|
|
544
|
+
name: "codex", description: "Switch to OpenAI Codex backend",
|
|
545
|
+
handler: async (env) => {
|
|
546
|
+
if (!authorized(env)) return;
|
|
547
|
+
if (!resolvedCodexPath) return send("Codex CLI not found.\nInstall: npm install -g @openai/codex\nThen run `codex login` to authenticate.");
|
|
548
|
+
const state = currentState();
|
|
549
|
+
state.settings.backend = "codex";
|
|
550
|
+
state.settings.model = null;
|
|
551
|
+
saveState();
|
|
552
|
+
const sid = state.codexSessionId ? `\nSession: ${state.codexSessionId.slice(0, 8)}...` : "\nNew session.";
|
|
553
|
+
send(`Switched to Codex.${sid}\n\n/claude — Claude · /cursor — Cursor`);
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
register({
|
|
558
|
+
name: "backend", description: "Show/switch active backend",
|
|
559
|
+
handler: async (env) => {
|
|
560
|
+
if (!authorized(env)) return;
|
|
561
|
+
const { settings } = currentState();
|
|
562
|
+
const label = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
|
|
563
|
+
const cursorAvail = resolvedCursorPath ? "available" : "not found";
|
|
564
|
+
const codexAvail = resolvedCodexPath ? "available" : "not found";
|
|
565
|
+
send(`Backend: ${label}\n\nClaude Code: available\nCursor Agent: ${cursorAvail}\nCodex: ${codexAvail}`, {
|
|
566
|
+
keyboard: { inline_keyboard: [
|
|
567
|
+
[{ text: "Claude Code", callback_data: "be:claude" }, { text: "Cursor Agent", callback_data: "be:cursor" }, { text: "Codex", callback_data: "be:codex" }],
|
|
568
|
+
] },
|
|
569
|
+
});
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
register({
|
|
574
|
+
name: "mode", description: "Bot mode (direct/agent)",
|
|
575
|
+
handler: async (env) => {
|
|
576
|
+
if (!authorized(env)) return;
|
|
577
|
+
await send("Bot mode: *direct* (default)\n\nSwitch to agent mode for non-blocking execution.\nIn agent mode, heavy tasks run in the background and you can keep chatting.", {
|
|
578
|
+
parseMode: "Markdown",
|
|
579
|
+
keyboard: { inline_keyboard: [[{ text: "Switch to Agent Mode", callback_data: "mode:agent" }]] },
|
|
580
|
+
});
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
register({
|
|
585
|
+
name: "stop", description: "Cancel running task",
|
|
586
|
+
handler: async (env) => {
|
|
587
|
+
if (!authorized(env)) return;
|
|
588
|
+
const state = currentState();
|
|
589
|
+
if (state.runningProcess) {
|
|
590
|
+
const pid = state.runningProcess.pid;
|
|
591
|
+
const { killProcessTree } = require("./process-tree");
|
|
592
|
+
killProcessTree(pid, "SIGTERM");
|
|
593
|
+
setTimeout(() => killProcessTree(pid, "SIGKILL"), 3000);
|
|
594
|
+
state.runningProcess = null;
|
|
595
|
+
if (state.streamInterval) clearTimeout(state.streamInterval);
|
|
596
|
+
state.messageQueue = [];
|
|
597
|
+
await send("Cancelled.");
|
|
598
|
+
} else await send("Nothing running.");
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
register({
|
|
603
|
+
name: "doctor", aliases: ["requirements"], description: "Check CLI requirements",
|
|
604
|
+
handler: async (env) => {
|
|
605
|
+
if (!authorized(env)) return;
|
|
606
|
+
await send(formatDoctorReport(runDoctorChecks()));
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
register({
|
|
611
|
+
name: "status", description: "Session & settings info",
|
|
612
|
+
handler: async (env) => {
|
|
613
|
+
if (!authorized(env)) return;
|
|
614
|
+
const state = currentState();
|
|
615
|
+
if (!state.currentSession) return send("No session.", { keyboard: { inline_keyboard: [[{ text: "Pick", callback_data: "show:projects" }]] } });
|
|
616
|
+
const { settings } = state;
|
|
617
|
+
const backendLabel = settings.backend === "cursor" ? "Cursor Agent" : settings.backend === "codex" ? "Codex" : "Claude Code";
|
|
618
|
+
send([
|
|
619
|
+
`Project: ${state.currentSession.name}`,
|
|
620
|
+
`Backend: ${backendLabel}`,
|
|
621
|
+
`Model: ${settings.model || "default"} | Effort: ${settings.effort || "default"}`,
|
|
622
|
+
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"} | Crons: ${activeCrons.size}`,
|
|
623
|
+
state.runningProcess ? "Working..." : "Ready.",
|
|
624
|
+
].join("\n"));
|
|
625
|
+
},
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
register({
|
|
629
|
+
name: "end", description: "End current session",
|
|
630
|
+
handler: async (env) => {
|
|
631
|
+
if (!authorized(env)) return;
|
|
632
|
+
const state = currentState();
|
|
633
|
+
if (state.currentSession) {
|
|
634
|
+
const n = state.currentSession.name;
|
|
635
|
+
state.currentSession = null;
|
|
636
|
+
state.lastSessionId = null;
|
|
637
|
+
state.messageQueue = [];
|
|
638
|
+
resetSettings(state);
|
|
639
|
+
resetSessionUsage(state);
|
|
640
|
+
saveState();
|
|
641
|
+
send(`Ended: ${n}`, { keyboard: { inline_keyboard: [[{ text: "New session", callback_data: "show:projects" }]] } });
|
|
642
|
+
} else send("No session.");
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
register({
|
|
647
|
+
name: "usage", description: "Show token usage & cost for this session",
|
|
648
|
+
handler: async (env) => {
|
|
649
|
+
if (!authorized(env)) return;
|
|
650
|
+
const u = currentState().sessionUsage;
|
|
651
|
+
if (u.turns === 0) return send("No usage yet in this session.");
|
|
652
|
+
const fmt = (n) => n >= 1000 ? (n / 1000).toFixed(1) + "k" : String(n);
|
|
653
|
+
const lines = [
|
|
654
|
+
`*Session usage* (${u.turns} turn${u.turns === 1 ? "" : "s"})`,
|
|
655
|
+
`Input: ${fmt(u.inputTokens)} · Output: ${fmt(u.outputTokens)}`,
|
|
656
|
+
`Cache read: ${fmt(u.cacheReadTokens)} · Cache write: ${fmt(u.cacheCreationTokens)}`,
|
|
657
|
+
`Cost: $${u.costUsd.toFixed(4)}`,
|
|
658
|
+
`Last turn context: ${fmt(u.lastInputTokens)}`,
|
|
659
|
+
];
|
|
660
|
+
if (u.lastInputTokens > 200000) lines.push(`\nTip: context is large. The bot auto-compacts after the next reply at ${fmt(AUTO_COMPACT_TOKENS)} tokens; /compact does it now.`);
|
|
661
|
+
send(lines.join("\n"), { parseMode: "Markdown" });
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
register({
|
|
666
|
+
name: "soul", description: "View/edit assistant identity",
|
|
667
|
+
handler: async (env) => {
|
|
668
|
+
if (!authorized(env)) return;
|
|
669
|
+
let soul;
|
|
670
|
+
try { soul = fs.readFileSync(SOUL_FILE, "utf-8"); } catch (e) { soul = ""; }
|
|
671
|
+
const preview = soul.length > 3000 ? soul.slice(0, 3000) + "\n..." : (soul || "(empty)");
|
|
672
|
+
await send(preview);
|
|
673
|
+
await send(`Edit: ${SOUL_FILE}\nOr tell me what to change and I'll update it.`);
|
|
674
|
+
},
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
// ── Claude auth ─────────────────────────────────────────────────────
|
|
678
|
+
|
|
679
|
+
register({
|
|
680
|
+
name: "auth_status", aliases: ["auth status"], description: "Check Claude Code auth",
|
|
681
|
+
handler: async (env) => {
|
|
682
|
+
if (!authorized(env)) return;
|
|
683
|
+
const { spawn } = require("child_process");
|
|
684
|
+
const { CLAUDE_PATH } = require("./config");
|
|
685
|
+
const tokenInfo = getClaudeOAuthToken();
|
|
686
|
+
const proc = spawn(CLAUDE_PATH, ["auth", "status"], {
|
|
687
|
+
cwd: process.env.HOME || require("os").homedir(),
|
|
688
|
+
env: claudeSubprocessEnv(),
|
|
689
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
690
|
+
});
|
|
691
|
+
let output = "";
|
|
692
|
+
proc.stdout.on("data", (d) => { output += d.toString(); });
|
|
693
|
+
proc.stderr.on("data", (d) => { output += d.toString(); });
|
|
694
|
+
await new Promise((resolve) => {
|
|
695
|
+
proc.on("close", async (code) => {
|
|
696
|
+
const clean = redactSensitive(output.trim()) || "(no output)";
|
|
697
|
+
await send([
|
|
698
|
+
`Claude auth status: exit ${code}`,
|
|
699
|
+
...summarizeClaudeAuthStatus(output, code, tokenInfo),
|
|
700
|
+
`Bot OAuth token: ${tokenInfo.value ? "configured via " + tokenInfo.source : "not configured"}`,
|
|
701
|
+
`Vault: ${vault.isUnlocked() ? "unlocked" : "locked"}`,
|
|
702
|
+
"",
|
|
703
|
+
clean.slice(-2500),
|
|
704
|
+
].join("\n"));
|
|
705
|
+
resolve();
|
|
706
|
+
});
|
|
707
|
+
proc.on("error", async (err) => { await send(`Claude auth status failed: ${redactSensitive(err.message)}`); resolve(); });
|
|
708
|
+
});
|
|
709
|
+
},
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
register({
|
|
713
|
+
name: "cancel_auth", description: "Cancel a pending Claude login flow", ownerOnly: true,
|
|
714
|
+
handler: async (env) => {
|
|
715
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
716
|
+
const state = currentState();
|
|
717
|
+
if (!state.pendingClaudeAuthProcess) return send("No Claude auth flow is pending.");
|
|
718
|
+
clearPendingClaudeAuth(state);
|
|
719
|
+
await send("Claude auth flow cancelled. Normal messages will go to the assistant again.");
|
|
720
|
+
},
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
register({
|
|
724
|
+
name: "auth_code", description: "Send a Claude login code", args: "<code>", ownerOnly: true,
|
|
725
|
+
handler: async (env, { tail }) => {
|
|
726
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
727
|
+
const state = currentState();
|
|
728
|
+
const code = (tail || "").trim();
|
|
729
|
+
await deleteMessage(env.messageId);
|
|
730
|
+
if (!state.pendingClaudeAuthProcess || state.pendingClaudeAuthLabel === "manual OAuth token save") {
|
|
731
|
+
return send("No Claude login flow is waiting for an auth code. Start with /login, or use /use_oauth_token for tokens.");
|
|
732
|
+
}
|
|
733
|
+
if (!code || !looksLikeClaudeAuthReply(code)) {
|
|
734
|
+
return send("That does not look like a Claude auth code/callback. Use /cancel_auth to cancel the login flow.");
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
state.pendingClaudeAuthProcess.stdin.write(code + "\n");
|
|
738
|
+
await send("Auth code sent to Claude. I'll confirm with auth status when Claude finishes.");
|
|
739
|
+
} catch (e) {
|
|
740
|
+
clearPendingClaudeAuth(state);
|
|
741
|
+
await send(`Could not send auth code to Claude: ${redactSensitive(e.message)}`);
|
|
742
|
+
}
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
register({
|
|
747
|
+
name: "login", description: "Start Claude Code login", ownerOnly: true,
|
|
748
|
+
handler: async (env) => {
|
|
749
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
750
|
+
await runClaudeAuthCommand(["auth", "login", "--claudeai", "--email", "sumeet@inet.africa"], "Claude login");
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
register({
|
|
755
|
+
name: "setup_token", description: "Create Claude OAuth token", ownerOnly: true,
|
|
756
|
+
handler: async (env) => {
|
|
757
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
758
|
+
await runClaudeAuthCommand(["setup-token"], "Claude setup-token", { captureToken: true });
|
|
759
|
+
},
|
|
760
|
+
});
|
|
761
|
+
|
|
762
|
+
register({
|
|
763
|
+
name: "use_oauth_token", description: "Paste a Claude OAuth token", args: "[<token>]", ownerOnly: true,
|
|
764
|
+
handler: async (env, { tail }) => {
|
|
765
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
766
|
+
const state = currentState();
|
|
767
|
+
const token = (tail || "").trim();
|
|
768
|
+
await deleteMessage(env.messageId);
|
|
769
|
+
if (!token) {
|
|
770
|
+
state.pendingClaudeAuthProcess = { stdin: { write: (value) => saveClaudeOAuthToken(value.trim()) } };
|
|
771
|
+
state.pendingClaudeAuthLabel = "manual OAuth token save";
|
|
772
|
+
await send("Send the Claude OAuth token in your next message. I'll delete it and store it without echoing it.");
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
if (!looksLikeClaudeToken(token)) return send("That doesn't look like a Claude OAuth token. Not saved.");
|
|
776
|
+
saveClaudeOAuthToken(token);
|
|
777
|
+
await send(`Claude OAuth token stored in .env${vault.isUnlocked() ? " and vault" : ""}. Restart the bot so launchd picks it up, or use /restart.`);
|
|
778
|
+
await sendClaudeAuthStatusSummary("Stored token. Current Claude auth status:");
|
|
779
|
+
},
|
|
780
|
+
});
|
|
781
|
+
|
|
782
|
+
register({
|
|
783
|
+
name: "clear_oauth_token", description: "Clear stored Claude OAuth token", ownerOnly: true,
|
|
784
|
+
handler: async (env) => {
|
|
785
|
+
if (!ownerEnv(env)) return send("Owner only — Claude auth is shared across users.");
|
|
786
|
+
clearClaudeOAuthToken();
|
|
787
|
+
await send("Claude OAuth token cleared from .env/process" + (vault.isUnlocked() ? " and vault." : ". Unlock vault and run again if you also stored it there."));
|
|
788
|
+
},
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
register({
|
|
792
|
+
name: "codex_auth_status", aliases: ["codex auth status"], description: "Check Codex auth",
|
|
793
|
+
handler: async (env) => {
|
|
794
|
+
if (!authorized(env)) return;
|
|
795
|
+
await sendCodexAuthStatusSummary();
|
|
796
|
+
},
|
|
797
|
+
});
|
|
798
|
+
|
|
799
|
+
register({
|
|
800
|
+
name: "codex_login", description: "Start Codex device login", ownerOnly: true,
|
|
801
|
+
handler: async (env) => {
|
|
802
|
+
if (!ownerEnv(env)) return send("Owner only — Codex auth is shared across users.");
|
|
803
|
+
await runCodexDeviceLogin();
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
register({
|
|
808
|
+
name: "cancel_codex_auth", description: "Cancel a pending Codex login flow", ownerOnly: true,
|
|
809
|
+
handler: async (env) => {
|
|
810
|
+
if (!ownerEnv(env)) return send("Owner only — Codex auth is shared across users.");
|
|
811
|
+
const state = currentState();
|
|
812
|
+
if (!state.pendingCodexAuthProcess) return send("No Codex auth flow is pending.");
|
|
813
|
+
clearPendingCodexAuth(state);
|
|
814
|
+
await send("Codex auth flow cancelled. Normal messages will go to the assistant again.");
|
|
815
|
+
},
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
register({
|
|
819
|
+
name: "codex_setup_token", aliases: ["codex_use_api_key"], description: "Save an OpenAI API key", args: "[<key>]", ownerOnly: true,
|
|
820
|
+
handler: async (env, { tail }) => {
|
|
821
|
+
if (!ownerEnv(env)) return send("Owner only — Codex auth is shared across users.");
|
|
822
|
+
if (!resolvedCodexPath) return send("Codex CLI not found. Install: npm install -g @openai/codex");
|
|
823
|
+
const key = (tail || "").trim();
|
|
824
|
+
await deleteMessage(env.messageId);
|
|
825
|
+
if (!key) {
|
|
826
|
+
const state = currentState();
|
|
827
|
+
state.pendingCodexAuthProcess = { kill: () => {} };
|
|
828
|
+
state.pendingCodexAuthLabel = "manual OpenAI API key save";
|
|
829
|
+
await send("Send the OpenAI API key in your next message. I'll delete it and pass it to `codex login --with-api-key` without echoing it.");
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (!looksLikeOpenAIKey(key)) return send("That does not look like an OpenAI API key. Not saved.");
|
|
833
|
+
const result = await saveCodexApiKeyWithCli(key);
|
|
834
|
+
await send(result.ok ? "Codex API key stored by the Codex CLI. I did not print it." : `Codex CLI could not store the API key: ${redactSensitive(result.output).slice(-800)}`);
|
|
835
|
+
await sendCodexAuthStatusSummary("Current Codex auth status:");
|
|
836
|
+
},
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
// ── Vault ──────────────────────────────────────────────────────────
|
|
840
|
+
|
|
841
|
+
register({
|
|
842
|
+
name: "vault", description: "Manage credentials (password required)", args: "[set|remove|lock] ...",
|
|
843
|
+
handler: async (env, { tail }) => {
|
|
844
|
+
if (!authorized(env)) return;
|
|
845
|
+
const state = currentState();
|
|
846
|
+
|
|
847
|
+
if (!tail) {
|
|
848
|
+
if (!vault.exists()) return send("No vault found. Run setup first: node setup.js");
|
|
849
|
+
if (vault.isUnlocked()) {
|
|
850
|
+
const entries = vault.list();
|
|
851
|
+
const keys = Object.keys(entries);
|
|
852
|
+
if (keys.length === 0) await send("Vault is unlocked but empty.\n\nUse /vault set <name> <value>");
|
|
853
|
+
else await send("Vault (unlocked):\n\n" + keys.map((k) => `${k}: ${entries[k]}`).join("\n") + "\n\nLocks automatically in 5 min.");
|
|
854
|
+
} else {
|
|
855
|
+
state.pendingVaultUnlock = true;
|
|
856
|
+
state.pendingVaultAction = { type: "list" };
|
|
857
|
+
await send("Vault is locked. Send your vault password.\n(Message will be deleted after reading)");
|
|
858
|
+
}
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (tail === "lock") {
|
|
863
|
+
vault.lock();
|
|
864
|
+
return send("Vault locked.");
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const setMatch = tail.match(/^set\s+(\S+)\s+(.+)$/);
|
|
868
|
+
if (setMatch) {
|
|
869
|
+
await deleteMessage(env.messageId);
|
|
870
|
+
if (vault.isUnlocked()) {
|
|
871
|
+
vault.set(setMatch[1], setMatch[2].trim());
|
|
872
|
+
return send(`Saved: ${setMatch[1]}`);
|
|
873
|
+
}
|
|
874
|
+
state.pendingVaultUnlock = true;
|
|
875
|
+
state.pendingVaultAction = { type: "set", key: setMatch[1], value: setMatch[2].trim() };
|
|
876
|
+
return send("Vault locked. Send password to unlock.");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
const removeMatch = tail.match(/^remove\s+(\S+)$/);
|
|
880
|
+
if (removeMatch) {
|
|
881
|
+
if (vault.isUnlocked()) {
|
|
882
|
+
vault.remove(removeMatch[1]);
|
|
883
|
+
return send(`Removed: ${removeMatch[1]}`);
|
|
884
|
+
}
|
|
885
|
+
state.pendingVaultUnlock = true;
|
|
886
|
+
state.pendingVaultAction = { type: "remove", key: removeMatch[1] };
|
|
887
|
+
return send("Vault locked. Send password to unlock.");
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
send("Usage: /vault | /vault set <name> <value> | /vault remove <name> | /vault lock");
|
|
891
|
+
},
|
|
892
|
+
});
|
|
893
|
+
|
|
894
|
+
// ── Cron ───────────────────────────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
register({
|
|
897
|
+
name: "cron", description: "Manage scheduled tasks", args: "[add|remove ...]",
|
|
898
|
+
handler: async (env, { tail }) => {
|
|
899
|
+
if (!authorized(env)) return;
|
|
900
|
+
if (!tail) {
|
|
901
|
+
const list = loadCrons();
|
|
902
|
+
if (list.length === 0) {
|
|
903
|
+
return send("No crons.\n\nAdd: /cron add \"<schedule>\" <project> \"<prompt>\"\n\nOr pick a preset:", {
|
|
904
|
+
keyboard: { inline_keyboard: [
|
|
905
|
+
[{ text: "Standup 9am", callback_data: "cp:standup" }, { text: "Git digest 6pm", callback_data: "cp:git" }],
|
|
906
|
+
[{ text: "Dep check Mon", callback_data: "cp:deps" }, { text: "Health 30min", callback_data: "cp:health" }],
|
|
907
|
+
] },
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
return send("Crons:\n\n" + list.map((c, i) => `${i + 1}. ${c.label} (${c.schedule}) — ${c.project}`).join("\n") + "\n\nRemove: /cron remove <#>");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
const addMatch = tail.match(/^add\s+"(.+)"\s+(\S+)\s+"(.+)"$/);
|
|
914
|
+
if (addMatch) {
|
|
915
|
+
if (!cronLib.validate(addMatch[1])) return send("Invalid cron schedule.");
|
|
916
|
+
const proj = findProject(addMatch[2]);
|
|
917
|
+
if (!proj || Array.isArray(proj)) return send("Project not found.");
|
|
918
|
+
const c = { id: `cron_${Date.now()}`, schedule: addMatch[1], project: proj, prompt: addMatch[3], label: addMatch[3].slice(0, 50) };
|
|
919
|
+
const list = loadCrons(); list.push(c); saveCrons(list); scheduleCron(c);
|
|
920
|
+
return send(`Added: ${c.label} (${c.schedule}) for ${proj}`);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
const removeMatch = tail.match(/^remove\s+(\d+)$/);
|
|
924
|
+
if (removeMatch) {
|
|
925
|
+
const list = loadCrons();
|
|
926
|
+
const idx = parseInt(removeMatch[1], 10) - 1;
|
|
927
|
+
if (idx < 0 || idx >= list.length) return send("Invalid number.");
|
|
928
|
+
const removed = list.splice(idx, 1)[0]; saveCrons(list);
|
|
929
|
+
if (activeCrons.has(removed.id)) { activeCrons.get(removed.id).task.stop(); activeCrons.delete(removed.id); }
|
|
930
|
+
return send(`Removed: ${removed.label}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
send('Usage: /cron | /cron add "<schedule>" <project> "<prompt>" | /cron remove <#>');
|
|
934
|
+
},
|
|
935
|
+
});
|
|
936
|
+
|
|
937
|
+
// ── Channels ───────────────────────────────────────────────────────
|
|
938
|
+
|
|
939
|
+
register({
|
|
940
|
+
name: "channel", description: "Add or remove channels (Kazee, etc.)", args: "[add kazee | remove <id>]", ownerOnly: true,
|
|
941
|
+
handler: async (env, { tail }) => {
|
|
942
|
+
if (!ownerEnv(env)) return send("Owner only — channels are bot-wide.");
|
|
943
|
+
const registry = require("./adapter-registry");
|
|
944
|
+
const wizard = require("./channel-wizard");
|
|
945
|
+
|
|
946
|
+
if (!tail) {
|
|
947
|
+
const live = registry.getAdapters();
|
|
948
|
+
const lines = ["Active channels:"];
|
|
949
|
+
for (const a of live) lines.push(` ${a.id} (${a.type})`);
|
|
950
|
+
if (live.length === 0) lines.push(" (none)");
|
|
951
|
+
lines.push("", "Add Kazee with /channel add kazee.");
|
|
952
|
+
const buttons = [[{ text: "Add Kazee", callback_data: "chn:add:kazee" }]];
|
|
953
|
+
const removable = live.filter((a) => a.type !== "telegram");
|
|
954
|
+
for (const a of removable) {
|
|
955
|
+
buttons.push([{ text: `Remove ${a.id}`, callback_data: `chn:rm:${a.id}` }]);
|
|
956
|
+
}
|
|
957
|
+
return send(lines.join("\n"), { keyboard: { inline_keyboard: buttons } });
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const addMatch = tail.match(/^add\s+(\S+)$/i);
|
|
961
|
+
if (addMatch) {
|
|
962
|
+
const kind = addMatch[1].toLowerCase();
|
|
963
|
+
if (kind !== "kazee") return send(`Channel type "${kind}" not supported.`);
|
|
964
|
+
await wizard.start(env.canonicalUserId, "kazee");
|
|
965
|
+
return;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
const removeMatch = tail.match(/^remove\s+(\S+)$/i);
|
|
969
|
+
if (removeMatch) {
|
|
970
|
+
const id = removeMatch[1];
|
|
971
|
+
if (id === "telegram") return send("Refusing to remove the Telegram adapter from a /channel command — that's your own connection.");
|
|
972
|
+
const result = await registry.removeAdapter(id);
|
|
973
|
+
if (!result.ok) return send(result.error);
|
|
974
|
+
// Strip from .env so the change survives restart.
|
|
975
|
+
const channels = (config.CHANNELS || "telegram").split(",").map((s) => s.trim()).filter(Boolean);
|
|
976
|
+
const kept = channels.filter((c) => c !== id && !c.startsWith(id + ":") && c !== id.split(":")[0]);
|
|
977
|
+
const next = kept.join(",") || "telegram";
|
|
978
|
+
saveEnvKey("CHANNELS", next);
|
|
979
|
+
config.CHANNELS = next;
|
|
980
|
+
if (id === "kazee") {
|
|
981
|
+
saveEnvKey("KAZEE_URL", "");
|
|
982
|
+
saveEnvKey("KAZEE_BOT_TOKEN", "");
|
|
983
|
+
saveEnvKey("KAZEE_OWNER_USER_ID", "");
|
|
984
|
+
config.KAZEE_URL = "";
|
|
985
|
+
config.KAZEE_BOT_TOKEN = "";
|
|
986
|
+
config.KAZEE_OWNER_USER_ID = "";
|
|
987
|
+
}
|
|
988
|
+
return send(`Removed channel: ${id}`);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
send("Usage: /channel | /channel add kazee | /channel remove <id>");
|
|
992
|
+
},
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
module.exports.startSession = startSession;
|