afk-code 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,193 @@
1
+ import { homedir } from 'os';
2
+ import { mkdir } from 'fs/promises';
3
+ import * as readline from 'readline';
4
+
5
+ const CONFIG_DIR = `${homedir()}/.afk-code`;
6
+ const SLACK_CONFIG_FILE = `${CONFIG_DIR}/slack.env`;
7
+ const MANIFEST_URL = 'https://github.com/clharman/afk-code/blob/main/slack-manifest.json';
8
+
9
+ function prompt(question: string): Promise<string> {
10
+ const rl = readline.createInterface({
11
+ input: process.stdin,
12
+ output: process.stdout,
13
+ });
14
+ return new Promise((resolve) => {
15
+ rl.question(question, (answer) => {
16
+ rl.close();
17
+ resolve(answer.trim());
18
+ });
19
+ });
20
+ }
21
+
22
+ export async function slackSetup(): Promise<void> {
23
+ console.log(`
24
+ ┌─────────────────────────────────────────────────────────────┐
25
+ │ AFK Code Slack Setup │
26
+ └─────────────────────────────────────────────────────────────┘
27
+
28
+ This will guide you through setting up the Slack bot for
29
+ monitoring Claude Code sessions.
30
+
31
+ Step 1: Create a Slack App
32
+ ──────────────────────────
33
+ 1. Go to: https://api.slack.com/apps
34
+ 2. Click "Create New App" → "From manifest"
35
+ 3. Select your workspace
36
+ 4. Paste the manifest from: ${MANIFEST_URL}
37
+ (Or copy from slack-manifest.json in this repo)
38
+ 5. Click "Create"
39
+
40
+ Step 2: Install the App
41
+ ───────────────────────
42
+ 1. Go to "Install App" in the sidebar
43
+ 2. Click "Install to Workspace"
44
+ 3. Authorize the app
45
+
46
+ Step 3: Get Your Tokens
47
+ ───────────────────────
48
+ `);
49
+
50
+ await prompt('Press Enter when you have created and installed the app...');
51
+
52
+ console.log(`
53
+ Now let's collect your tokens:
54
+
55
+ • Bot Token: "OAuth & Permissions" → "Bot User OAuth Token" (starts with xoxb-)
56
+ • App Token: "Basic Information" → "App-Level Tokens" → Generate one with
57
+ "connections:write" scope (starts with xapp-)
58
+ • User ID: Click your profile in Slack → "..." → "Copy member ID"
59
+ `);
60
+
61
+ const botToken = await prompt('Bot Token (xoxb-...): ');
62
+ if (!botToken.startsWith('xoxb-')) {
63
+ console.error('Invalid bot token. Should start with xoxb-');
64
+ process.exit(1);
65
+ }
66
+
67
+ const appToken = await prompt('App Token (xapp-...): ');
68
+ if (!appToken.startsWith('xapp-')) {
69
+ console.error('Invalid app token. Should start with xapp-');
70
+ process.exit(1);
71
+ }
72
+
73
+ const userId = await prompt('Your Slack User ID (U...): ');
74
+ if (!userId.startsWith('U')) {
75
+ console.error('Invalid user ID. Should start with U');
76
+ process.exit(1);
77
+ }
78
+
79
+ // Save configuration
80
+ await mkdir(CONFIG_DIR, { recursive: true });
81
+
82
+ const envContent = `# AFK Code Slack Configuration
83
+ SLACK_BOT_TOKEN=${botToken}
84
+ SLACK_APP_TOKEN=${appToken}
85
+ SLACK_USER_ID=${userId}
86
+ `;
87
+
88
+ await Bun.write(SLACK_CONFIG_FILE, envContent);
89
+ console.log(`
90
+ ✓ Configuration saved to ${SLACK_CONFIG_FILE}
91
+
92
+ To start the Slack bot, run:
93
+ afk-code slack
94
+
95
+ Then start a Claude Code session with:
96
+ afk-code run -- claude
97
+ `);
98
+ }
99
+
100
+ async function loadEnvFile(path: string): Promise<Record<string, string>> {
101
+ const file = Bun.file(path);
102
+ if (!(await file.exists())) return {};
103
+
104
+ const content = await file.text();
105
+ const config: Record<string, string> = {};
106
+
107
+ for (const line of content.split('\n')) {
108
+ if (line.startsWith('#') || !line.includes('=')) continue;
109
+ const [key, ...valueParts] = line.split('=');
110
+ config[key.trim()] = valueParts.join('=').trim();
111
+ }
112
+ return config;
113
+ }
114
+
115
+ export async function slackRun(): Promise<void> {
116
+ // Load config from multiple sources (in order of precedence):
117
+ // 1. Environment variables (highest priority)
118
+ // 2. Local .env file
119
+ // 3. ~/.afk-code/slack.env (lowest priority)
120
+
121
+ const globalConfig = await loadEnvFile(SLACK_CONFIG_FILE);
122
+ const localConfig = await loadEnvFile(`${process.cwd()}/.env`);
123
+
124
+ // Merge configs (local overrides global, env vars override both)
125
+ const config: Record<string, string> = {
126
+ ...globalConfig,
127
+ ...localConfig,
128
+ };
129
+
130
+ // Environment variables take highest precedence
131
+ if (process.env.SLACK_BOT_TOKEN) config.SLACK_BOT_TOKEN = process.env.SLACK_BOT_TOKEN;
132
+ if (process.env.SLACK_APP_TOKEN) config.SLACK_APP_TOKEN = process.env.SLACK_APP_TOKEN;
133
+ if (process.env.SLACK_USER_ID) config.SLACK_USER_ID = process.env.SLACK_USER_ID;
134
+
135
+ // Validate required config
136
+ const required = ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN', 'SLACK_USER_ID'];
137
+ const missing = required.filter((key) => !config[key]);
138
+
139
+ if (missing.length > 0) {
140
+ console.error(`Missing config: ${missing.join(', ')}`);
141
+ console.error('');
142
+ console.error('Provide tokens via:');
143
+ console.error(' - Environment variables (SLACK_BOT_TOKEN, SLACK_APP_TOKEN, SLACK_USER_ID)');
144
+ console.error(' - Local .env file');
145
+ console.error(' - Run "afk-code slack setup" for guided configuration');
146
+ process.exit(1);
147
+ }
148
+
149
+ // Set environment variables and start the bot
150
+ process.env.SLACK_BOT_TOKEN = config.SLACK_BOT_TOKEN;
151
+ process.env.SLACK_APP_TOKEN = config.SLACK_APP_TOKEN;
152
+ process.env.SLACK_USER_ID = config.SLACK_USER_ID;
153
+
154
+ // Import and run the slack bot
155
+ const { createSlackApp } = await import('../slack/slack-app');
156
+
157
+ // Show where config was loaded from
158
+ const localEnvExists = await Bun.file(`${process.cwd()}/.env`).exists();
159
+ const globalEnvExists = await Bun.file(SLACK_CONFIG_FILE).exists();
160
+ const source = localEnvExists ? '.env' : globalEnvExists ? SLACK_CONFIG_FILE : 'environment';
161
+ console.log(`[AFK Code] Loaded config from ${source}`);
162
+ console.log('[AFK Code] Starting Slack bot...');
163
+
164
+ const slackConfig = {
165
+ botToken: config.SLACK_BOT_TOKEN,
166
+ appToken: config.SLACK_APP_TOKEN,
167
+ signingSecret: '',
168
+ userId: config.SLACK_USER_ID,
169
+ };
170
+
171
+ const { app, sessionManager } = createSlackApp(slackConfig);
172
+
173
+ // Start session manager (Unix socket server for CLI connections)
174
+ try {
175
+ await sessionManager.start();
176
+ console.log('[AFK Code] Session manager started');
177
+ } catch (err) {
178
+ console.error('[AFK Code] Failed to start session manager:', err);
179
+ process.exit(1);
180
+ }
181
+
182
+ // Start Slack app
183
+ try {
184
+ await app.start();
185
+ console.log('[AFK Code] Slack bot is running!');
186
+ console.log('');
187
+ console.log('Start a Claude Code session with: afk-code run -- claude');
188
+ console.log('Each session will create a private #afk-* channel');
189
+ } catch (err) {
190
+ console.error('[AFK Code] Failed to start Slack app:', err);
191
+ process.exit(1);
192
+ }
193
+ }
@@ -0,0 +1,191 @@
1
+ import type { Client, TextChannel, CategoryChannel, Guild } from 'discord.js';
2
+ import { ChannelType, PermissionFlagsBits } from 'discord.js';
3
+
4
+ export interface ChannelMapping {
5
+ sessionId: string;
6
+ channelId: string;
7
+ channelName: string;
8
+ sessionName: string;
9
+ status: 'running' | 'idle' | 'ended';
10
+ createdAt: Date;
11
+ }
12
+
13
+ /**
14
+ * Sanitize a string for use as a Discord channel name.
15
+ * Rules: lowercase, no spaces, max 100 chars, only letters/numbers/hyphens/underscores
16
+ */
17
+ function sanitizeChannelName(name: string): string {
18
+ return name
19
+ .toLowerCase()
20
+ .replace(/[^a-z0-9-_\s]/g, '') // Remove invalid chars
21
+ .replace(/\s+/g, '-') // Spaces to hyphens
22
+ .replace(/-+/g, '-') // Collapse multiple hyphens
23
+ .replace(/^-|-$/g, '') // Trim hyphens from ends
24
+ .slice(0, 90); // Leave room for "afk-" prefix and uniqueness suffix
25
+ }
26
+
27
+ export class ChannelManager {
28
+ private channels = new Map<string, ChannelMapping>();
29
+ private channelToSession = new Map<string, string>();
30
+ private client: Client;
31
+ private userId: string;
32
+ private guild: Guild | null = null;
33
+ private category: CategoryChannel | null = null;
34
+
35
+ constructor(client: Client, userId: string) {
36
+ this.client = client;
37
+ this.userId = userId;
38
+ }
39
+
40
+ async initialize(): Promise<void> {
41
+ // Find the first guild the bot is in
42
+ const guilds = await this.client.guilds.fetch();
43
+ if (guilds.size === 0) {
44
+ throw new Error('Bot is not in any servers. Please invite the bot first.');
45
+ }
46
+
47
+ const guildId = guilds.first()!.id;
48
+ this.guild = await this.client.guilds.fetch(guildId);
49
+
50
+ // Find or create AFK Code category
51
+ const existingCategory = this.guild.channels.cache.find(
52
+ (ch) => ch.type === ChannelType.GuildCategory && ch.name.toLowerCase() === 'afk code sessions'
53
+ ) as CategoryChannel | undefined;
54
+
55
+ if (existingCategory) {
56
+ this.category = existingCategory;
57
+ } else {
58
+ this.category = await this.guild.channels.create({
59
+ name: 'AFK Code Sessions',
60
+ type: ChannelType.GuildCategory,
61
+ });
62
+ }
63
+
64
+ console.log(`[ChannelManager] Using guild: ${this.guild.name}`);
65
+ console.log(`[ChannelManager] Using category: ${this.category.name}`);
66
+ }
67
+
68
+ async createChannel(
69
+ sessionId: string,
70
+ sessionName: string,
71
+ cwd: string
72
+ ): Promise<ChannelMapping | null> {
73
+ if (!this.guild || !this.category) {
74
+ console.error('[ChannelManager] Not initialized');
75
+ return null;
76
+ }
77
+
78
+ // Check if channel already exists for this session
79
+ if (this.channels.has(sessionId)) {
80
+ return this.channels.get(sessionId)!;
81
+ }
82
+
83
+ // Extract just the folder name from the path
84
+ const folderName = cwd.split('/').filter(Boolean).pop() || 'session';
85
+ const baseName = `afk-${sanitizeChannelName(folderName)}`;
86
+
87
+ // Try to create channel, incrementing suffix if name is taken
88
+ let channelName = baseName;
89
+ let suffix = 1;
90
+ let channel: TextChannel | null = null;
91
+
92
+ while (true) {
93
+ const nameToTry = channelName.length > 100 ? channelName.slice(0, 100) : channelName;
94
+
95
+ // Check if name exists
96
+ const existing = this.guild.channels.cache.find(
97
+ (ch) => ch.name === nameToTry && ch.parentId === this.category!.id
98
+ );
99
+
100
+ if (!existing) {
101
+ try {
102
+ channel = await this.guild.channels.create({
103
+ name: nameToTry,
104
+ type: ChannelType.GuildText,
105
+ parent: this.category,
106
+ topic: `Claude Code session: ${sessionName}`,
107
+ });
108
+ channelName = nameToTry;
109
+ break;
110
+ } catch (err: any) {
111
+ console.error('[ChannelManager] Failed to create channel:', err.message);
112
+ return null;
113
+ }
114
+ } else {
115
+ suffix++;
116
+ channelName = `${baseName}-${suffix}`;
117
+ }
118
+ }
119
+
120
+ if (!channel) {
121
+ return null;
122
+ }
123
+
124
+ const mapping: ChannelMapping = {
125
+ sessionId,
126
+ channelId: channel.id,
127
+ channelName,
128
+ sessionName,
129
+ status: 'running',
130
+ createdAt: new Date(),
131
+ };
132
+
133
+ this.channels.set(sessionId, mapping);
134
+ this.channelToSession.set(channel.id, sessionId);
135
+
136
+ console.log(`[ChannelManager] Created channel #${channelName} for session ${sessionId}`);
137
+ return mapping;
138
+ }
139
+
140
+ async archiveChannel(sessionId: string): Promise<boolean> {
141
+ if (!this.guild) return false;
142
+
143
+ const mapping = this.channels.get(sessionId);
144
+ if (!mapping) return false;
145
+
146
+ try {
147
+ const channel = await this.guild.channels.fetch(mapping.channelId);
148
+ if (channel && channel.type === ChannelType.GuildText) {
149
+ // Rename with archived suffix
150
+ const timestamp = Date.now().toString(36);
151
+ const archivedName = `${mapping.channelName}-archived-${timestamp}`.slice(0, 100);
152
+
153
+ await channel.setName(archivedName);
154
+
155
+ // Move out of category or delete (Discord doesn't have archive)
156
+ // For now, just rename to indicate it's archived
157
+ console.log(`[ChannelManager] Archived channel #${mapping.channelName}`);
158
+ }
159
+ return true;
160
+ } catch (err: any) {
161
+ console.error('[ChannelManager] Failed to archive channel:', err.message);
162
+ return false;
163
+ }
164
+ }
165
+
166
+ getChannel(sessionId: string): ChannelMapping | undefined {
167
+ return this.channels.get(sessionId);
168
+ }
169
+
170
+ getSessionByChannel(channelId: string): string | undefined {
171
+ return this.channelToSession.get(channelId);
172
+ }
173
+
174
+ updateStatus(sessionId: string, status: 'running' | 'idle' | 'ended'): void {
175
+ const mapping = this.channels.get(sessionId);
176
+ if (mapping) {
177
+ mapping.status = status;
178
+ }
179
+ }
180
+
181
+ updateName(sessionId: string, name: string): void {
182
+ const mapping = this.channels.get(sessionId);
183
+ if (mapping) {
184
+ mapping.sessionName = name;
185
+ }
186
+ }
187
+
188
+ getAllActive(): ChannelMapping[] {
189
+ return Array.from(this.channels.values()).filter((c) => c.status !== 'ended');
190
+ }
191
+ }