@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,120 @@
1
+ // `flo completions <shell>` — emit autocomplete scripts for bash/zsh/fish.
2
+ // Static command list; users sourcing the output get tab completion.
3
+
4
+ const TOP_COMMANDS = [
5
+ 'help', 'version', 'guidance', 'migrate', 'sessions', 'inbox', 'doctor',
6
+ 'mcp', 'transcribe', 'swarm', 'memory', 'messages', 'transcripts',
7
+ 'tasks', 'notes', 'setup', 'export', 'import', 'log', 'completions',
8
+ 'session', 'activity', 'edit',
9
+ ];
10
+
11
+ const SUBCOMMANDS = {
12
+ guidance: ['audit'],
13
+ sessions: ['list'],
14
+ inbox: ['watch', 'status', 'add', 'list', 'remove', 'install', 'uninstall', 'help'],
15
+ mcp: ['start'],
16
+ swarm: ['status'],
17
+ memory: ['store', 'search', 'list', 'get', 'delete', 'namespaces'],
18
+ messages: ['list', 'read', 'archive', 'help'],
19
+ transcripts: ['list'],
20
+ tasks: ['create', 'list', 'update', 'complete', 'delete', 'get', 'counts'],
21
+ notes: ['list', 'search'],
22
+ session: ['terminal-add', 'terminal-list', 'terminal-remove', 'terminal-restore'],
23
+ activity: ['list'],
24
+ completions: ['bash', 'zsh', 'fish'],
25
+ };
26
+
27
+ export async function completionsCommand(args) {
28
+ const [shell] = args;
29
+ if (!shell || shell === '--help' || shell === '-h') return printHelp();
30
+ if (shell === 'bash') return printBash();
31
+ if (shell === 'zsh') return printZsh();
32
+ if (shell === 'fish') return printFish();
33
+ console.error(`flo completions: unknown shell '${shell}'. Try: bash, zsh, fish`);
34
+ process.exit(2);
35
+ }
36
+
37
+ function printHelp() {
38
+ console.log(`flo completions — emit shell autocomplete scripts
39
+
40
+ Usage:
41
+ flo completions bash >> ~/.bashrc # or sourceable file
42
+ flo completions zsh >> ~/.zshrc # or to a fpath dir
43
+ flo completions fish > ~/.config/fish/completions/flo.fish
44
+
45
+ Each output is a complete, sourceable shell snippet. No runtime dependencies
46
+ beyond a working flo binary (uses the static command list, not introspection).
47
+ `);
48
+ }
49
+
50
+ function topWords() { return TOP_COMMANDS.sort().join(' '); }
51
+
52
+ function subcaseBash() {
53
+ return Object.entries(SUBCOMMANDS)
54
+ .map(([cmd, subs]) => ` ${cmd}) COMPREPLY=($(compgen -W "${subs.join(' ')}" -- "$cur")); return 0;;`)
55
+ .join('\n');
56
+ }
57
+
58
+ function printBash() {
59
+ console.log(`# flo bash completion. Source from .bashrc or save to /etc/bash_completion.d/flo
60
+ _flo() {
61
+ local cur prev cmd
62
+ COMPREPLY=()
63
+ cur="\${COMP_WORDS[COMP_CWORD]}"
64
+ cmd="\${COMP_WORDS[1]}"
65
+ if [ "$COMP_CWORD" -eq 1 ]; then
66
+ COMPREPLY=($(compgen -W "${topWords()}" -- "$cur"))
67
+ return 0
68
+ fi
69
+ if [ "$COMP_CWORD" -eq 2 ]; then
70
+ case "$cmd" in
71
+ ${subcaseBash()}
72
+ esac
73
+ fi
74
+ return 0
75
+ }
76
+ complete -F _flo flo
77
+ `);
78
+ }
79
+
80
+ function subcaseZsh() {
81
+ return Object.entries(SUBCOMMANDS)
82
+ .map(([cmd, subs]) => ` ${cmd}) _values 'subcommand' ${subs.map((s) => `'${s}'`).join(' ')} ;;`)
83
+ .join('\n');
84
+ }
85
+
86
+ function printZsh() {
87
+ console.log(`#compdef flo
88
+ # flo zsh completion. Save to a directory in $fpath, e.g. ~/.zsh/completions/_flo
89
+ _flo() {
90
+ local -a top
91
+ top=(${TOP_COMMANDS.map((c) => `'${c}'`).join(' ')})
92
+ if (( CURRENT == 2 )); then
93
+ _values 'flo command' \${top[@]}
94
+ return
95
+ fi
96
+ if (( CURRENT == 3 )); then
97
+ case "\${words[2]}" in
98
+ ${subcaseZsh()}
99
+ esac
100
+ fi
101
+ }
102
+ _flo "$@"
103
+ `);
104
+ }
105
+
106
+ function printFish() {
107
+ // fish: emit one completion line per subcommand
108
+ const lines = [];
109
+ lines.push(`# flo fish completion. Save to ~/.config/fish/completions/flo.fish`);
110
+ lines.push(`complete -c flo -f`);
111
+ for (const cmd of TOP_COMMANDS) {
112
+ lines.push(`complete -c flo -n '__fish_use_subcommand' -a '${cmd}'`);
113
+ }
114
+ for (const [cmd, subs] of Object.entries(SUBCOMMANDS)) {
115
+ for (const sub of subs) {
116
+ lines.push(`complete -c flo -n "__fish_seen_subcommand_from ${cmd}" -a '${sub}'`);
117
+ }
118
+ }
119
+ console.log(lines.join('\n'));
120
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,101 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { stat, readFile } from 'node:fs/promises';
3
+ import { execFileSync } from 'node:child_process';
4
+ import { homedir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ const CHECKS = [
8
+ { name: 'node>=20', run: checkNode },
9
+ { name: 'git', run: checkGit },
10
+ { name: '.claude/ project', run: checkProjectClaude },
11
+ { name: '.claude/checkpoints', run: checkCheckpoints },
12
+ { name: '~/.claude/mcp.json', run: checkMcp },
13
+ { name: 'flo bin resolvable', run: checkFloBin },
14
+ ];
15
+
16
+ export async function doctor(args) {
17
+ if (args.includes('-h') || args.includes('--help')) {
18
+ console.log(`flo doctor — quick health check
19
+
20
+ Usage:
21
+ flo doctor [--json]
22
+ `);
23
+ return;
24
+ }
25
+ const json = args.includes('--json');
26
+ const results = [];
27
+ for (const c of CHECKS) {
28
+ try {
29
+ const r = await c.run();
30
+ results.push({ name: c.name, ok: r.ok, message: r.message });
31
+ } catch (err) {
32
+ results.push({ name: c.name, ok: false, message: err.message });
33
+ }
34
+ }
35
+ if (json) {
36
+ console.log(JSON.stringify(results, null, 2));
37
+ return;
38
+ }
39
+ const longest = Math.max(...results.map(r => r.name.length));
40
+ for (const r of results) {
41
+ const pad = r.name.padEnd(longest);
42
+ const status = r.ok ? 'PASS' : 'FAIL';
43
+ console.log(` ${pad} ${status} ${r.message}`);
44
+ }
45
+ const failed = results.filter(r => !r.ok).length;
46
+ console.log(`\nflo doctor: ${results.length - failed}/${results.length} checks passed.`);
47
+ if (failed) process.exit(1);
48
+ }
49
+
50
+ async function checkNode() {
51
+ const major = Number(process.versions.node.split('.')[0]);
52
+ if (major >= 20) return { ok: true, message: process.versions.node };
53
+ return { ok: false, message: `Node ${process.versions.node} (need >=20)` };
54
+ }
55
+
56
+ async function checkGit() {
57
+ try {
58
+ const v = execFileSync('git', ['--version'], { stdio: ['ignore', 'pipe', 'ignore'] }).toString().trim();
59
+ return { ok: true, message: v };
60
+ } catch {
61
+ return { ok: false, message: 'git not on PATH' };
62
+ }
63
+ }
64
+
65
+ async function checkProjectClaude() {
66
+ const p = join(process.cwd(), '.claude');
67
+ if (existsSync(p)) {
68
+ const st = await stat(p);
69
+ if (st.isDirectory()) return { ok: true, message: p };
70
+ }
71
+ return { ok: false, message: `${p} not found (not in a Claude Code project?)` };
72
+ }
73
+
74
+ async function checkCheckpoints() {
75
+ const p = join(process.cwd(), '.claude', 'checkpoints');
76
+ if (!existsSync(p)) return { ok: false, message: 'no checkpoints dir (sessions not yet captured)' };
77
+ return { ok: true, message: p };
78
+ }
79
+
80
+ async function checkMcp() {
81
+ const p = join(homedir(), '.claude', 'mcp.json');
82
+ if (!existsSync(p)) return { ok: false, message: `${p} missing (run 'flo migrate')` };
83
+ try {
84
+ const raw = await readFile(p, 'utf8');
85
+ const parsed = JSON.parse(raw);
86
+ const servers = Object.keys(parsed.mcpServers || {});
87
+ const hasFlo = servers.includes('flo');
88
+ return {
89
+ ok: true,
90
+ message: `servers=[${servers.join(',')}]${hasFlo ? '' : " (run 'flo migrate' to register flo)"}`,
91
+ };
92
+ } catch (err) {
93
+ return { ok: false, message: `parse error: ${err.message}` };
94
+ }
95
+ }
96
+
97
+ async function checkFloBin() {
98
+ const p = new URL('../bin/flo.js', import.meta.url).pathname;
99
+ if (existsSync(p)) return { ok: true, message: p };
100
+ return { ok: false, message: `expected ${p}` };
101
+ }
@@ -0,0 +1,136 @@
1
+ // `flo edit` — open memory entries, notes, or tasks in $EDITOR.
2
+ // Writes the content to a tmpfile, invokes the editor, then applies the result.
3
+ // Memory/note edits create a new entry (the old one stays in history via the
4
+ // append-only log + tombstone pattern); task edits use updateTask.
5
+
6
+ import { mkdtemp, writeFile, readFile } from 'node:fs/promises';
7
+ import { tmpdir } from 'node:os';
8
+ import { join } from 'node:path';
9
+ import { spawn } from 'node:child_process';
10
+ import { getEntry, storeEntry, deleteEntry } from './memory-store.js';
11
+ import { getTask, updateTask } from './tasks-store.js';
12
+
13
+ export async function editCommand(args) {
14
+ const [kind = 'help', ...rest] = args;
15
+ if (kind === 'help' || kind === '--help' || kind === '-h') return printHelp();
16
+ if (kind === 'memory' || kind === 'note') return editMemory(kind, rest);
17
+ if (kind === 'task') return editTask(rest);
18
+ console.error(`flo edit: unknown kind '${kind}'. Try: memory, note, task`);
19
+ process.exit(2);
20
+ }
21
+
22
+ function printHelp() {
23
+ console.log(`flo edit — open in \$EDITOR (defaults to vim)
24
+
25
+ Usage:
26
+ flo edit memory <id> # edit memory entry by id
27
+ flo edit memory --key <key> [--namespace <ns>]
28
+ flo edit note <id> # alias of: flo edit memory <id> (in 'notes' ns)
29
+ flo edit task <id> # edit a task's subject + description
30
+
31
+ Memory/note edits create a new entry (the old one is tombstoned but still in
32
+ the history log). Task edits update the existing task event log.
33
+
34
+ \$EDITOR is honored; falls back to vim, then nano.
35
+ `);
36
+ }
37
+
38
+ function pickEditor() {
39
+ if (process.env.EDITOR) return process.env.EDITOR;
40
+ if (process.env.VISUAL) return process.env.VISUAL;
41
+ // Reasonable fallbacks
42
+ return 'vim';
43
+ }
44
+
45
+ async function openInEditor(initialContent) {
46
+ const dir = await mkdtemp(join(tmpdir(), 'flo-edit-'));
47
+ const path = join(dir, 'flo-edit.md');
48
+ await writeFile(path, initialContent, 'utf8');
49
+ const editor = pickEditor();
50
+ // Parse simple "editor +line" / "editor --wait" forms by splitting on whitespace.
51
+ // Shell expansion isn't supported (we pass an arg array to spawn, never a shell string).
52
+ const parts = editor.split(/\s+/);
53
+ await new Promise((resolve, reject) => {
54
+ const proc = spawn(parts[0], [...parts.slice(1), path], { stdio: 'inherit' });
55
+ proc.on('error', reject);
56
+ proc.on('exit', (code) => code === 0 ? resolve() : reject(new Error(`editor exited ${code}`)));
57
+ });
58
+ const updated = await readFile(path, 'utf8');
59
+ return { path, original: initialContent, updated };
60
+ }
61
+
62
+ function parseFlags(args) {
63
+ const out = { positional: [] };
64
+ for (let i = 0; i < args.length; i++) {
65
+ const a = args[i];
66
+ if (a === '--namespace') out.namespace = args[++i];
67
+ else if (a === '--key') out.key = args[++i];
68
+ else if (!a.startsWith('--')) out.positional.push(a);
69
+ }
70
+ return out;
71
+ }
72
+
73
+ async function editMemory(kind, args) {
74
+ const opts = parseFlags(args);
75
+ let namespace = opts.namespace || (kind === 'note' ? 'notes' : 'default');
76
+ const id = opts.positional[0];
77
+ let entry;
78
+ if (id) {
79
+ entry = await getEntry({ namespace, id });
80
+ } else if (opts.key) {
81
+ entry = await getEntry({ namespace, key: opts.key });
82
+ } else {
83
+ console.error(`flo edit ${kind}: need <id> or --key`);
84
+ process.exit(2);
85
+ }
86
+ if (!entry) {
87
+ console.error(`flo edit ${kind}: not found in namespace '${namespace}'`);
88
+ process.exit(1);
89
+ }
90
+ const { updated } = await openInEditor(entry.value);
91
+ if (updated.trim() === entry.value.trim()) {
92
+ console.log(`flo edit ${kind}: no changes`);
93
+ return;
94
+ }
95
+ const next = await storeEntry({
96
+ namespace: entry.namespace,
97
+ key: entry.key,
98
+ value: updated,
99
+ tags: entry.tags,
100
+ metadata: { ...entry.metadata, editedFrom: entry.id, editedAt: new Date().toISOString() },
101
+ });
102
+ await deleteEntry({ namespace: entry.namespace, id: entry.id });
103
+ console.log(`flo edit ${kind}: ${entry.id} → ${next.id} (${updated.length} chars)`);
104
+ }
105
+
106
+ async function editTask(args) {
107
+ const id = args[0];
108
+ if (!id) {
109
+ console.error(`flo edit task: missing <id>`);
110
+ process.exit(2);
111
+ }
112
+ const task = await getTask(id);
113
+ if (!task) { console.error(`flo edit task: no task ${id}`); process.exit(1); }
114
+ const body = `# subject (first line) and optional description (below)
115
+ ${task.subject}
116
+ ${task.description ? '\n' + task.description : ''}
117
+ `;
118
+ const { updated } = await openInEditor(body);
119
+ const lines = updated.split('\n');
120
+ // Skip the comment line if user kept it
121
+ const firstReal = lines.findIndex((l) => l && !l.startsWith('#'));
122
+ const newSubject = (firstReal >= 0 ? lines[firstReal] : '').trim();
123
+ const newDesc = firstReal >= 0
124
+ ? lines.slice(firstReal + 1).join('\n').trim() || null
125
+ : null;
126
+ if (!newSubject) {
127
+ console.error(`flo edit task: empty subject — refused`);
128
+ process.exit(1);
129
+ }
130
+ if (newSubject === task.subject && (newDesc || '') === (task.description || '')) {
131
+ console.log(`flo edit task: no changes`);
132
+ return;
133
+ }
134
+ const next = await updateTask({ id, subject: newSubject, description: newDesc });
135
+ console.log(`flo edit task: ${next.id} updated`);
136
+ }
package/lib/export.js ADDED
@@ -0,0 +1,182 @@
1
+ // `flo export` / `flo import` — portable ~/.flo backup.
2
+ // Uses Node's stream APIs + a tiny tar-like JSON-bundle format so we don't
3
+ // pull in any tar library. Easy to unpack from any language.
4
+ //
5
+ // Bundle format (.flo.json.gz):
6
+ // gzipped JSON: { manifest: {version, exportedAt, source}, files: [
7
+ // { path: "memory/notes.jsonl", content: "<base64>", size, mtime },
8
+ // ...
9
+ // ]}
10
+
11
+ import { createGzip, createGunzip } from 'node:zlib';
12
+ import { readFile, writeFile, mkdir, stat, readdir } from 'node:fs/promises';
13
+ import { existsSync, createReadStream, createWriteStream } from 'node:fs';
14
+ import { pipeline } from 'node:stream/promises';
15
+ import { homedir } from 'node:os';
16
+ import { join, resolve, dirname, sep, normalize } from 'node:path';
17
+ import { Readable } from 'node:stream';
18
+
19
+ const FLO_HOME = process.env.FLO_HOME || join(homedir(), '.flo');
20
+ const BUNDLE_VERSION = 1;
21
+
22
+ export async function exportCommand(args) {
23
+ const opts = parseFlags(args);
24
+ if (opts.help) return printExportHelp();
25
+ const outPath = opts.out || `flo-export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.flo.json.gz`;
26
+
27
+ if (!existsSync(FLO_HOME)) {
28
+ console.error(`flo export: no ~/.flo directory at ${FLO_HOME}`);
29
+ process.exit(1);
30
+ }
31
+
32
+ const files = await collectFiles(FLO_HOME, '');
33
+ const bundle = {
34
+ manifest: {
35
+ version: BUNDLE_VERSION,
36
+ exportedAt: new Date().toISOString(),
37
+ source: FLO_HOME,
38
+ fileCount: files.length,
39
+ totalBytes: files.reduce((s, f) => s + f.size, 0),
40
+ },
41
+ files,
42
+ };
43
+
44
+ const json = JSON.stringify(bundle);
45
+ await pipeline(
46
+ Readable.from(json),
47
+ createGzip({ level: 9 }),
48
+ createWriteStream(resolve(outPath)),
49
+ );
50
+ const outStat = await stat(resolve(outPath));
51
+ console.log(`flo export: wrote ${outPath}`);
52
+ console.log(` ${files.length} files, ${bundle.manifest.totalBytes} bytes uncompressed, ${outStat.size} bytes compressed`);
53
+ }
54
+
55
+ export async function importCommand(args) {
56
+ const opts = parseFlags(args);
57
+ if (opts.help) return printImportHelp();
58
+ const inPath = opts.positional[0];
59
+ if (!inPath) {
60
+ console.error(`flo import: missing <bundle>`);
61
+ console.error(`Usage: flo import <bundle.flo.json.gz> [--target <dir>] [--force]`);
62
+ process.exit(2);
63
+ }
64
+ if (!existsSync(inPath)) {
65
+ console.error(`flo import: file not found: ${inPath}`);
66
+ process.exit(1);
67
+ }
68
+ const target = resolve((opts.target || FLO_HOME).replace(/^~/, homedir()));
69
+
70
+ // Stream-decode the gzipped JSON
71
+ const chunks = [];
72
+ await pipeline(
73
+ createReadStream(inPath),
74
+ createGunzip(),
75
+ async function* sink(source) {
76
+ for await (const chunk of source) chunks.push(Buffer.from(chunk));
77
+ },
78
+ );
79
+ const text = Buffer.concat(chunks).toString('utf8');
80
+ let bundle;
81
+ try { bundle = JSON.parse(text); } catch (err) {
82
+ console.error(`flo import: bundle is not valid JSON — ${err.message}`);
83
+ process.exit(1);
84
+ }
85
+
86
+ if (!bundle?.manifest?.version) {
87
+ console.error(`flo import: invalid bundle (no manifest)`);
88
+ process.exit(1);
89
+ }
90
+ if (bundle.manifest.version > BUNDLE_VERSION) {
91
+ console.error(`flo import: bundle version ${bundle.manifest.version} exceeds supported ${BUNDLE_VERSION}`);
92
+ process.exit(1);
93
+ }
94
+
95
+ console.log(`flo import: ${bundle.files.length} files from bundle ${bundle.manifest.exportedAt}`);
96
+ if (opts.dryRun) {
97
+ for (const f of bundle.files.slice(0, 10)) console.log(` would write: ${f.path}`);
98
+ if (bundle.files.length > 10) console.log(` …and ${bundle.files.length - 10} more`);
99
+ return;
100
+ }
101
+
102
+ if (existsSync(target) && !opts.force) {
103
+ const existing = (await readdir(target)).filter((n) => !n.startsWith('.')).length;
104
+ if (existing > 0) {
105
+ console.error(`flo import: target ${target} is non-empty. Use --force to overwrite.`);
106
+ process.exit(1);
107
+ }
108
+ }
109
+ await mkdir(target, { recursive: true });
110
+
111
+ for (const f of bundle.files) {
112
+ // Path safety: must be a relative path inside target, no traversal
113
+ const rel = normalize(f.path);
114
+ if (rel.startsWith('..') || rel.startsWith(sep) || rel.includes('\0')) {
115
+ console.error(`flo import: refusing unsafe path '${f.path}' — skipped`);
116
+ continue;
117
+ }
118
+ const fullPath = join(target, rel);
119
+ await mkdir(dirname(fullPath), { recursive: true });
120
+ await writeFile(fullPath, Buffer.from(f.content, 'base64'));
121
+ }
122
+ console.log(`flo import: extracted ${bundle.files.length} files to ${target}`);
123
+ }
124
+
125
+ async function collectFiles(root, relPrefix) {
126
+ const out = [];
127
+ const entries = await readdir(root, { withFileTypes: true });
128
+ for (const e of entries) {
129
+ const full = join(root, e.name);
130
+ const rel = relPrefix ? `${relPrefix}/${e.name}` : e.name;
131
+ if (e.isDirectory()) {
132
+ out.push(...await collectFiles(full, rel));
133
+ } else if (e.isFile()) {
134
+ const st = await stat(full);
135
+ const buf = await readFile(full);
136
+ out.push({
137
+ path: rel,
138
+ size: st.size,
139
+ mtime: st.mtimeMs,
140
+ content: buf.toString('base64'),
141
+ });
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+
147
+ function printExportHelp() {
148
+ console.log(`flo export — bundle ~/.flo/ into a portable .flo.json.gz file
149
+
150
+ Usage:
151
+ flo export [--out <path>]
152
+
153
+ Default output filename: flo-export-<timestamp>.flo.json.gz
154
+ Format: gzipped JSON {manifest, files[]} with base64 content. No native deps,
155
+ portable across platforms.
156
+ `);
157
+ }
158
+
159
+ function printImportHelp() {
160
+ console.log(`flo import — restore a flo export bundle
161
+
162
+ Usage:
163
+ flo import <bundle.flo.json.gz> [--target <dir>] [--force] [--dry-run]
164
+
165
+ By default extracts to ~/.flo. Refuses to overwrite a non-empty target unless
166
+ --force. Path traversal in bundle entries is blocked.
167
+ `);
168
+ }
169
+
170
+ function parseFlags(args) {
171
+ const out = { positional: [] };
172
+ for (let i = 0; i < args.length; i++) {
173
+ const a = args[i];
174
+ if (a === '--help' || a === '-h') out.help = true;
175
+ else if (a === '--out') out.out = args[++i];
176
+ else if (a === '--target') out.target = args[++i];
177
+ else if (a === '--force') out.force = true;
178
+ else if (a === '--dry-run') out.dryRun = true;
179
+ else if (!a.startsWith('--')) out.positional.push(a);
180
+ }
181
+ return out;
182
+ }