afk-code 0.1.0 → 0.1.3
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/LICENSE +21 -0
- package/README.md +64 -97
- package/dist/cli/index.js +1972 -0
- package/package.json +13 -9
- package/slack-manifest.json +3 -3
- package/src/cli/discord.ts +0 -183
- package/src/cli/index.ts +0 -83
- package/src/cli/run.ts +0 -126
- package/src/cli/slack.ts +0 -193
- package/src/discord/channel-manager.ts +0 -191
- package/src/discord/discord-app.ts +0 -359
- package/src/discord/types.ts +0 -4
- package/src/slack/channel-manager.ts +0 -175
- package/src/slack/index.ts +0 -58
- package/src/slack/message-formatter.ts +0 -91
- package/src/slack/session-manager.ts +0 -567
- package/src/slack/slack-app.ts +0 -443
- package/src/slack/types.ts +0 -6
- package/src/types/index.ts +0 -6
- package/src/utils/image-extractor.ts +0 -72
package/src/slack/slack-app.ts
DELETED
|
@@ -1,443 +0,0 @@
|
|
|
1
|
-
import { App, LogLevel } from '@slack/bolt';
|
|
2
|
-
import { createReadStream } from 'fs';
|
|
3
|
-
import type { SlackConfig } from './types';
|
|
4
|
-
import { SessionManager, type SessionInfo, type ToolCallInfo, type ToolResultInfo } from './session-manager';
|
|
5
|
-
import { ChannelManager } from './channel-manager';
|
|
6
|
-
import {
|
|
7
|
-
markdownToSlack,
|
|
8
|
-
chunkMessage,
|
|
9
|
-
formatSessionStatus,
|
|
10
|
-
formatTodos,
|
|
11
|
-
} from './message-formatter';
|
|
12
|
-
import { extractImagePaths } from '../utils/image-extractor';
|
|
13
|
-
|
|
14
|
-
export function createSlackApp(config: SlackConfig) {
|
|
15
|
-
const app = new App({
|
|
16
|
-
token: config.botToken,
|
|
17
|
-
appToken: config.appToken,
|
|
18
|
-
socketMode: true,
|
|
19
|
-
logLevel: LogLevel.INFO,
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
const channelManager = new ChannelManager(app.client, config.userId);
|
|
23
|
-
|
|
24
|
-
// Track messages sent from Slack to avoid re-posting them when they come back via JSONL
|
|
25
|
-
const slackSentMessages = new Set<string>();
|
|
26
|
-
|
|
27
|
-
// Track tool call messages for threading results
|
|
28
|
-
const toolCallMessages = new Map<string, string>(); // toolUseId -> message ts
|
|
29
|
-
|
|
30
|
-
// Create session manager with event handlers that post to Slack
|
|
31
|
-
const sessionManager = new SessionManager({
|
|
32
|
-
onSessionStart: async (session) => {
|
|
33
|
-
const channel = await channelManager.createChannel(session.id, session.name, session.cwd);
|
|
34
|
-
if (channel) {
|
|
35
|
-
// Post initial message to channel
|
|
36
|
-
await app.client.chat.postMessage({
|
|
37
|
-
channel: channel.channelId,
|
|
38
|
-
text: `${formatSessionStatus(session.status)} *Session started*\n\`${session.cwd}\``,
|
|
39
|
-
mrkdwn: true,
|
|
40
|
-
});
|
|
41
|
-
}
|
|
42
|
-
},
|
|
43
|
-
|
|
44
|
-
onSessionEnd: async (sessionId) => {
|
|
45
|
-
const channel = channelManager.getChannel(sessionId);
|
|
46
|
-
if (channel) {
|
|
47
|
-
channelManager.updateStatus(sessionId, 'ended');
|
|
48
|
-
|
|
49
|
-
// Post final message
|
|
50
|
-
await app.client.chat.postMessage({
|
|
51
|
-
channel: channel.channelId,
|
|
52
|
-
text: ':stop_sign: *Session ended* - this channel will be archived',
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
// Archive the channel
|
|
56
|
-
await channelManager.archiveChannel(sessionId);
|
|
57
|
-
}
|
|
58
|
-
},
|
|
59
|
-
|
|
60
|
-
onSessionUpdate: async (sessionId, name) => {
|
|
61
|
-
const channel = channelManager.getChannel(sessionId);
|
|
62
|
-
if (channel) {
|
|
63
|
-
channelManager.updateName(sessionId, name);
|
|
64
|
-
// Update channel topic with new name
|
|
65
|
-
try {
|
|
66
|
-
await app.client.conversations.setTopic({
|
|
67
|
-
channel: channel.channelId,
|
|
68
|
-
topic: `Claude Code session: ${name}`,
|
|
69
|
-
});
|
|
70
|
-
} catch (err) {
|
|
71
|
-
console.error('[Slack] Failed to update channel topic:', err);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
|
|
76
|
-
onSessionStatus: async (sessionId, status) => {
|
|
77
|
-
const channel = channelManager.getChannel(sessionId);
|
|
78
|
-
if (channel) {
|
|
79
|
-
channelManager.updateStatus(sessionId, status);
|
|
80
|
-
}
|
|
81
|
-
},
|
|
82
|
-
|
|
83
|
-
onMessage: async (sessionId, role, content) => {
|
|
84
|
-
const channel = channelManager.getChannel(sessionId);
|
|
85
|
-
if (channel) {
|
|
86
|
-
const formatted = markdownToSlack(content);
|
|
87
|
-
|
|
88
|
-
if (role === 'user') {
|
|
89
|
-
// Skip messages that originated from Slack (already visible in channel)
|
|
90
|
-
const contentKey = content.trim();
|
|
91
|
-
if (slackSentMessages.has(contentKey)) {
|
|
92
|
-
slackSentMessages.delete(contentKey);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// User message from terminal - post as the user (using their name/avatar)
|
|
97
|
-
const chunks = chunkMessage(formatted);
|
|
98
|
-
for (const chunk of chunks) {
|
|
99
|
-
try {
|
|
100
|
-
// Fetch user profile to get their name and avatar
|
|
101
|
-
const userInfo = await app.client.users.info({ user: config.userId });
|
|
102
|
-
const userName = userInfo.user?.real_name || userInfo.user?.name || 'User';
|
|
103
|
-
const userIcon = userInfo.user?.profile?.image_72;
|
|
104
|
-
|
|
105
|
-
await app.client.chat.postMessage({
|
|
106
|
-
channel: channel.channelId,
|
|
107
|
-
text: chunk,
|
|
108
|
-
username: userName,
|
|
109
|
-
icon_url: userIcon,
|
|
110
|
-
mrkdwn: true,
|
|
111
|
-
});
|
|
112
|
-
} catch (err) {
|
|
113
|
-
console.error('[Slack] Failed to post message:', err);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
} else {
|
|
117
|
-
// Claude's response - post as "Claude Code"
|
|
118
|
-
const chunks = chunkMessage(formatted);
|
|
119
|
-
for (const chunk of chunks) {
|
|
120
|
-
try {
|
|
121
|
-
await app.client.chat.postMessage({
|
|
122
|
-
channel: channel.channelId,
|
|
123
|
-
text: chunk,
|
|
124
|
-
username: 'Claude Code',
|
|
125
|
-
icon_url: 'https://claude.ai/favicon.ico',
|
|
126
|
-
mrkdwn: true,
|
|
127
|
-
});
|
|
128
|
-
} catch (err) {
|
|
129
|
-
console.error('[Slack] Failed to post message:', err);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Extract and upload any images mentioned in the response
|
|
134
|
-
const session = sessionManager.getSession(sessionId);
|
|
135
|
-
const images = extractImagePaths(content, session?.cwd);
|
|
136
|
-
for (const image of images) {
|
|
137
|
-
try {
|
|
138
|
-
console.log(`[Slack] Uploading image: ${image.resolvedPath}`);
|
|
139
|
-
await app.client.files.uploadV2({
|
|
140
|
-
channel_id: channel.channelId,
|
|
141
|
-
file: createReadStream(image.resolvedPath),
|
|
142
|
-
filename: image.resolvedPath.split('/').pop() || 'image',
|
|
143
|
-
initial_comment: `📎 ${image.originalPath}`,
|
|
144
|
-
});
|
|
145
|
-
} catch (err) {
|
|
146
|
-
console.error('[Slack] Failed to upload image:', err);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
|
|
153
|
-
onTodos: async (sessionId, todos) => {
|
|
154
|
-
const channel = channelManager.getChannel(sessionId);
|
|
155
|
-
if (channel && todos.length > 0) {
|
|
156
|
-
const todosText = formatTodos(todos);
|
|
157
|
-
try {
|
|
158
|
-
await app.client.chat.postMessage({
|
|
159
|
-
channel: channel.channelId,
|
|
160
|
-
text: `*Tasks:*\n${todosText}`,
|
|
161
|
-
mrkdwn: true,
|
|
162
|
-
});
|
|
163
|
-
} catch (err) {
|
|
164
|
-
console.error('[Slack] Failed to post todos:', err);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
},
|
|
168
|
-
|
|
169
|
-
onToolCall: async (sessionId, tool) => {
|
|
170
|
-
const channel = channelManager.getChannel(sessionId);
|
|
171
|
-
if (!channel) return;
|
|
172
|
-
|
|
173
|
-
// Format tool call summary
|
|
174
|
-
let inputSummary = '';
|
|
175
|
-
if (tool.name === 'Bash' && tool.input.command) {
|
|
176
|
-
inputSummary = `\`${tool.input.command.slice(0, 100)}${tool.input.command.length > 100 ? '...' : ''}\``;
|
|
177
|
-
} else if (tool.name === 'Read' && tool.input.file_path) {
|
|
178
|
-
inputSummary = `\`${tool.input.file_path}\``;
|
|
179
|
-
} else if (tool.name === 'Edit' && tool.input.file_path) {
|
|
180
|
-
inputSummary = `\`${tool.input.file_path}\``;
|
|
181
|
-
} else if (tool.name === 'Write' && tool.input.file_path) {
|
|
182
|
-
inputSummary = `\`${tool.input.file_path}\``;
|
|
183
|
-
} else if (tool.name === 'Grep' && tool.input.pattern) {
|
|
184
|
-
inputSummary = `\`${tool.input.pattern}\``;
|
|
185
|
-
} else if (tool.name === 'Glob' && tool.input.pattern) {
|
|
186
|
-
inputSummary = `\`${tool.input.pattern}\``;
|
|
187
|
-
} else if (tool.name === 'Task' && tool.input.description) {
|
|
188
|
-
inputSummary = tool.input.description;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const text = inputSummary
|
|
192
|
-
? `:wrench: *${tool.name}*: ${inputSummary}`
|
|
193
|
-
: `:wrench: *${tool.name}*`;
|
|
194
|
-
|
|
195
|
-
try {
|
|
196
|
-
const result = await app.client.chat.postMessage({
|
|
197
|
-
channel: channel.channelId,
|
|
198
|
-
text,
|
|
199
|
-
mrkdwn: true,
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
// Store the message ts for threading results
|
|
203
|
-
if (result.ts) {
|
|
204
|
-
toolCallMessages.set(tool.id, result.ts);
|
|
205
|
-
}
|
|
206
|
-
} catch (err) {
|
|
207
|
-
console.error('[Slack] Failed to post tool call:', err);
|
|
208
|
-
}
|
|
209
|
-
},
|
|
210
|
-
|
|
211
|
-
onToolResult: async (sessionId, result) => {
|
|
212
|
-
const channel = channelManager.getChannel(sessionId);
|
|
213
|
-
if (!channel) return;
|
|
214
|
-
|
|
215
|
-
const parentTs = toolCallMessages.get(result.toolUseId);
|
|
216
|
-
if (!parentTs) return; // No parent message to reply to
|
|
217
|
-
|
|
218
|
-
// Truncate long results
|
|
219
|
-
const maxLen = 2000;
|
|
220
|
-
let content = result.content;
|
|
221
|
-
if (content.length > maxLen) {
|
|
222
|
-
content = content.slice(0, maxLen) + '\n... (truncated)';
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const prefix = result.isError ? ':x: Error:' : ':white_check_mark: Result:';
|
|
226
|
-
const text = `${prefix}\n\`\`\`\n${content}\n\`\`\``;
|
|
227
|
-
|
|
228
|
-
try {
|
|
229
|
-
await app.client.chat.postMessage({
|
|
230
|
-
channel: channel.channelId,
|
|
231
|
-
thread_ts: parentTs,
|
|
232
|
-
text: markdownToSlack(text),
|
|
233
|
-
mrkdwn: true,
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
// Clean up the mapping
|
|
237
|
-
toolCallMessages.delete(result.toolUseId);
|
|
238
|
-
} catch (err) {
|
|
239
|
-
console.error('[Slack] Failed to post tool result:', err);
|
|
240
|
-
}
|
|
241
|
-
},
|
|
242
|
-
|
|
243
|
-
onPlanModeChange: async (sessionId, inPlanMode) => {
|
|
244
|
-
const channel = channelManager.getChannel(sessionId);
|
|
245
|
-
if (!channel) return;
|
|
246
|
-
|
|
247
|
-
const emoji = inPlanMode ? ':clipboard:' : ':hammer:';
|
|
248
|
-
const status = inPlanMode ? 'Planning mode - Claude is designing a solution' : 'Execution mode - Claude is implementing';
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
await app.client.chat.postMessage({
|
|
252
|
-
channel: channel.channelId,
|
|
253
|
-
text: `${emoji} ${status}`,
|
|
254
|
-
mrkdwn: true,
|
|
255
|
-
});
|
|
256
|
-
} catch (err) {
|
|
257
|
-
console.error('[Slack] Failed to post plan mode change:', err);
|
|
258
|
-
}
|
|
259
|
-
},
|
|
260
|
-
});
|
|
261
|
-
|
|
262
|
-
// Handle messages in session channels (user sending input to Claude)
|
|
263
|
-
app.message(async ({ message, say }) => {
|
|
264
|
-
// Type guard for regular messages
|
|
265
|
-
if ('subtype' in message && message.subtype) return;
|
|
266
|
-
if (!('text' in message) || !message.text) return;
|
|
267
|
-
if (!('channel' in message) || !message.channel) return;
|
|
268
|
-
|
|
269
|
-
// Ignore bot's own messages
|
|
270
|
-
if ('bot_id' in message && message.bot_id) return;
|
|
271
|
-
|
|
272
|
-
// Ignore thread replies (we want top-level messages only)
|
|
273
|
-
if ('thread_ts' in message && message.thread_ts) return;
|
|
274
|
-
|
|
275
|
-
const sessionId = channelManager.getSessionByChannel(message.channel);
|
|
276
|
-
if (!sessionId) return; // Not a session channel
|
|
277
|
-
|
|
278
|
-
const channel = channelManager.getChannel(sessionId);
|
|
279
|
-
if (!channel || channel.status === 'ended') {
|
|
280
|
-
await say(':warning: This session has ended.');
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
console.log(`[Slack] Sending input to session ${sessionId}: ${message.text.slice(0, 50)}...`);
|
|
285
|
-
|
|
286
|
-
// Track this message so we don't re-post it when it comes back via JSONL
|
|
287
|
-
slackSentMessages.add(message.text.trim());
|
|
288
|
-
|
|
289
|
-
const sent = sessionManager.sendInput(sessionId, message.text);
|
|
290
|
-
if (!sent) {
|
|
291
|
-
slackSentMessages.delete(message.text.trim());
|
|
292
|
-
await say(':warning: Failed to send input - session not connected.');
|
|
293
|
-
}
|
|
294
|
-
});
|
|
295
|
-
|
|
296
|
-
// Slash command: /afk [sessions]
|
|
297
|
-
app.command('/afk', async ({ command, ack, respond }) => {
|
|
298
|
-
await ack();
|
|
299
|
-
|
|
300
|
-
const subcommand = command.text.trim().split(' ')[0];
|
|
301
|
-
|
|
302
|
-
if (subcommand === 'sessions' || !subcommand) {
|
|
303
|
-
const active = channelManager.getAllActive();
|
|
304
|
-
if (active.length === 0) {
|
|
305
|
-
await respond('No active sessions. Start a session with `afk-code run -- claude`');
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
const text = active
|
|
310
|
-
.map((c) => `<#${c.channelId}> - ${formatSessionStatus(c.status)}`)
|
|
311
|
-
.join('\n');
|
|
312
|
-
|
|
313
|
-
await respond({
|
|
314
|
-
text: `*Active Sessions:*\n${text}`,
|
|
315
|
-
mrkdwn: true,
|
|
316
|
-
});
|
|
317
|
-
} else {
|
|
318
|
-
await respond('Unknown command. Available: `/afk sessions`');
|
|
319
|
-
}
|
|
320
|
-
});
|
|
321
|
-
|
|
322
|
-
// Slash command: /background - Send Ctrl+B to put Claude in background mode
|
|
323
|
-
app.command('/background', async ({ command, ack, respond }) => {
|
|
324
|
-
await ack();
|
|
325
|
-
|
|
326
|
-
const sessionId = channelManager.getSessionByChannel(command.channel_id);
|
|
327
|
-
if (!sessionId) {
|
|
328
|
-
await respond(':warning: This channel is not associated with an active session.');
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const channel = channelManager.getChannel(sessionId);
|
|
333
|
-
if (!channel || channel.status === 'ended') {
|
|
334
|
-
await respond(':warning: This session has ended.');
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
// Send Ctrl+B (ASCII 2)
|
|
339
|
-
const sent = sessionManager.sendInput(sessionId, '\x02');
|
|
340
|
-
if (sent) {
|
|
341
|
-
await respond(':arrow_heading_down: Sent background command (Ctrl+B)');
|
|
342
|
-
} else {
|
|
343
|
-
await respond(':warning: Failed to send command - session not connected.');
|
|
344
|
-
}
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
// Slash command: /interrupt - Send Escape to interrupt Claude
|
|
348
|
-
app.command('/interrupt', async ({ command, ack, respond }) => {
|
|
349
|
-
await ack();
|
|
350
|
-
|
|
351
|
-
const sessionId = channelManager.getSessionByChannel(command.channel_id);
|
|
352
|
-
if (!sessionId) {
|
|
353
|
-
await respond(':warning: This channel is not associated with an active session.');
|
|
354
|
-
return;
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
const channel = channelManager.getChannel(sessionId);
|
|
358
|
-
if (!channel || channel.status === 'ended') {
|
|
359
|
-
await respond(':warning: This session has ended.');
|
|
360
|
-
return;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// Send Escape (ASCII 27)
|
|
364
|
-
const sent = sessionManager.sendInput(sessionId, '\x1b');
|
|
365
|
-
if (sent) {
|
|
366
|
-
await respond(':stop_sign: Sent interrupt (Escape)');
|
|
367
|
-
} else {
|
|
368
|
-
await respond(':warning: Failed to send command - session not connected.');
|
|
369
|
-
}
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// Slash command: /mode - Send Shift+Tab to toggle mode
|
|
373
|
-
app.command('/mode', async ({ command, ack, respond }) => {
|
|
374
|
-
await ack();
|
|
375
|
-
|
|
376
|
-
const sessionId = channelManager.getSessionByChannel(command.channel_id);
|
|
377
|
-
if (!sessionId) {
|
|
378
|
-
await respond(':warning: This channel is not associated with an active session.');
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const channel = channelManager.getChannel(sessionId);
|
|
383
|
-
if (!channel || channel.status === 'ended') {
|
|
384
|
-
await respond(':warning: This session has ended.');
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// Send Shift+Tab (ESC [ Z)
|
|
389
|
-
const sent = sessionManager.sendInput(sessionId, '\x1b[Z');
|
|
390
|
-
if (sent) {
|
|
391
|
-
await respond(':arrows_counterclockwise: Sent mode toggle (Shift+Tab)');
|
|
392
|
-
} else {
|
|
393
|
-
await respond(':warning: Failed to send command - session not connected.');
|
|
394
|
-
}
|
|
395
|
-
});
|
|
396
|
-
|
|
397
|
-
// App Home tab
|
|
398
|
-
app.event('app_home_opened', async ({ event, client }) => {
|
|
399
|
-
const active = channelManager.getAllActive();
|
|
400
|
-
|
|
401
|
-
const blocks: any[] = [
|
|
402
|
-
{
|
|
403
|
-
type: 'header',
|
|
404
|
-
text: { type: 'plain_text', text: 'AFK Code Sessions', emoji: true },
|
|
405
|
-
},
|
|
406
|
-
{ type: 'divider' },
|
|
407
|
-
];
|
|
408
|
-
|
|
409
|
-
if (active.length === 0) {
|
|
410
|
-
blocks.push({
|
|
411
|
-
type: 'section',
|
|
412
|
-
text: {
|
|
413
|
-
type: 'mrkdwn',
|
|
414
|
-
text: '_No active sessions_\n\nStart a session with `afk-code run -- claude`',
|
|
415
|
-
},
|
|
416
|
-
});
|
|
417
|
-
} else {
|
|
418
|
-
for (const c of active) {
|
|
419
|
-
blocks.push({
|
|
420
|
-
type: 'section',
|
|
421
|
-
text: {
|
|
422
|
-
type: 'mrkdwn',
|
|
423
|
-
text: `*${c.sessionName}*\n${formatSessionStatus(c.status)}\n<#${c.channelId}>`,
|
|
424
|
-
},
|
|
425
|
-
});
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
try {
|
|
430
|
-
await client.views.publish({
|
|
431
|
-
user_id: event.user,
|
|
432
|
-
view: {
|
|
433
|
-
type: 'home',
|
|
434
|
-
blocks,
|
|
435
|
-
},
|
|
436
|
-
});
|
|
437
|
-
} catch (err) {
|
|
438
|
-
console.error('[Slack] Failed to publish home view:', err);
|
|
439
|
-
}
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
return { app, sessionManager, channelManager };
|
|
443
|
-
}
|
package/src/slack/types.ts
DELETED
package/src/types/index.ts
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import { existsSync, statSync } from 'fs';
|
|
2
|
-
import { resolve } from 'path';
|
|
3
|
-
import { homedir } from 'os';
|
|
4
|
-
|
|
5
|
-
// Common image extensions
|
|
6
|
-
const IMAGE_EXTENSIONS = new Set([
|
|
7
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.tiff', '.tif'
|
|
8
|
-
]);
|
|
9
|
-
|
|
10
|
-
// Regex to match file paths that could be images
|
|
11
|
-
// Matches: /absolute/path.png, ./relative/path.jpg, ~/home/path.gif, "quoted/path.png"
|
|
12
|
-
const PATH_PATTERN = /(?:["'`]([^"'`\n]+\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?))|(?:^|[\s(])([~./][^\s)"'`\n]*\.(?:png|jpe?g|gif|webp|svg|bmp|ico|tiff?)))/gi;
|
|
13
|
-
|
|
14
|
-
export interface ExtractedImage {
|
|
15
|
-
originalPath: string;
|
|
16
|
-
resolvedPath: string;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Extract image paths from text content and verify they exist on disk
|
|
21
|
-
*/
|
|
22
|
-
export function extractImagePaths(content: string, cwd?: string): ExtractedImage[] {
|
|
23
|
-
const images: ExtractedImage[] = [];
|
|
24
|
-
const seen = new Set<string>();
|
|
25
|
-
|
|
26
|
-
// Reset regex state
|
|
27
|
-
PATH_PATTERN.lastIndex = 0;
|
|
28
|
-
|
|
29
|
-
let match;
|
|
30
|
-
while ((match = PATH_PATTERN.exec(content)) !== null) {
|
|
31
|
-
// Get the captured path (from quoted or unquoted group)
|
|
32
|
-
const originalPath = (match[1] || match[2]).trim();
|
|
33
|
-
|
|
34
|
-
if (!originalPath || seen.has(originalPath)) continue;
|
|
35
|
-
seen.add(originalPath);
|
|
36
|
-
|
|
37
|
-
// Resolve the path
|
|
38
|
-
let resolvedPath = originalPath;
|
|
39
|
-
|
|
40
|
-
// Handle home directory
|
|
41
|
-
if (resolvedPath.startsWith('~/')) {
|
|
42
|
-
resolvedPath = resolve(homedir(), resolvedPath.slice(2));
|
|
43
|
-
}
|
|
44
|
-
// Handle relative paths
|
|
45
|
-
else if (resolvedPath.startsWith('./') || resolvedPath.startsWith('../')) {
|
|
46
|
-
resolvedPath = resolve(cwd || process.cwd(), resolvedPath);
|
|
47
|
-
}
|
|
48
|
-
// Handle absolute paths (already resolved)
|
|
49
|
-
else if (!resolvedPath.startsWith('/')) {
|
|
50
|
-
// Not a recognizable path format, skip
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Verify file exists and is a file (not directory)
|
|
55
|
-
try {
|
|
56
|
-
if (existsSync(resolvedPath)) {
|
|
57
|
-
const stat = statSync(resolvedPath);
|
|
58
|
-
if (stat.isFile()) {
|
|
59
|
-
// Check extension
|
|
60
|
-
const ext = resolvedPath.toLowerCase().slice(resolvedPath.lastIndexOf('.'));
|
|
61
|
-
if (IMAGE_EXTENSIONS.has(ext)) {
|
|
62
|
-
images.push({ originalPath, resolvedPath });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
} catch {
|
|
67
|
-
// File doesn't exist or can't be accessed, skip
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
return images;
|
|
72
|
-
}
|