@fuzeelogik/myflo 1.0.0-rc.4

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,160 @@
1
+ import {
2
+ createTask,
3
+ updateTask,
4
+ completeTask,
5
+ deleteTask,
6
+ listTasks,
7
+ getTask,
8
+ taskCounts,
9
+ STATUSES,
10
+ } from './tasks-store.js';
11
+
12
+ export async function tasksCommand(args) {
13
+ const [sub = 'help', ...rest] = args;
14
+ if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
15
+ if (sub === 'create' || sub === 'add') return createCmd(rest);
16
+ if (sub === 'list') return listCmd(rest);
17
+ if (sub === 'update') return updateCmd(rest);
18
+ if (sub === 'complete' || sub === 'done') return completeCmd(rest);
19
+ if (sub === 'delete' || sub === 'rm') return deleteCmd(rest);
20
+ if (sub === 'get' || sub === 'show') return getCmd(rest);
21
+ if (sub === 'counts' || sub === 'count') return countsCmd(rest);
22
+ console.error(`flo tasks: unknown subcommand '${sub}'`);
23
+ console.error(`Available: create, list, update, complete, delete, get, counts, help`);
24
+ process.exit(2);
25
+ }
26
+
27
+ function printHelp() {
28
+ console.log(`flo tasks — persistent task tracker (~/.flo/tasks.jsonl)
29
+
30
+ Usage:
31
+ flo tasks create <subject> [--description <s>] [--tags a,b] [--owner <name>] [--parent <id>] [--status pending|in_progress|completed]
32
+ flo tasks list [--status <s>] [--owner <name>] [--tag <t>] [--limit N] [--json]
33
+ flo tasks update <id> [--subject <s>] [--description <s>] [--tags a,b] [--owner <name>] [--status <s>]
34
+ flo tasks complete <id>
35
+ flo tasks delete <id>
36
+ flo tasks get <id>
37
+ flo tasks counts [--json]
38
+
39
+ Status transitions: pending → in_progress → completed. Storage is append-only
40
+ event log; deletes are tombstones, history is preserved.
41
+ `);
42
+ }
43
+
44
+ function parseFlags(args) {
45
+ const out = { positional: [] };
46
+ for (let i = 0; i < args.length; i++) {
47
+ const a = args[i];
48
+ if (a === '--json') out.json = true;
49
+ else if (a === '--description') out.description = args[++i];
50
+ else if (a === '--tags') out.tags = (args[++i] || '').split(',').map((t) => t.trim()).filter(Boolean);
51
+ else if (a === '--tag') out.tag = args[++i];
52
+ else if (a === '--owner') out.owner = args[++i];
53
+ else if (a === '--parent') out.parent = args[++i];
54
+ else if (a === '--status') out.status = args[++i];
55
+ else if (a === '--subject') out.subject = args[++i];
56
+ else if (a === '--limit') out.limit = Number(args[++i]);
57
+ else if (!a.startsWith('--')) out.positional.push(a);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ async function createCmd(args) {
63
+ const opts = parseFlags(args);
64
+ const subject = opts.subject || opts.positional.join(' ');
65
+ if (!subject) { console.error(`flo tasks create: missing <subject>`); process.exit(2); }
66
+ const task = await createTask({
67
+ subject,
68
+ description: opts.description,
69
+ tags: opts.tags,
70
+ owner: opts.owner,
71
+ parent: opts.parent,
72
+ status: opts.status,
73
+ });
74
+ if (opts.json) console.log(JSON.stringify(task));
75
+ else console.log(`flo tasks create: ${task.id} — ${task.subject}`);
76
+ }
77
+
78
+ async function listCmd(args) {
79
+ const opts = parseFlags(args);
80
+ const tasks = await listTasks({
81
+ status: opts.status,
82
+ owner: opts.owner,
83
+ tag: opts.tag,
84
+ limit: opts.limit || 100,
85
+ });
86
+ if (opts.json) { console.log(JSON.stringify(tasks, null, 2)); return; }
87
+ if (!tasks.length) { console.log(`flo tasks: nothing matches`); return; }
88
+ const stsW = 13, idW = 20;
89
+ console.log(`status id subject`);
90
+ console.log(`------------- -------------------- -------`);
91
+ for (const t of tasks) {
92
+ const sts = (t.status || '?').padEnd(stsW).slice(0, stsW);
93
+ const id = (t.id || '?').padEnd(idW).slice(0, idW);
94
+ const tagStr = t.tags?.length ? ` [${t.tags.join(',')}]` : '';
95
+ console.log(`${sts} ${id} ${t.subject}${tagStr}`);
96
+ }
97
+ }
98
+
99
+ async function updateCmd(args) {
100
+ const opts = parseFlags(args);
101
+ const id = opts.positional[0];
102
+ if (!id) { console.error(`flo tasks update: missing <id>`); process.exit(2); }
103
+ const patch = {};
104
+ for (const k of ['subject', 'description', 'tags', 'owner', 'status']) {
105
+ if (opts[k] !== undefined) patch[k] = opts[k];
106
+ }
107
+ if (Object.keys(patch).length === 0) {
108
+ console.error(`flo tasks update: nothing to change`);
109
+ process.exit(2);
110
+ }
111
+ const task = await updateTask({ id, ...patch });
112
+ if (!task) { console.error(`flo tasks update: no task ${id}`); process.exit(1); }
113
+ if (opts.json) console.log(JSON.stringify(task));
114
+ else console.log(`flo tasks update: ${task.id} → status=${task.status}`);
115
+ }
116
+
117
+ async function completeCmd(args) {
118
+ const opts = parseFlags(args);
119
+ const id = opts.positional[0];
120
+ if (!id) { console.error(`flo tasks complete: missing <id>`); process.exit(2); }
121
+ const task = await completeTask(id);
122
+ if (!task) { console.error(`flo tasks complete: no task ${id}`); process.exit(1); }
123
+ console.log(`flo tasks complete: ${task.id} — ${task.subject}`);
124
+ }
125
+
126
+ async function deleteCmd(args) {
127
+ const opts = parseFlags(args);
128
+ const id = opts.positional[0];
129
+ if (!id) { console.error(`flo tasks delete: missing <id>`); process.exit(2); }
130
+ await deleteTask(id);
131
+ console.log(`flo tasks delete: tombstoned ${id}`);
132
+ }
133
+
134
+ async function getCmd(args) {
135
+ const opts = parseFlags(args);
136
+ const id = opts.positional[0];
137
+ if (!id) { console.error(`flo tasks get: missing <id>`); process.exit(2); }
138
+ const task = await getTask(id);
139
+ if (!task) { console.error(`flo tasks get: no task ${id}`); process.exit(1); }
140
+ if (opts.json) console.log(JSON.stringify(task, null, 2));
141
+ else {
142
+ console.log(`${task.id}`);
143
+ console.log(`subject: ${task.subject}`);
144
+ console.log(`status: ${task.status}`);
145
+ if (task.tags?.length) console.log(`tags: ${task.tags.join(', ')}`);
146
+ if (task.owner) console.log(`owner: ${task.owner}`);
147
+ if (task.parent) console.log(`parent: ${task.parent}`);
148
+ console.log(`createdAt: ${task.createdAt}`);
149
+ console.log(`updatedAt: ${task.updatedAt}`);
150
+ if (task.completedAt) console.log(`completedAt: ${task.completedAt}`);
151
+ if (task.description) { console.log(`\n${task.description}`); }
152
+ }
153
+ }
154
+
155
+ async function countsCmd(args) {
156
+ const opts = parseFlags(args);
157
+ const c = await taskCounts();
158
+ if (opts.json) console.log(JSON.stringify(c));
159
+ else console.log(`flo tasks counts: total=${c.total} pending=${c.pending} in_progress=${c.in_progress} completed=${c.completed}`);
160
+ }
@@ -0,0 +1,152 @@
1
+ // flo tasks — persistent task tracker.
2
+ // Append-only event log at ~/.flo/tasks.jsonl. Each line is one event:
3
+ // { id, op: 'create' | 'update' | 'delete', ts, ...patch }
4
+ // listTasks() folds events into current state. Survives session boundaries.
5
+
6
+ import { mkdir, readFile, appendFile } from 'node:fs/promises';
7
+ import { existsSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join } from 'node:path';
10
+ import { randomBytes } from 'node:crypto';
11
+
12
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
13
+ const TASKS_PATH = join(FLO_HOME, 'tasks.jsonl');
14
+
15
+ export const STATUSES = ['pending', 'in_progress', 'completed'];
16
+
17
+ async function ensureHome() {
18
+ if (!existsSync(FLO_HOME)) await mkdir(FLO_HOME, { recursive: true });
19
+ }
20
+
21
+ function newId() {
22
+ return `t-${Date.now()}-${randomBytes(2).toString('hex')}`;
23
+ }
24
+
25
+ async function appendEvent(event) {
26
+ await ensureHome();
27
+ await appendFile(TASKS_PATH, JSON.stringify(event) + '\n', 'utf8');
28
+ }
29
+
30
+ async function readEvents() {
31
+ if (!existsSync(TASKS_PATH)) return [];
32
+ let raw = '';
33
+ try { raw = await readFile(TASKS_PATH, 'utf8'); } catch { return []; }
34
+ const out = [];
35
+ for (const line of raw.split(/\r?\n/)) {
36
+ if (!line.trim()) continue;
37
+ try { out.push(JSON.parse(line)); } catch { /* skip malformed */ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ export async function createTask({ subject, description, tags, owner, parent, status }) {
43
+ if (!subject || !subject.trim()) throw new Error('createTask: subject is required');
44
+ const id = newId();
45
+ const now = new Date().toISOString();
46
+ const event = {
47
+ id,
48
+ op: 'create',
49
+ ts: now,
50
+ subject: subject.trim(),
51
+ description: description || null,
52
+ tags: Array.isArray(tags) ? tags.map(String) : [],
53
+ owner: owner || null,
54
+ parent: parent || null,
55
+ status: STATUSES.includes(status) ? status : 'pending',
56
+ createdAt: now,
57
+ updatedAt: now,
58
+ };
59
+ await appendEvent(event);
60
+ return materializeOne([event]);
61
+ }
62
+
63
+ export async function updateTask({ id, ...patch }) {
64
+ if (!id) throw new Error('updateTask: id is required');
65
+ if (patch.status && !STATUSES.includes(patch.status)) {
66
+ throw new Error(`updateTask: invalid status '${patch.status}'. Valid: ${STATUSES.join(', ')}`);
67
+ }
68
+ const ts = new Date().toISOString();
69
+ const event = { id, op: 'update', ts, ...patch, updatedAt: ts };
70
+ await appendEvent(event);
71
+ // Read full state to return updated record
72
+ const tasks = await listAllTasks({ includeDeleted: false });
73
+ return tasks.find((t) => t.id === id) || null;
74
+ }
75
+
76
+ export async function completeTask(id) {
77
+ return updateTask({ id, status: 'completed', completedAt: new Date().toISOString() });
78
+ }
79
+
80
+ export async function deleteTask(id) {
81
+ if (!id) throw new Error('deleteTask: id is required');
82
+ await appendEvent({ id, op: 'delete', ts: new Date().toISOString() });
83
+ }
84
+
85
+ function materializeOne(events) {
86
+ const map = new Map();
87
+ for (const e of events) {
88
+ if (e.op === 'delete') {
89
+ map.delete(e.id);
90
+ continue;
91
+ }
92
+ if (e.op === 'create') {
93
+ const { op, ts, ...record } = e;
94
+ map.set(e.id, record);
95
+ } else if (e.op === 'update') {
96
+ const existing = map.get(e.id);
97
+ if (!existing) continue;
98
+ const { op, ts, ...patch } = e;
99
+ map.set(e.id, { ...existing, ...patch });
100
+ }
101
+ }
102
+ return [...map.values()][0] || null;
103
+ }
104
+
105
+ export async function listAllTasks({ includeDeleted = false } = {}) {
106
+ const events = await readEvents();
107
+ const map = new Map();
108
+ const tombstones = new Set();
109
+ for (const e of events) {
110
+ if (e.op === 'delete') {
111
+ tombstones.add(e.id);
112
+ if (!includeDeleted) map.delete(e.id);
113
+ else if (map.has(e.id)) map.set(e.id, { ...map.get(e.id), _deleted: true });
114
+ continue;
115
+ }
116
+ if (e.op === 'create') {
117
+ const { op, ts, ...record } = e;
118
+ map.set(e.id, record);
119
+ } else if (e.op === 'update') {
120
+ const existing = map.get(e.id);
121
+ if (!existing) continue;
122
+ const { op, ts, ...patch } = e;
123
+ map.set(e.id, { ...existing, ...patch });
124
+ }
125
+ }
126
+ return [...map.values()].sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
127
+ }
128
+
129
+ export async function listTasks({ status, owner, tag, limit = 100 } = {}) {
130
+ const all = await listAllTasks();
131
+ return all
132
+ .filter((t) => !status || t.status === status)
133
+ .filter((t) => !owner || t.owner === owner)
134
+ .filter((t) => !tag || (t.tags || []).includes(tag))
135
+ .slice(0, limit);
136
+ }
137
+
138
+ export async function getTask(id) {
139
+ const all = await listAllTasks();
140
+ return all.find((t) => t.id === id) || null;
141
+ }
142
+
143
+ export async function taskCounts() {
144
+ const all = await listAllTasks();
145
+ const out = { total: all.length, pending: 0, in_progress: 0, completed: 0 };
146
+ for (const t of all) {
147
+ if (out[t.status] !== undefined) out[t.status]++;
148
+ }
149
+ return out;
150
+ }
151
+
152
+ export const _internal = { TASKS_PATH };
@@ -0,0 +1,281 @@
1
+ // `flo session terminal-attach` — port of nigelglenday/a-team.
2
+ // Tracks named terminal sessions (cwd + title + app) in ~/.flo/terminals.json
3
+ // and uses AppleScript on macOS to open them in Ghostty/iTerm/Terminal.
4
+
5
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
6
+ import { existsSync } from 'node:fs';
7
+ import { execFile } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import { homedir } from 'node:os';
10
+ import { join, resolve } from 'node:path';
11
+
12
+ const execFileAsync = promisify(execFile);
13
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
14
+ const REGISTRY_PATH = join(FLO_HOME, 'terminals.json');
15
+
16
+ const VALID_APPS = ['ghostty', 'iterm', 'terminal'];
17
+
18
+ function slugify(input) {
19
+ return String(input || '')
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9-]+/g, '-')
22
+ .replace(/^-+|-+$/g, '')
23
+ .slice(0, 40) || 'terminal';
24
+ }
25
+
26
+ async function ensureHome() {
27
+ if (!existsSync(FLO_HOME)) await mkdir(FLO_HOME, { recursive: true });
28
+ }
29
+
30
+ export async function loadRegistry() {
31
+ await ensureHome();
32
+ if (!existsSync(REGISTRY_PATH)) return { version: 1, terminals: [] };
33
+ try {
34
+ const raw = await readFile(REGISTRY_PATH, 'utf8');
35
+ const parsed = JSON.parse(raw);
36
+ if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.terminals)) {
37
+ return { version: 1, terminals: [] };
38
+ }
39
+ return parsed;
40
+ } catch {
41
+ return { version: 1, terminals: [] };
42
+ }
43
+ }
44
+
45
+ export async function saveRegistry(reg) {
46
+ await ensureHome();
47
+ await writeFile(REGISTRY_PATH, JSON.stringify(reg, null, 2) + '\n', 'utf8');
48
+ }
49
+
50
+ export async function addTerminal({ slug, cwd, title, app = 'ghostty', command }) {
51
+ if (!VALID_APPS.includes(app)) {
52
+ throw new Error(`unknown app '${app}'. Valid: ${VALID_APPS.join(', ')}`);
53
+ }
54
+ const reg = await loadRegistry();
55
+ const resolvedCwd = resolve(cwd.replace(/^~/, homedir()));
56
+ const finalSlug = slugify(slug || title || `term-${reg.terminals.length + 1}`);
57
+ let unique = finalSlug;
58
+ let n = 1;
59
+ while (reg.terminals.find((t) => t.slug === unique)) {
60
+ unique = `${finalSlug}-${++n}`;
61
+ }
62
+ const entry = {
63
+ slug: unique,
64
+ cwd: resolvedCwd,
65
+ title: title || unique,
66
+ app,
67
+ command: command || null,
68
+ createdAt: new Date().toISOString(),
69
+ };
70
+ reg.terminals.push(entry);
71
+ await saveRegistry(reg);
72
+ return entry;
73
+ }
74
+
75
+ export async function removeTerminal(slug) {
76
+ const reg = await loadRegistry();
77
+ const before = reg.terminals.length;
78
+ reg.terminals = reg.terminals.filter((t) => t.slug !== slug);
79
+ await saveRegistry(reg);
80
+ return before - reg.terminals.length > 0;
81
+ }
82
+
83
+ export async function listTerminals() {
84
+ const reg = await loadRegistry();
85
+ return reg.terminals;
86
+ }
87
+
88
+ function escapeAppleScriptString(s) {
89
+ return String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
90
+ }
91
+
92
+ function applescriptFor({ app, cwd, title, command }) {
93
+ const escCwd = escapeAppleScriptString(cwd);
94
+ const escTitle = escapeAppleScriptString(title);
95
+ const cmdPart = command
96
+ ? `${escapeAppleScriptString(command)}`
97
+ : '';
98
+ if (app === 'ghostty') {
99
+ // Ghostty does not have first-class AppleScript support; use `open` on the
100
+ // app and an osascript that focuses it, then type via System Events. Most
101
+ // users prefer the simpler `open -a Ghostty .` approach via `open`.
102
+ return null; // caller falls back to `open` flow
103
+ }
104
+ if (app === 'iterm') {
105
+ const tellCmd = command
106
+ ? `set command to "cd \\"${escCwd}\\" && ${cmdPart}"`
107
+ : `set command to "cd \\"${escCwd}\\""`;
108
+ return [
109
+ 'tell application "iTerm2"',
110
+ ' activate',
111
+ ' create window with default profile',
112
+ ' tell current session of current window',
113
+ ` ${tellCmd}`,
114
+ ' write text command',
115
+ ` set name to "${escTitle}"`,
116
+ ' end tell',
117
+ 'end tell',
118
+ ].join('\n');
119
+ }
120
+ if (app === 'terminal') {
121
+ const tellCmd = command
122
+ ? `do script "cd \\"${escCwd}\\" && ${cmdPart}"`
123
+ : `do script "cd \\"${escCwd}\\""`;
124
+ return [
125
+ 'tell application "Terminal"',
126
+ ' activate',
127
+ ` set newTab to ${tellCmd}`,
128
+ ` set custom title of newTab to "${escTitle}"`,
129
+ 'end tell',
130
+ ].join('\n');
131
+ }
132
+ return null;
133
+ }
134
+
135
+ async function openGhostty({ cwd, command }) {
136
+ // Ghostty CLI accepts --working-directory and -e for command. Fall back to
137
+ // `open -a Ghostty <cwd>` if the binary isn't on PATH.
138
+ try {
139
+ const args = ['--working-directory', cwd];
140
+ if (command) args.push('-e', command);
141
+ await execFileAsync('ghostty', args);
142
+ return;
143
+ } catch {
144
+ await execFileAsync('open', ['-na', 'Ghostty', '--args', '--working-directory', cwd]);
145
+ }
146
+ }
147
+
148
+ export async function restoreTerminal(entry) {
149
+ if (process.platform !== 'darwin') {
150
+ throw new Error(`terminal-attach restore is macOS-only (current: ${process.platform})`);
151
+ }
152
+ if (entry.app === 'ghostty') {
153
+ await openGhostty({ cwd: entry.cwd, command: entry.command });
154
+ return;
155
+ }
156
+ const script = applescriptFor(entry);
157
+ if (!script) throw new Error(`no AppleScript path for app '${entry.app}'`);
158
+ await execFileAsync('osascript', ['-e', script], { timeout: 30_000 });
159
+ }
160
+
161
+ export async function restoreAll() {
162
+ const terminals = await listTerminals();
163
+ const results = [];
164
+ for (const t of terminals) {
165
+ try {
166
+ await restoreTerminal(t);
167
+ results.push({ slug: t.slug, ok: true });
168
+ } catch (err) {
169
+ results.push({ slug: t.slug, ok: false, error: err.message });
170
+ }
171
+ }
172
+ return results;
173
+ }
174
+
175
+ export async function sessionCommand(args) {
176
+ const [sub = 'help', ...rest] = args;
177
+ if (sub === 'help' || sub === '--help' || sub === '-h') return printHelp();
178
+ if (sub === 'terminal-add') return addCmd(rest);
179
+ if (sub === 'terminal-list') return listCmd(rest);
180
+ if (sub === 'terminal-remove') return removeCmd(rest);
181
+ if (sub === 'terminal-restore') return restoreCmd(rest);
182
+ console.error(`flo session: unknown subcommand '${sub}'`);
183
+ console.error(`Available: terminal-add, terminal-list, terminal-remove, terminal-restore, help`);
184
+ process.exit(2);
185
+ }
186
+
187
+ function printHelp() {
188
+ console.log(`flo session — registered terminal windows (a-team port, macOS)
189
+
190
+ Usage:
191
+ flo session terminal-add <slug> [--cwd <path>] [--title <s>] [--app ghostty|iterm|terminal] [--command <cmd>]
192
+ flo session terminal-list [--json]
193
+ flo session terminal-remove <slug>
194
+ flo session terminal-restore [<slug> | --all]
195
+
196
+ Registry: ~/.flo/terminals.json. Each entry records cwd, title, target app,
197
+ and an optional command to run on attach.
198
+
199
+ Defaults: --cwd is the current directory, --app is ghostty, --title is the slug.
200
+
201
+ Supported apps:
202
+ - ghostty uses the ghostty CLI (or 'open -a Ghostty' fallback)
203
+ - iterm drives iTerm2 via AppleScript
204
+ - terminal drives macOS Terminal.app via AppleScript
205
+ `);
206
+ }
207
+
208
+ function parseFlags(args) {
209
+ const out = { positional: [] };
210
+ for (let i = 0; i < args.length; i++) {
211
+ const a = args[i];
212
+ if (a === '--cwd') out.cwd = args[++i];
213
+ else if (a === '--title') out.title = args[++i];
214
+ else if (a === '--app') out.app = args[++i];
215
+ else if (a === '--command') out.command = args[++i];
216
+ else if (a === '--json') out.json = true;
217
+ else if (a === '--all') out.all = true;
218
+ else if (!a.startsWith('--')) out.positional.push(a);
219
+ }
220
+ return out;
221
+ }
222
+
223
+ async function addCmd(args) {
224
+ const opts = parseFlags(args);
225
+ const slug = opts.positional[0];
226
+ if (!slug) {
227
+ console.error(`flo session terminal-add: missing <slug>`);
228
+ process.exit(2);
229
+ }
230
+ const entry = await addTerminal({
231
+ slug,
232
+ cwd: opts.cwd || process.cwd(),
233
+ title: opts.title,
234
+ app: opts.app || 'ghostty',
235
+ command: opts.command,
236
+ });
237
+ console.log(`flo session terminal-add: registered '${entry.slug}' (${entry.app}) → ${entry.cwd}`);
238
+ }
239
+
240
+ async function listCmd(args) {
241
+ const opts = parseFlags(args);
242
+ const terminals = await listTerminals();
243
+ if (opts.json) { console.log(JSON.stringify(terminals, null, 2)); return; }
244
+ if (!terminals.length) { console.log(`flo session: no terminals registered`); return; }
245
+ console.log(`slug app title cwd`);
246
+ console.log(`-------------------- --------- -------------------- ---`);
247
+ for (const t of terminals) {
248
+ const slug = (t.slug || '?').padEnd(20).slice(0, 20);
249
+ const app = (t.app || '?').padEnd(9).slice(0, 9);
250
+ const title = (t.title || '?').padEnd(20).slice(0, 20);
251
+ console.log(`${slug} ${app} ${title} ${t.cwd}`);
252
+ }
253
+ }
254
+
255
+ async function removeCmd(args) {
256
+ const opts = parseFlags(args);
257
+ const slug = opts.positional[0];
258
+ if (!slug) { console.error(`flo session terminal-remove: missing <slug>`); process.exit(2); }
259
+ const removed = await removeTerminal(slug);
260
+ if (removed) console.log(`flo session terminal-remove: removed '${slug}'`);
261
+ else { console.error(`flo session terminal-remove: no terminal with slug '${slug}'`); process.exit(1); }
262
+ }
263
+
264
+ async function restoreCmd(args) {
265
+ const opts = parseFlags(args);
266
+ if (opts.all || !opts.positional.length) {
267
+ const results = await restoreAll();
268
+ for (const r of results) {
269
+ console.log(` ${r.ok ? 'OK ' : 'FAIL'} ${r.slug}${r.error ? ` — ${r.error}` : ''}`);
270
+ }
271
+ const failed = results.filter((r) => !r.ok).length;
272
+ if (failed) process.exit(1);
273
+ return;
274
+ }
275
+ const slug = opts.positional[0];
276
+ const terminals = await listTerminals();
277
+ const entry = terminals.find((t) => t.slug === slug);
278
+ if (!entry) { console.error(`flo session terminal-restore: no terminal '${slug}'`); process.exit(1); }
279
+ await restoreTerminal(entry);
280
+ console.log(`flo session terminal-restore: launched '${entry.slug}' (${entry.app})`);
281
+ }
@@ -0,0 +1,75 @@
1
+ import { transcribe, transcribeAndSaveSidecar, detectTool } from './transcribe.js';
2
+
3
+ export async function transcribeCommand(args) {
4
+ const parsed = parseArgs(args);
5
+ if (parsed.help) {
6
+ console.log(`flo transcribe — local audio transcription
7
+
8
+ Detects mlx-whisper / openai-whisper / whisper-cpp at runtime and uses the
9
+ first available. No cloud calls.
10
+
11
+ Usage:
12
+ flo transcribe <audio-file> [--save] [--model base|small|medium|large]
13
+ flo transcribe --detect
14
+
15
+ Options:
16
+ --save Write a sidecar .txt next to the audio file.
17
+ --model <name> Whisper model (default: base; env FLO_WHISPER_MODEL).
18
+ --detect Just report which tool would be used.
19
+ --json JSON output.
20
+ -h, --help Show this help.
21
+
22
+ Audio formats: m4a, wav, mp3, aiff, flac (anything ffmpeg can decode).
23
+ `);
24
+ return;
25
+ }
26
+
27
+ if (parsed.detect) {
28
+ const tool = await detectTool();
29
+ if (parsed.json) {
30
+ console.log(JSON.stringify({ tool: tool?.name || null, binary: tool?.binary || null }));
31
+ } else if (tool) {
32
+ console.log(`flo transcribe: would use ${tool.name} (${tool.binary})`);
33
+ } else {
34
+ console.log(`flo transcribe: no tool available. Install one of: mlx-whisper, openai-whisper, whisper-cpp.`);
35
+ process.exit(1);
36
+ }
37
+ return;
38
+ }
39
+
40
+ if (!parsed.file) {
41
+ console.error(`flo transcribe: missing <audio-file>`);
42
+ console.error(`Usage: flo transcribe <audio-file> [--save]`);
43
+ process.exit(2);
44
+ }
45
+
46
+ const result = parsed.save
47
+ ? await transcribeAndSaveSidecar(parsed.file, { model: parsed.model })
48
+ : await transcribe(parsed.file, { model: parsed.model });
49
+
50
+ if (parsed.json) {
51
+ console.log(JSON.stringify(result, null, 2));
52
+ } else if (result.text) {
53
+ if (parsed.save) {
54
+ process.stderr.write(`flo transcribe: wrote ${result.sidecar}\n`);
55
+ }
56
+ console.log(result.text);
57
+ } else {
58
+ console.error(`flo transcribe: ${result.error}`);
59
+ process.exit(1);
60
+ }
61
+ }
62
+
63
+ function parseArgs(args) {
64
+ const out = { help: false, file: null, save: false, json: false, detect: false, model: null };
65
+ for (let i = 0; i < args.length; i++) {
66
+ const a = args[i];
67
+ if (a === '--help' || a === '-h') out.help = true;
68
+ else if (a === '--save') out.save = true;
69
+ else if (a === '--json') out.json = true;
70
+ else if (a === '--detect') out.detect = true;
71
+ else if (a === '--model') out.model = args[++i];
72
+ else if (!a.startsWith('--') && !out.file) out.file = a;
73
+ }
74
+ return out;
75
+ }