flowcollab 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env node
2
+ /* flow:decisions — list pending decisions.
3
+ Usage: flow-decisions
4
+ */
5
+
6
+ import { flowFetch, die } from './_client.mjs';
7
+
8
+ async function main() {
9
+ const [decRes, tasksRes] = await Promise.all([
10
+ flowFetch('/api/flow/decisions'),
11
+ flowFetch('/api/flow/tasks?limit=500'),
12
+ ]);
13
+ const decisions = decRes.decisions || [];
14
+ const taskById = new Map((tasksRes.tasks || []).map(t => [t.id, t]));
15
+ if (!decisions.length) {
16
+ process.stdout.write('No pending decisions.\n');
17
+ return;
18
+ }
19
+ process.stdout.write(`${decisions.length} pending decision${decisions.length === 1 ? '' : 's'}:\n\n`);
20
+ for (const d of decisions) {
21
+ process.stdout.write(`#${d.id.slice(0, 6)} ${d.proposal_title}\n`);
22
+ if (d.parent_task_id) {
23
+ const p = taskById.get(d.parent_task_id);
24
+ const ref = p ? `#${p.id.slice(0, 6)} — "${p.title}"` : `#${d.parent_task_id.slice(0, 6)}`;
25
+ process.stdout.write(` Parent: ${ref}\n`);
26
+ }
27
+ process.stdout.write(` Confidence: ${d.confidence} | Suggest: ${d.suggested_assignee_id || '-'} / ${d.suggested_priority || '-'} / ${d.suggested_type || '-'}\n`);
28
+ if (d.source_refs?.length) process.stdout.write(` Refs: ${d.source_refs.join(', ')}\n`);
29
+ process.stdout.write(` Expires: ${d.expires_at}\n\n`);
30
+ }
31
+ }
32
+
33
+ main().catch(e => die(e.message || e));
package/bin/edit.mjs ADDED
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ /* flow:edit — update task fields from the CLI.
3
+ Usage:
4
+ flow-edit <id> --title="new title"
5
+ flow-edit <id> --priority=p1 --area=backend
6
+ flow-edit <id> --due=2026-07-01
7
+ flow-edit <id> --milestone="v1.0"
8
+ flow-edit <id> --blocked-by=<uuid>
9
+ */
10
+
11
+ import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
12
+
13
+ const FIELD_MAP = {
14
+ title: v => ({ title: v }),
15
+ priority: v => ({ priority: v }),
16
+ area: v => ({ area: v }),
17
+ due: v => ({ due_at: /^\d{4}-\d{2}-\d{2}$/.test(v) ? v + 'T00:00:00Z' : v }),
18
+ milestone: v => ({ milestone: v }),
19
+ 'blocked-by': v => ({ blocked_by: v === 'null' ? [] : [v] }),
20
+ };
21
+
22
+ async function main() {
23
+ const raw = positional(0);
24
+ if (!raw) die('Usage: flow-edit <task_id> [--title=...] [--priority=...] [--area=...] [--due=YYYY-MM-DD] [--milestone=...] [--blocked-by=<uuid>|null]');
25
+
26
+ const changes = {};
27
+ for (const [flag, toField] of Object.entries(FIELD_MAP)) {
28
+ const val = arg(flag);
29
+ if (val !== undefined) Object.assign(changes, toField(val));
30
+ }
31
+
32
+ if (Object.keys(changes).length === 0) die('No fields to update. Pass at least one flag like --title="..." or --priority=p1');
33
+
34
+ const id = await resolveTaskId(raw);
35
+ const res = await flowFetch(`/api/flow/tasks/${id}`, { method: 'PATCH', body: changes });
36
+
37
+ const updated = res.task || res;
38
+ const label = updated.title || id.slice(0, 8);
39
+ process.stdout.write(`Updated: ${label}\n`);
40
+ for (const [k, v] of Object.entries(changes)) {
41
+ process.stdout.write(` ${k}: ${v === null ? '(cleared)' : v}\n`);
42
+ }
43
+ }
44
+
45
+ main().catch(e => die(e.message || e));
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env node
2
+ /* flow:handoff — hand a task off to another agent with structured context.
3
+ Usage:
4
+ flow-handoff --task=<id> --to=<actor> --context="..." \
5
+ [--branch=<name>] [--questions="q1|q2|q3"] [--next-step="..."]
6
+
7
+ --questions accepts pipe-delimited questions for the recipient to answer.
8
+ --next-step describes the concrete first action the recipient should take.
9
+ flow_files are auto-read from the task and included in handoff_data.
10
+ */
11
+
12
+ import { flowFetch, arg } from './_client.mjs';
13
+
14
+ async function main() {
15
+ const task_id = arg('task');
16
+ const to = arg('to');
17
+ const context = arg('context');
18
+ const branch = arg('branch') || null;
19
+ const questionsArg = arg('questions');
20
+ const next_step = arg('next-step') || undefined;
21
+
22
+ if (!task_id) { process.stderr.write('--task=<id> is required\n'); process.exit(1); }
23
+ if (!to) { process.stderr.write('--to=<actor> is required\n'); process.exit(1); }
24
+ if (!context) { process.stderr.write('--context="..." is required\n'); process.exit(1); }
25
+
26
+ const questions = questionsArg
27
+ ? questionsArg.split('|').map(q => q.trim()).filter(Boolean)
28
+ : undefined;
29
+
30
+ const r = await flowFetch('/api/flow/handoff', {
31
+ method: 'POST',
32
+ body: {
33
+ task_id,
34
+ to,
35
+ context,
36
+ ...(branch ? { branch } : {}),
37
+ ...(questions ? { questions } : {}),
38
+ ...(next_step ? { next_step } : {}),
39
+ },
40
+ });
41
+
42
+ const hd = r.task?.handoff_data;
43
+ process.stdout.write(`Handed off ${task_id.slice(0, 8)} → ${to}${branch ? ' (branch: ' + branch + ')' : ''}\n`);
44
+ if (hd?.files?.length) {
45
+ process.stdout.write(` files: ${hd.files.join(', ')}\n`);
46
+ }
47
+ if (hd?.next_step) {
48
+ process.stdout.write(` next step: ${hd.next_step}\n`);
49
+ }
50
+ if (hd?.questions?.length) {
51
+ process.stdout.write(` open questions:\n`);
52
+ for (const q of hd.questions) process.stdout.write(` - ${q}\n`);
53
+ }
54
+ process.stdout.write(`They will see it in their next flow-pull.\n`);
55
+ }
56
+
57
+ main().catch(e => { process.stderr.write('handoff failed: ' + (e.message || e) + '\n'); process.exit(1); });
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /* flow:heartbeat — announce presence to the board.
3
+ Call after claiming a task, and every ~60s while actively working.
4
+
5
+ Usage:
6
+ flow-heartbeat # idle ping (no task)
7
+ flow-heartbeat --task=<task_id> # ping with current task
8
+ */
9
+
10
+ import { flowFetch, arg } from './_client.mjs';
11
+
12
+ async function main() {
13
+ const task = arg('task') || null;
14
+ await flowFetch('/api/flow/heartbeat', {
15
+ method: 'POST',
16
+ body: task ? { task_id: task } : {},
17
+ });
18
+ const label = task ? ` (task ${task.slice(0, 6)})` : '';
19
+ process.stdout.write(`Heartbeat sent${label}\n`);
20
+ }
21
+
22
+ main().catch(e => { process.stderr.write('heartbeat failed: ' + (e.message || e) + '\n'); process.exit(1); });
package/bin/init.mjs ADDED
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+ /* flow:init — set up CLI access for a web-provisioned Flow account.
3
+
4
+ You signed up at https://flow-production-84b7.up.railway.app and have a
5
+ token from the Flow setup page. This writes your .env and CLAUDE.md.
6
+
7
+ Usage: flow-init --web
8
+ flow-init --web
9
+ */
10
+
11
+ import { readFileSync, writeFileSync, existsSync } from 'node:fs';
12
+ import path from 'node:path';
13
+ import { createInterface } from 'node:readline';
14
+
15
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
16
+ const ask = (q) => new Promise(r => rl.question(q, r));
17
+
18
+ function buildWebClaudeMd({ actorId, ownerActorId, boardUrl, scope, areas = [] }) {
19
+ const ownerRef = scope === 'owner' ? actorId : (ownerActorId || 'owner');
20
+ const areaLine = areas.length ? areas.join(', ') : 'backend, frontend, infra, docs';
21
+ return `# Flow — Claude Code workflow
22
+
23
+ This project uses Flow for multi-agent coordination.
24
+ Follow this workflow every session without exception.
25
+
26
+ ## Your identity
27
+ - **Your actor ID:** \`${actorId}\`
28
+ - **Your scope:** ${scope}
29
+ - **Board:** ${boardUrl}
30
+
31
+ ## Required session workflow
32
+
33
+ \`\`\`bash
34
+ flow-pull # ALWAYS start here — board snapshot + your mentions
35
+ flow-claim <task_id> # claim before writing any code
36
+ flow-comment <task_id> "..." # update at milestones, use @to:${ownerRef} to ping
37
+ flow-close <task_id> "summary" # close with a meaningful summary when done
38
+ flow-propose --parent=<id> --title="..." --md="..." # propose new work; never start without approval
39
+ \`\`\`
40
+
41
+ ## Rules — never violate
42
+ - \`flow-pull\` at session start — no exceptions
43
+ - Claim a task before touching related code
44
+ - Comment at major milestones, not every commit
45
+ - Close with a summary the next agent can act on
46
+ - Never approve your own proposals — only **owners** can approve (in the browser at ${boardUrl})
47
+ - Use \`@to:${ownerRef}\` in comments to request human action or review
48
+
49
+ ## All CLI commands
50
+ \`\`\`
51
+ flow-pull — board snapshot + mentions + handoffs
52
+ flow-pull --focus — top-3 open tasks sorted by priority
53
+ flow-pull --milestone=<name> — filter snapshot to a sprint
54
+ flow-claim <id> — self-assign a task
55
+ flow-comment <id> "..." — add a comment or milestone update
56
+ flow-close <id> "summary" — mark task done with summary
57
+ flow-create --title="..." — create a task directly (no approval gate)
58
+ flow-create --from-issue=<num> — import a GitHub Issue as a task
59
+ flow-create --from-project-item=<id> — import a GitHub Projects item
60
+ flow-create --template=<id> — create from template (standup, pr-review, retro, ...)
61
+ flow-propose --parent=<id> --title="..." --md="..." — propose work for human approval
62
+ flow-approve/reject <id> — resolve a proposal (owner only)
63
+ flow-assign <id> <actor> — reassign a task (owner only)
64
+ flow-decisions — list pending proposals
65
+ flow-search "query" — search tasks by title
66
+ flow-status — board summary (counts, active agents)
67
+ flow-standup — recent activity digest
68
+ flow-standup --velocity — recent activity + week-over-week velocity chart
69
+ flow-handoff --task=<id> --to=<actor> — hand off a task with context
70
+ flow-heartbeat — send presence ping (run in background)
71
+ flow-sync — delta sync since N minutes ago
72
+ flow-ping — health check
73
+ flow-whoami — verify token and actor identity
74
+ flow-project — list GitHub Projects v2 items
75
+ flow-close-sprint --milestone=<name> — close sprint (owner only)
76
+ flow-review <id> [--pr=<num>] [--reviewer=<actor>] — request code review; moves → in_review, pings reviewer
77
+ flow-edit <id> [--title=] [--priority=] [--area=] [--due=] [--milestone=] [--blocked-by=]
78
+ flow-unblock <id> — clear blocked_by
79
+ flow-log [--task=<id>] [--limit=20] — tail recent timeline events
80
+ \`\`\`
81
+
82
+ ## Area labels
83
+ ${areaLine}
84
+ `;
85
+ }
86
+
87
+ async function main() {
88
+ if (!process.argv.includes('--web')) {
89
+ console.error(
90
+ 'Usage: flow-init --web\n\n' +
91
+ 'Flow is a hosted SaaS. Sign up at:\n' +
92
+ ' https://flow-production-84b7.up.railway.app\n\n' +
93
+ 'Once you have a token from the setup page, run:\n' +
94
+ ' flow-init --web\n'
95
+ );
96
+ process.exit(1);
97
+ }
98
+
99
+ console.log('\nFlow Board — web setup\n');
100
+ console.log('You signed up via the web and already have a token from the Flow setup page.');
101
+ console.log('This writes your .env and CLAUDE.md — no Supabase config needed.\n');
102
+
103
+ const apiBase = (await ask('Flow server URL (e.g. https://flow-production-84b7.up.railway.app): '))
104
+ .trim().replace(/\/+$/, '');
105
+ const token = (await ask('Your API token (from setup page): ')).trim();
106
+ const actorId = (await ask('Your actor ID (from setup page, e.g. "alice"): ')).trim() || 'owner';
107
+ const scopeRaw = (await ask('Your scope [owner/contributor, default: owner]: ')).trim().toLowerCase();
108
+ const scope = scopeRaw === 'contributor' ? 'contributor' : 'owner';
109
+
110
+ let ownerActorId = actorId;
111
+ if (scope === 'contributor') {
112
+ ownerActorId = (await ask("Owner's actor ID (for CLAUDE.md @mentions, e.g. \"alice\"): ")).trim() || 'owner';
113
+ }
114
+
115
+ const cwd = process.cwd();
116
+ const localAreas = (() => {
117
+ try {
118
+ const raw = readFileSync(path.join(cwd, 'flow.config.json'), 'utf8');
119
+ return Object.keys(JSON.parse(raw).labels?.area || {});
120
+ } catch { return []; }
121
+ })();
122
+
123
+ const envTarget = path.join(cwd, '.env');
124
+ const claudeMdTarget = path.join(cwd, 'CLAUDE.md');
125
+ const envExists = existsSync(envTarget);
126
+ const claudeExists = existsSync(claudeMdTarget);
127
+
128
+ const writeEnvAns = (await ask(
129
+ `\nWrite .env to ${cwd}? ${envExists ? '[y/N]' : '[Y/n]'}: `
130
+ )).trim().toLowerCase();
131
+ const writeEnv = envExists
132
+ ? (writeEnvAns === 'y' || writeEnvAns === 'yes')
133
+ : (writeEnvAns !== 'n' && writeEnvAns !== 'no');
134
+
135
+ const writeClaudeAns = (await ask(
136
+ `Write CLAUDE.md to ${cwd}? ${claudeExists ? '[y/N]' : '[Y/n]'}: `
137
+ )).trim().toLowerCase();
138
+ const writeClaude = claudeExists
139
+ ? (writeClaudeAns === 'y' || writeClaudeAns === 'yes')
140
+ : (writeClaudeAns !== 'n' && writeClaudeAns !== 'no');
141
+
142
+ rl.close();
143
+
144
+ const tokenVar = scope === 'owner' ? 'FLOW_API_TOKEN_OWNER' : 'FLOW_API_TOKEN_CONTRIBUTOR';
145
+ const actorVar = scope === 'owner' ? 'FLOW_TOKEN_OWNER_ACTOR' : 'FLOW_TOKEN_CONTRIBUTOR_ACTOR';
146
+ const envLines = [
147
+ `${tokenVar}=${token}`,
148
+ `${actorVar}=${actorId}`,
149
+ `FLOW_API_BASE=${apiBase}`,
150
+ `FLOW_ACTING_VIA=claude`,
151
+ `FLOW_DEFAULT_ASSIGNEE=${actorId}`,
152
+ ];
153
+
154
+ console.log('\n========== .env ==========\n');
155
+ envLines.forEach(l => console.log(l));
156
+
157
+ if (writeEnv) {
158
+ if (envExists) {
159
+ const existing = readFileSync(envTarget, 'utf8');
160
+ const conflicts = envLines.map(l => l.split('=')[0]).filter(k => new RegExp(`^${k}=`, 'm').test(existing));
161
+ if (conflicts.length) {
162
+ console.log(`\n⚠ These keys already exist in .env — not overwriting: ${conflicts.join(', ')}`);
163
+ console.log(' Paste the block above into .env manually.');
164
+ } else {
165
+ writeFileSync(envTarget, existing.trimEnd() + '\n\n' + envLines.join('\n') + '\n', 'utf8');
166
+ console.log(`\n✓ .env updated. Add .env to .gitignore — it contains your API token.`);
167
+ }
168
+ } else {
169
+ writeFileSync(envTarget, envLines.join('\n') + '\n', 'utf8');
170
+ console.log(`\n✓ .env written. Add .env to .gitignore — it contains your API token.`);
171
+ }
172
+ }
173
+
174
+ if (writeClaude) {
175
+ const content = buildWebClaudeMd({ actorId, ownerActorId, boardUrl: `${apiBase}/flow/`, scope, areas: localAreas });
176
+ writeFileSync(claudeMdTarget, content, 'utf8');
177
+ console.log(`✓ CLAUDE.md written. Commit this so every agent on the team gets the workflow.`);
178
+ }
179
+
180
+ console.log('\n========== Next steps ==========\n');
181
+ console.log(`1. Verify your connection: flow-whoami`);
182
+ console.log(`2. See your board: flow-pull`);
183
+ console.log(`3. Open the board: ${apiBase}/flow/`);
184
+ console.log('');
185
+ }
186
+
187
+ main().catch(e => { console.error('init failed:', e.message || e); process.exit(1); });
package/bin/log.mjs ADDED
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env node
2
+ /* flow:log — tail recent timeline events, newest first.
3
+ Usage:
4
+ flow-log # board-wide, last 20 events
5
+ flow-log --task=<id> # single task timeline
6
+ flow-log --limit=50 # more events
7
+ */
8
+
9
+ import { flowFetch, resolveTaskId, arg, die } from './_client.mjs';
10
+
11
+ function sanitize(s) {
12
+ if (!s || typeof s !== 'string') return '';
13
+ return s
14
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
15
+ .replace(/@to:\S+/g, '[mention]')
16
+ .replace(/\r?\n|\r/g, ' ')
17
+ .trim()
18
+ .slice(0, 120);
19
+ }
20
+
21
+ function fmt(ts) {
22
+ if (!ts) return ' ';
23
+ const d = new Date(ts);
24
+ if (isNaN(d)) return String(ts).slice(0, 10);
25
+ return d.toISOString().replace('T', ' ').slice(0, 16);
26
+ }
27
+
28
+ function pad(s, n) { return String(s ?? '').padEnd(n).slice(0, n); }
29
+
30
+ function fmtEvent(e) {
31
+ const p = e.payload || {};
32
+ if (p.comment) return sanitize(p.comment);
33
+ if (p.act) return `[${p.act}${p.summary ? ': ' + sanitize(p.summary, 80) : ''}]`;
34
+ return `[${e.kind || 'event'}]`;
35
+ }
36
+
37
+ async function main() {
38
+ const taskRaw = arg('task');
39
+ const limit = Math.min(200, Math.max(1, parseInt(arg('limit') || '20')));
40
+
41
+ const res = await flowFetch('/api/flow/tasks?limit=500');
42
+ const tasks = res.tasks ?? [];
43
+ const timeline = res.timeline ?? [];
44
+
45
+ if (taskRaw) {
46
+ const id = await resolveTaskId(taskRaw);
47
+ const task = tasks.find(t => t.id === id);
48
+ if (!task) die(`Task ${id} not found`);
49
+
50
+ const events = timeline
51
+ .filter(e => e.task_id === id)
52
+ .slice(0, limit);
53
+
54
+ if (events.length === 0) { process.stdout.write('No timeline events.\n'); return; }
55
+
56
+ process.stdout.write(`Timeline for #${task.issue_num ?? id.slice(0, 6)}: ${task.title}\n\n`);
57
+ for (const e of events) {
58
+ const who = e.acting_as_id ? `${e.acting_as_id}(${e.actor_id})` : (e.actor_id || '?');
59
+ process.stdout.write(` ${fmt(e.ts)} ${pad(who, 18)} ${fmtEvent(e)}\n`);
60
+ }
61
+ } else {
62
+ const taskById = new Map(tasks.map(t => [t.id, t]));
63
+ const events = timeline
64
+ .filter(e => e.ts)
65
+ .slice(0, limit);
66
+
67
+ if (events.length === 0) { process.stdout.write('No timeline events.\n'); return; }
68
+
69
+ process.stdout.write(`Last ${events.length} board events:\n\n`);
70
+ for (const e of events) {
71
+ const t = taskById.get(e.task_id);
72
+ const ref = t ? `#${t.issue_num ?? e.task_id.slice(0, 6)}` : `#${e.task_id.slice(0, 6)}`;
73
+ const who = e.acting_as_id ? `${e.acting_as_id}(${e.actor_id})` : (e.actor_id || '?');
74
+ process.stdout.write(` ${fmt(e.ts)} ${pad(who, 18)} ${pad(ref, 8)} ${fmtEvent(e)}\n`);
75
+ }
76
+ }
77
+ }
78
+
79
+ main().catch(e => die(e.message || e));
package/bin/login.mjs ADDED
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env node
2
+ /* flow-login — authorize the CLI via browser OAuth (device flow).
3
+ Usage:
4
+ flow-login [--server=https://flow-production-84b7.up.railway.app]
5
+ */
6
+ import { homedir } from 'os';
7
+ import { mkdirSync, writeFileSync } from 'fs';
8
+ import { join } from 'path';
9
+ import { exec } from 'child_process';
10
+
11
+ const serverArg = process.argv.find(a => a.startsWith('--server='))?.slice(9);
12
+ const base = (serverArg || process.env.FLOW_API_BASE || 'https://flow-production-84b7.up.railway.app').replace(/\/+$/, '');
13
+
14
+ function openBrowser(url) {
15
+ try {
16
+ const cmd = process.platform === 'darwin' ? `open "${url}"`
17
+ : process.platform === 'win32' ? `start "" "${url}"`
18
+ : `xdg-open "${url}"`;
19
+ exec(cmd);
20
+ } catch { /* best-effort */ }
21
+ }
22
+
23
+ async function main() {
24
+ // 1. Request device code from server
25
+ const r = await fetch(`${base}/api/flow/auth/device`, {
26
+ method: 'POST',
27
+ headers: { 'Content-Type': 'application/json' },
28
+ }).catch(e => { throw new Error(`Cannot reach ${base}: ${e.message}`); });
29
+
30
+ if (!r.ok) throw new Error(`Server error: ${r.status}`);
31
+ const { code, device_token, url, expires_in } = await r.json();
32
+
33
+ // 2. Show instructions and open browser
34
+ process.stdout.write('\nAuthorize Flow CLI\n\n');
35
+ process.stdout.write(` Visit: ${url}\n`);
36
+ process.stdout.write(` Code: ${code}\n\n`);
37
+ process.stdout.write('Opening browser… (or visit the URL above manually)\n\n');
38
+ openBrowser(url);
39
+
40
+ // 3. Poll every 2s until authorized or expired
41
+ const deadline = Date.now() + expires_in * 1000;
42
+ process.stdout.write('Waiting for authorization');
43
+ while (Date.now() < deadline) {
44
+ await new Promise(ok => setTimeout(ok, 2000));
45
+ process.stdout.write('.');
46
+ const poll = await fetch(
47
+ `${base}/api/flow/auth/poll?device_token=${encodeURIComponent(device_token)}`,
48
+ ).catch(() => null);
49
+ if (!poll) continue;
50
+ if (poll.status === 410) {
51
+ process.stdout.write('\n');
52
+ const body = await poll.json().catch(() => ({}));
53
+ throw new Error(
54
+ body.error === 'already_used'
55
+ ? 'This device code was already claimed.'
56
+ : 'Code expired. Run flow-login again.',
57
+ );
58
+ }
59
+ if (poll.status === 202) continue;
60
+ if (poll.ok) {
61
+ const { token, actor_id, org_id, api_base } = await poll.json();
62
+ const dir = join(homedir(), '.flow');
63
+ mkdirSync(dir, { recursive: true });
64
+ writeFileSync(
65
+ join(dir, 'config.json'),
66
+ JSON.stringify({ apiBase: api_base || base, token, actorId: actor_id, orgId: org_id }, null, 2) + '\n',
67
+ { encoding: 'utf8', mode: 0o600 },
68
+ );
69
+ process.stdout.write('\n\n');
70
+ process.stdout.write(`✓ Logged in as ${actor_id}\n`);
71
+ process.stdout.write(` Config saved to ~/.flow/config.json\n`);
72
+ process.stdout.write(` Run: flow-pull\n\n`);
73
+ return;
74
+ }
75
+ }
76
+ process.stdout.write('\n');
77
+ throw new Error('Timed out. Run flow-login again.');
78
+ }
79
+
80
+ main().catch(e => { process.stderr.write(`flow-login: ${e.message}\n`); process.exit(1); });
package/bin/ping.mjs ADDED
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ /* flow:ping — dual mode:
3
+ 0 args → healthz check (circuit breaker status)
4
+ flow-ping <actor> "message" → post @to:<actor>-claude on shared agent-comms task
5
+ (server fireNotifications routes to actor's notify_webhook)
6
+ */
7
+
8
+ import { flowFetch, positional, die } from './_client.mjs';
9
+
10
+ async function main() {
11
+ const actor = positional(0);
12
+ const message = positional(1);
13
+
14
+ if (!actor) {
15
+ const r = await flowFetch('/api/flow/healthz');
16
+ const breaker = r.breaker;
17
+ const status = r.ok ? 'ok' : 'tripped';
18
+ const trips = breaker?.trips != null ? ` trips=${breaker.trips}` : '';
19
+ process.stdout.write(`Flow ${status}.${trips}\n`);
20
+ return;
21
+ }
22
+
23
+ if (!message) die('Usage: flow-ping <actor> "message"');
24
+
25
+ // Find or create the shared agent-comms task
26
+ const tasksRes = await flowFetch('/api/flow/tasks?limit=500');
27
+ const all = tasksRes.tasks || [];
28
+ let commsTask = all.find(t => t.type === 'chore' && t.title === 'agent-comms' && t.status !== 'done');
29
+
30
+ if (!commsTask) {
31
+ const created = await flowFetch('/api/flow/tasks', {
32
+ method: 'POST',
33
+ body: {
34
+ title: 'agent-comms',
35
+ type: 'chore',
36
+ status: 'in_progress',
37
+ body_md: 'Shared channel for inter-agent messages. Do not close.',
38
+ },
39
+ });
40
+ commsTask = created.task;
41
+ }
42
+
43
+ const body = `@to:${actor}-claude — ${message}`;
44
+ await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: commsTask.id, body } });
45
+ process.stdout.write(`Pinged @to:${actor}-claude on #${commsTask.issue_num ?? commsTask.id.slice(0, 6)} (agent-comms).\n`);
46
+ }
47
+
48
+ main().catch(e => die(e.message || e));
package/bin/pr.mjs ADDED
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env node
2
+ /* flow:pr — record a PR link on a task's timeline.
3
+ Usage: flow-pr <task_id> <pr_url>
4
+ Posts a standardized "PR open: <url>" comment so pull.mjs is uniform.
5
+ */
6
+
7
+ import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
8
+
9
+ async function main() {
10
+ const rawId = positional(0);
11
+ const url = positional(1);
12
+ if (!rawId || !url) {
13
+ die('Usage: flow-pr <task_id> <pr_url>');
14
+ }
15
+ if (!url.startsWith('http')) {
16
+ die(`pr_url must be a full URL, got: ${url}`);
17
+ }
18
+ const id = await resolveTaskId(rawId);
19
+ const body = `PR open: ${url}`;
20
+ await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: id, body } });
21
+ process.stdout.write(`Recorded PR link on task #${rawId.replace(/^#/, '').slice(0, 6)}.\n`);
22
+ }
23
+
24
+ main().catch(e => die(e.message || e));
@@ -0,0 +1,50 @@
1
+ #!/usr/bin/env node
2
+ /* flow-project — list items in a GitHub Projects (v2) board.
3
+
4
+ Lists all items in the project configured via FLOW_GITHUB_PROJECT_ID.
5
+ Use the printed node IDs with flow-create --from-project-item=<id>.
6
+
7
+ Usage:
8
+ flow-project # list items from FLOW_GITHUB_PROJECT_ID
9
+ flow-project --project=<node_id> # override project node ID
10
+ */
11
+
12
+ import 'dotenv/config';
13
+ import { die } from './_client.mjs';
14
+ import { fetchProjectItems, contentKind } from './_github.mjs';
15
+
16
+ async function main() {
17
+ const projectId = process.argv.find(a => a.startsWith('--project='))?.split('=')[1]
18
+ || process.env.FLOW_GITHUB_PROJECT_ID;
19
+
20
+ if (!projectId) {
21
+ die('No project specified. Set FLOW_GITHUB_PROJECT_ID in .env or pass --project=<node_id>.\n\n' +
22
+ 'Find your project node ID at: github.com/orgs/<org>/projects or github.com/users/<user>/projects\n' +
23
+ ' → Settings → in the URL: /projects/<number> → run `gh project list` for the node ID.');
24
+ }
25
+ if (!process.env.FLOW_GITHUB_TOKEN) {
26
+ die('FLOW_GITHUB_TOKEN is not set. Set it in .env (needs "read:project" scope).');
27
+ }
28
+
29
+ process.stdout.write(`Fetching project items from ${projectId}...\n\n`);
30
+
31
+ const { title, items } = await fetchProjectItems(projectId);
32
+
33
+ process.stdout.write(`Project: ${title}\n`);
34
+ process.stdout.write(` ${items.length} item${items.length !== 1 ? 's' : ''}\n\n`);
35
+
36
+ if (!items.length) {
37
+ process.stdout.write('No items found.\n');
38
+ return;
39
+ }
40
+
41
+ for (const { id, content } of items) {
42
+ const kind = contentKind(content);
43
+ const num = content.number ? ` #${content.number}` : '';
44
+ process.stdout.write(` [${id}] ${content.title}${num ? ` (${kind}${num})` : ` (${kind})`}\n`);
45
+ }
46
+
47
+ process.stdout.write(`\nTo import an item:\n flow-create --from-project-item=<id above>\n`);
48
+ }
49
+
50
+ main().catch(e => die(e.message || e));