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
package/LICENSE
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Copyright (c) 2026 Prefabs AI. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Permission is granted to download and use this CLI software solely for the
|
|
4
|
+
purpose of accessing the Flow hosted service. No part of this software may be
|
|
5
|
+
reproduced, distributed, modified, or transmitted in any form or by any means
|
|
6
|
+
without the prior written permission of the copyright holder.
|
package/bin/_client.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/* Flow CLI — shared client.
|
|
2
|
+
- Resolves API base + token from env vars → ~/.flow/config.json → defaults
|
|
3
|
+
- Exposes one fetch helper with sensible error reporting
|
|
4
|
+
- All CLI scripts import from here
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import 'dotenv/config';
|
|
8
|
+
import { homedir } from 'os';
|
|
9
|
+
import { readFileSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
function loadGlobalConfig() {
|
|
13
|
+
try { return JSON.parse(readFileSync(join(homedir(), '.flow', 'config.json'), 'utf8')); }
|
|
14
|
+
catch { return {}; }
|
|
15
|
+
}
|
|
16
|
+
const _gc = loadGlobalConfig();
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = process.env.FLOW_API_BASE || _gc.apiBase || 'http://localhost:3000';
|
|
19
|
+
|
|
20
|
+
function envToken() {
|
|
21
|
+
return process.env.FLOW_API_TOKEN_OWNER || process.env.FLOW_API_TOKEN_CONTRIBUTOR || _gc.token || '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function actingViaClaude() {
|
|
25
|
+
const v = (process.env.FLOW_ACTING_VIA || 'claude').toLowerCase();
|
|
26
|
+
return v === 'claude';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function flowFetch(path, { method = 'GET', body } = {}) {
|
|
30
|
+
const token = envToken();
|
|
31
|
+
if (!token) {
|
|
32
|
+
throw new Error('No FLOW_API_TOKEN_OWNER or FLOW_API_TOKEN_CONTRIBUTOR in env. Aborting.');
|
|
33
|
+
}
|
|
34
|
+
const url = DEFAULT_BASE.replace(/\/+$/, '') + path;
|
|
35
|
+
const headers = {
|
|
36
|
+
'x-flow-token': token,
|
|
37
|
+
'content-type': 'application/json',
|
|
38
|
+
};
|
|
39
|
+
if (actingViaClaude()) headers['x-flow-acting-via'] = 'claude';
|
|
40
|
+
|
|
41
|
+
const res = await fetch(url, {
|
|
42
|
+
method,
|
|
43
|
+
headers,
|
|
44
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
45
|
+
});
|
|
46
|
+
const text = await res.text();
|
|
47
|
+
let parsed = null;
|
|
48
|
+
try { parsed = text ? JSON.parse(text) : {}; } catch { parsed = { raw: text }; }
|
|
49
|
+
if (!res.ok) {
|
|
50
|
+
const err = new Error(`Flow API ${method} ${path} -> ${res.status}: ${parsed?.error || text}`);
|
|
51
|
+
err.status = res.status;
|
|
52
|
+
err.body = parsed;
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
return parsed;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function die(msg, code = 1) {
|
|
59
|
+
process.stderr.write(`flow-cli: ${msg}\n`);
|
|
60
|
+
process.exit(code);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function arg(name, fallback) {
|
|
64
|
+
const argv = process.argv.slice(2);
|
|
65
|
+
for (let i = 0; i < argv.length; i++) {
|
|
66
|
+
const a = argv[i];
|
|
67
|
+
if (a === `--${name}`) return argv[i + 1] ?? '';
|
|
68
|
+
if (a.startsWith(`--${name}=`)) return a.slice(name.length + 3);
|
|
69
|
+
}
|
|
70
|
+
return fallback;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function positional(idx) {
|
|
74
|
+
const pos = process.argv.slice(2).filter(a => !a.startsWith('--'));
|
|
75
|
+
return pos[idx];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
79
|
+
|
|
80
|
+
export async function resolveTaskId(input) {
|
|
81
|
+
if (!input) return input;
|
|
82
|
+
const stripped = input.replace(/^#/, '');
|
|
83
|
+
if (UUID_RE.test(stripped)) return stripped;
|
|
84
|
+
const [tasksRes, decRes] = await Promise.all([
|
|
85
|
+
flowFetch('/api/flow/tasks?limit=500'),
|
|
86
|
+
flowFetch('/api/flow/decisions'),
|
|
87
|
+
]);
|
|
88
|
+
const list = Array.isArray(tasksRes) ? tasksRes : (tasksRes.tasks ?? []);
|
|
89
|
+
const taskMatches = list.filter(t => t.id.startsWith(stripped));
|
|
90
|
+
if (taskMatches.length === 1) return taskMatches[0].id;
|
|
91
|
+
if (taskMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${taskMatches.length} tasks:\n${taskMatches.map(t => ` #${t.id.slice(0, 6)} ${t.title}`).join('\n')}`);
|
|
92
|
+
const decisions = decRes.decisions || [];
|
|
93
|
+
const decMatches = decisions.filter(d => d.id.startsWith(stripped));
|
|
94
|
+
if (decMatches.length === 0) throw new Error(`No task or decision found with ID prefix "${stripped}". Run \`flow-pull\` to see your current tasks.`);
|
|
95
|
+
if (decMatches.length > 1) throw new Error(`Ambiguous prefix "${stripped}" matches ${decMatches.length} decisions:\n${decMatches.map(d => ` #${d.id.slice(0, 6)} ${d.proposal_title || ''}`).join('\n')}`);
|
|
96
|
+
return decMatches[0].id;
|
|
97
|
+
}
|
package/bin/_github.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* Shared GitHub API helpers for Flow CLI commands. */
|
|
2
|
+
|
|
3
|
+
import 'dotenv/config';
|
|
4
|
+
|
|
5
|
+
export function githubHeaders() {
|
|
6
|
+
const token = process.env.FLOW_GITHUB_TOKEN;
|
|
7
|
+
const headers = { 'User-Agent': 'flowcollab/1', 'Accept': 'application/vnd.github+json' };
|
|
8
|
+
if (token) headers['Authorization'] = `Bearer ${token}`;
|
|
9
|
+
return headers;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function githubGraphQL(query, variables = {}) {
|
|
13
|
+
const res = await fetch('https://api.github.com/graphql', {
|
|
14
|
+
method: 'POST',
|
|
15
|
+
headers: { ...githubHeaders(), 'Content-Type': 'application/json' },
|
|
16
|
+
body: JSON.stringify({ query, variables }),
|
|
17
|
+
});
|
|
18
|
+
if (!res.ok) throw new Error(`GitHub GraphQL HTTP ${res.status}`);
|
|
19
|
+
const json = await res.json();
|
|
20
|
+
if (json.errors?.length) throw new Error(json.errors.map(e => e.message).join('; '));
|
|
21
|
+
return json.data;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function fetchProjectItem(nodeId) {
|
|
25
|
+
const data = await githubGraphQL(`
|
|
26
|
+
query($id: ID!) {
|
|
27
|
+
node(id: $id) {
|
|
28
|
+
... on ProjectV2Item {
|
|
29
|
+
id
|
|
30
|
+
content {
|
|
31
|
+
... on Issue {
|
|
32
|
+
title body number url
|
|
33
|
+
labels(first: 10) { nodes { name } }
|
|
34
|
+
}
|
|
35
|
+
... on DraftIssue { title body }
|
|
36
|
+
... on PullRequest { title body number url }
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
`, { id: nodeId });
|
|
42
|
+
const item = data?.node;
|
|
43
|
+
if (!item?.content) throw new Error(`Node "${nodeId}" is not a ProjectV2Item or has no content.`);
|
|
44
|
+
return item;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function fetchProjectItems(projectNodeId) {
|
|
48
|
+
const items = [];
|
|
49
|
+
let cursor = null;
|
|
50
|
+
|
|
51
|
+
while (true) {
|
|
52
|
+
const data = await githubGraphQL(`
|
|
53
|
+
query($projectId: ID!, $cursor: String) {
|
|
54
|
+
node(id: $projectId) {
|
|
55
|
+
... on ProjectV2 {
|
|
56
|
+
title
|
|
57
|
+
items(first: 50, after: $cursor) {
|
|
58
|
+
pageInfo { hasNextPage endCursor }
|
|
59
|
+
nodes {
|
|
60
|
+
id
|
|
61
|
+
content {
|
|
62
|
+
... on Issue { title number }
|
|
63
|
+
... on DraftIssue { title }
|
|
64
|
+
... on PullRequest { title number }
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
`, { projectId: projectNodeId, cursor });
|
|
72
|
+
|
|
73
|
+
const proj = data?.node;
|
|
74
|
+
if (!proj?.items) throw new Error(`Node "${projectNodeId}" is not a ProjectV2 or is inaccessible.`);
|
|
75
|
+
|
|
76
|
+
for (const node of proj.items.nodes || []) {
|
|
77
|
+
if (node?.content) items.push({ id: node.id, content: node.content });
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!proj.items.pageInfo.hasNextPage) {
|
|
81
|
+
return { title: proj.title, items };
|
|
82
|
+
}
|
|
83
|
+
cursor = proj.items.pageInfo.endCursor;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function labelsToType(labels) {
|
|
88
|
+
const lower = (labels || []).map(l => l.name?.toLowerCase() || '');
|
|
89
|
+
if (lower.some(l => l === 'bug')) return 'bug';
|
|
90
|
+
if (lower.some(l => l === 'documentation')) return 'docs';
|
|
91
|
+
return 'feature';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function contentKind(content) {
|
|
95
|
+
if ('number' in content && content.url?.includes('/issues/')) return 'Issue';
|
|
96
|
+
if ('number' in content && content.url?.includes('/pull/')) return 'PR';
|
|
97
|
+
return 'Draft';
|
|
98
|
+
}
|
package/bin/approve.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:approve — promote a decision to a real Todo task.
|
|
3
|
+
Usage:
|
|
4
|
+
flow-approve <decision_id>
|
|
5
|
+
flow-approve <decision_id> --title="override" --assignee=...
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
const raw = positional(0);
|
|
12
|
+
if (!raw) die('Usage: flow-approve <decision_id>');
|
|
13
|
+
const id = await resolveTaskId(raw);
|
|
14
|
+
|
|
15
|
+
const overrides = {};
|
|
16
|
+
for (const k of ['title', 'body_md', 'assignee_id', 'priority', 'type', 'area']) {
|
|
17
|
+
const v = arg(k);
|
|
18
|
+
if (v !== undefined && v !== '') overrides[k] = v;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const r = await flowFetch(`/api/flow/decisions/${id}/approve`, {
|
|
22
|
+
method: 'POST', body: overrides,
|
|
23
|
+
});
|
|
24
|
+
process.stdout.write(`Promoted to task #${r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
main().catch(e => die(e.message || e));
|
package/bin/assign.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:assign — reassign or assign a task to any actor.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
flow-assign <task_id> <actor_id>
|
|
6
|
+
flow-assign <task_id> none # clear assignee
|
|
7
|
+
|
|
8
|
+
Server-side: human-only (claude alone cannot push work onto humans).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
|
|
12
|
+
|
|
13
|
+
async function main() {
|
|
14
|
+
const raw = positional(0);
|
|
15
|
+
const who = positional(1);
|
|
16
|
+
if (!raw || !who) die('Required: <task_id> <actor_id|none>');
|
|
17
|
+
const taskId = await resolveTaskId(raw);
|
|
18
|
+
|
|
19
|
+
const assignee_id = (who === 'none' || who === 'null' || who === '-') ? null : who;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const r = await flowFetch(`/api/flow/tasks/${taskId}/assign`, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
body: { assignee_id },
|
|
25
|
+
});
|
|
26
|
+
process.stdout.write(`Assigned: ${r.task.title}\n`);
|
|
27
|
+
process.stdout.write(` Now assignee: ${r.task.assignee_id || '(unassigned)'}\n`);
|
|
28
|
+
} catch (e) {
|
|
29
|
+
if (e.status === 403) die('Assignment requires a human actor (FLOW_ACTING_VIA=self or no claude header).');
|
|
30
|
+
if (e.status === 404) die('Task not found.');
|
|
31
|
+
if (e.status === 400) die(`Invalid body: ${e.body?.error || e.message}`);
|
|
32
|
+
die(e.message || e);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
main().catch(e => die(e.message || e));
|
package/bin/claim.mjs
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:claim — self-assign a task and move it to in_progress.
|
|
3
|
+
Usage: flow-claim <task_id>
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const raw = positional(0);
|
|
10
|
+
if (!raw) die('Usage: flow-claim <task_id> [--files=src/a.js,lib/b.js] [--watch]');
|
|
11
|
+
const id = await resolveTaskId(raw);
|
|
12
|
+
|
|
13
|
+
const filesArg = arg('files');
|
|
14
|
+
const files = filesArg ? filesArg.split(',').map(f => f.trim()).filter(Boolean) : undefined;
|
|
15
|
+
const watch = arg('watch') !== undefined;
|
|
16
|
+
|
|
17
|
+
const body = { task_id: id, ...(files ? { files } : {}) };
|
|
18
|
+
|
|
19
|
+
let r;
|
|
20
|
+
if (watch) {
|
|
21
|
+
while (true) {
|
|
22
|
+
try {
|
|
23
|
+
r = await flowFetch('/api/flow/claim', { method: 'POST', body });
|
|
24
|
+
break;
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (e.status === 409 || e.status === 429) {
|
|
27
|
+
const reason = e.status === 429 ? 'WIP limit reached' : 'Task already claimed';
|
|
28
|
+
process.stdout.write(`${reason} — retrying in 30s… (Ctrl+C to cancel)\n`);
|
|
29
|
+
await new Promise(ok => setTimeout(ok, 30000));
|
|
30
|
+
} else {
|
|
31
|
+
throw e;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} else {
|
|
36
|
+
r = await flowFetch('/api/flow/claim', { method: 'POST', body });
|
|
37
|
+
}
|
|
38
|
+
process.stdout.write(`Claimed #${r.task.issue_num ?? r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
|
|
39
|
+
|
|
40
|
+
// Warn on file conflicts
|
|
41
|
+
if (r.conflict_warnings?.length) {
|
|
42
|
+
process.stdout.write(`\n⚠ File conflict${r.conflict_warnings.length === 1 ? '' : 's'} detected:\n`);
|
|
43
|
+
for (const w of r.conflict_warnings) {
|
|
44
|
+
process.stdout.write(` #${w.task_ref} "${w.title}" (${w.claimed_by || 'unassigned'})\n`);
|
|
45
|
+
process.stdout.write(` overlapping files: ${w.overlapping_files.join(', ')}\n`);
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write('Consider coordinating before editing these files.\n\n');
|
|
48
|
+
} else if (!files && !r.files_checked?.length) {
|
|
49
|
+
process.stdout.write('Tip: use --files=src/a.js,lib/b.js to enable file conflict detection.\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Announce presence on this task (non-fatal if heartbeat fails)
|
|
53
|
+
try {
|
|
54
|
+
await flowFetch('/api/flow/heartbeat', { method: 'POST', body: { task_id: r.task.id } });
|
|
55
|
+
} catch { /* best-effort */ }
|
|
56
|
+
|
|
57
|
+
// Print suggested branch name
|
|
58
|
+
const slug = r.task.title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40);
|
|
59
|
+
const num = r.task.issue_num ?? r.task.id.slice(0, 6);
|
|
60
|
+
process.stdout.write(`Suggested branch: flow/${num}-${slug}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
main().catch(e => die(e.message || e));
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:close-sprint — close a sprint milestone.
|
|
3
|
+
Archives all done tasks (clears their milestone field, appends sprint_closed event).
|
|
4
|
+
Non-done tasks keep the milestone so they can be carried to the next sprint.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
flow-close-sprint --milestone="Sprint 3"
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { flowFetch, arg } from './_client.mjs';
|
|
11
|
+
|
|
12
|
+
function pad(s, n) { return String(s || '').padEnd(n).slice(0, n); }
|
|
13
|
+
function sanitize(s, max = 65) {
|
|
14
|
+
if (!s || typeof s !== 'string') return '';
|
|
15
|
+
return s.replace(/[\x00-\x1f]/g, ' ').trim().slice(0, max);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function main() {
|
|
19
|
+
const milestone = arg('milestone');
|
|
20
|
+
if (!milestone) {
|
|
21
|
+
process.stderr.write('Usage: flow-close-sprint --milestone="Sprint Name"\n');
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const res = await flowFetch(`/api/flow/milestones/${encodeURIComponent(milestone)}/close`, { method: 'POST' });
|
|
26
|
+
|
|
27
|
+
const { completed = [], carried_forward = [] } = res;
|
|
28
|
+
const total = completed.length + carried_forward.length;
|
|
29
|
+
const pct = total ? Math.round(completed.length / total * 100) : 0;
|
|
30
|
+
|
|
31
|
+
const out = [];
|
|
32
|
+
out.push(`=== Sprint closed: ${milestone} ===`);
|
|
33
|
+
out.push(`${completed.length}/${total} tasks done (${pct}%)`);
|
|
34
|
+
out.push('');
|
|
35
|
+
|
|
36
|
+
if (completed.length) {
|
|
37
|
+
out.push(`[completed — ${completed.length}]`);
|
|
38
|
+
for (const t of completed) {
|
|
39
|
+
const ref = `#${t.issue_num ?? t.id.slice(0, 6)}`;
|
|
40
|
+
out.push(` ${ref} ${pad(t.assignee_id || 'unassigned', 14)} ${sanitize(t.title)}`);
|
|
41
|
+
}
|
|
42
|
+
out.push('');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (carried_forward.length) {
|
|
46
|
+
out.push(`[carried forward — ${carried_forward.length}]`);
|
|
47
|
+
for (const t of carried_forward) {
|
|
48
|
+
const ref = `#${t.issue_num ?? t.id.slice(0, 6)}`;
|
|
49
|
+
out.push(` ${ref} ${pad(t.assignee_id || 'unassigned', 14)} [${t.status}] ${sanitize(t.title, 50)}`);
|
|
50
|
+
}
|
|
51
|
+
out.push('');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
out.push('Done tasks archived (milestone cleared). Carried tasks keep the milestone for the next sprint.');
|
|
55
|
+
process.stdout.write(out.join('\n') + '\n');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
main().catch(e => {
|
|
59
|
+
process.stderr.write('close-sprint failed: ' + (e.message || e) + '\n');
|
|
60
|
+
process.exit(1);
|
|
61
|
+
});
|
package/bin/close.mjs
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:close — mark a task done.
|
|
3
|
+
Usage: flow-close <task_id> [--summary="..."]
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, arg, die } from './_client.mjs';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const raw = positional(0);
|
|
10
|
+
if (!raw) die('Usage: flow-close <task_id> [--summary="..."]');
|
|
11
|
+
const id = await resolveTaskId(raw);
|
|
12
|
+
const summary = arg('summary');
|
|
13
|
+
|
|
14
|
+
const r = await flowFetch('/api/flow/close', {
|
|
15
|
+
method: 'POST',
|
|
16
|
+
body: { task_id: id, ...(summary ? { summary } : {}) },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
process.stdout.write(`Closed #${r.task.id.slice(0, 6)} — "${r.task.title}"\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
main().catch(e => die(e.message || e));
|
package/bin/comment.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:comment — append a free-form comment to a task's timeline.
|
|
3
|
+
Usage: flow-comment <task_id> "<body>"
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { flowFetch, resolveTaskId, positional, die } from './_client.mjs';
|
|
7
|
+
|
|
8
|
+
async function main() {
|
|
9
|
+
const raw = positional(0);
|
|
10
|
+
const body = positional(1);
|
|
11
|
+
if (!raw || !body) die('Usage: flow-comment <task_id> "<body>"');
|
|
12
|
+
const id = await resolveTaskId(raw);
|
|
13
|
+
await flowFetch('/api/flow/comment', { method: 'POST', body: { task_id: id, body } });
|
|
14
|
+
process.stdout.write('Comment posted.\n');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
main().catch(e => die(e.message || e));
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:completion — generate shell completion scripts.
|
|
3
|
+
Usage:
|
|
4
|
+
flow-completion bash # print bash completion script
|
|
5
|
+
flow-completion zsh # print zsh completion script
|
|
6
|
+
flow-completion fish # print fish completion script
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { positional, die } from './_client.mjs';
|
|
10
|
+
|
|
11
|
+
const COMMANDS = [
|
|
12
|
+
'pull', 'claim', 'comment', 'close', 'create', 'propose', 'approve', 'reject',
|
|
13
|
+
'assign', 'decisions', 'ping', 'whoami', 'heartbeat', 'sync', 'status', 'handoff',
|
|
14
|
+
'standup', 'search', 'init', 'project', 'close-sprint', 'review', 'login',
|
|
15
|
+
'edit', 'unblock', 'log', 'completion',
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
const FLAGS = {
|
|
19
|
+
pull: ['--focus', '--milestone=', '--sync-md'],
|
|
20
|
+
claim: ['--files=', '--watch'],
|
|
21
|
+
create: ['--title=', '--type=', '--area=', '--priority=', '--due=', '--blocked-by=', '--milestone=', '--from-issue=', '--from-project-item=', '--template='],
|
|
22
|
+
handoff: ['--task=', '--to=', '--context=', '--branch=', '--questions=', '--next-step='],
|
|
23
|
+
standup: ['--since=', '--velocity'],
|
|
24
|
+
search: ['--status=', '--area=', '--assignee='],
|
|
25
|
+
sync: ['--since='],
|
|
26
|
+
review: ['--pr=', '--reviewer=', '--context='],
|
|
27
|
+
edit: ['--title=', '--priority=', '--area=', '--due=', '--milestone=', '--blocked-by='],
|
|
28
|
+
log: ['--task=', '--limit='],
|
|
29
|
+
'close-sprint': ['--milestone='],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function bashScript() {
|
|
33
|
+
const cmds = COMMANDS.map(c => `flow-${c}`).join(' ');
|
|
34
|
+
return `# Flow CLI bash completion
|
|
35
|
+
# Source this file or add to ~/.bash_completion.d/flow
|
|
36
|
+
|
|
37
|
+
_flow_complete() {
|
|
38
|
+
local cur prev words
|
|
39
|
+
COMPREPLY=()
|
|
40
|
+
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
41
|
+
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
42
|
+
|
|
43
|
+
local commands="${cmds}"
|
|
44
|
+
|
|
45
|
+
if [[ \${COMP_CWORD} -eq 1 ]]; then
|
|
46
|
+
COMPREPLY=( \$(compgen -W "\$commands" "\$cur") )
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
|
|
50
|
+
local cmd="\${COMP_WORDS[0]}"
|
|
51
|
+
local subcmd="\${cmd#flow-}"
|
|
52
|
+
case "\$subcmd" in
|
|
53
|
+
${Object.entries(FLAGS).map(([cmd, flags]) => ` ${cmd})
|
|
54
|
+
COMPREPLY=( \$(compgen -W "${flags.join(' ')}" "\$cur") )
|
|
55
|
+
return ;;`).join('\n')}
|
|
56
|
+
esac
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
${COMMANDS.map(c => `complete -F _flow_complete flow-${c}`).join('\n')}
|
|
60
|
+
`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function zshScript() {
|
|
64
|
+
const cmds = COMMANDS.map(c => `flow-${c}`);
|
|
65
|
+
return `#compdef ${cmds.join(' ')}
|
|
66
|
+
# Flow CLI zsh completion
|
|
67
|
+
# Place in ~/.zfunc/_flow and add fpath=(~/.zfunc $fpath) to ~/.zshrc
|
|
68
|
+
|
|
69
|
+
_flow_args() {
|
|
70
|
+
local cmd="\${words[1]#flow-}"
|
|
71
|
+
case "\$cmd" in
|
|
72
|
+
${Object.entries(FLAGS).map(([cmd, flags]) => ` ${cmd})
|
|
73
|
+
local opts=(${flags.map(f => `'${f}'`).join(' ')})
|
|
74
|
+
_arguments "\${opts[@]}"
|
|
75
|
+
;;`).join('\n')}
|
|
76
|
+
*)
|
|
77
|
+
_arguments '*:arg:'
|
|
78
|
+
;;
|
|
79
|
+
esac
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
${cmds.map(c => `compdef _flow_args ${c}`).join('\n')}
|
|
83
|
+
`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fishScript() {
|
|
87
|
+
const lines = [];
|
|
88
|
+
for (const cmd of COMMANDS) {
|
|
89
|
+
lines.push(`# flow-${cmd}`);
|
|
90
|
+
const flags = FLAGS[cmd] || [];
|
|
91
|
+
for (const f of flags) {
|
|
92
|
+
const name = f.replace(/^--/, '').replace(/=$/, '');
|
|
93
|
+
const hasArg = f.endsWith('=');
|
|
94
|
+
lines.push(`complete -c flow-${cmd} -l ${name}${hasArg ? ' -r' : ''}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return lines.join('\n') + '\n';
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function main() {
|
|
101
|
+
const shell = positional(0);
|
|
102
|
+
if (!shell) die('Usage: flow-completion <bash|zsh|fish>');
|
|
103
|
+
|
|
104
|
+
switch (shell) {
|
|
105
|
+
case 'bash': process.stdout.write(bashScript()); break;
|
|
106
|
+
case 'zsh': process.stdout.write(zshScript()); break;
|
|
107
|
+
case 'fish': process.stdout.write(fishScript()); break;
|
|
108
|
+
default: die(`Unknown shell "${shell}". Supported: bash, zsh, fish`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
main().catch(e => die(e.message || e));
|
package/bin/create.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/* flow:create — directly create a task (no propose→approve gate).
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
flow-create --title="..." \
|
|
6
|
+
[--body="..."] \
|
|
7
|
+
[--type=bug|feature|chore|docs] \
|
|
8
|
+
[--area=<area>] \
|
|
9
|
+
[--priority=P0-now|P1-soon|P2-later] \
|
|
10
|
+
[--status=backlog|todo|in_progress|in_review|done] \
|
|
11
|
+
[--assignee=<actor_id>] \
|
|
12
|
+
[--due=YYYY-MM-DD] \
|
|
13
|
+
[--blocked-by=<uuid>,...] \
|
|
14
|
+
[--milestone=<name>] \
|
|
15
|
+
[--from-issue=<num>] Import a GitHub Issue (requires FLOW_GITHUB_TOKEN + FLOW_GITHUB_REPO)
|
|
16
|
+
[--from-project-item=<node_id>] Import a GitHub Projects item by node ID (requires FLOW_GITHUB_TOKEN)
|
|
17
|
+
[--template=<id>] Pre-fill from a task template defined in flow.config.json
|
|
18
|
+
|
|
19
|
+
Use flow-propose when the work needs human approval before it starts.
|
|
20
|
+
Use flow-create for tasks you are directly starting or tracking.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import 'dotenv/config';
|
|
24
|
+
import { flowFetch, arg, die } from './_client.mjs';
|
|
25
|
+
import { githubHeaders, fetchProjectItem, labelsToType } from './_github.mjs';
|
|
26
|
+
|
|
27
|
+
async function fetchGithubIssue(issueNum) {
|
|
28
|
+
const repo = process.env.FLOW_GITHUB_REPO;
|
|
29
|
+
if (!repo) die('FLOW_GITHUB_REPO is not set. Set it in .env to use --from-issue.');
|
|
30
|
+
const url = `https://api.github.com/repos/${repo}/issues/${issueNum}`;
|
|
31
|
+
const res = await fetch(url, { headers: githubHeaders() });
|
|
32
|
+
if (res.status === 404) die(`GitHub Issue #${issueNum} not found in ${repo}.`);
|
|
33
|
+
if (!res.ok) die(`GitHub API error ${res.status} fetching issue #${issueNum}.`);
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function main() {
|
|
38
|
+
const fromIssue = arg('from-issue');
|
|
39
|
+
const fromProjectItem = arg('from-project-item');
|
|
40
|
+
const fromTemplate = arg('template');
|
|
41
|
+
if ([fromIssue, fromProjectItem, fromTemplate].filter(Boolean).length > 1) die('Use only one of --from-issue, --from-project-item, --template.');
|
|
42
|
+
let prefill = {};
|
|
43
|
+
|
|
44
|
+
if (fromTemplate) {
|
|
45
|
+
const r = await flowFetch('/api/flow/templates');
|
|
46
|
+
const tmpl = r.templates?.[fromTemplate];
|
|
47
|
+
if (!tmpl) die(`Unknown template "${fromTemplate}". Available: ${Object.keys(r.templates || {}).join(', ') || 'none'}`);
|
|
48
|
+
prefill = { ...tmpl };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (fromIssue) {
|
|
52
|
+
const num = parseInt(fromIssue, 10);
|
|
53
|
+
if (!num || num < 1) die('--from-issue must be a positive integer (e.g. --from-issue=42)');
|
|
54
|
+
process.stdout.write(`Fetching GitHub Issue #${num}...\n`);
|
|
55
|
+
const issue = await fetchGithubIssue(num);
|
|
56
|
+
const labels = (issue.labels || []).map(l => l.name.toLowerCase());
|
|
57
|
+
const type = labels.includes('bug') ? 'bug'
|
|
58
|
+
: labels.includes('documentation') ? 'docs'
|
|
59
|
+
: 'feature';
|
|
60
|
+
prefill = {
|
|
61
|
+
title: issue.title,
|
|
62
|
+
body_md: issue.body || '',
|
|
63
|
+
type,
|
|
64
|
+
status: 'backlog',
|
|
65
|
+
github_issue_num: issue.number,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (fromProjectItem) {
|
|
70
|
+
if (!process.env.FLOW_GITHUB_TOKEN) die('FLOW_GITHUB_TOKEN is not set. Set it in .env to use --from-project-item.');
|
|
71
|
+
process.stdout.write(`Fetching GitHub Projects item ${fromProjectItem}...\n`);
|
|
72
|
+
const item = await fetchProjectItem(fromProjectItem);
|
|
73
|
+
const c = item.content;
|
|
74
|
+
const type = labelsToType(c.labels?.nodes);
|
|
75
|
+
prefill = {
|
|
76
|
+
title: c.title,
|
|
77
|
+
body_md: c.body || '',
|
|
78
|
+
type,
|
|
79
|
+
status: 'backlog',
|
|
80
|
+
...(c.number && c.url?.includes('/issues/') ? { github_issue_num: c.number } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const title = arg('title') || prefill.title;
|
|
85
|
+
if (!title) die('Required: --title="..." (or use --from-issue=<num> / --template=<id>)\n\nUsage: flow-create --title="..." [--type=bug|feature|chore|docs] [--area=<area>] [--priority=P0-now|P1-soon|P2-later] [--status=backlog|todo] [--assignee=<actor_id>] [--from-issue=<num>] [--template=<id>]');
|
|
86
|
+
|
|
87
|
+
const body = {
|
|
88
|
+
...prefill,
|
|
89
|
+
title,
|
|
90
|
+
...(arg('body') ? { body_md: arg('body') } : {}),
|
|
91
|
+
...(arg('type') ? { type: arg('type') } : {}),
|
|
92
|
+
...(arg('area') ? { area: arg('area') } : {}),
|
|
93
|
+
...(arg('priority') ? { priority: arg('priority') } : {}),
|
|
94
|
+
...(arg('status') ? { status: arg('status') } : {}),
|
|
95
|
+
...(arg('assignee') ? { assignee_id: arg('assignee') } : {}),
|
|
96
|
+
...(arg('due') ? { due_at: new Date(arg('due')).toISOString() } : {}),
|
|
97
|
+
...(arg('blocked-by') ? { blocked_by: arg('blocked-by').split(',').map(s => s.trim()).filter(Boolean) } : {}),
|
|
98
|
+
...(arg('milestone') ? { milestone: arg('milestone') } : {}),
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
const r = await flowFetch('/api/flow/tasks', { method: 'POST', body });
|
|
103
|
+
const t = r.task;
|
|
104
|
+
process.stdout.write(`Created #${t.id.slice(0, 6)} — "${t.title}"\n`);
|
|
105
|
+
process.stdout.write(` id: ${t.id}\n`);
|
|
106
|
+
process.stdout.write(` status: ${t.status}\n`);
|
|
107
|
+
process.stdout.write(` priority: ${t.priority} type: ${t.type} area: ${t.area}\n`);
|
|
108
|
+
if (t.assignee_id) process.stdout.write(` assignee: ${t.assignee_id}\n`);
|
|
109
|
+
if (t.github_issue_num) process.stdout.write(` github: #${t.github_issue_num}\n`);
|
|
110
|
+
} catch (e) {
|
|
111
|
+
if (e.status === 400) die(`Invalid fields: ${e.body?.error || e.message}`);
|
|
112
|
+
die(e.message || e);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
main().catch(e => die(e.message || e));
|