becki 0.5.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/LICENSE +32 -0
- package/README.md +57 -0
- package/bin/becki.js +31 -0
- package/dist/cli.js +77 -0
- package/dist/client.js +286 -0
- package/dist/commands.js +321 -0
- package/dist/config.js +138 -0
- package/dist/entry.js +14 -0
- package/dist/handoff.js +54 -0
- package/dist/loops.js +127 -0
- package/dist/menu.js +108 -0
- package/dist/prompt.js +121 -0
- package/dist/screens/_shared.js +67 -0
- package/dist/screens/account.js +35 -0
- package/dist/screens/backfills.js +92 -0
- package/dist/screens/diagnostics.js +87 -0
- package/dist/screens/git.js +211 -0
- package/dist/screens/install-token.js +65 -0
- package/dist/screens/logs.js +74 -0
- package/dist/screens/status.js +77 -0
- package/dist/screens/subscription.js +60 -0
- package/dist/screens/vault.js +155 -0
- package/dist/screens/watch-folders.js +406 -0
- package/dist/setup.js +71 -0
- package/dist/splash.js +27 -0
- package/dist/theme.js +38 -0
- package/dist/vault.js +357 -0
- package/package.json +59 -0
package/dist/commands.js
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.ts — becki subcommands: ask / loops / save / resolve.
|
|
3
|
+
*
|
|
4
|
+
* These are one-shot vault operations: `becki ask "..."`, `becki loops`,
|
|
5
|
+
* `becki save "..."`, `becki resolve <row_id>`. They print plain text and
|
|
6
|
+
* exit — no Ink, no splash, no CLI handoff. The interactive splash stays the
|
|
7
|
+
* job of the bare `becki` command.
|
|
8
|
+
*
|
|
9
|
+
* Chunk 1a: auth comes from the local Becki.app install (resolveAuth). A Mac
|
|
10
|
+
* that has never run Becki.app gets a clear "not registered" message rather
|
|
11
|
+
* than a cryptic failure.
|
|
12
|
+
*/
|
|
13
|
+
import React from 'react';
|
|
14
|
+
import { render } from 'ink';
|
|
15
|
+
import { resolveAuth, query, listLoops, save, resolve, getBriefing, listProjects, SOURCE_TYPES, } from './client.js';
|
|
16
|
+
import { LoopsApp } from './loops.js';
|
|
17
|
+
/** Subcommands that route here instead of the splash/launcher path. */
|
|
18
|
+
export const COMMANDS = [
|
|
19
|
+
'ask',
|
|
20
|
+
'loops',
|
|
21
|
+
'save',
|
|
22
|
+
'resolve',
|
|
23
|
+
'brief',
|
|
24
|
+
'projects',
|
|
25
|
+
];
|
|
26
|
+
export function isCommand(s) {
|
|
27
|
+
return !!s && COMMANDS.includes(s);
|
|
28
|
+
}
|
|
29
|
+
// --- tiny output helpers ----------------------------------------------
|
|
30
|
+
const useColor = process.stdout.isTTY === true;
|
|
31
|
+
const gold = (s) => (useColor ? `\x1b[38;5;179m${s}\x1b[0m` : s);
|
|
32
|
+
const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s);
|
|
33
|
+
const bold = (s) => (useColor ? `\x1b[1m${s}\x1b[0m` : s);
|
|
34
|
+
function errln(s) {
|
|
35
|
+
process.stderr.write(s + '\n');
|
|
36
|
+
}
|
|
37
|
+
function notRegistered() {
|
|
38
|
+
errln('becki: not registered on this Mac.');
|
|
39
|
+
errln(" becki's subcommands use Becki.app's vault credentials");
|
|
40
|
+
errln(' (~/Library/Application Support/Becki/mcp-user-id).');
|
|
41
|
+
errln(' Open Becki.app and sign in once, then retry.');
|
|
42
|
+
return 1;
|
|
43
|
+
}
|
|
44
|
+
function firstLine(s) {
|
|
45
|
+
const line = (s.split('\n').find((l) => l.trim() !== '') ?? s).trim();
|
|
46
|
+
return line.length > 88 ? line.slice(0, 87) + '…' : line;
|
|
47
|
+
}
|
|
48
|
+
function clip(s, max) {
|
|
49
|
+
const t = s.trim();
|
|
50
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
|
|
51
|
+
}
|
|
52
|
+
function indent(s) {
|
|
53
|
+
return s
|
|
54
|
+
.split('\n')
|
|
55
|
+
.map((l) => ' ' + l)
|
|
56
|
+
.join('\n');
|
|
57
|
+
}
|
|
58
|
+
function clampInt(v, def, lo, hi) {
|
|
59
|
+
const n = typeof v === 'string' ? parseInt(v, 10) : NaN;
|
|
60
|
+
if (!Number.isFinite(n))
|
|
61
|
+
return def;
|
|
62
|
+
return Math.min(hi, Math.max(lo, n));
|
|
63
|
+
}
|
|
64
|
+
function ago(iso) {
|
|
65
|
+
const ms = Date.now() - new Date(iso).getTime();
|
|
66
|
+
if (!Number.isFinite(ms))
|
|
67
|
+
return '';
|
|
68
|
+
const d = Math.floor(ms / 86_400_000);
|
|
69
|
+
if (d <= 0)
|
|
70
|
+
return 'today';
|
|
71
|
+
if (d < 30)
|
|
72
|
+
return `${d}d ago`;
|
|
73
|
+
const mo = Math.floor(d / 30);
|
|
74
|
+
return mo < 12 ? `${mo}mo ago` : `${Math.floor(mo / 12)}y ago`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Minimal flag parser. `valueFlags` take the next token as their value;
|
|
78
|
+
* everything else `--flag` is a boolean; bare tokens are positional.
|
|
79
|
+
*/
|
|
80
|
+
function parseArgs(args, valueFlags) {
|
|
81
|
+
const positional = [];
|
|
82
|
+
const flags = {};
|
|
83
|
+
for (let i = 0; i < args.length; i++) {
|
|
84
|
+
const a = args[i];
|
|
85
|
+
if (a.startsWith('--')) {
|
|
86
|
+
const key = a.slice(2);
|
|
87
|
+
if (valueFlags.includes(key))
|
|
88
|
+
flags[key] = args[++i] ?? '';
|
|
89
|
+
else
|
|
90
|
+
flags[key] = true;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
positional.push(a);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { positional, flags };
|
|
97
|
+
}
|
|
98
|
+
// --- commands ----------------------------------------------------------
|
|
99
|
+
/** becki ask "<query>" [--limit N] [--json] */
|
|
100
|
+
async function cmdAsk(args, auth) {
|
|
101
|
+
const { positional, flags } = parseArgs(args, ['limit']);
|
|
102
|
+
const q = positional.join(' ').trim();
|
|
103
|
+
if (!q) {
|
|
104
|
+
errln('usage: becki ask "<query>" [--limit N] [--json]');
|
|
105
|
+
return 2;
|
|
106
|
+
}
|
|
107
|
+
if (!auth.userId)
|
|
108
|
+
return notRegistered();
|
|
109
|
+
const limit = clampInt(flags.limit, 8, 1, 30);
|
|
110
|
+
const chunks = await query(auth.userId, q, limit);
|
|
111
|
+
if (flags.json) {
|
|
112
|
+
process.stdout.write(JSON.stringify(chunks, null, 2) + '\n');
|
|
113
|
+
return 0;
|
|
114
|
+
}
|
|
115
|
+
if (chunks.length === 0) {
|
|
116
|
+
console.log(dim('no matches.'));
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
for (const c of chunks) {
|
|
120
|
+
const pct = Math.round(c.similarity * 100);
|
|
121
|
+
const project = c.metadata?.project_name ? ` · ${c.metadata.project_name}` : '';
|
|
122
|
+
console.log(gold(`◆ ${c.source_type}`) + dim(` ${pct}% match${project}`));
|
|
123
|
+
if (c.id)
|
|
124
|
+
console.log(dim(` ${c.id}`));
|
|
125
|
+
console.log(indent(clip(c.content, 600)));
|
|
126
|
+
console.log('');
|
|
127
|
+
}
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
130
|
+
/** Render the interactive open-threads manager; resolves with exit code 0. */
|
|
131
|
+
function runLoopsInteractive(userId) {
|
|
132
|
+
return new Promise((res) => {
|
|
133
|
+
const app = render(React.createElement(LoopsApp, {
|
|
134
|
+
userId,
|
|
135
|
+
onExit: () => app.unmount(),
|
|
136
|
+
}));
|
|
137
|
+
app.waitUntilExit().then(() => res(0));
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
/** becki loops [--limit N] [--json] */
|
|
141
|
+
async function cmdLoops(args, auth) {
|
|
142
|
+
const { flags } = parseArgs(args, ['limit']);
|
|
143
|
+
if (!auth.userId)
|
|
144
|
+
return notRegistered();
|
|
145
|
+
// Interactive manager when attached to a TTY; plain text for --json or
|
|
146
|
+
// when piped (so `becki loops | …` stays scriptable).
|
|
147
|
+
if (!flags.json && process.stdout.isTTY === true) {
|
|
148
|
+
return runLoopsInteractive(auth.userId);
|
|
149
|
+
}
|
|
150
|
+
const limit = clampInt(flags.limit, 20, 1, 100);
|
|
151
|
+
const rows = await listLoops(auth.userId, limit);
|
|
152
|
+
if (flags.json) {
|
|
153
|
+
process.stdout.write(JSON.stringify(rows, null, 2) + '\n');
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
if (rows.length === 0) {
|
|
157
|
+
console.log(dim('no open threads — clear slate.'));
|
|
158
|
+
return 0;
|
|
159
|
+
}
|
|
160
|
+
console.log(bold(`${rows.length} open thread${rows.length === 1 ? '' : 's'}`));
|
|
161
|
+
console.log('');
|
|
162
|
+
rows.forEach((r, i) => {
|
|
163
|
+
console.log(`${gold(String(i + 1).padStart(2))} ${firstLine(r.content)}`);
|
|
164
|
+
console.log(dim(` ${r.id} · ${ago(r.created_at)}`));
|
|
165
|
+
});
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
/** becki save "<text>" [--type T] [--title T] [--project P] */
|
|
169
|
+
async function cmdSave(args, auth) {
|
|
170
|
+
const { positional, flags } = parseArgs(args, ['type', 'title', 'project']);
|
|
171
|
+
const content = positional.join(' ').trim();
|
|
172
|
+
if (!content) {
|
|
173
|
+
errln('usage: becki save "<text>" [--type decision|commitment|note|open_loop|dead_end] [--title T] [--project P]');
|
|
174
|
+
return 2;
|
|
175
|
+
}
|
|
176
|
+
if (!auth.ingestToken) {
|
|
177
|
+
errln('becki: no ingest token — open Becki.app and sign in once to enable saving.');
|
|
178
|
+
return 1;
|
|
179
|
+
}
|
|
180
|
+
const type = String(flags.type ?? 'note');
|
|
181
|
+
if (!SOURCE_TYPES.includes(type)) {
|
|
182
|
+
errln(`becki: --type must be one of: ${SOURCE_TYPES.join(', ')}`);
|
|
183
|
+
return 2;
|
|
184
|
+
}
|
|
185
|
+
const result = await save(auth.ingestToken, {
|
|
186
|
+
type: type,
|
|
187
|
+
content,
|
|
188
|
+
title: flags.title ? String(flags.title) : undefined,
|
|
189
|
+
project: flags.project ? String(flags.project) : undefined,
|
|
190
|
+
});
|
|
191
|
+
console.log(gold('saved') + ` ${type}` + (result.vault_id ? dim(` · ${result.vault_id}`) : ''));
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
/** becki resolve <row_id> [--note "<reason>"] */
|
|
195
|
+
async function cmdResolve(args, auth) {
|
|
196
|
+
const { positional, flags } = parseArgs(args, ['note']);
|
|
197
|
+
const rowId = positional[0];
|
|
198
|
+
if (!rowId) {
|
|
199
|
+
errln('usage: becki resolve <row_id> [--note "<reason>"]');
|
|
200
|
+
return 2;
|
|
201
|
+
}
|
|
202
|
+
if (!auth.userId)
|
|
203
|
+
return notRegistered();
|
|
204
|
+
await resolve(auth.userId, rowId, flags.note ? String(flags.note) : undefined);
|
|
205
|
+
console.log(gold('resolved') + ` ${rowId}`);
|
|
206
|
+
return 0;
|
|
207
|
+
}
|
|
208
|
+
/** Render one briefing item — defensive across the varying section kinds. */
|
|
209
|
+
function renderBriefItem(item) {
|
|
210
|
+
if (typeof item === 'string') {
|
|
211
|
+
console.log(' ' + firstLine(item));
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (item == null || typeof item !== 'object')
|
|
215
|
+
return;
|
|
216
|
+
const o = item;
|
|
217
|
+
// meeting_context item — a calendar event with related vault chunks.
|
|
218
|
+
if (typeof o.event_title === 'string') {
|
|
219
|
+
console.log(' ' + bold(o.event_title));
|
|
220
|
+
const chunks = Array.isArray(o.chunks) ? o.chunks : [];
|
|
221
|
+
for (const c of chunks) {
|
|
222
|
+
const preview = c?.preview;
|
|
223
|
+
if (typeof preview === 'string') {
|
|
224
|
+
console.log(dim(' ' + firstLine(preview)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// chunk-like item.
|
|
230
|
+
if (typeof o.preview === 'string') {
|
|
231
|
+
console.log(dim(' ' + firstLine(o.preview)));
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
// generic fallback — first stringy field.
|
|
235
|
+
const text = o.title ?? o.content ?? o.text ?? o.summary;
|
|
236
|
+
if (typeof text === 'string')
|
|
237
|
+
console.log(' ' + firstLine(text));
|
|
238
|
+
}
|
|
239
|
+
/** becki brief [--json] */
|
|
240
|
+
async function cmdBrief(args, auth) {
|
|
241
|
+
const { flags } = parseArgs(args, []);
|
|
242
|
+
if (!auth.userId)
|
|
243
|
+
return notRegistered();
|
|
244
|
+
const briefing = await getBriefing(auth.userId);
|
|
245
|
+
if (flags.json) {
|
|
246
|
+
process.stdout.write(JSON.stringify(briefing, null, 2) + '\n');
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
if (!briefing) {
|
|
250
|
+
console.log(dim('no briefing computed yet.'));
|
|
251
|
+
return 0;
|
|
252
|
+
}
|
|
253
|
+
console.log(bold('briefing') + dim(` ${briefing.brief_date}`));
|
|
254
|
+
for (const section of briefing.sections ?? []) {
|
|
255
|
+
console.log('');
|
|
256
|
+
console.log(gold(section.title ?? section.kind));
|
|
257
|
+
const items = Array.isArray(section.items) ? section.items : [];
|
|
258
|
+
if (items.length === 0) {
|
|
259
|
+
console.log(dim(' (nothing)'));
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
for (const item of items)
|
|
263
|
+
renderBriefItem(item);
|
|
264
|
+
}
|
|
265
|
+
return 0;
|
|
266
|
+
}
|
|
267
|
+
/** becki projects [--json] */
|
|
268
|
+
async function cmdProjects(args, auth) {
|
|
269
|
+
const { flags } = parseArgs(args, []);
|
|
270
|
+
if (!auth.userId)
|
|
271
|
+
return notRegistered();
|
|
272
|
+
const projects = await listProjects(auth.userId);
|
|
273
|
+
if (flags.json) {
|
|
274
|
+
process.stdout.write(JSON.stringify(projects, null, 2) + '\n');
|
|
275
|
+
return 0;
|
|
276
|
+
}
|
|
277
|
+
if (projects.length === 0) {
|
|
278
|
+
console.log(dim('no projects yet.'));
|
|
279
|
+
return 0;
|
|
280
|
+
}
|
|
281
|
+
const sorted = [...projects].sort((a, b) => (b.atomic_count ?? 0) - (a.atomic_count ?? 0));
|
|
282
|
+
console.log(bold(`${sorted.length} project${sorted.length === 1 ? '' : 's'}`));
|
|
283
|
+
console.log('');
|
|
284
|
+
for (const p of sorted) {
|
|
285
|
+
const count = (p.atomic_count != null ? String(p.atomic_count) : '·').padStart(4);
|
|
286
|
+
const recent = p.most_recent_at ? dim(` ${ago(p.most_recent_at)}`) : '';
|
|
287
|
+
const dormant = p.is_dormant ? dim(' · dormant') : '';
|
|
288
|
+
console.log(` ${gold(count)} ${p.name}${recent}${dormant}`);
|
|
289
|
+
}
|
|
290
|
+
return 0;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Run a subcommand. Resolves auth once, dispatches, and turns any thrown
|
|
294
|
+
* error into a clean one-line stderr message + exit code 1.
|
|
295
|
+
*/
|
|
296
|
+
export async function runCommand(name, args) {
|
|
297
|
+
const auth = resolveAuth();
|
|
298
|
+
try {
|
|
299
|
+
switch (name) {
|
|
300
|
+
case 'ask':
|
|
301
|
+
return await cmdAsk(args, auth);
|
|
302
|
+
case 'loops':
|
|
303
|
+
return await cmdLoops(args, auth);
|
|
304
|
+
case 'save':
|
|
305
|
+
return await cmdSave(args, auth);
|
|
306
|
+
case 'resolve':
|
|
307
|
+
return await cmdResolve(args, auth);
|
|
308
|
+
case 'brief':
|
|
309
|
+
return await cmdBrief(args, auth);
|
|
310
|
+
case 'projects':
|
|
311
|
+
return await cmdProjects(args, auth);
|
|
312
|
+
default:
|
|
313
|
+
errln(`becki: unknown command "${name}"`);
|
|
314
|
+
return 2;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (err) {
|
|
318
|
+
errln(`becki: ${err instanceof Error ? err.message : String(err)}`);
|
|
319
|
+
return 1;
|
|
320
|
+
}
|
|
321
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config.ts — load / create / validate ~/.becki/config.json
|
|
3
|
+
*
|
|
4
|
+
* The config file is auto-created with defaults on first run. Validation is
|
|
5
|
+
* forgiving by design: a malformed or partial config never blocks the CLI
|
|
6
|
+
* handoff — bad keys fall back to defaults and a warning is collected.
|
|
7
|
+
*/
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
11
|
+
export const DEFAULT_CONFIG = {
|
|
12
|
+
cli: 'claude',
|
|
13
|
+
vault_path: '~/Documents/Becki',
|
|
14
|
+
show_open_threads: true,
|
|
15
|
+
max_open_threads: 5,
|
|
16
|
+
skip_prompt: false,
|
|
17
|
+
pass_focus_to_cli: true,
|
|
18
|
+
};
|
|
19
|
+
/** AI CLIs becki offers on the first-run picker, in display order. */
|
|
20
|
+
export const KNOWN_CLIS = ['claude', 'codex', 'gemini', 'opencode'];
|
|
21
|
+
export const CONFIG_DIR = join(homedir(), '.becki');
|
|
22
|
+
export const CONFIG_PATH = join(CONFIG_DIR, 'config.json');
|
|
23
|
+
/** Expand a leading ~ to the user's home directory. */
|
|
24
|
+
export function expandHome(p) {
|
|
25
|
+
if (p === '~')
|
|
26
|
+
return homedir();
|
|
27
|
+
if (p.startsWith('~/'))
|
|
28
|
+
return join(homedir(), p.slice(2));
|
|
29
|
+
return p;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Coerce an unknown parsed JSON object into a valid BeckiConfig.
|
|
33
|
+
* Any key that is missing or the wrong type falls back to its default and
|
|
34
|
+
* appends a warning. This is what keeps a hand-edited config from crashing
|
|
35
|
+
* the launcher.
|
|
36
|
+
*/
|
|
37
|
+
function validate(raw, warnings) {
|
|
38
|
+
const out = { ...DEFAULT_CONFIG };
|
|
39
|
+
if (raw === null || typeof raw !== 'object') {
|
|
40
|
+
warnings.push('config.json is not a JSON object — using defaults');
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
const obj = raw;
|
|
44
|
+
const str = (key) => {
|
|
45
|
+
const v = obj[key];
|
|
46
|
+
if (v === undefined)
|
|
47
|
+
return;
|
|
48
|
+
if (typeof v === 'string' && v.trim() !== '') {
|
|
49
|
+
out[key] = v;
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
warnings.push(`config: "${key}" must be a non-empty string — using default`);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
const bool = (key) => {
|
|
56
|
+
const v = obj[key];
|
|
57
|
+
if (v === undefined)
|
|
58
|
+
return;
|
|
59
|
+
if (typeof v === 'boolean') {
|
|
60
|
+
out[key] = v;
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
warnings.push(`config: "${key}" must be true/false — using default`);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
str('cli');
|
|
67
|
+
str('vault_path');
|
|
68
|
+
bool('show_open_threads');
|
|
69
|
+
bool('skip_prompt');
|
|
70
|
+
bool('pass_focus_to_cli');
|
|
71
|
+
const max = obj['max_open_threads'];
|
|
72
|
+
if (max !== undefined) {
|
|
73
|
+
if (typeof max === 'number' && Number.isFinite(max) && max > 0) {
|
|
74
|
+
out.max_open_threads = Math.floor(max);
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
warnings.push('config: "max_open_threads" must be a positive number — using default');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
/** True when `cmd` resolves to an executable on the user's PATH. */
|
|
83
|
+
export function isOnPath(cmd) {
|
|
84
|
+
const dirs = (process.env.PATH ?? '').split(':').filter(Boolean);
|
|
85
|
+
for (const dir of dirs) {
|
|
86
|
+
try {
|
|
87
|
+
accessSync(join(dir, cmd), constants.X_OK);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
// not in this dir — keep looking
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
/** Which of the known AI CLIs are installed on PATH right now. */
|
|
97
|
+
export function detectClis() {
|
|
98
|
+
return KNOWN_CLIS.filter(isOnPath);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Write the config to disk. Never throws — returns a warning string on
|
|
102
|
+
* failure, or null on success. Used by the first-run flow once the CLI
|
|
103
|
+
* picker has resolved.
|
|
104
|
+
*/
|
|
105
|
+
export function persistConfig(config) {
|
|
106
|
+
try {
|
|
107
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
108
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + '\n', 'utf8');
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
return `could not save ${CONFIG_PATH} (${err.message})`;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Load the config. Never throws — filesystem failures degrade to in-memory
|
|
117
|
+
* defaults plus a warning, so the launcher always proceeds.
|
|
118
|
+
*
|
|
119
|
+
* On first run the file does NOT exist yet: this returns the defaults flagged
|
|
120
|
+
* `created: true` WITHOUT writing anything. The caller runs the first-run CLI
|
|
121
|
+
* picker and then persists via persistConfig() — so a Ctrl-C during setup
|
|
122
|
+
* leaves no config and the picker shows again next run.
|
|
123
|
+
*/
|
|
124
|
+
export function loadConfig() {
|
|
125
|
+
const warnings = [];
|
|
126
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
127
|
+
return { config: { ...DEFAULT_CONFIG }, created: true, warnings };
|
|
128
|
+
}
|
|
129
|
+
let parsed;
|
|
130
|
+
try {
|
|
131
|
+
parsed = JSON.parse(readFileSync(CONFIG_PATH, 'utf8'));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
warnings.push(`config.json is unreadable or malformed (${err.message}) — using defaults`);
|
|
135
|
+
return { config: { ...DEFAULT_CONFIG }, created: false, warnings };
|
|
136
|
+
}
|
|
137
|
+
return { config: validate(parsed, warnings), created: false, warnings };
|
|
138
|
+
}
|
package/dist/entry.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* entry.ts — the single-file bundle entry point.
|
|
3
|
+
*
|
|
4
|
+
* esbuild bundles this (with all of src/ + node_modules) into one
|
|
5
|
+
* self-contained bundle/becki.js, which Becki.app embeds in its Resources and
|
|
6
|
+
* runs with its bundled node binary. The npm-link / dev path is unchanged —
|
|
7
|
+
* it still goes through bin/becki.js → dist/cli.js.
|
|
8
|
+
*/
|
|
9
|
+
import { main } from './cli.js';
|
|
10
|
+
main().catch((err) => {
|
|
11
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
12
|
+
process.stderr.write(`becki: unexpected error — ${msg}\n`);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
});
|
package/dist/handoff.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* handoff.ts — spawn the configured AI CLI, inherit the TTY, propagate exit.
|
|
3
|
+
*
|
|
4
|
+
* This is the launcher's whole reason to exist. It injects nothing into the
|
|
5
|
+
* CLI's context pipeline — the focus is passed only as the CLI's first
|
|
6
|
+
* positional prompt arg (Claude Code reads that as its opening prompt).
|
|
7
|
+
*/
|
|
8
|
+
import { spawn } from 'node:child_process';
|
|
9
|
+
/**
|
|
10
|
+
* Build the argv for the CLI.
|
|
11
|
+
* Focus goes first (only when typed AND pass_focus_to_cli is on), then any
|
|
12
|
+
* args the user appended after `becki` (so `becki --resume` flows through).
|
|
13
|
+
*/
|
|
14
|
+
export function buildArgs(config, focus, passthroughArgs) {
|
|
15
|
+
const focusArgs = focus && focus.trim() !== '' && config.pass_focus_to_cli ? [focus.trim()] : [];
|
|
16
|
+
return [...focusArgs, ...passthroughArgs];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Spawn the CLI and resolve once it exits.
|
|
20
|
+
*
|
|
21
|
+
* Resolves (never rejects) so the caller controls the process exit. A missing
|
|
22
|
+
* CLI binary is the one "fail loudly" case in the spec — it resolves with a
|
|
23
|
+
* spawnError and a non-zero exit code rather than launching nothing silently.
|
|
24
|
+
*/
|
|
25
|
+
export function handoff(config, focus, passthroughArgs) {
|
|
26
|
+
const args = buildArgs(config, focus, passthroughArgs);
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
let child;
|
|
29
|
+
try {
|
|
30
|
+
child = spawn(config.cli, args, { stdio: 'inherit' });
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
resolve({ args, exitCode: 127, spawnError: err.message });
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
child.on('error', (err) => {
|
|
37
|
+
const e = err;
|
|
38
|
+
const msg = e.code === 'ENOENT'
|
|
39
|
+
? `command not found: "${config.cli}" — set "cli" in ~/.becki/config.json`
|
|
40
|
+
: e.message;
|
|
41
|
+
resolve({ args, exitCode: 127, spawnError: msg });
|
|
42
|
+
});
|
|
43
|
+
child.on('exit', (code, signal) => {
|
|
44
|
+
// A signal-terminated child has code === null; map to the conventional
|
|
45
|
+
// 128 + signal-number exit status.
|
|
46
|
+
if (code === null && signal) {
|
|
47
|
+
const sigNum = { SIGINT: 2, SIGTERM: 15, SIGKILL: 9 };
|
|
48
|
+
resolve({ args, exitCode: 128 + (sigNum[signal] ?? 0) });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
resolve({ args, exitCode: code ?? 0 });
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
}
|
package/dist/loops.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* loops.tsx — interactive open-threads manager.
|
|
4
|
+
*
|
|
5
|
+
* Opened by `becki loops` in a TTY, or by Ctrl+L from the splash. Shows the
|
|
6
|
+
* cloud open_loop rows — arrow-navigable, with in-place view + resolve.
|
|
7
|
+
*
|
|
8
|
+
* Why this, and not the splash itself: the splash's threads come from the
|
|
9
|
+
* local _meta/open-loops.md file and carry no row IDs, so they can't be
|
|
10
|
+
* resolved against the vault. These rows come from list_vault_rows — every
|
|
11
|
+
* one has its real row_id, so `r` actually closes the loop in the cloud.
|
|
12
|
+
*
|
|
13
|
+
* There is no free-text input here, so plain keys (r / v / q) are unambiguous.
|
|
14
|
+
*/
|
|
15
|
+
import { useState, useEffect } from 'react';
|
|
16
|
+
import { Box, Text, useInput } from 'ink';
|
|
17
|
+
import { theme } from './theme.js';
|
|
18
|
+
import { listLoops, resolve } from './client.js';
|
|
19
|
+
/** First non-empty line of a row's content, capped. */
|
|
20
|
+
function firstLine(s) {
|
|
21
|
+
const line = (s.split('\n').find((l) => l.trim() !== '') ?? s).trim();
|
|
22
|
+
return line.length > 64 ? line.slice(0, 63) + '…' : line;
|
|
23
|
+
}
|
|
24
|
+
/** Trim content to a max length for the expanded view. */
|
|
25
|
+
function clip(s, max) {
|
|
26
|
+
const t = s.trim();
|
|
27
|
+
return t.length > max ? t.slice(0, max - 1).trimEnd() + '…' : t;
|
|
28
|
+
}
|
|
29
|
+
export function LoopsApp({ userId, onExit }) {
|
|
30
|
+
const [rows, setRows] = useState(null);
|
|
31
|
+
const [loadError, setLoadError] = useState(null);
|
|
32
|
+
const [highlight, setHighlight] = useState(0);
|
|
33
|
+
const [expanded, setExpanded] = useState(false);
|
|
34
|
+
const [confirming, setConfirming] = useState(false);
|
|
35
|
+
const [rowState, setRowState] = useState({});
|
|
36
|
+
const [rowError, setRowError] = useState({});
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let alive = true;
|
|
39
|
+
listLoops(userId, 50)
|
|
40
|
+
.then((r) => {
|
|
41
|
+
if (alive)
|
|
42
|
+
setRows(r);
|
|
43
|
+
})
|
|
44
|
+
.catch((e) => {
|
|
45
|
+
if (alive)
|
|
46
|
+
setLoadError(e instanceof Error ? e.message : String(e));
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
alive = false;
|
|
50
|
+
};
|
|
51
|
+
}, [userId]);
|
|
52
|
+
const doResolve = (row) => {
|
|
53
|
+
setRowState((s) => ({ ...s, [row.id]: 'resolving' }));
|
|
54
|
+
resolve(userId, row.id)
|
|
55
|
+
.then(() => setRowState((s) => ({ ...s, [row.id]: 'resolved' })))
|
|
56
|
+
.catch((e) => {
|
|
57
|
+
setRowState((s) => ({ ...s, [row.id]: 'error' }));
|
|
58
|
+
setRowError((m) => ({
|
|
59
|
+
...m,
|
|
60
|
+
[row.id]: e instanceof Error ? e.message : String(e),
|
|
61
|
+
}));
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
useInput((input, key) => {
|
|
65
|
+
// Resolve confirmation intercepts everything until answered.
|
|
66
|
+
if (confirming) {
|
|
67
|
+
if (input === 'y' && rows && rows.length > 0)
|
|
68
|
+
doResolve(rows[highlight]);
|
|
69
|
+
setConfirming(false);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (key.escape || input === 'q') {
|
|
73
|
+
onExit();
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (!rows || rows.length === 0)
|
|
77
|
+
return;
|
|
78
|
+
if (key.upArrow) {
|
|
79
|
+
setExpanded(false);
|
|
80
|
+
setHighlight((h) => (h <= 0 ? rows.length - 1 : h - 1));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (key.downArrow) {
|
|
84
|
+
setExpanded(false);
|
|
85
|
+
setHighlight((h) => (h >= rows.length - 1 ? 0 : h + 1));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (/^[1-9]$/.test(input)) {
|
|
89
|
+
const idx = Number(input) - 1;
|
|
90
|
+
if (idx < rows.length) {
|
|
91
|
+
setExpanded(false);
|
|
92
|
+
setHighlight(idx);
|
|
93
|
+
}
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (input === 'v' || input === ' ' || key.return) {
|
|
97
|
+
setExpanded((e) => !e);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (input === 'r') {
|
|
101
|
+
const st = rowState[rows[highlight].id] ?? 'open';
|
|
102
|
+
if (st === 'open' || st === 'error')
|
|
103
|
+
setConfirming(true);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
// --- render ------------------------------------------------------------
|
|
108
|
+
if (loadError) {
|
|
109
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Text, { color: theme.gold, bold: true, children: ["\u25C6 becki ", _jsx(Text, { color: theme.gray, bold: false, children: "\u00B7 open threads" })] }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { color: theme.gray, children: ["could not load loops: ", loadError] }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.gray, children: "q to exit" }) })] }));
|
|
110
|
+
}
|
|
111
|
+
if (!rows) {
|
|
112
|
+
return (_jsx(Box, { paddingX: 2, paddingY: 1, children: _jsx(Text, { color: theme.gray, children: "loading open threads\u2026" }) }));
|
|
113
|
+
}
|
|
114
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 2, paddingY: 1, children: [_jsxs(Text, { color: theme.gold, bold: true, children: ["\u25C6 becki", ' ', _jsxs(Text, { color: theme.gray, bold: false, children: ["\u00B7 open threads (", rows.length, ")"] })] }), rows.length === 0 ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.gray, children: "no open threads \u2014 clear slate." }) })) : (_jsx(Box, { flexDirection: "column", marginTop: 1, children: rows.map((row, i) => {
|
|
115
|
+
const hl = i === highlight;
|
|
116
|
+
const st = rowState[row.id] ?? 'open';
|
|
117
|
+
const tag = st === 'resolved'
|
|
118
|
+
? ' ✓ resolved'
|
|
119
|
+
: st === 'resolving'
|
|
120
|
+
? ' resolving…'
|
|
121
|
+
: st === 'error'
|
|
122
|
+
? ' ✗ failed'
|
|
123
|
+
: '';
|
|
124
|
+
const titleColor = st === 'resolved' ? theme.gray : hl ? theme.gold : theme.text;
|
|
125
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: hl ? theme.gold : theme.gray, children: String(i + 1).padStart(2, ' ') }), _jsx(Text, { color: hl ? theme.gold : theme.gray, children: hl ? ' ▸ ' : ' · ' }), _jsx(Box, { width: 62, children: _jsx(Text, { color: titleColor, bold: hl, strikethrough: st === 'resolved', wrap: "truncate-end", children: firstLine(row.content) }) }), _jsx(Text, { color: theme.gray, children: tag })] }), hl && expanded ? (_jsx(Box, { marginLeft: 5, marginTop: 1, marginBottom: 1, width: 74, children: _jsx(Text, { color: theme.gray, children: clip(row.content, 800) }) })) : null, hl && st === 'error' && rowError[row.id] ? (_jsx(Box, { marginLeft: 5, children: _jsx(Text, { color: theme.gold, children: rowError[row.id] }) })) : null] }, row.id));
|
|
126
|
+
}) })), _jsx(Box, { marginTop: 1, children: confirming && rows.length > 0 ? (_jsxs(Text, { color: theme.gold, children: ["resolve \u201C", firstLine(rows[highlight].content), "\u201D?", ' ', _jsx(Text, { color: theme.gray, children: "y to confirm \u00B7 any key cancels" })] })) : rows.length === 0 ? (_jsx(Text, { color: theme.gray, children: "q to exit" })) : (_jsx(Text, { color: theme.gray, children: "\u2191\u2193 move \u00B7 1-9 jump \u00B7 v view \u00B7 r resolve \u00B7 q back" })) })] }));
|
|
127
|
+
}
|