@gonzih/cc-discord 0.1.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.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Claude Code subprocess wrapper.
3
+ * Mirrors ce_ce's mechanism: spawn `claude` CLI with stream-json I/O,
4
+ * pipe prompts in, parse streaming JSON messages out.
5
+ */
6
+ import { EventEmitter } from "events";
7
+ export type MessageType = "system" | "assistant" | "user" | "result";
8
+ export interface ClaudeMessage {
9
+ type: MessageType;
10
+ session_id?: string;
11
+ uuid?: string;
12
+ payload: Record<string, unknown>;
13
+ raw: Record<string, unknown>;
14
+ }
15
+ export interface ClaudeOptions {
16
+ cwd?: string;
17
+ systemPrompt?: string;
18
+ /** OAuth token (sk-ant-oat01-...) or API key (sk-ant-api03-...) */
19
+ token?: string;
20
+ }
21
+ export interface UsageEvent {
22
+ inputTokens: number;
23
+ outputTokens: number;
24
+ cacheReadTokens: number;
25
+ cacheWriteTokens: number;
26
+ }
27
+ export declare interface ClaudeProcess {
28
+ on(event: "message", listener: (msg: ClaudeMessage) => void): this;
29
+ on(event: "usage", listener: (usage: UsageEvent) => void): this;
30
+ on(event: "error", listener: (err: Error) => void): this;
31
+ on(event: "exit", listener: (code: number | null) => void): this;
32
+ on(event: "stderr", listener: (data: string) => void): this;
33
+ }
34
+ export declare class ClaudeProcess extends EventEmitter {
35
+ private proc;
36
+ private buffer;
37
+ private _exited;
38
+ constructor(opts?: ClaudeOptions);
39
+ sendPrompt(text: string): void;
40
+ /**
41
+ * Send an image (with optional text caption) to Claude via stream-json content blocks.
42
+ * mediaType: image/jpeg | image/png | image/gif | image/webp
43
+ */
44
+ sendImage(base64Data: string, mediaType: string, caption?: string): void;
45
+ kill(): void;
46
+ get exited(): boolean;
47
+ private drainBuffer;
48
+ private parseMessage;
49
+ }
50
+ /**
51
+ * Extract the text content from an assistant message payload.
52
+ * Handles both simple string content and content-block arrays.
53
+ */
54
+ export declare function extractText(msg: ClaudeMessage): string;
package/dist/claude.js ADDED
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Claude Code subprocess wrapper.
3
+ * Mirrors ce_ce's mechanism: spawn `claude` CLI with stream-json I/O,
4
+ * pipe prompts in, parse streaming JSON messages out.
5
+ */
6
+ import { spawn } from "child_process";
7
+ import { EventEmitter } from "events";
8
+ import { existsSync } from "fs";
9
+ export class ClaudeProcess extends EventEmitter {
10
+ proc;
11
+ buffer = "";
12
+ _exited = false;
13
+ constructor(opts = {}) {
14
+ super();
15
+ const args = [
16
+ "--continue",
17
+ "--output-format", "stream-json",
18
+ "--input-format", "stream-json",
19
+ "--print",
20
+ "--verbose",
21
+ "--dangerously-skip-permissions",
22
+ ];
23
+ if (opts.systemPrompt) {
24
+ args.push("--system-prompt", opts.systemPrompt);
25
+ }
26
+ const env = { ...process.env };
27
+ if (opts.token) {
28
+ // API keys start with sk-ant-api — set ANTHROPIC_API_KEY only
29
+ // Everything else (OAuth sk-ant-oat, setup-token format with #, etc.)
30
+ // goes into CLAUDE_CODE_OAUTH_TOKEN
31
+ // Mixing them causes "Invalid API key" errors
32
+ if (opts.token.startsWith("sk-ant-api")) {
33
+ env.ANTHROPIC_API_KEY = opts.token;
34
+ delete env.CLAUDE_CODE_OAUTH_TOKEN;
35
+ }
36
+ else {
37
+ env.CLAUDE_CODE_OAUTH_TOKEN = opts.token;
38
+ delete env.ANTHROPIC_API_KEY;
39
+ }
40
+ }
41
+ // Resolve claude binary — check common install locations if not in PATH
42
+ const claudeBin = resolveClaude(env.PATH);
43
+ this.proc = spawn(claudeBin, args, {
44
+ cwd: opts.cwd ?? process.cwd(),
45
+ env,
46
+ stdio: ["pipe", "pipe", "pipe"],
47
+ });
48
+ this.proc.stdout.on("data", (chunk) => {
49
+ this.buffer += chunk.toString();
50
+ this.drainBuffer();
51
+ });
52
+ this.proc.stderr.on("data", (chunk) => {
53
+ this.emit("stderr", chunk.toString());
54
+ });
55
+ this.proc.on("exit", (code) => {
56
+ this._exited = true;
57
+ this.emit("exit", code);
58
+ });
59
+ this.proc.on("error", (err) => {
60
+ this.emit("error", err);
61
+ });
62
+ }
63
+ sendPrompt(text) {
64
+ if (this._exited)
65
+ throw new Error("Claude process has exited");
66
+ const payload = JSON.stringify({
67
+ type: "user",
68
+ message: { role: "user", content: text },
69
+ });
70
+ this.proc.stdin.write(payload + "\n");
71
+ }
72
+ /**
73
+ * Send an image (with optional text caption) to Claude via stream-json content blocks.
74
+ * mediaType: image/jpeg | image/png | image/gif | image/webp
75
+ */
76
+ sendImage(base64Data, mediaType, caption) {
77
+ if (this._exited)
78
+ throw new Error("Claude process has exited");
79
+ const content = [];
80
+ if (caption) {
81
+ content.push({ type: "text", text: caption });
82
+ }
83
+ content.push({
84
+ type: "image",
85
+ source: {
86
+ type: "base64",
87
+ media_type: mediaType,
88
+ data: base64Data,
89
+ },
90
+ });
91
+ const payload = JSON.stringify({
92
+ type: "user",
93
+ message: { role: "user", content },
94
+ });
95
+ this.proc.stdin.write(payload + "\n");
96
+ }
97
+ kill() {
98
+ this.proc.kill();
99
+ }
100
+ get exited() {
101
+ return this._exited;
102
+ }
103
+ drainBuffer() {
104
+ const lines = this.buffer.split("\n");
105
+ // Last element may be incomplete — keep it
106
+ this.buffer = lines.pop() ?? "";
107
+ for (const line of lines) {
108
+ if (!line.trim())
109
+ continue;
110
+ let raw;
111
+ try {
112
+ raw = JSON.parse(line);
113
+ }
114
+ catch {
115
+ // Non-JSON line (startup noise etc.) — ignore
116
+ continue;
117
+ }
118
+ // Emit usage events from Anthropic API stream events passed through by Claude CLI
119
+ if (raw.type === "message_start") {
120
+ const usage = (raw.message?.usage);
121
+ if (usage) {
122
+ this.emit("usage", {
123
+ inputTokens: usage.input_tokens ?? 0,
124
+ outputTokens: 0, // output_tokens at message_start is always 0
125
+ cacheReadTokens: usage.cache_read_input_tokens ?? 0,
126
+ cacheWriteTokens: usage.cache_creation_input_tokens ?? 0,
127
+ });
128
+ }
129
+ }
130
+ else if (raw.type === "message_delta") {
131
+ const usage = raw.usage;
132
+ if (usage?.output_tokens) {
133
+ this.emit("usage", {
134
+ inputTokens: 0,
135
+ outputTokens: usage.output_tokens,
136
+ cacheReadTokens: 0,
137
+ cacheWriteTokens: 0,
138
+ });
139
+ }
140
+ }
141
+ const msg = this.parseMessage(raw);
142
+ if (msg)
143
+ this.emit("message", msg);
144
+ }
145
+ }
146
+ parseMessage(raw) {
147
+ const type = raw.type;
148
+ if (!type)
149
+ return null;
150
+ return {
151
+ type,
152
+ session_id: raw.session_id,
153
+ uuid: raw.uuid,
154
+ payload: raw,
155
+ raw,
156
+ };
157
+ }
158
+ }
159
+ /**
160
+ * Extract the text content from an assistant message payload.
161
+ * Handles both simple string content and content-block arrays.
162
+ */
163
+ export function extractText(msg) {
164
+ const message = msg.payload.message;
165
+ if (!message) {
166
+ // result message type
167
+ if (msg.type === "result") {
168
+ return msg.payload.result ?? "";
169
+ }
170
+ return "";
171
+ }
172
+ const content = message.content;
173
+ if (typeof content === "string")
174
+ return content;
175
+ if (Array.isArray(content)) {
176
+ return content
177
+ .filter((b) => b.type === "text")
178
+ .map((b) => b.text)
179
+ .join("");
180
+ }
181
+ return "";
182
+ }
183
+ /**
184
+ * Resolve the claude CLI binary path.
185
+ * Checks PATH entries + common npm global install locations.
186
+ */
187
+ function resolveClaude(pathEnv) {
188
+ // Try PATH entries first
189
+ const dirs = (pathEnv ?? process.env.PATH ?? "").split(":");
190
+ for (const dir of dirs) {
191
+ const candidate = `${dir}/claude`;
192
+ if (existsSync(candidate))
193
+ return candidate;
194
+ }
195
+ // Common fallback locations
196
+ const fallbacks = [
197
+ `${process.env.HOME}/.npm-global/bin/claude`,
198
+ "/opt/homebrew/bin/claude",
199
+ "/usr/local/bin/claude",
200
+ "/usr/bin/claude",
201
+ ];
202
+ for (const p of fallbacks) {
203
+ if (existsSync(p))
204
+ return p;
205
+ }
206
+ // Last resort — let the OS resolve it (will throw ENOENT if missing)
207
+ return "claude";
208
+ }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Cron job manager for cc-tg.
3
+ * Persists jobs to <cwd>/.cc-tg/crons.json.
4
+ * Fires prompts into Claude sessions on schedule.
5
+ */
6
+ export interface CronJob {
7
+ id: string;
8
+ chatId: number;
9
+ intervalMs: number;
10
+ prompt: string;
11
+ createdAt: string;
12
+ schedule: string;
13
+ }
14
+ /** Called when a job fires. `done` must be called when the task completes so
15
+ * the next scheduled tick is allowed to run. Until `done` is called, concurrent
16
+ * ticks for the same job are silently skipped (prevents the resume-loop explosion
17
+ * where each tick spawns more agents than the last). */
18
+ type FireCallback = (chatId: number, prompt: string, jobId: string, done: () => void) => void;
19
+ export declare class CronManager {
20
+ private jobs;
21
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
22
+ private activeJobs;
23
+ private storePath;
24
+ private fire;
25
+ constructor(cwd: string, fire: FireCallback);
26
+ /** Parse "every 30m", "every 2h", "every 1d" → ms */
27
+ static parseSchedule(schedule: string): number | null;
28
+ add(chatId: number, schedule: string, prompt: string): CronJob | null;
29
+ remove(chatId: number, id: string): boolean;
30
+ clearAll(chatId: number): number;
31
+ list(chatId: number): CronJob[];
32
+ update(chatId: number, id: string, updates: {
33
+ schedule?: string;
34
+ prompt?: string;
35
+ }): CronJob | null | false;
36
+ private persist;
37
+ private load;
38
+ }
39
+ export {};
package/dist/cron.js ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Cron job manager for cc-tg.
3
+ * Persists jobs to <cwd>/.cc-tg/crons.json.
4
+ * Fires prompts into Claude sessions on schedule.
5
+ */
6
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
7
+ import { join } from "path";
8
+ export class CronManager {
9
+ jobs = new Map();
10
+ /** Job IDs whose fire callback has been invoked but whose `done` hasn't fired yet. */
11
+ activeJobs = new Set();
12
+ storePath;
13
+ fire;
14
+ constructor(cwd, fire) {
15
+ this.storePath = join(cwd, ".cc-tg", "crons.json");
16
+ this.fire = fire;
17
+ this.load();
18
+ }
19
+ /** Parse "every 30m", "every 2h", "every 1d" → ms */
20
+ static parseSchedule(schedule) {
21
+ const m = schedule.trim().match(/^every\s+(\d+)(m|h|d)$/i);
22
+ if (!m)
23
+ return null;
24
+ const n = parseInt(m[1]);
25
+ const unit = m[2].toLowerCase();
26
+ if (unit === "m")
27
+ return n * 60_000;
28
+ if (unit === "h")
29
+ return n * 3_600_000;
30
+ if (unit === "d")
31
+ return n * 86_400_000;
32
+ return null;
33
+ }
34
+ add(chatId, schedule, prompt) {
35
+ const intervalMs = CronManager.parseSchedule(schedule);
36
+ if (!intervalMs)
37
+ return null;
38
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
39
+ const job = { id, chatId, intervalMs, prompt, schedule, createdAt: new Date().toISOString() };
40
+ const timer = setInterval(() => {
41
+ if (this.activeJobs.has(id)) {
42
+ console.log(`[cron:${id}] skipping tick — previous task still running`);
43
+ return;
44
+ }
45
+ this.activeJobs.add(id);
46
+ console.log(`[cron:${id}] firing for chat=${chatId} prompt="${prompt}"`);
47
+ this.fire(chatId, prompt, id, () => { this.activeJobs.delete(id); });
48
+ }, intervalMs);
49
+ this.jobs.set(id, { ...job, timer });
50
+ this.persist();
51
+ return job;
52
+ }
53
+ remove(chatId, id) {
54
+ const job = this.jobs.get(id);
55
+ if (!job || job.chatId !== chatId)
56
+ return false;
57
+ clearInterval(job.timer);
58
+ this.activeJobs.delete(id);
59
+ this.jobs.delete(id);
60
+ this.persist();
61
+ return true;
62
+ }
63
+ clearAll(chatId) {
64
+ let count = 0;
65
+ for (const [id, job] of this.jobs) {
66
+ if (job.chatId === chatId) {
67
+ clearInterval(job.timer);
68
+ this.activeJobs.delete(id);
69
+ this.jobs.delete(id);
70
+ count++;
71
+ }
72
+ }
73
+ if (count)
74
+ this.persist();
75
+ return count;
76
+ }
77
+ list(chatId) {
78
+ return [...this.jobs.values()]
79
+ .filter((j) => j.chatId === chatId)
80
+ .map(({ timer: _t, ...j }) => j);
81
+ }
82
+ update(chatId, id, updates) {
83
+ const job = this.jobs.get(id);
84
+ if (!job || job.chatId !== chatId)
85
+ return false;
86
+ if (updates.schedule !== undefined) {
87
+ const intervalMs = CronManager.parseSchedule(updates.schedule);
88
+ if (!intervalMs)
89
+ return null;
90
+ job.intervalMs = intervalMs;
91
+ job.schedule = updates.schedule;
92
+ }
93
+ if (updates.prompt !== undefined) {
94
+ job.prompt = updates.prompt;
95
+ }
96
+ // Recreate timer so it uses updated intervalMs and always reads latest job.prompt
97
+ clearInterval(job.timer);
98
+ // Also clear any active-job lock so the updated timer can fire immediately next tick
99
+ this.activeJobs.delete(job.id);
100
+ job.timer = setInterval(() => {
101
+ if (this.activeJobs.has(job.id)) {
102
+ console.log(`[cron:${job.id}] skipping tick — previous task still running`);
103
+ return;
104
+ }
105
+ this.activeJobs.add(job.id);
106
+ console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
107
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
108
+ }, job.intervalMs);
109
+ this.persist();
110
+ const { timer: _t, ...cronJob } = job;
111
+ return cronJob;
112
+ }
113
+ persist() {
114
+ try {
115
+ const dir = join(this.storePath, "..");
116
+ if (!existsSync(dir))
117
+ mkdirSync(dir, { recursive: true });
118
+ const data = [...this.jobs.values()].map(({ timer: _t, ...j }) => j);
119
+ writeFileSync(this.storePath, JSON.stringify(data, null, 2));
120
+ }
121
+ catch (err) {
122
+ console.error("[cron] persist error:", err.message);
123
+ }
124
+ }
125
+ load() {
126
+ if (!existsSync(this.storePath))
127
+ return;
128
+ try {
129
+ const data = JSON.parse(readFileSync(this.storePath, "utf8"));
130
+ for (const job of data) {
131
+ const timer = setInterval(() => {
132
+ if (this.activeJobs.has(job.id)) {
133
+ console.log(`[cron:${job.id}] skipping tick — previous task still running`);
134
+ return;
135
+ }
136
+ this.activeJobs.add(job.id);
137
+ console.log(`[cron:${job.id}] firing for chat=${job.chatId} prompt="${job.prompt}"`);
138
+ this.fire(job.chatId, job.prompt, job.id, () => { this.activeJobs.delete(job.id); });
139
+ }, job.intervalMs);
140
+ this.jobs.set(job.id, { ...job, timer });
141
+ }
142
+ console.log(`[cron] loaded ${data.length} jobs from disk`);
143
+ }
144
+ catch (err) {
145
+ console.error("[cron] load error:", err.message);
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Discord markdown post-processor.
3
+ * Discord renders standard markdown natively — no HTML escaping needed.
4
+ * Headings become bold, lists use bullet characters.
5
+ */
6
+ /**
7
+ * Convert standard markdown text to Discord-friendly format.
8
+ *
9
+ * Discord renders most markdown natively (bold, italic, code blocks).
10
+ * We only need to:
11
+ * 1. Preserve fenced code blocks (``` ... ```) — Discord renders them
12
+ * 2. Preserve inline code (`...`) — Discord renders it
13
+ * 3. Convert ## headings → **Heading**
14
+ * 4. Convert --- → blank line
15
+ * 5. Leave **bold**, *italic*, _italic_ as-is (Discord handles them)
16
+ */
17
+ export declare function formatForDiscord(text: string): string;
18
+ /**
19
+ * Split a long message at natural boundaries (paragraph > line > word).
20
+ * Never splits inside code blocks. Chunks are at most maxLen characters.
21
+ * Discord's limit is 2000 characters.
22
+ */
23
+ export declare function splitLongMessage(text: string, maxLen?: number): string[];
24
+ /** Strip ANSI escape sequences from a string before sending to Discord. */
25
+ export declare function stripAnsi(text: string): string;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Discord markdown post-processor.
3
+ * Discord renders standard markdown natively — no HTML escaping needed.
4
+ * Headings become bold, lists use bullet characters.
5
+ */
6
+ /**
7
+ * Convert standard markdown text to Discord-friendly format.
8
+ *
9
+ * Discord renders most markdown natively (bold, italic, code blocks).
10
+ * We only need to:
11
+ * 1. Preserve fenced code blocks (``` ... ```) — Discord renders them
12
+ * 2. Preserve inline code (`...`) — Discord renders it
13
+ * 3. Convert ## headings → **Heading**
14
+ * 4. Convert --- → blank line
15
+ * 5. Leave **bold**, *italic*, _italic_ as-is (Discord handles them)
16
+ */
17
+ export function formatForDiscord(text) {
18
+ const placeholders = [];
19
+ // Step 1: Extract fenced code blocks — protect from further processing
20
+ let out = text.replace(/```(?:\w*)\n?([\s\S]*?)```/g, (match) => {
21
+ placeholders.push(match);
22
+ return `\x00P${placeholders.length - 1}\x00`;
23
+ });
24
+ // Step 2: Extract inline code — protect from further processing
25
+ out = out.replace(/`([^`\n]+)`/g, (match) => {
26
+ placeholders.push(match);
27
+ return `\x00P${placeholders.length - 1}\x00`;
28
+ });
29
+ // Step 3: Convert --- → blank line
30
+ out = out.replace(/^-{3,}$/gm, "");
31
+ // Step 4: Convert ## headings → **Heading**
32
+ out = out.replace(/^#{1,6}\s+(.+)$/gm, "**$1**");
33
+ // Step 5: Reinsert code blocks/inline code
34
+ out = out.replace(/\x00P(\d+)\x00/g, (_, i) => placeholders[parseInt(i, 10)]);
35
+ return out;
36
+ }
37
+ function findCodeBlockRanges(text) {
38
+ const ranges = [];
39
+ const re = /```[\s\S]*?```/g;
40
+ let m;
41
+ while ((m = re.exec(text)) !== null) {
42
+ ranges.push([m.index, m.index + m[0].length]);
43
+ }
44
+ return ranges;
45
+ }
46
+ function isInsideCodeBlock(pos, ranges) {
47
+ return ranges.some(([start, end]) => pos > start && pos < end);
48
+ }
49
+ /**
50
+ * Split a long message at natural boundaries (paragraph > line > word).
51
+ * Never splits inside code blocks. Chunks are at most maxLen characters.
52
+ * Discord's limit is 2000 characters.
53
+ */
54
+ export function splitLongMessage(text, maxLen = 2000) {
55
+ if (text.length <= maxLen)
56
+ return [text];
57
+ const chunks = [];
58
+ let remaining = text;
59
+ while (remaining.length > maxLen) {
60
+ const slice = remaining.slice(0, maxLen);
61
+ const codeRanges = findCodeBlockRanges(remaining);
62
+ // Prefer paragraph boundary (\n\n)
63
+ const lastPara = slice.lastIndexOf("\n\n");
64
+ // Then line boundary (\n)
65
+ const lastLine = slice.lastIndexOf("\n");
66
+ // Then word boundary (space)
67
+ const lastSpace = slice.lastIndexOf(" ");
68
+ let splitAt;
69
+ if (lastPara > 0 && !isInsideCodeBlock(lastPara, codeRanges)) {
70
+ splitAt = lastPara + 2;
71
+ }
72
+ else if (lastLine > 0 && !isInsideCodeBlock(lastLine, codeRanges)) {
73
+ splitAt = lastLine + 1;
74
+ }
75
+ else if (lastSpace > 0 && !isInsideCodeBlock(lastSpace, codeRanges)) {
76
+ splitAt = lastSpace + 1;
77
+ }
78
+ else {
79
+ // If all candidate split points are inside a code block, split after it
80
+ const coveringBlock = codeRanges.find(([start, end]) => start < maxLen && end > maxLen);
81
+ if (coveringBlock) {
82
+ splitAt = coveringBlock[1];
83
+ }
84
+ else {
85
+ splitAt = maxLen;
86
+ }
87
+ }
88
+ chunks.push(remaining.slice(0, splitAt).trimEnd());
89
+ remaining = remaining.slice(splitAt).trimStart();
90
+ }
91
+ if (remaining.length > 0) {
92
+ chunks.push(remaining);
93
+ }
94
+ return chunks;
95
+ }
96
+ /** Strip ANSI escape sequences from a string before sending to Discord. */
97
+ export function stripAnsi(text) {
98
+ // eslint-disable-next-line no-control-regex
99
+ return text.replace(/\x1B\[[0-9;]*[mGKHF]/g, "");
100
+ }
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * cc-discord — Claude Code Discord bot
4
+ *
5
+ * Usage:
6
+ * npx @gonzih/cc-discord
7
+ *
8
+ * Required env:
9
+ * DISCORD_BOT_TOKEN — from Discord Developer Portal
10
+ * CLAUDE_CODE_OAUTH_TOKEN — your Claude Code OAuth token (or ANTHROPIC_API_KEY)
11
+ *
12
+ * Optional env:
13
+ * DISCORD_GUILD_IDS — comma-separated Discord guild/server IDs (for instant slash command registration)
14
+ * DISCORD_ALLOWED_USER_IDS — comma-separated Discord user IDs to whitelist (leave empty to allow all)
15
+ * DISCORD_NOTIFY_CHANNEL_ID — Discord channel ID for job notifications
16
+ * CC_AGENT_NAMESPACE — cc-agent namespace (default: money-brain)
17
+ * REDIS_URL — Redis connection URL (default: redis://localhost:6379)
18
+ * CWD — working directory for Claude Code (default: process.cwd())
19
+ * DEFAULT_GITHUB_ORG — default GitHub org for #repo routing (default: gonzih)
20
+ */
21
+ export {};