okiro 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.
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1408 -0
- package/package.json +65 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1408 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { program } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/spawn.ts
|
|
7
|
+
import path4 from "path";
|
|
8
|
+
import chalk2 from "chalk";
|
|
9
|
+
import ora from "ora";
|
|
10
|
+
|
|
11
|
+
// src/lib/config.ts
|
|
12
|
+
import fs from "fs-extra";
|
|
13
|
+
import path from "path";
|
|
14
|
+
import os from "os";
|
|
15
|
+
var CONFIG_DIR = path.join(os.homedir(), ".okiro");
|
|
16
|
+
var CONFIG_FILE = path.join(CONFIG_DIR, "config.json");
|
|
17
|
+
function getProjectDir(projectName) {
|
|
18
|
+
return path.join(CONFIG_DIR, "projects", projectName);
|
|
19
|
+
}
|
|
20
|
+
async function ensureConfigDir() {
|
|
21
|
+
await fs.ensureDir(CONFIG_DIR);
|
|
22
|
+
}
|
|
23
|
+
async function getConfig() {
|
|
24
|
+
await ensureConfigDir();
|
|
25
|
+
try {
|
|
26
|
+
const content = await fs.readFile(CONFIG_FILE, "utf-8");
|
|
27
|
+
return JSON.parse(content);
|
|
28
|
+
} catch {
|
|
29
|
+
return {
|
|
30
|
+
version: "1.0.0",
|
|
31
|
+
projects: {}
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function saveConfig(config) {
|
|
36
|
+
await ensureConfigDir();
|
|
37
|
+
await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2));
|
|
38
|
+
}
|
|
39
|
+
async function addProject(projectPath, variations) {
|
|
40
|
+
const config = await getConfig();
|
|
41
|
+
const projectName = path.basename(projectPath);
|
|
42
|
+
const variationsDir = getProjectDir(projectName);
|
|
43
|
+
const projectConfig = {
|
|
44
|
+
originalPath: projectPath,
|
|
45
|
+
variationsDir,
|
|
46
|
+
variations,
|
|
47
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
48
|
+
};
|
|
49
|
+
config.projects[projectPath] = projectConfig;
|
|
50
|
+
await saveConfig(config);
|
|
51
|
+
return projectConfig;
|
|
52
|
+
}
|
|
53
|
+
async function getProjectConfig(projectPath) {
|
|
54
|
+
const config = await getConfig();
|
|
55
|
+
return config.projects[projectPath] || null;
|
|
56
|
+
}
|
|
57
|
+
async function removeProject(projectPath) {
|
|
58
|
+
const config = await getConfig();
|
|
59
|
+
delete config.projects[projectPath];
|
|
60
|
+
await saveConfig(config);
|
|
61
|
+
}
|
|
62
|
+
function resolveProjectPath(inputPath) {
|
|
63
|
+
if (inputPath) {
|
|
64
|
+
return path.resolve(inputPath);
|
|
65
|
+
}
|
|
66
|
+
return process.cwd();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// src/lib/workspace.ts
|
|
70
|
+
import { execa as execa2 } from "execa";
|
|
71
|
+
import fs2 from "fs-extra";
|
|
72
|
+
import path2 from "path";
|
|
73
|
+
|
|
74
|
+
// src/lib/platform.ts
|
|
75
|
+
import { execa } from "execa";
|
|
76
|
+
import os2 from "os";
|
|
77
|
+
var cachedPlatformInfo = null;
|
|
78
|
+
async function getPlatformInfo() {
|
|
79
|
+
if (cachedPlatformInfo) {
|
|
80
|
+
return cachedPlatformInfo;
|
|
81
|
+
}
|
|
82
|
+
const nodePlatform = os2.platform();
|
|
83
|
+
let platform;
|
|
84
|
+
if (nodePlatform === "darwin") {
|
|
85
|
+
platform = "macos";
|
|
86
|
+
} else if (nodePlatform === "linux") {
|
|
87
|
+
platform = "linux";
|
|
88
|
+
} else if (nodePlatform === "win32") {
|
|
89
|
+
platform = "windows";
|
|
90
|
+
} else {
|
|
91
|
+
platform = "linux";
|
|
92
|
+
}
|
|
93
|
+
const [supportsApfsClone, supportsBtrfsReflink, hasTmux2, hasIterm2] = await Promise.all([
|
|
94
|
+
checkApfsCloneSupport(),
|
|
95
|
+
checkBtrfsReflinkSupport(),
|
|
96
|
+
checkTmuxAvailable(),
|
|
97
|
+
checkIterm2Available()
|
|
98
|
+
]);
|
|
99
|
+
cachedPlatformInfo = {
|
|
100
|
+
platform,
|
|
101
|
+
supportsApfsClone,
|
|
102
|
+
supportsBtrfsReflink,
|
|
103
|
+
hasTmux: hasTmux2,
|
|
104
|
+
hasIterm2,
|
|
105
|
+
shell: process.env.SHELL || "/bin/bash"
|
|
106
|
+
};
|
|
107
|
+
return cachedPlatformInfo;
|
|
108
|
+
}
|
|
109
|
+
async function checkApfsCloneSupport() {
|
|
110
|
+
if (os2.platform() !== "darwin") {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
const { stdout } = await execa("sw_vers", ["-productVersion"]);
|
|
115
|
+
const [major, minor] = stdout.trim().split(".").map(Number);
|
|
116
|
+
return major > 10 || major === 10 && minor >= 13;
|
|
117
|
+
} catch {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async function checkBtrfsReflinkSupport() {
|
|
122
|
+
if (os2.platform() !== "linux") {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
try {
|
|
126
|
+
await execa("cp", ["--help"]);
|
|
127
|
+
return true;
|
|
128
|
+
} catch {
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
async function checkTmuxAvailable() {
|
|
133
|
+
try {
|
|
134
|
+
await execa("which", ["tmux"]);
|
|
135
|
+
return true;
|
|
136
|
+
} catch {
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function checkIterm2Available() {
|
|
141
|
+
if (os2.platform() !== "darwin") {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
try {
|
|
145
|
+
await execa("test", ["-d", "/Applications/iTerm.app"]);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// src/lib/workspace.ts
|
|
153
|
+
async function createWorkspace(sourcePath, destPath, options = {}) {
|
|
154
|
+
const platformInfo = await getPlatformInfo();
|
|
155
|
+
await fs2.ensureDir(path2.dirname(destPath));
|
|
156
|
+
if (platformInfo.supportsApfsClone) {
|
|
157
|
+
await createApfsClone(sourcePath, destPath);
|
|
158
|
+
} else if (platformInfo.supportsBtrfsReflink) {
|
|
159
|
+
await createReflinkCopy(sourcePath, destPath);
|
|
160
|
+
} else {
|
|
161
|
+
await createRsyncCopy(sourcePath, destPath, options);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async function createApfsClone(source, dest) {
|
|
165
|
+
await execa2("cp", ["-c", "-R", source, dest]);
|
|
166
|
+
}
|
|
167
|
+
async function createReflinkCopy(source, dest) {
|
|
168
|
+
await execa2("cp", ["-r", "--reflink=auto", source, dest]);
|
|
169
|
+
}
|
|
170
|
+
async function createRsyncCopy(source, dest, options) {
|
|
171
|
+
const excludes = [];
|
|
172
|
+
if (options.excludeGitDir) {
|
|
173
|
+
excludes.push("--exclude=.git");
|
|
174
|
+
}
|
|
175
|
+
await execa2("rsync", [
|
|
176
|
+
"-a",
|
|
177
|
+
"--delete",
|
|
178
|
+
...excludes,
|
|
179
|
+
source + "/",
|
|
180
|
+
dest + "/"
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
async function removeAllWorkspaces(projectPath) {
|
|
184
|
+
const projectName = path2.basename(projectPath);
|
|
185
|
+
const projectDir = getProjectDir(projectName);
|
|
186
|
+
if (await fs2.pathExists(projectDir)) {
|
|
187
|
+
await fs2.remove(projectDir);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
async function listChangedFiles(originalPath, variationPath) {
|
|
191
|
+
const changedFiles = [];
|
|
192
|
+
async function compareDir(relPath) {
|
|
193
|
+
const origDir = path2.join(originalPath, relPath);
|
|
194
|
+
const varDir = path2.join(variationPath, relPath);
|
|
195
|
+
const [origExists, varExists] = await Promise.all([
|
|
196
|
+
fs2.pathExists(origDir),
|
|
197
|
+
fs2.pathExists(varDir)
|
|
198
|
+
]);
|
|
199
|
+
if (!origExists && !varExists) return;
|
|
200
|
+
const origFiles = origExists ? await fs2.readdir(origDir) : [];
|
|
201
|
+
const varFiles = varExists ? await fs2.readdir(varDir) : [];
|
|
202
|
+
const allFiles = /* @__PURE__ */ new Set([...origFiles, ...varFiles]);
|
|
203
|
+
for (const file of allFiles) {
|
|
204
|
+
if (shouldSkipFile(file)) continue;
|
|
205
|
+
const relFilePath = path2.join(relPath, file);
|
|
206
|
+
const origFilePath = path2.join(originalPath, relFilePath);
|
|
207
|
+
const varFilePath = path2.join(variationPath, relFilePath);
|
|
208
|
+
const [origStat, varStat] = await Promise.all([
|
|
209
|
+
fs2.stat(origFilePath).catch(() => null),
|
|
210
|
+
fs2.stat(varFilePath).catch(() => null)
|
|
211
|
+
]);
|
|
212
|
+
if (origStat?.isDirectory() || varStat?.isDirectory()) {
|
|
213
|
+
await compareDir(relFilePath);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
if (!origStat && varStat) {
|
|
217
|
+
changedFiles.push({ path: relFilePath, status: "A" });
|
|
218
|
+
} else if (origStat && !varStat) {
|
|
219
|
+
changedFiles.push({ path: relFilePath, status: "D" });
|
|
220
|
+
} else if (origStat && varStat) {
|
|
221
|
+
const [origContent, varContent] = await Promise.all([
|
|
222
|
+
fs2.readFile(origFilePath),
|
|
223
|
+
fs2.readFile(varFilePath)
|
|
224
|
+
]);
|
|
225
|
+
if (!origContent.equals(varContent)) {
|
|
226
|
+
changedFiles.push({ path: relFilePath, status: "M" });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await compareDir("");
|
|
232
|
+
return changedFiles.sort((a, b) => a.path.localeCompare(b.path));
|
|
233
|
+
}
|
|
234
|
+
function shouldSkipFile(filename) {
|
|
235
|
+
const skipPatterns = [
|
|
236
|
+
"node_modules",
|
|
237
|
+
".git",
|
|
238
|
+
".DS_Store",
|
|
239
|
+
"dist",
|
|
240
|
+
"build",
|
|
241
|
+
".next",
|
|
242
|
+
".nuxt",
|
|
243
|
+
".cache",
|
|
244
|
+
"coverage",
|
|
245
|
+
"AGENTS.md",
|
|
246
|
+
".cursor"
|
|
247
|
+
];
|
|
248
|
+
return skipPatterns.includes(filename);
|
|
249
|
+
}
|
|
250
|
+
async function getWorkspaceSize(workspacePath) {
|
|
251
|
+
try {
|
|
252
|
+
const { stdout } = await execa2("du", ["-sk", workspacePath]);
|
|
253
|
+
const sizeKb = parseInt(stdout.split(" ")[0], 10);
|
|
254
|
+
return sizeKb * 1024;
|
|
255
|
+
} catch {
|
|
256
|
+
return 0;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function formatBytes(bytes) {
|
|
260
|
+
if (bytes === 0) return "0 B";
|
|
261
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
262
|
+
const k = 1024;
|
|
263
|
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
264
|
+
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${units[i]}`;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// src/lib/terminal.ts
|
|
268
|
+
import { execa as execa3 } from "execa";
|
|
269
|
+
import chalk from "chalk";
|
|
270
|
+
function getTerminalProgram() {
|
|
271
|
+
return process.env.TERM_PROGRAM || null;
|
|
272
|
+
}
|
|
273
|
+
async function hasTmux() {
|
|
274
|
+
try {
|
|
275
|
+
await execa3("which", ["tmux"]);
|
|
276
|
+
return true;
|
|
277
|
+
} catch {
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function isInsideVSCodeOrCursor() {
|
|
282
|
+
const termProgram = getTerminalProgram();
|
|
283
|
+
return termProgram === "vscode" || termProgram === "cursor";
|
|
284
|
+
}
|
|
285
|
+
async function openTerminals(sessions, sessionName) {
|
|
286
|
+
if (isInsideVSCodeOrCursor()) {
|
|
287
|
+
printManualInstructions(sessions);
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
const termProgram = getTerminalProgram();
|
|
291
|
+
if (termProgram === "iTerm.app") {
|
|
292
|
+
await openWithIterm(sessions);
|
|
293
|
+
} else if (await hasTmux()) {
|
|
294
|
+
await openWithTmux(sessions, sessionName);
|
|
295
|
+
} else if (termProgram === "Apple_Terminal") {
|
|
296
|
+
await openWithTerminalApp(sessions);
|
|
297
|
+
} else if (process.platform === "darwin") {
|
|
298
|
+
await openWithTerminalApp(sessions);
|
|
299
|
+
} else {
|
|
300
|
+
printManualInstructions(sessions);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
async function openWithIterm(sessions) {
|
|
304
|
+
const script = `
|
|
305
|
+
tell application "iTerm"
|
|
306
|
+
activate
|
|
307
|
+
tell current window
|
|
308
|
+
${sessions.map((session) => `
|
|
309
|
+
set newTab to (create tab with default profile)
|
|
310
|
+
tell current session of newTab
|
|
311
|
+
write text "cd '${session.path}' && clear && echo '[ ${session.id} ]'"
|
|
312
|
+
end tell
|
|
313
|
+
`).join("\n")}
|
|
314
|
+
end tell
|
|
315
|
+
end tell
|
|
316
|
+
`;
|
|
317
|
+
await execa3("osascript", ["-e", script]);
|
|
318
|
+
console.log(chalk.green(`
|
|
319
|
+
\u2713 Opened ${sessions.length} iTerm tabs`));
|
|
320
|
+
console.log(chalk.dim("Cmd+Shift+] to switch tabs. Run okiro commands from any tab.\n"));
|
|
321
|
+
}
|
|
322
|
+
async function openWithTmux(sessions, sessionName) {
|
|
323
|
+
const existingSession = await checkTmuxSessionExists(sessionName);
|
|
324
|
+
if (existingSession) {
|
|
325
|
+
await execa3("tmux", ["kill-session", "-t", sessionName]);
|
|
326
|
+
}
|
|
327
|
+
const firstSession = sessions[0];
|
|
328
|
+
await execa3("tmux", [
|
|
329
|
+
"new-session",
|
|
330
|
+
"-d",
|
|
331
|
+
"-s",
|
|
332
|
+
sessionName,
|
|
333
|
+
"-c",
|
|
334
|
+
firstSession.path,
|
|
335
|
+
"-n",
|
|
336
|
+
firstSession.id
|
|
337
|
+
]);
|
|
338
|
+
for (let i = 1; i < sessions.length; i++) {
|
|
339
|
+
const session = sessions[i];
|
|
340
|
+
await execa3("tmux", [
|
|
341
|
+
"new-window",
|
|
342
|
+
"-t",
|
|
343
|
+
sessionName,
|
|
344
|
+
"-c",
|
|
345
|
+
session.path,
|
|
346
|
+
"-n",
|
|
347
|
+
session.id
|
|
348
|
+
]);
|
|
349
|
+
}
|
|
350
|
+
for (const session of sessions) {
|
|
351
|
+
await execa3("tmux", [
|
|
352
|
+
"send-keys",
|
|
353
|
+
"-t",
|
|
354
|
+
`${sessionName}:${session.id}`,
|
|
355
|
+
`clear && echo "[ ${session.id} ]"`,
|
|
356
|
+
"Enter"
|
|
357
|
+
]);
|
|
358
|
+
}
|
|
359
|
+
await execa3("tmux", ["select-window", "-t", `${sessionName}:${sessions[0].id}`]);
|
|
360
|
+
console.log(chalk.green(`
|
|
361
|
+
\u2713 Opening tmux session: ${sessionName}`));
|
|
362
|
+
console.log(chalk.dim("Ctrl+B then N/P to switch windows. Run okiro commands from any window.\n"));
|
|
363
|
+
await execa3("tmux", ["attach-session", "-t", sessionName], {
|
|
364
|
+
stdio: "inherit"
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
async function checkTmuxSessionExists(sessionName) {
|
|
368
|
+
try {
|
|
369
|
+
await execa3("tmux", ["has-session", "-t", sessionName]);
|
|
370
|
+
return true;
|
|
371
|
+
} catch {
|
|
372
|
+
return false;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function openWithTerminalApp(sessions) {
|
|
376
|
+
for (const session of sessions) {
|
|
377
|
+
const script = `
|
|
378
|
+
tell application "Terminal"
|
|
379
|
+
do script "cd '${session.path}' && clear && echo '[ ${session.id} ]'"
|
|
380
|
+
activate
|
|
381
|
+
end tell
|
|
382
|
+
`;
|
|
383
|
+
await execa3("osascript", ["-e", script]);
|
|
384
|
+
}
|
|
385
|
+
console.log(chalk.green(`
|
|
386
|
+
\u2713 Opened ${sessions.length} Terminal windows`));
|
|
387
|
+
console.log(chalk.dim("Run okiro commands from any window.\n"));
|
|
388
|
+
}
|
|
389
|
+
function printManualInstructions(sessions) {
|
|
390
|
+
console.log(chalk.dim("\nOpen terminals manually:\n"));
|
|
391
|
+
for (const session of sessions) {
|
|
392
|
+
console.log(` ${chalk.cyan(session.id)}: cd ${session.path}`);
|
|
393
|
+
}
|
|
394
|
+
console.log(chalk.dim("\nRun okiro commands from any terminal.\n"));
|
|
395
|
+
}
|
|
396
|
+
async function killTmuxSession(sessionName) {
|
|
397
|
+
try {
|
|
398
|
+
await execa3("tmux", ["kill-session", "-t", sessionName]);
|
|
399
|
+
return true;
|
|
400
|
+
} catch {
|
|
401
|
+
return false;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// src/lib/prompt.ts
|
|
406
|
+
import readline from "readline";
|
|
407
|
+
import fs3 from "fs-extra";
|
|
408
|
+
import path3 from "path";
|
|
409
|
+
async function promptForInput(message) {
|
|
410
|
+
const rl = readline.createInterface({
|
|
411
|
+
input: process.stdin,
|
|
412
|
+
output: process.stdout
|
|
413
|
+
});
|
|
414
|
+
return new Promise((resolve) => {
|
|
415
|
+
rl.question(message, (answer) => {
|
|
416
|
+
rl.close();
|
|
417
|
+
resolve(answer.trim());
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
async function promptForVariationDirections(count, basePrompt) {
|
|
422
|
+
const directions = [];
|
|
423
|
+
console.log("");
|
|
424
|
+
if (basePrompt) {
|
|
425
|
+
console.log(`Base task: ${basePrompt}
|
|
426
|
+
`);
|
|
427
|
+
}
|
|
428
|
+
console.log("Enter a direction/style for each variation (or press Enter to skip):\n");
|
|
429
|
+
for (let i = 1; i <= count; i++) {
|
|
430
|
+
const direction = await promptForInput(` var-${i}: `);
|
|
431
|
+
directions.push(direction);
|
|
432
|
+
}
|
|
433
|
+
return directions;
|
|
434
|
+
}
|
|
435
|
+
async function writeAIConfigFiles(variationPath, variationId, direction, basePrompt) {
|
|
436
|
+
if (!direction && !basePrompt) return;
|
|
437
|
+
const configContent = buildAIConfigContent(variationId, direction, basePrompt);
|
|
438
|
+
await fs3.writeFile(
|
|
439
|
+
path3.join(variationPath, "AGENTS.md"),
|
|
440
|
+
configContent
|
|
441
|
+
);
|
|
442
|
+
const cursorDir = path3.join(variationPath, ".cursor");
|
|
443
|
+
await fs3.ensureDir(cursorDir);
|
|
444
|
+
await fs3.writeFile(
|
|
445
|
+
path3.join(cursorDir, "rules"),
|
|
446
|
+
configContent
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
function buildAIConfigContent(variationId, direction, basePrompt) {
|
|
450
|
+
const lines = [
|
|
451
|
+
`# Okiro Variation: ${variationId}`,
|
|
452
|
+
"",
|
|
453
|
+
"You are working on an isolated variation of this project.",
|
|
454
|
+
"Your changes will be compared against other variations.",
|
|
455
|
+
""
|
|
456
|
+
];
|
|
457
|
+
if (basePrompt) {
|
|
458
|
+
lines.push("## Task");
|
|
459
|
+
lines.push("");
|
|
460
|
+
lines.push(basePrompt);
|
|
461
|
+
lines.push("");
|
|
462
|
+
}
|
|
463
|
+
if (direction) {
|
|
464
|
+
lines.push("## Direction for this variation");
|
|
465
|
+
lines.push("");
|
|
466
|
+
lines.push(direction);
|
|
467
|
+
lines.push("");
|
|
468
|
+
}
|
|
469
|
+
return lines.join("\n");
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/commands/spawn.ts
|
|
473
|
+
async function spawn(count, options) {
|
|
474
|
+
const projectPath = resolveProjectPath();
|
|
475
|
+
const projectName = path4.basename(projectPath);
|
|
476
|
+
const existingProject = await getProjectConfig(projectPath);
|
|
477
|
+
if (existingProject && !options.force) {
|
|
478
|
+
console.log(
|
|
479
|
+
chalk2.yellow(
|
|
480
|
+
`
|
|
481
|
+
Project already has ${existingProject.variations.length} active variations.`
|
|
482
|
+
)
|
|
483
|
+
);
|
|
484
|
+
console.log("Run with --force to replace them, or run `okiro cleanup` first.\n");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
if (existingProject) {
|
|
488
|
+
const cleanupSpinner = ora("Cleaning up existing variations...").start();
|
|
489
|
+
await removeAllWorkspaces(projectPath);
|
|
490
|
+
cleanupSpinner.succeed("Cleaned up existing variations");
|
|
491
|
+
}
|
|
492
|
+
const variationsDir = getProjectDir(projectName);
|
|
493
|
+
const variations = [];
|
|
494
|
+
const sessions = [];
|
|
495
|
+
let directions = [];
|
|
496
|
+
if (options.prompt) {
|
|
497
|
+
const basePrompt = typeof options.prompt === "string" ? options.prompt : void 0;
|
|
498
|
+
directions = await promptForVariationDirections(count, basePrompt);
|
|
499
|
+
}
|
|
500
|
+
console.log(chalk2.bold(`
|
|
501
|
+
Creating ${count} variations for ${projectName}
|
|
502
|
+
`));
|
|
503
|
+
for (let i = 1; i <= count; i++) {
|
|
504
|
+
const varId = `var-${i}`;
|
|
505
|
+
const varPath = path4.join(variationsDir, varId);
|
|
506
|
+
const spinner = ora(`Creating ${varId}...`).start();
|
|
507
|
+
try {
|
|
508
|
+
await createWorkspace(projectPath, varPath);
|
|
509
|
+
const direction = directions[i - 1] || "";
|
|
510
|
+
const basePrompt = typeof options.prompt === "string" ? options.prompt : void 0;
|
|
511
|
+
if (direction || basePrompt) {
|
|
512
|
+
await writeAIConfigFiles(varPath, varId, direction, basePrompt);
|
|
513
|
+
}
|
|
514
|
+
spinner.succeed(`Created ${varId} at ${chalk2.dim(varPath)}`);
|
|
515
|
+
variations.push({
|
|
516
|
+
id: varId,
|
|
517
|
+
path: varPath,
|
|
518
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
519
|
+
});
|
|
520
|
+
sessions.push({
|
|
521
|
+
id: varId,
|
|
522
|
+
path: varPath
|
|
523
|
+
});
|
|
524
|
+
} catch (error) {
|
|
525
|
+
spinner.fail(`Failed to create ${varId}`);
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
await addProject(projectPath, variations);
|
|
530
|
+
console.log(chalk2.green(`
|
|
531
|
+
\u2713 Created ${count} variations
|
|
532
|
+
`));
|
|
533
|
+
if (!options.noTerminal) {
|
|
534
|
+
await openTerminals(sessions, `okiro-${projectName}`);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/commands/status.ts
|
|
539
|
+
import path5 from "path";
|
|
540
|
+
import chalk3 from "chalk";
|
|
541
|
+
async function status() {
|
|
542
|
+
const projectPath = resolveProjectPath();
|
|
543
|
+
const projectName = path5.basename(projectPath);
|
|
544
|
+
const project = await getProjectConfig(projectPath);
|
|
545
|
+
if (!project) {
|
|
546
|
+
console.log(chalk3.yellow("\nNo active variations for this project."));
|
|
547
|
+
console.log("Run `okiro 3` to create variations.\n");
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
console.log(chalk3.bold(`
|
|
551
|
+
Variations for ${projectName}:
|
|
552
|
+
`));
|
|
553
|
+
for (const variation of project.variations) {
|
|
554
|
+
const changedFiles = await listChangedFiles(projectPath, variation.path);
|
|
555
|
+
const diskUsage = await getWorkspaceSize(variation.path);
|
|
556
|
+
console.log(` ${chalk3.cyan(variation.id)}`);
|
|
557
|
+
console.log(` Path: ${chalk3.dim(variation.path)}`);
|
|
558
|
+
console.log(` Changed: ${changedFiles.length} files`);
|
|
559
|
+
console.log(` Size: ${formatBytes(diskUsage)}`);
|
|
560
|
+
console.log("");
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// src/commands/cleanup.ts
|
|
565
|
+
import path6 from "path";
|
|
566
|
+
import chalk4 from "chalk";
|
|
567
|
+
import ora2 from "ora";
|
|
568
|
+
import readline2 from "readline";
|
|
569
|
+
async function confirm(message) {
|
|
570
|
+
const rl = readline2.createInterface({
|
|
571
|
+
input: process.stdin,
|
|
572
|
+
output: process.stdout
|
|
573
|
+
});
|
|
574
|
+
return new Promise((resolve) => {
|
|
575
|
+
rl.question(message, (answer) => {
|
|
576
|
+
rl.close();
|
|
577
|
+
resolve(answer.toLowerCase() === "y");
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
async function cleanup(options) {
|
|
582
|
+
const projectPath = resolveProjectPath();
|
|
583
|
+
const projectName = path6.basename(projectPath);
|
|
584
|
+
const project = await getProjectConfig(projectPath);
|
|
585
|
+
if (!project) {
|
|
586
|
+
console.log(chalk4.yellow("\nNo active variations to clean up.\n"));
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
let totalSize = 0;
|
|
590
|
+
for (const variation of project.variations) {
|
|
591
|
+
totalSize += await getWorkspaceSize(variation.path);
|
|
592
|
+
}
|
|
593
|
+
console.log(
|
|
594
|
+
`
|
|
595
|
+
This will remove ${project.variations.length} variations (${formatBytes(totalSize)})`
|
|
596
|
+
);
|
|
597
|
+
if (!options.force) {
|
|
598
|
+
const confirmed = await confirm("Continue? [y/N] ");
|
|
599
|
+
if (!confirmed) {
|
|
600
|
+
console.log("Aborted.\n");
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
const sessionName = `okiro-${projectName}`;
|
|
605
|
+
const killedSession = await killTmuxSession(sessionName);
|
|
606
|
+
if (killedSession) {
|
|
607
|
+
console.log(chalk4.dim(`Killed tmux session: ${sessionName}`));
|
|
608
|
+
}
|
|
609
|
+
const spinner = ora2("Removing variations...").start();
|
|
610
|
+
try {
|
|
611
|
+
await removeAllWorkspaces(projectPath);
|
|
612
|
+
await removeProject(projectPath);
|
|
613
|
+
spinner.succeed(
|
|
614
|
+
`Removed ${project.variations.length} variations (freed ${formatBytes(totalSize)})`
|
|
615
|
+
);
|
|
616
|
+
} catch (error) {
|
|
617
|
+
spinner.fail("Failed to clean up variations");
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
console.log("");
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// src/commands/diff.ts
|
|
624
|
+
import chalk5 from "chalk";
|
|
625
|
+
|
|
626
|
+
// src/lib/diff.ts
|
|
627
|
+
import { createTwoFilesPatch } from "diff";
|
|
628
|
+
import fs4 from "fs-extra";
|
|
629
|
+
import path7 from "path";
|
|
630
|
+
async function generateFileDiff(originalPath, variationPath, relativeFilePath) {
|
|
631
|
+
const origFile = path7.join(originalPath, relativeFilePath);
|
|
632
|
+
const varFile = path7.join(variationPath, relativeFilePath);
|
|
633
|
+
const [origContent, varContent] = await Promise.all([
|
|
634
|
+
fs4.readFile(origFile, "utf-8").catch(() => ""),
|
|
635
|
+
fs4.readFile(varFile, "utf-8").catch(() => "")
|
|
636
|
+
]);
|
|
637
|
+
let status2;
|
|
638
|
+
if (!origContent && varContent) {
|
|
639
|
+
status2 = "A";
|
|
640
|
+
} else if (origContent && !varContent) {
|
|
641
|
+
status2 = "D";
|
|
642
|
+
} else {
|
|
643
|
+
status2 = "M";
|
|
644
|
+
}
|
|
645
|
+
const patch = createTwoFilesPatch(
|
|
646
|
+
`a/${relativeFilePath}`,
|
|
647
|
+
`b/${relativeFilePath}`,
|
|
648
|
+
origContent,
|
|
649
|
+
varContent,
|
|
650
|
+
"",
|
|
651
|
+
""
|
|
652
|
+
);
|
|
653
|
+
return {
|
|
654
|
+
filePath: relativeFilePath,
|
|
655
|
+
status: status2,
|
|
656
|
+
original: origContent,
|
|
657
|
+
modified: varContent,
|
|
658
|
+
patch
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
function colorizeUnifiedDiff(patch) {
|
|
662
|
+
const lines = patch.split("\n");
|
|
663
|
+
const coloredLines = lines.map((line) => {
|
|
664
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
665
|
+
return `\x1B[1m${line}\x1B[0m`;
|
|
666
|
+
}
|
|
667
|
+
if (line.startsWith("+")) {
|
|
668
|
+
return `\x1B[32m${line}\x1B[0m`;
|
|
669
|
+
}
|
|
670
|
+
if (line.startsWith("-")) {
|
|
671
|
+
return `\x1B[31m${line}\x1B[0m`;
|
|
672
|
+
}
|
|
673
|
+
if (line.startsWith("@@")) {
|
|
674
|
+
return `\x1B[36m${line}\x1B[0m`;
|
|
675
|
+
}
|
|
676
|
+
return line;
|
|
677
|
+
});
|
|
678
|
+
return coloredLines.join("\n");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/commands/diff.ts
|
|
682
|
+
async function diff(var1, var2) {
|
|
683
|
+
const projectPath = resolveProjectPath();
|
|
684
|
+
const project = await getProjectConfig(projectPath);
|
|
685
|
+
if (!project) {
|
|
686
|
+
console.log(chalk5.yellow("\nNo active variations for this project."));
|
|
687
|
+
console.log("Run `okiro spawn <count>` to create variations.\n");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
let sourcePath;
|
|
691
|
+
let targetPath;
|
|
692
|
+
let sourceLabel;
|
|
693
|
+
let targetLabel;
|
|
694
|
+
if (!var1 && !var2) {
|
|
695
|
+
sourcePath = projectPath;
|
|
696
|
+
sourceLabel = "original";
|
|
697
|
+
targetPath = project.variations[0].path;
|
|
698
|
+
targetLabel = project.variations[0].id;
|
|
699
|
+
} else if (var1 && !var2) {
|
|
700
|
+
sourcePath = projectPath;
|
|
701
|
+
sourceLabel = "original";
|
|
702
|
+
const variation = project.variations.find((v) => v.id === var1);
|
|
703
|
+
if (!variation) {
|
|
704
|
+
console.log(chalk5.red(`
|
|
705
|
+
Variation "${var1}" not found.`));
|
|
706
|
+
console.log("Available:", project.variations.map((v) => v.id).join(", "));
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
targetPath = variation.path;
|
|
710
|
+
targetLabel = variation.id;
|
|
711
|
+
} else {
|
|
712
|
+
if (var1 === "original") {
|
|
713
|
+
sourcePath = projectPath;
|
|
714
|
+
sourceLabel = "original";
|
|
715
|
+
} else {
|
|
716
|
+
const sourceVar = project.variations.find((v) => v.id === var1);
|
|
717
|
+
if (!sourceVar) {
|
|
718
|
+
console.log(chalk5.red(`
|
|
719
|
+
Variation "${var1}" not found.`));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
sourcePath = sourceVar.path;
|
|
723
|
+
sourceLabel = sourceVar.id;
|
|
724
|
+
}
|
|
725
|
+
if (var2 === "original") {
|
|
726
|
+
targetPath = projectPath;
|
|
727
|
+
targetLabel = "original";
|
|
728
|
+
} else {
|
|
729
|
+
const targetVar = project.variations.find((v) => v.id === var2);
|
|
730
|
+
if (!targetVar) {
|
|
731
|
+
console.log(chalk5.red(`
|
|
732
|
+
Variation "${var2}" not found.`));
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
targetPath = targetVar.path;
|
|
736
|
+
targetLabel = targetVar.id;
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
console.log(chalk5.bold(`
|
|
740
|
+
Comparing ${sourceLabel} \u2192 ${targetLabel}
|
|
741
|
+
`));
|
|
742
|
+
const changedFiles = await listChangedFiles(sourcePath, targetPath);
|
|
743
|
+
if (changedFiles.length === 0) {
|
|
744
|
+
console.log(chalk5.dim("No changes detected.\n"));
|
|
745
|
+
return;
|
|
746
|
+
}
|
|
747
|
+
console.log(chalk5.dim(`${changedFiles.length} file(s) changed:
|
|
748
|
+
`));
|
|
749
|
+
for (const file of changedFiles) {
|
|
750
|
+
const fileDiff = await generateFileDiff(sourcePath, targetPath, file.path);
|
|
751
|
+
console.log(colorizeUnifiedDiff(fileDiff.patch));
|
|
752
|
+
console.log("");
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
// src/commands/promote.ts
|
|
757
|
+
import path8 from "path";
|
|
758
|
+
import chalk6 from "chalk";
|
|
759
|
+
import ora3 from "ora";
|
|
760
|
+
import readline3 from "readline";
|
|
761
|
+
import fs5 from "fs-extra";
|
|
762
|
+
import { execa as execa4 } from "execa";
|
|
763
|
+
async function confirm2(message) {
|
|
764
|
+
const rl = readline3.createInterface({
|
|
765
|
+
input: process.stdin,
|
|
766
|
+
output: process.stdout
|
|
767
|
+
});
|
|
768
|
+
return new Promise((resolve) => {
|
|
769
|
+
rl.question(message, (answer) => {
|
|
770
|
+
rl.close();
|
|
771
|
+
resolve(answer.toLowerCase() === "y");
|
|
772
|
+
});
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
async function promote(variationId, options) {
|
|
776
|
+
const projectPath = resolveProjectPath();
|
|
777
|
+
const projectName = path8.basename(projectPath);
|
|
778
|
+
const project = await getProjectConfig(projectPath);
|
|
779
|
+
if (!project) {
|
|
780
|
+
console.log(chalk6.yellow("\nNo active variations for this project.\n"));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
const variation = project.variations.find((v) => v.id === variationId);
|
|
784
|
+
if (!variation) {
|
|
785
|
+
console.log(chalk6.red(`
|
|
786
|
+
Variation "${variationId}" not found.`));
|
|
787
|
+
console.log("Available:", project.variations.map((v) => v.id).join(", "));
|
|
788
|
+
return;
|
|
789
|
+
}
|
|
790
|
+
const changedFiles = await listChangedFiles(projectPath, variation.path);
|
|
791
|
+
if (changedFiles.length === 0) {
|
|
792
|
+
console.log(chalk6.yellow("\nNo changes to promote.\n"));
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
console.log(chalk6.bold(`
|
|
796
|
+
Promoting ${variationId} to ${projectName}
|
|
797
|
+
`));
|
|
798
|
+
console.log("Changed files:");
|
|
799
|
+
for (const file of changedFiles) {
|
|
800
|
+
const icon = file.status === "M" ? "~" : file.status === "A" ? "+" : "-";
|
|
801
|
+
const color = file.status === "A" ? chalk6.green : file.status === "D" ? chalk6.red : chalk6.yellow;
|
|
802
|
+
console.log(color(` ${icon} ${file.path}`));
|
|
803
|
+
}
|
|
804
|
+
console.log("");
|
|
805
|
+
if (!options.force) {
|
|
806
|
+
const confirmed = await confirm2("Apply these changes? [y/N] ");
|
|
807
|
+
if (!confirmed) {
|
|
808
|
+
console.log("Aborted.\n");
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
const spinner = ora3("Applying changes...").start();
|
|
813
|
+
try {
|
|
814
|
+
for (const file of changedFiles) {
|
|
815
|
+
const srcPath = path8.join(variation.path, file.path);
|
|
816
|
+
const destPath = path8.join(projectPath, file.path);
|
|
817
|
+
if (file.status === "D") {
|
|
818
|
+
await fs5.remove(destPath);
|
|
819
|
+
} else {
|
|
820
|
+
await fs5.ensureDir(path8.dirname(destPath));
|
|
821
|
+
await fs5.copy(srcPath, destPath);
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
spinner.succeed(`Promoted ${changedFiles.length} files from ${variationId}`);
|
|
825
|
+
if (options.commit) {
|
|
826
|
+
const commitSpinner = ora3("Committing changes...").start();
|
|
827
|
+
try {
|
|
828
|
+
await execa4("git", ["add", ...changedFiles.map((f) => f.path)], {
|
|
829
|
+
cwd: projectPath
|
|
830
|
+
});
|
|
831
|
+
const defaultMessage = `feat: promote ${variationId} from okiro`;
|
|
832
|
+
const commitMessage = typeof options.commit === "string" ? options.commit : defaultMessage;
|
|
833
|
+
await execa4("git", ["commit", "-m", commitMessage], {
|
|
834
|
+
cwd: projectPath
|
|
835
|
+
});
|
|
836
|
+
commitSpinner.succeed(`Committed: "${commitMessage}"`);
|
|
837
|
+
} catch (error) {
|
|
838
|
+
commitSpinner.fail("Failed to commit (is this a git repository?)");
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
} catch (error) {
|
|
842
|
+
spinner.fail("Failed to promote changes");
|
|
843
|
+
throw error;
|
|
844
|
+
}
|
|
845
|
+
console.log("");
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// src/commands/compare.ts
|
|
849
|
+
import path9 from "path";
|
|
850
|
+
import chalk7 from "chalk";
|
|
851
|
+
import express from "express";
|
|
852
|
+
import open from "open";
|
|
853
|
+
async function compare(options) {
|
|
854
|
+
const projectPath = resolveProjectPath();
|
|
855
|
+
const project = await getProjectConfig(projectPath);
|
|
856
|
+
if (!project) {
|
|
857
|
+
console.log(chalk7.yellow("\nNo active variations for this project."));
|
|
858
|
+
console.log("Run `okiro 3` to create variations.\n");
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
const app = express();
|
|
862
|
+
const port = options.port ?? 6789;
|
|
863
|
+
app.use(express.json());
|
|
864
|
+
app.use((_req, res, next) => {
|
|
865
|
+
res.header("Access-Control-Allow-Origin", "*");
|
|
866
|
+
res.header("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept");
|
|
867
|
+
next();
|
|
868
|
+
});
|
|
869
|
+
app.get("/api/project", (_req, res) => {
|
|
870
|
+
res.json({
|
|
871
|
+
name: path9.basename(projectPath),
|
|
872
|
+
originalPath: projectPath,
|
|
873
|
+
variations: project.variations.map((v) => ({
|
|
874
|
+
id: v.id,
|
|
875
|
+
createdAt: v.createdAt
|
|
876
|
+
}))
|
|
877
|
+
});
|
|
878
|
+
});
|
|
879
|
+
app.get("/api/all-files", async (_req, res) => {
|
|
880
|
+
const fileMap = /* @__PURE__ */ new Map();
|
|
881
|
+
for (const variation of project.variations) {
|
|
882
|
+
const files = await listChangedFiles(projectPath, variation.path);
|
|
883
|
+
for (const file of files) {
|
|
884
|
+
const existing = fileMap.get(file.path);
|
|
885
|
+
if (existing) {
|
|
886
|
+
existing.variations.push(variation.id);
|
|
887
|
+
if (file.status === "A" && existing.status !== "A") {
|
|
888
|
+
existing.status = file.status;
|
|
889
|
+
}
|
|
890
|
+
} else {
|
|
891
|
+
fileMap.set(file.path, {
|
|
892
|
+
...file,
|
|
893
|
+
variations: [variation.id]
|
|
894
|
+
});
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const allFiles = Array.from(fileMap.values()).sort(
|
|
899
|
+
(a, b) => a.path.localeCompare(b.path)
|
|
900
|
+
);
|
|
901
|
+
res.json(allFiles);
|
|
902
|
+
});
|
|
903
|
+
app.get("/api/files/:variation", async (req, res) => {
|
|
904
|
+
const variationId = req.params.variation;
|
|
905
|
+
if (variationId === "original") {
|
|
906
|
+
res.json([]);
|
|
907
|
+
return;
|
|
908
|
+
}
|
|
909
|
+
const variation = project.variations.find((v) => v.id === variationId);
|
|
910
|
+
if (!variation) {
|
|
911
|
+
res.status(404).json({ error: "Variation not found" });
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
const files = await listChangedFiles(projectPath, variation.path);
|
|
915
|
+
res.json(files);
|
|
916
|
+
});
|
|
917
|
+
app.get("/api/diff/:variation", async (req, res) => {
|
|
918
|
+
const variation = req.params.variation;
|
|
919
|
+
const file = req.query.file;
|
|
920
|
+
if (!file) {
|
|
921
|
+
res.status(400).json({ error: "Missing file query param" });
|
|
922
|
+
return;
|
|
923
|
+
}
|
|
924
|
+
const varConfig = project.variations.find((v) => v.id === variation);
|
|
925
|
+
if (!varConfig) {
|
|
926
|
+
res.status(404).json({ error: "Variation not found" });
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
const diff2 = await generateFileDiff(projectPath, varConfig.path, file);
|
|
930
|
+
res.json(diff2);
|
|
931
|
+
});
|
|
932
|
+
app.get("/", (_req, res) => {
|
|
933
|
+
res.send(getCompareHtml(project.variations.map((v) => v.id)));
|
|
934
|
+
});
|
|
935
|
+
app.listen(port, () => {
|
|
936
|
+
console.log(chalk7.green(`
|
|
937
|
+
\u2713 Diff viewer at http://localhost:${port}
|
|
938
|
+
`));
|
|
939
|
+
if (!options.noBrowser) {
|
|
940
|
+
open(`http://localhost:${port}`);
|
|
941
|
+
}
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
function getCompareHtml(variations) {
|
|
945
|
+
return `<!DOCTYPE html>
|
|
946
|
+
<html lang="en">
|
|
947
|
+
<head>
|
|
948
|
+
<meta charset="UTF-8">
|
|
949
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
950
|
+
<title>okiro</title>
|
|
951
|
+
<link rel="preconnect" href="https://cdn.jsdelivr.net">
|
|
952
|
+
<link href="https://cdn.jsdelivr.net/npm/iosevka@31.1.0/iosevka.min.css" rel="stylesheet">
|
|
953
|
+
<style>
|
|
954
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
955
|
+
|
|
956
|
+
:root {
|
|
957
|
+
--rp-base: #191724;
|
|
958
|
+
--rp-surface: #1f1d2e;
|
|
959
|
+
--rp-overlay: #26233a;
|
|
960
|
+
--rp-muted: #6e6a86;
|
|
961
|
+
--rp-subtle: #908caa;
|
|
962
|
+
--rp-text: #e0def4;
|
|
963
|
+
--rp-love: #eb6f92;
|
|
964
|
+
--rp-gold: #f6c177;
|
|
965
|
+
--rp-rose: #ebbcba;
|
|
966
|
+
--rp-pine: #31748f;
|
|
967
|
+
--rp-foam: #9ccfd8;
|
|
968
|
+
--rp-iris: #c4a7e7;
|
|
969
|
+
--rp-highlight-low: #21202e;
|
|
970
|
+
--rp-highlight-med: #403d52;
|
|
971
|
+
--rp-highlight-high: #524f67;
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
body {
|
|
975
|
+
font-family: 'Iosevka Web', 'Iosevka', monospace;
|
|
976
|
+
background: var(--rp-base);
|
|
977
|
+
color: var(--rp-text);
|
|
978
|
+
min-height: 100vh;
|
|
979
|
+
font-size: 13px;
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
header {
|
|
983
|
+
padding: 12px 20px;
|
|
984
|
+
border-bottom: 1px solid var(--rp-overlay);
|
|
985
|
+
display: flex;
|
|
986
|
+
justify-content: space-between;
|
|
987
|
+
align-items: center;
|
|
988
|
+
background: var(--rp-surface);
|
|
989
|
+
position: sticky;
|
|
990
|
+
top: 0;
|
|
991
|
+
z-index: 100;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
.header-left {
|
|
995
|
+
display: flex;
|
|
996
|
+
align-items: center;
|
|
997
|
+
gap: 24px;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
h1 {
|
|
1001
|
+
font-size: 13px;
|
|
1002
|
+
font-weight: 600;
|
|
1003
|
+
color: var(--rp-iris);
|
|
1004
|
+
letter-spacing: 1px;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.view-toggle {
|
|
1008
|
+
display: flex;
|
|
1009
|
+
gap: 2px;
|
|
1010
|
+
background: var(--rp-base);
|
|
1011
|
+
padding: 2px;
|
|
1012
|
+
border-radius: 4px;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.view-toggle button {
|
|
1016
|
+
padding: 4px 10px;
|
|
1017
|
+
background: transparent;
|
|
1018
|
+
border: none;
|
|
1019
|
+
color: var(--rp-muted);
|
|
1020
|
+
font-family: inherit;
|
|
1021
|
+
font-size: 11px;
|
|
1022
|
+
cursor: pointer;
|
|
1023
|
+
border-radius: 3px;
|
|
1024
|
+
transition: all 0.15s;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
.view-toggle button:hover {
|
|
1028
|
+
color: var(--rp-subtle);
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
.view-toggle button.active {
|
|
1032
|
+
background: var(--rp-overlay);
|
|
1033
|
+
color: var(--rp-text);
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
.variation-tabs {
|
|
1037
|
+
display: flex;
|
|
1038
|
+
gap: 4px;
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
.variation-tab {
|
|
1042
|
+
padding: 5px 12px;
|
|
1043
|
+
background: var(--rp-base);
|
|
1044
|
+
border: 1px solid var(--rp-overlay);
|
|
1045
|
+
border-radius: 4px;
|
|
1046
|
+
color: var(--rp-muted);
|
|
1047
|
+
font-family: inherit;
|
|
1048
|
+
font-size: 11px;
|
|
1049
|
+
cursor: pointer;
|
|
1050
|
+
transition: all 0.15s;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
.variation-tab:hover {
|
|
1054
|
+
border-color: var(--rp-highlight-med);
|
|
1055
|
+
color: var(--rp-subtle);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
.variation-tab.active {
|
|
1059
|
+
background: var(--rp-overlay);
|
|
1060
|
+
color: var(--rp-text);
|
|
1061
|
+
border-color: var(--rp-highlight-high);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
.variation-tab.hidden {
|
|
1065
|
+
display: none;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
main {
|
|
1069
|
+
display: flex;
|
|
1070
|
+
height: calc(100vh - 49px);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
.sidebar {
|
|
1074
|
+
width: 280px;
|
|
1075
|
+
min-width: 280px;
|
|
1076
|
+
border-right: 1px solid var(--rp-overlay);
|
|
1077
|
+
overflow-y: auto;
|
|
1078
|
+
background: var(--rp-base);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
.sidebar-header {
|
|
1082
|
+
padding: 10px 14px;
|
|
1083
|
+
font-size: 10px;
|
|
1084
|
+
text-transform: uppercase;
|
|
1085
|
+
letter-spacing: 1.5px;
|
|
1086
|
+
color: var(--rp-muted);
|
|
1087
|
+
border-bottom: 1px solid var(--rp-overlay);
|
|
1088
|
+
position: sticky;
|
|
1089
|
+
top: 0;
|
|
1090
|
+
background: var(--rp-base);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
.file-list {
|
|
1094
|
+
list-style: none;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
.file-item {
|
|
1098
|
+
padding: 8px 14px;
|
|
1099
|
+
cursor: pointer;
|
|
1100
|
+
border-bottom: 1px solid var(--rp-highlight-low);
|
|
1101
|
+
transition: background 0.1s;
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
.file-item:hover {
|
|
1105
|
+
background: var(--rp-surface);
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
.file-item.selected {
|
|
1109
|
+
background: var(--rp-overlay);
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
.file-item-header {
|
|
1113
|
+
display: flex;
|
|
1114
|
+
align-items: center;
|
|
1115
|
+
gap: 8px;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
.status {
|
|
1119
|
+
font-weight: 600;
|
|
1120
|
+
font-size: 11px;
|
|
1121
|
+
width: 14px;
|
|
1122
|
+
text-align: center;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
.status.M { color: var(--rp-gold); }
|
|
1126
|
+
.status.A { color: var(--rp-foam); }
|
|
1127
|
+
.status.D { color: var(--rp-love); }
|
|
1128
|
+
|
|
1129
|
+
.file-path {
|
|
1130
|
+
overflow: hidden;
|
|
1131
|
+
text-overflow: ellipsis;
|
|
1132
|
+
white-space: nowrap;
|
|
1133
|
+
color: var(--rp-text);
|
|
1134
|
+
font-size: 12px;
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
.file-variations {
|
|
1138
|
+
margin-top: 3px;
|
|
1139
|
+
margin-left: 22px;
|
|
1140
|
+
font-size: 10px;
|
|
1141
|
+
color: var(--rp-muted);
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
.file-variations span {
|
|
1145
|
+
margin-right: 6px;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
.diff-view {
|
|
1149
|
+
flex: 1;
|
|
1150
|
+
overflow: auto;
|
|
1151
|
+
background: var(--rp-base);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
.diff-wrapper {
|
|
1155
|
+
min-height: 100%;
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
.empty-state {
|
|
1159
|
+
display: flex;
|
|
1160
|
+
align-items: center;
|
|
1161
|
+
justify-content: center;
|
|
1162
|
+
height: 100%;
|
|
1163
|
+
color: var(--rp-muted);
|
|
1164
|
+
font-size: 12px;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
#diffContainer {
|
|
1168
|
+
height: 100%;
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
::-webkit-scrollbar {
|
|
1172
|
+
width: 8px;
|
|
1173
|
+
height: 8px;
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
::-webkit-scrollbar-track {
|
|
1177
|
+
background: var(--rp-base);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
::-webkit-scrollbar-thumb {
|
|
1181
|
+
background: var(--rp-overlay);
|
|
1182
|
+
border-radius: 4px;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
::-webkit-scrollbar-thumb:hover {
|
|
1186
|
+
background: var(--rp-highlight-med);
|
|
1187
|
+
}
|
|
1188
|
+
</style>
|
|
1189
|
+
</head>
|
|
1190
|
+
<body>
|
|
1191
|
+
<header>
|
|
1192
|
+
<div class="header-left">
|
|
1193
|
+
<h1>OKIRO</h1>
|
|
1194
|
+
<div class="view-toggle">
|
|
1195
|
+
<button id="splitBtn" class="active">Split</button>
|
|
1196
|
+
<button id="unifiedBtn">Unified</button>
|
|
1197
|
+
</div>
|
|
1198
|
+
</div>
|
|
1199
|
+
<div class="variation-tabs" id="variationTabs"></div>
|
|
1200
|
+
</header>
|
|
1201
|
+
<main>
|
|
1202
|
+
<aside class="sidebar">
|
|
1203
|
+
<div class="sidebar-header">Changed Files</div>
|
|
1204
|
+
<ul class="file-list" id="fileList"></ul>
|
|
1205
|
+
</aside>
|
|
1206
|
+
<section class="diff-view" id="diffView">
|
|
1207
|
+
<div class="empty-state">Select a file to view diff</div>
|
|
1208
|
+
</section>
|
|
1209
|
+
</main>
|
|
1210
|
+
<script type="module">
|
|
1211
|
+
import { FileDiff } from 'https://esm.sh/@pierre/diffs@1.0.5';
|
|
1212
|
+
|
|
1213
|
+
const variations = ${JSON.stringify(variations)};
|
|
1214
|
+
const fileList = document.getElementById('fileList');
|
|
1215
|
+
const diffView = document.getElementById('diffView');
|
|
1216
|
+
const variationTabs = document.getElementById('variationTabs');
|
|
1217
|
+
const splitBtn = document.getElementById('splitBtn');
|
|
1218
|
+
const unifiedBtn = document.getElementById('unifiedBtn');
|
|
1219
|
+
|
|
1220
|
+
let selectedFile = null;
|
|
1221
|
+
let selectedVariation = variations[0];
|
|
1222
|
+
let allFiles = [];
|
|
1223
|
+
let diffStyle = 'split';
|
|
1224
|
+
let diffInstance = null;
|
|
1225
|
+
|
|
1226
|
+
splitBtn.addEventListener('click', () => {
|
|
1227
|
+
diffStyle = 'split';
|
|
1228
|
+
splitBtn.classList.add('active');
|
|
1229
|
+
unifiedBtn.classList.remove('active');
|
|
1230
|
+
if (diffInstance) {
|
|
1231
|
+
diffInstance.setOptions({ diffStyle: 'split' });
|
|
1232
|
+
diffInstance.rerender();
|
|
1233
|
+
}
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
unifiedBtn.addEventListener('click', () => {
|
|
1237
|
+
diffStyle = 'unified';
|
|
1238
|
+
unifiedBtn.classList.add('active');
|
|
1239
|
+
splitBtn.classList.remove('active');
|
|
1240
|
+
if (diffInstance) {
|
|
1241
|
+
diffInstance.setOptions({ diffStyle: 'unified' });
|
|
1242
|
+
diffInstance.rerender();
|
|
1243
|
+
}
|
|
1244
|
+
});
|
|
1245
|
+
|
|
1246
|
+
async function loadAllFiles() {
|
|
1247
|
+
const response = await fetch('/api/all-files');
|
|
1248
|
+
allFiles = await response.json();
|
|
1249
|
+
renderFileList();
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function renderFileList() {
|
|
1253
|
+
fileList.innerHTML = allFiles.map(f => \`
|
|
1254
|
+
<li class="file-item" data-path="\${f.path}">
|
|
1255
|
+
<div class="file-item-header">
|
|
1256
|
+
<span class="status \${f.status}">\${f.status}</span>
|
|
1257
|
+
<span class="file-path">\${f.path}</span>
|
|
1258
|
+
</div>
|
|
1259
|
+
<div class="file-variations">
|
|
1260
|
+
\${f.variations.map(v => \`<span>\${v}</span>\`).join('')}
|
|
1261
|
+
</div>
|
|
1262
|
+
</li>
|
|
1263
|
+
\`).join('');
|
|
1264
|
+
|
|
1265
|
+
document.querySelectorAll('.file-item').forEach(item => {
|
|
1266
|
+
item.addEventListener('click', () => selectFile(item.dataset.path));
|
|
1267
|
+
});
|
|
1268
|
+
|
|
1269
|
+
if (allFiles.length > 0 && !selectedFile) {
|
|
1270
|
+
selectFile(allFiles[0].path);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function selectFile(filePath) {
|
|
1275
|
+
selectedFile = filePath;
|
|
1276
|
+
|
|
1277
|
+
document.querySelectorAll('.file-item').forEach(item => {
|
|
1278
|
+
item.classList.toggle('selected', item.dataset.path === filePath);
|
|
1279
|
+
});
|
|
1280
|
+
|
|
1281
|
+
const fileData = allFiles.find(f => f.path === filePath);
|
|
1282
|
+
|
|
1283
|
+
renderVariationTabs(fileData?.variations || []);
|
|
1284
|
+
|
|
1285
|
+
if (fileData && !fileData.variations.includes(selectedVariation)) {
|
|
1286
|
+
selectedVariation = fileData.variations[0];
|
|
1287
|
+
}
|
|
1288
|
+
updateVariationTabs();
|
|
1289
|
+
|
|
1290
|
+
loadDiff();
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
function renderVariationTabs(fileVariations) {
|
|
1294
|
+
variationTabs.innerHTML = fileVariations.map(v =>
|
|
1295
|
+
\`<button class="variation-tab" data-var="\${v}">\${v}</button>\`
|
|
1296
|
+
).join('');
|
|
1297
|
+
|
|
1298
|
+
variationTabs.querySelectorAll('.variation-tab').forEach(tab => {
|
|
1299
|
+
tab.addEventListener('click', () => selectVariation(tab.dataset.var));
|
|
1300
|
+
});
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function selectVariation(varId) {
|
|
1304
|
+
selectedVariation = varId;
|
|
1305
|
+
updateVariationTabs();
|
|
1306
|
+
if (selectedFile) {
|
|
1307
|
+
loadDiff();
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function updateVariationTabs() {
|
|
1312
|
+
document.querySelectorAll('.variation-tab').forEach(tab => {
|
|
1313
|
+
tab.classList.toggle('active', tab.dataset.var === selectedVariation);
|
|
1314
|
+
});
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
async function loadDiff() {
|
|
1318
|
+
if (!selectedFile || !selectedVariation) return;
|
|
1319
|
+
|
|
1320
|
+
const fileData = allFiles.find(f => f.path === selectedFile);
|
|
1321
|
+
if (!fileData || !fileData.variations.includes(selectedVariation)) {
|
|
1322
|
+
diffView.innerHTML = '<div class="empty-state">No changes in ' + selectedVariation + '</div>';
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
diffView.innerHTML = '<div class="empty-state">Loading...</div>';
|
|
1327
|
+
|
|
1328
|
+
const response = await fetch(\`/api/diff/\${selectedVariation}?file=\${encodeURIComponent(selectedFile)}\`);
|
|
1329
|
+
const diff = await response.json();
|
|
1330
|
+
|
|
1331
|
+
renderDiff(diff);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function renderDiff(diff) {
|
|
1335
|
+
diffView.innerHTML = '<div id="diffContainer"></div>';
|
|
1336
|
+
|
|
1337
|
+
if (diffInstance) {
|
|
1338
|
+
diffInstance.cleanUp();
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
diffInstance = new FileDiff({
|
|
1342
|
+
theme: 'rose-pine',
|
|
1343
|
+
themeType: 'dark',
|
|
1344
|
+
diffStyle: diffStyle,
|
|
1345
|
+
diffIndicators: 'classic',
|
|
1346
|
+
overflow: 'scroll',
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
diffInstance.render({
|
|
1350
|
+
oldFile: { name: diff.filePath, contents: diff.original },
|
|
1351
|
+
newFile: { name: diff.filePath, contents: diff.modified },
|
|
1352
|
+
containerWrapper: document.getElementById('diffContainer'),
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
loadAllFiles();
|
|
1357
|
+
</script>
|
|
1358
|
+
</body>
|
|
1359
|
+
</html>`;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// src/index.ts
|
|
1363
|
+
var firstArg = process.argv[2];
|
|
1364
|
+
if (firstArg && /^\d+$/.test(firstArg)) {
|
|
1365
|
+
process.argv.splice(2, 0, "spawn");
|
|
1366
|
+
} else if (!firstArg) {
|
|
1367
|
+
process.argv.push("--help");
|
|
1368
|
+
}
|
|
1369
|
+
program.name("okiro").description("Ephemeral code variation previews for AI-assisted development").version("0.1.0");
|
|
1370
|
+
program.command("spawn <count>").description("Create variation workspaces").option("-f, --force", "Replace existing variations").option("--no-terminal", "Do not open terminal sessions").option("--prompt [task]", "Prompt for direction per variation").action(async (count, options) => {
|
|
1371
|
+
await spawn(parseInt(count, 10), {
|
|
1372
|
+
force: options.force,
|
|
1373
|
+
noTerminal: !options.terminal,
|
|
1374
|
+
prompt: options.prompt
|
|
1375
|
+
});
|
|
1376
|
+
});
|
|
1377
|
+
program.command("status").description("Show active variations").action(async () => {
|
|
1378
|
+
await status();
|
|
1379
|
+
});
|
|
1380
|
+
program.command("diff [var1] [var2]").description("Show diff between variations").action(async (var1, var2) => {
|
|
1381
|
+
await diff(var1, var2);
|
|
1382
|
+
});
|
|
1383
|
+
program.command("promote <variation>").description("Apply variation changes to original codebase").option("-f, --force", "Skip confirmation").option("-c, --commit [message]", "Git commit after promoting").action(async (variation, options) => {
|
|
1384
|
+
await promote(variation, {
|
|
1385
|
+
force: options.force,
|
|
1386
|
+
commit: options.commit
|
|
1387
|
+
});
|
|
1388
|
+
});
|
|
1389
|
+
program.command("compare").description("Open diff viewer UI in browser").option("-p, --port <port>", "Port for diff viewer", "6789").option("--no-browser", "Do not open browser automatically").action(async (options) => {
|
|
1390
|
+
await compare({
|
|
1391
|
+
port: parseInt(options.port, 10),
|
|
1392
|
+
noBrowser: !options.browser
|
|
1393
|
+
});
|
|
1394
|
+
});
|
|
1395
|
+
program.command("cleanup").description("Remove all variation workspaces").option("-f, --force", "Skip confirmation").action(async (options) => {
|
|
1396
|
+
await cleanup({ force: options.force });
|
|
1397
|
+
});
|
|
1398
|
+
program.addHelpText("after", `
|
|
1399
|
+
Examples:
|
|
1400
|
+
$ okiro 3 Create 3 variations
|
|
1401
|
+
$ okiro 3 --prompt Create 3 with AI directions
|
|
1402
|
+
$ okiro status Show variation status
|
|
1403
|
+
$ okiro diff var-1 Diff original vs var-1
|
|
1404
|
+
$ okiro compare Open diff viewer
|
|
1405
|
+
$ okiro promote var-2 Apply var-2 to codebase
|
|
1406
|
+
$ okiro cleanup Remove all variations
|
|
1407
|
+
`);
|
|
1408
|
+
program.parse();
|