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.
- package/LICENSE +6 -0
- package/bin/_client.mjs +97 -0
- package/bin/_github.mjs +98 -0
- package/bin/approve.mjs +27 -0
- package/bin/assign.mjs +36 -0
- package/bin/claim.mjs +63 -0
- package/bin/close-sprint.mjs +61 -0
- package/bin/close.mjs +22 -0
- package/bin/comment.mjs +17 -0
- package/bin/completion.mjs +112 -0
- package/bin/create.mjs +116 -0
- package/bin/decisions.mjs +33 -0
- package/bin/edit.mjs +45 -0
- package/bin/handoff.mjs +57 -0
- package/bin/heartbeat.mjs +22 -0
- package/bin/init.mjs +187 -0
- package/bin/log.mjs +79 -0
- package/bin/login.mjs +80 -0
- package/bin/ping.mjs +48 -0
- package/bin/pr.mjs +24 -0
- package/bin/project.mjs +50 -0
- package/bin/propose.mjs +57 -0
- package/bin/pull.mjs +392 -0
- package/bin/reject.mjs +19 -0
- package/bin/review.mjs +85 -0
- package/bin/search.mjs +48 -0
- package/bin/standup.mjs +136 -0
- package/bin/status.mjs +102 -0
- package/bin/sync.mjs +104 -0
- package/bin/unblock.mjs +19 -0
- package/bin/whoami.mjs +39 -0
- package/package.json +61 -0
|
@@ -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));
|
package/bin/handoff.mjs
ADDED
|
@@ -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));
|
package/bin/project.mjs
ADDED
|
@@ -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));
|