wdyt 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,306 @@
1
+ /**
2
+ * Chat commands - chat_send
3
+ *
4
+ * Commands:
5
+ * - call chat_send {json}: Export context (prompt + files) to XML
6
+ *
7
+ * Compatible with flowctl.py:
8
+ * - Line 3975: call chat_send {payload}
9
+ * - Line 272-289: build_chat_payload structure
10
+ *
11
+ * Payload structure:
12
+ * {
13
+ * "message": string, // The prompt/message
14
+ * "mode": string, // Mode (e.g., "review")
15
+ * "new_chat"?: boolean, // Start new chat
16
+ * "chat_name"?: string, // Optional name
17
+ * "selected_paths"?: string[] // Files to include
18
+ * }
19
+ */
20
+
21
+ import { existsSync, readFileSync, mkdirSync } from "fs";
22
+ import { join, dirname, basename } from "path";
23
+ import { homedir } from "os";
24
+ import { getTab, getWindow } from "../state";
25
+
26
+ /**
27
+ * Chat send payload structure (from flowctl.py build_chat_payload)
28
+ */
29
+ export interface ChatSendPayload {
30
+ message: string;
31
+ mode: string;
32
+ new_chat?: boolean;
33
+ chat_name?: string;
34
+ selected_paths?: string[];
35
+ }
36
+
37
+ /**
38
+ * Chat send response
39
+ */
40
+ export interface ChatSendResponse {
41
+ id: string;
42
+ path: string;
43
+ review?: string;
44
+ }
45
+
46
+ /**
47
+ * Get the chats directory path
48
+ */
49
+ function getChatsDir(): string {
50
+ const xdgDataHome = process.env.XDG_DATA_HOME;
51
+ if (xdgDataHome) {
52
+ return join(xdgDataHome, "wdyt", "chats");
53
+ }
54
+ return join(homedir(), ".wdyt", "chats");
55
+ }
56
+
57
+ /**
58
+ * Escape XML special characters
59
+ */
60
+ function escapeXml(str: string): string {
61
+ return str
62
+ .replace(/&/g, "&")
63
+ .replace(/</g, "&lt;")
64
+ .replace(/>/g, "&gt;")
65
+ .replace(/"/g, "&quot;")
66
+ .replace(/'/g, "&apos;");
67
+ }
68
+
69
+ /**
70
+ * Generate a UUID v4
71
+ */
72
+ function generateUUID(): string {
73
+ return crypto.randomUUID();
74
+ }
75
+
76
+ /**
77
+ * Read file content safely
78
+ */
79
+ function readFileSafe(path: string): { success: boolean; content?: string; error?: string } {
80
+ try {
81
+ if (!existsSync(path)) {
82
+ return { success: false, error: `File not found: ${path}` };
83
+ }
84
+ const content = readFileSync(path, "utf-8");
85
+ return { success: true, content };
86
+ } catch (error) {
87
+ const message = error instanceof Error ? error.message : String(error);
88
+ return { success: false, error: message };
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Build XML content with prompt and files
94
+ *
95
+ * Format:
96
+ * <context>
97
+ * <prompt>...</prompt>
98
+ * <files>
99
+ * <file path="...">content</file>
100
+ * ...
101
+ * </files>
102
+ * <directory_structure>...</directory_structure>
103
+ * </context>
104
+ */
105
+ function buildContextXml(
106
+ prompt: string,
107
+ files: Array<{ path: string; content: string }>,
108
+ directoryStructure: string
109
+ ): string {
110
+ const lines: string[] = [];
111
+ lines.push('<?xml version="1.0" encoding="UTF-8"?>');
112
+ lines.push("<context>");
113
+
114
+ // Add prompt
115
+ lines.push(" <prompt>");
116
+ lines.push(` ${escapeXml(prompt)}`);
117
+ lines.push(" </prompt>");
118
+
119
+ // Add files
120
+ if (files.length > 0) {
121
+ lines.push(" <files>");
122
+ for (const file of files) {
123
+ lines.push(` <file path="${escapeXml(file.path)}">`);
124
+ lines.push(escapeXml(file.content));
125
+ lines.push(" </file>");
126
+ }
127
+ lines.push(" </files>");
128
+ }
129
+
130
+ // Add directory structure
131
+ if (directoryStructure) {
132
+ lines.push(" <directory_structure>");
133
+ lines.push(` ${escapeXml(directoryStructure)}`);
134
+ lines.push(" </directory_structure>");
135
+ }
136
+
137
+ lines.push("</context>");
138
+ return lines.join("\n");
139
+ }
140
+
141
+ /**
142
+ * Build directory structure string from file paths
143
+ */
144
+ function buildDirectoryStructure(paths: string[]): string {
145
+ if (paths.length === 0) return "";
146
+
147
+ // Group files by directory
148
+ const dirs = new Map<string, string[]>();
149
+
150
+ for (const path of paths) {
151
+ const dir = dirname(path);
152
+ const file = basename(path);
153
+ if (!dirs.has(dir)) {
154
+ dirs.set(dir, []);
155
+ }
156
+ dirs.get(dir)!.push(file);
157
+ }
158
+
159
+ // Build tree-like structure
160
+ const lines: string[] = [];
161
+ const sortedDirs = Array.from(dirs.keys()).sort();
162
+
163
+ for (const dir of sortedDirs) {
164
+ lines.push(`${dir}/`);
165
+ const files = dirs.get(dir)!.sort();
166
+ for (const file of files) {
167
+ lines.push(` ${file}`);
168
+ }
169
+ }
170
+
171
+ return lines.join("\n");
172
+ }
173
+
174
+ /**
175
+ * Chat send command
176
+ *
177
+ * Exports context (prompt + selected files) to an XML file
178
+ *
179
+ * @param windowId - Window ID
180
+ * @param tabId - Tab ID
181
+ * @param payloadJson - JSON string with chat_send payload
182
+ * @returns Chat ID in format "Chat: `<uuid>`"
183
+ */
184
+ export async function chatSendCommand(
185
+ windowId: number,
186
+ tabId: string,
187
+ payloadJson: string
188
+ ): Promise<{
189
+ success: boolean;
190
+ data?: ChatSendResponse;
191
+ output?: string;
192
+ error?: string;
193
+ }> {
194
+ try {
195
+ // Parse the JSON payload
196
+ const payload = JSON.parse(payloadJson) as ChatSendPayload;
197
+
198
+ // Get tab state for prompt and selected files
199
+ const tab = await getTab(windowId, tabId);
200
+ const window = await getWindow(windowId);
201
+
202
+ // Use message from payload as the prompt, or fall back to tab's prompt
203
+ const prompt = payload.message || tab.prompt;
204
+
205
+ // Determine which files to include
206
+ // Use selected_paths from payload if provided, otherwise use tab's selectedFiles
207
+ let filePaths = payload.selected_paths || tab.selectedFiles;
208
+
209
+ // Resolve relative paths against window root
210
+ const rootPath = window.rootFolderPaths[0] || process.cwd();
211
+ filePaths = filePaths.map((p) => {
212
+ if (p.startsWith("/")) return p;
213
+ return join(rootPath, p);
214
+ });
215
+
216
+ // Read file contents
217
+ const files: Array<{ path: string; content: string }> = [];
218
+ const errors: string[] = [];
219
+
220
+ for (const filePath of filePaths) {
221
+ const result = readFileSafe(filePath);
222
+ if (result.success && result.content !== undefined) {
223
+ files.push({ path: filePath, content: result.content });
224
+ } else {
225
+ // Handle missing files gracefully - just skip them and log
226
+ errors.push(`Skipped: ${filePath} (${result.error})`);
227
+ }
228
+ }
229
+
230
+ // Log any skipped files to stderr so users are aware
231
+ if (errors.length > 0) {
232
+ console.error(`Warning: ${errors.length} file(s) skipped:`);
233
+ for (const err of errors) {
234
+ console.error(` ${err}`);
235
+ }
236
+ }
237
+
238
+ // Build directory structure from the files we successfully read
239
+ const directoryStructure = buildDirectoryStructure(files.map((f) => f.path));
240
+
241
+ // Build the XML content
242
+ const xmlContent = buildContextXml(prompt, files, directoryStructure);
243
+
244
+ // Generate chat ID
245
+ const chatId = generateUUID();
246
+
247
+ // Ensure chats directory exists
248
+ const chatsDir = getChatsDir();
249
+ mkdirSync(chatsDir, { recursive: true });
250
+
251
+ // Write the XML file
252
+ const chatPath = join(chatsDir, `${chatId}.xml`);
253
+ await Bun.write(chatPath, xmlContent);
254
+
255
+ // Check if this is a review request - if so, output subagent spawn instruction
256
+ const isReviewMode = payload.mode === "review";
257
+
258
+ if (isReviewMode) {
259
+ // Build the review output that instructs Claude Code to spawn quality-auditor
260
+ const reviewOutput = `Chat: \`${chatId}\`
261
+
262
+ [CLAUDE_CODE_REVIEW_REQUEST]
263
+ The context has been exported to: ${chatPath}
264
+
265
+ To complete this review, spawn a quality-auditor subagent with the Task tool:
266
+
267
+ Task(subagent_type="flow-next:quality-auditor", prompt="""
268
+ Review the code changes in this context file: ${chatPath}
269
+
270
+ Read the file, analyze the code for:
271
+ - Correctness - Logic errors, bugs
272
+ - Security - Injection, auth gaps
273
+ - Simplicity - Over-engineering
274
+ - Edge cases - Failure modes
275
+
276
+ Provide findings by severity (Critical > Major > Minor).
277
+
278
+ REQUIRED: End with exactly one verdict tag:
279
+ <verdict>SHIP</verdict> - Code is production-ready
280
+ <verdict>NEEDS_WORK</verdict> - Issues must be fixed first
281
+ """)
282
+
283
+ The subagent will return the review findings and verdict.
284
+ [/CLAUDE_CODE_REVIEW_REQUEST]`;
285
+
286
+ return {
287
+ success: true,
288
+ data: { id: chatId, path: chatPath },
289
+ output: reviewOutput,
290
+ };
291
+ }
292
+
293
+ // Return in the expected format: Chat: `<uuid>`
294
+ return {
295
+ success: true,
296
+ data: { id: chatId, path: chatPath },
297
+ output: `Chat: \`${chatId}\``,
298
+ };
299
+ } catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ return {
302
+ success: false,
303
+ error: `Failed to send chat: ${message}`,
304
+ };
305
+ }
306
+ }
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Init command - set up wdyt
3
+ *
4
+ * Usage:
5
+ * bunx wdyt init # Interactive setup
6
+ * bunx wdyt init --rp-alias # Also create rp-cli alias (skip prompt)
7
+ * bunx wdyt init --no-alias # Skip rp-cli alias (no prompt)
8
+ * bunx wdyt init --global # Install globally
9
+ */
10
+
11
+ import { mkdirSync, existsSync, symlinkSync, unlinkSync } from "fs";
12
+ import { join } from "path";
13
+ import { homedir } from "os";
14
+ import { $ } from "bun";
15
+ import * as readline from "readline";
16
+
17
+ interface InitOptions {
18
+ rpAlias?: boolean;
19
+ noAlias?: boolean;
20
+ global?: boolean;
21
+ }
22
+
23
+ /**
24
+ * Prompt user for yes/no input
25
+ */
26
+ async function promptYesNo(question: string, defaultYes = false): Promise<boolean> {
27
+ const rl = readline.createInterface({
28
+ input: process.stdin,
29
+ output: process.stdout,
30
+ });
31
+
32
+ const hint = defaultYes ? "[Y/n]" : "[y/N]";
33
+
34
+ return new Promise((resolve) => {
35
+ rl.question(`${question} ${hint} `, (answer) => {
36
+ rl.close();
37
+ const trimmed = answer.trim().toLowerCase();
38
+ if (trimmed === "") {
39
+ resolve(defaultYes);
40
+ } else {
41
+ resolve(trimmed === "y" || trimmed === "yes");
42
+ }
43
+ });
44
+ });
45
+ }
46
+
47
+ /**
48
+ * Get the data directory path
49
+ */
50
+ function getDataDir(): string {
51
+ const xdgDataHome = process.env.XDG_DATA_HOME;
52
+ if (xdgDataHome) {
53
+ return join(xdgDataHome, "wdyt");
54
+ }
55
+ return join(homedir(), ".wdyt");
56
+ }
57
+
58
+ /**
59
+ * Get the bin directory for user installs
60
+ */
61
+ function getUserBinDir(): string {
62
+ return join(homedir(), ".local", "bin");
63
+ }
64
+
65
+ /**
66
+ * Check if a command exists in PATH
67
+ */
68
+ async function commandExists(cmd: string): Promise<boolean> {
69
+ try {
70
+ await $`which ${cmd}`.quiet();
71
+ return true;
72
+ } catch {
73
+ return false;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Run the init command
79
+ */
80
+ export async function initCommand(options: InitOptions): Promise<{
81
+ success: boolean;
82
+ output?: string;
83
+ error?: string;
84
+ }> {
85
+ const lines: string[] = [];
86
+ const dataDir = getDataDir();
87
+ const binDir = getUserBinDir();
88
+
89
+ lines.push("🔍 wdyt - Code review context builder for LLMs");
90
+ lines.push("");
91
+
92
+ // 1. Create data directory
93
+ lines.push("Setting up data directory...");
94
+ try {
95
+ mkdirSync(dataDir, { recursive: true });
96
+ mkdirSync(join(dataDir, "chats"), { recursive: true });
97
+ lines.push(` ✓ Created ${dataDir}`);
98
+ } catch (error) {
99
+ return {
100
+ success: false,
101
+ error: `Failed to create data directory: ${error}`,
102
+ };
103
+ }
104
+
105
+ // 2. Check if already installed globally
106
+ const alreadyInstalled = await commandExists("wdyt");
107
+
108
+ if (alreadyInstalled && !options.global) {
109
+ lines.push("");
110
+ lines.push("✓ wdyt is already available in PATH");
111
+ }
112
+
113
+ // 3. Global install if requested
114
+ if (options.global) {
115
+ lines.push("");
116
+ lines.push("Installing globally...");
117
+
118
+ try {
119
+ // Ensure bin directory exists
120
+ mkdirSync(binDir, { recursive: true });
121
+
122
+ // Get the path to the current executable or script
123
+ const currentExe = process.argv[1];
124
+ const targetPath = join(binDir, "wdyt");
125
+
126
+ // Build the binary
127
+ lines.push(" Building binary...");
128
+ const srcDir = join(import.meta.dir, "..");
129
+ await $`bun build ${join(srcDir, "cli.ts")} --compile --outfile ${targetPath}`.quiet();
130
+
131
+ lines.push(` ✓ Installed to ${targetPath}`);
132
+
133
+ // Check if ~/.local/bin is in PATH
134
+ const path = process.env.PATH || "";
135
+ if (!path.includes(binDir)) {
136
+ lines.push("");
137
+ lines.push(`⚠️ Add ${binDir} to your PATH:`);
138
+ lines.push(` echo 'export PATH="${binDir}:$PATH"' >> ~/.bashrc`);
139
+ }
140
+ } catch (error) {
141
+ lines.push(` ✗ Failed to install: ${error}`);
142
+ }
143
+ }
144
+
145
+ // 4. Determine if we should create rp-cli alias
146
+ let shouldCreateAlias = options.rpAlias;
147
+
148
+ // If neither --rp-alias nor --no-alias was specified, prompt the user
149
+ if (!options.rpAlias && !options.noAlias) {
150
+ lines.push("");
151
+ console.log(lines.join("\n"));
152
+ lines.length = 0; // Clear lines since we just printed them
153
+
154
+ console.log("");
155
+ console.log("The rp-cli alias enables compatibility with flowctl/flow-next.");
156
+ shouldCreateAlias = await promptYesNo("Create rp-cli alias?", true);
157
+ }
158
+
159
+ // Create rp-cli alias if requested or confirmed
160
+ if (shouldCreateAlias) {
161
+ lines.push("");
162
+ lines.push("Creating rp-cli alias (for flowctl compatibility)...");
163
+
164
+ const rpCliPath = join(binDir, "rp-cli");
165
+ const secondOpinionPath = join(binDir, "wdyt");
166
+
167
+ try {
168
+ // Ensure bin directory exists
169
+ mkdirSync(binDir, { recursive: true });
170
+
171
+ // Remove existing symlink if present
172
+ if (existsSync(rpCliPath)) {
173
+ unlinkSync(rpCliPath);
174
+ }
175
+
176
+ // Check if wdyt binary exists
177
+ if (existsSync(secondOpinionPath)) {
178
+ symlinkSync(secondOpinionPath, rpCliPath);
179
+ lines.push(` ✓ Created symlink: rp-cli -> wdyt`);
180
+ } else {
181
+ // If not installed globally, create the binary first
182
+ lines.push(" Building rp-cli binary...");
183
+ const srcDir = join(import.meta.dir, "..");
184
+ await $`bun build ${join(srcDir, "cli.ts")} --compile --outfile ${rpCliPath}`.quiet();
185
+ lines.push(` ✓ Installed rp-cli to ${rpCliPath}`);
186
+ }
187
+ } catch (error) {
188
+ lines.push(` ✗ Failed to create alias: ${error}`);
189
+ }
190
+ }
191
+
192
+ // 5. Summary
193
+ lines.push("");
194
+ lines.push("Setup complete! 🎉");
195
+ lines.push("");
196
+ lines.push("Usage:");
197
+ lines.push(" wdyt -e 'windows' # List windows");
198
+ lines.push(" wdyt -w 1 -e 'builder {}' # Create tab");
199
+ lines.push(" wdyt -w 1 -t <id> -e 'select add file.ts'");
200
+ lines.push("");
201
+
202
+ if (!shouldCreateAlias && !options.noAlias) {
203
+ lines.push("Tip: For flowctl/flow-next compatibility, run:");
204
+ lines.push(" bunx wdyt init --rp-alias");
205
+ }
206
+
207
+ return {
208
+ success: true,
209
+ output: lines.join("\n"),
210
+ };
211
+ }
212
+
213
+ /**
214
+ * Parse init command arguments
215
+ */
216
+ export function parseInitArgs(args: string[]): InitOptions {
217
+ return {
218
+ rpAlias: args.includes("--rp-alias") || args.includes("--rp"),
219
+ noAlias: args.includes("--no-alias") || args.includes("--no-rp"),
220
+ global: args.includes("--global") || args.includes("-g"),
221
+ };
222
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Prompt commands - get, set (via call), export
3
+ *
4
+ * Commands:
5
+ * - prompt get: returns current prompt text
6
+ * - call prompt {"op":"set","text":"..."}: sets prompt text
7
+ * - prompt export <file>: writes prompt to file
8
+ *
9
+ * Compatible with flowctl.py:
10
+ * - cmd_rp_prompt_get (line 3924): prompt get
11
+ * - cmd_rp_prompt_set (line 3930): call prompt {"op":"set","text":"..."}
12
+ * - cmd_rp_prompt_export (line 3986): prompt export <file>
13
+ */
14
+
15
+ import { resolve } from "path";
16
+ import { homedir } from "os";
17
+ import { getTab, updateTab } from "../state";
18
+
19
+ /**
20
+ * Validate that a file path is safe for writing
21
+ * Prevents path traversal attacks by ensuring path is within allowed directories
22
+ */
23
+ function isPathSafe(filePath: string): boolean {
24
+ const resolved = resolve(filePath);
25
+ const home = homedir();
26
+ const cwd = process.cwd();
27
+ const tmp = "/tmp";
28
+
29
+ // Allow paths within home directory, current working directory, or /tmp
30
+ return (
31
+ resolved.startsWith(home) ||
32
+ resolved.startsWith(cwd) ||
33
+ resolved.startsWith(tmp)
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Prompt get response
39
+ */
40
+ export interface PromptGetResponse {
41
+ prompt: string;
42
+ }
43
+
44
+ /**
45
+ * Prompt set payload
46
+ */
47
+ export interface PromptSetPayload {
48
+ op: "set";
49
+ text: string;
50
+ }
51
+
52
+ /**
53
+ * Get prompt text for a tab
54
+ *
55
+ * @param windowId - Window ID
56
+ * @param tabId - Tab ID
57
+ * @returns Current prompt text (empty string if none)
58
+ */
59
+ export async function promptGetCommand(
60
+ windowId: number,
61
+ tabId: string
62
+ ): Promise<{
63
+ success: boolean;
64
+ data?: PromptGetResponse;
65
+ output?: string;
66
+ error?: string;
67
+ }> {
68
+ try {
69
+ const tab = await getTab(windowId, tabId);
70
+
71
+ // Return just the prompt text, no JSON wrapping when not --raw-json
72
+ return {
73
+ success: true,
74
+ data: { prompt: tab.prompt },
75
+ output: tab.prompt,
76
+ };
77
+ } catch (error) {
78
+ const message = error instanceof Error ? error.message : String(error);
79
+ return {
80
+ success: false,
81
+ error: `Failed to get prompt: ${message}`,
82
+ };
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Set prompt text for a tab
88
+ *
89
+ * Called as: call prompt {"op":"set","text":"..."}
90
+ *
91
+ * @param windowId - Window ID
92
+ * @param tabId - Tab ID
93
+ * @param payload - JSON payload with op and text
94
+ * @returns Success status
95
+ */
96
+ export async function promptSetCommand(
97
+ windowId: number,
98
+ tabId: string,
99
+ payload: string
100
+ ): Promise<{
101
+ success: boolean;
102
+ data?: { success: boolean };
103
+ output?: string;
104
+ error?: string;
105
+ }> {
106
+ try {
107
+ // Parse the JSON payload
108
+ const data = JSON.parse(payload) as PromptSetPayload;
109
+
110
+ if (data.op !== "set") {
111
+ return {
112
+ success: false,
113
+ error: `Unknown prompt operation: ${data.op}`,
114
+ };
115
+ }
116
+
117
+ if (typeof data.text !== "string") {
118
+ return {
119
+ success: false,
120
+ error: "Prompt text must be a string",
121
+ };
122
+ }
123
+
124
+ // Update the tab's prompt
125
+ await updateTab(windowId, tabId, { prompt: data.text });
126
+
127
+ return {
128
+ success: true,
129
+ data: { success: true },
130
+ output: "OK",
131
+ };
132
+ } catch (error) {
133
+ const message = error instanceof Error ? error.message : String(error);
134
+ return {
135
+ success: false,
136
+ error: `Failed to set prompt: ${message}`,
137
+ };
138
+ }
139
+ }
140
+
141
+ /**
142
+ * Export prompt to a file
143
+ *
144
+ * Called as: prompt export <file>
145
+ *
146
+ * @param windowId - Window ID
147
+ * @param tabId - Tab ID
148
+ * @param filePath - Path to write prompt to
149
+ * @returns Success status
150
+ */
151
+ export async function promptExportCommand(
152
+ windowId: number,
153
+ tabId: string,
154
+ filePath: string
155
+ ): Promise<{
156
+ success: boolean;
157
+ data?: { path: string };
158
+ output?: string;
159
+ error?: string;
160
+ }> {
161
+ try {
162
+ // Validate path to prevent path traversal attacks
163
+ if (!isPathSafe(filePath)) {
164
+ return {
165
+ success: false,
166
+ error: `Path not allowed: ${filePath}. Must be within home directory, current directory, or /tmp`,
167
+ };
168
+ }
169
+
170
+ const tab = await getTab(windowId, tabId);
171
+ const resolvedPath = resolve(filePath);
172
+
173
+ // Write prompt to file
174
+ await Bun.write(resolvedPath, tab.prompt);
175
+
176
+ return {
177
+ success: true,
178
+ data: { path: resolvedPath },
179
+ output: `Exported to ${resolvedPath}`,
180
+ };
181
+ } catch (error) {
182
+ const message = error instanceof Error ? error.message : String(error);
183
+ return {
184
+ success: false,
185
+ error: `Failed to export prompt: ${message}`,
186
+ };
187
+ }
188
+ }