@flowapt/flowiq-cli 0.9.5 → 0.9.7
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/README.md +64 -4
- package/TEAM-GUIDE.md +11 -0
- package/package.json +1 -1
- package/src/commands/agent-test.js +2 -1
- package/src/commands/gaps.js +346 -0
- package/src/commands/plans.js +8 -2
- package/src/gaps.test.mjs +48 -0
- package/src/index.js +74 -1
package/README.md
CHANGED
|
@@ -1273,10 +1273,13 @@ flowiq report deck pull <org_id> 2026-08 # → ./.flowiq/repor
|
|
|
1273
1273
|
predates the current build logic, unless it is approved, sent or held).
|
|
1274
1274
|
- Send fees are estimated at the category each template had on its send day. A send whose category
|
|
1275
1275
|
cannot be known shows no fee and is left out of the broadcast return (`totals.unpriced_sends`).
|
|
1276
|
-
- `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true }`.
|
|
1276
|
+
- `inputs.json` shape: `{ "changes": { "<update_id>": { "hidden": false, "tag": "client_raised|flowapt_shipped", "title": "…", "description": "…" } }, "changes_reviewed": true, "action_points": { "<title>": "done|in_progress|waiting_on_you|ongoing|dropped" }, "waiting_on_you": [{ "title": "…", "unlocks": "…" }], "recipients_confirmed": true, "hidden": { "tile:tail": true, "pill:new_contacts": true } }`.
|
|
1277
|
+
`hidden` (29 Sep 2026) takes any tile, pill, card or block off the slides and the PDF for that month only: the keys are what the deck page's rail lists (`tile:…`, `pill:<metric>`, `card:…`, `block:…`, `box:…`, `strip:…`); a metric's pill hides everywhere that metric shows. The copy is not rewritten when something is hidden.
|
|
1277
1278
|
Free text (milestone, the four plan fields) is edited in the app; it is stored as overrides that survive `narrate`.
|
|
1278
1279
|
- `send --client` needs an approved deck AND `report.email.recipients` in the org's reporting config (Control center → Report delivery); it marks the deck `sent`. `--test-to` never changes status. Fees on the slides are the org's actual Meta billing when the token can read it, otherwise the rate-card estimate, and the footnote says which.
|
|
1279
1280
|
- Non-store orgs need `config.deck.outcome` (`source: handover | ticket_status | tag | keyword`, labels) — the revenue slides become outcome slides. Every verb except `status` and `pull` is audited.
|
|
1281
|
+
- A month with a Customer Insights report gets the "What your customers asked" slide after slide 7 (29 Sep 2026): `build` takes the one `org_insights` export-insights row whose window covers the most of the month (top questions, trending topics, complaints or product mentions, sentiment, each as "N customers"), stores it under `deck.insights`, and the copy carries `insights_title` + `insights_read`. `status --json` shows the window under `insights` (null = no report, no slide). The timing slide's next-step card is now written by the narrator (`next_step_title` / `next_step_body`) against next month's South African retail calendar (public holidays, Mother's / Father's Day, Black Friday, payday, month-end) and edited in place like every other block.
|
|
1282
|
+
- Cart funnel and buckets (29 Sep 2026): "messaged" counts reminders WhatsApp reports delivered (the old send-request count rides along as `messaged_requested`); a Woo cart reminder now matches its order, so Woo recoveries are no longer zero; our own cart link is a recovery, another tool's abandoned-cart email is not; on Woo an agent-built order is a conversation, not a campaign; chat-to-order counts buyers who chatted, any bucket. `status` refuses approval when the agent replied to nobody in the month (no live channel).
|
|
1280
1283
|
- Store orgs get slide 8, "Store health": cart abandonment (online checkouts started, bought, abandoned), first-time vs returning buyers and opt-in growth, each with a twelve-month strip and measured on the online store checkout only (till and other channels are named in the footer). `status --json` carries the headline figures under `store`; `config.deck.channel_labels {"<source_name>": "Label"}` renames a sales channel on the slide.
|
|
1281
1284
|
|
|
1282
1285
|
### Insights — `flowiq insights status|enable|disable|run` (v0.7.0)
|
|
@@ -1845,9 +1848,13 @@ flowiq plans status <plan_id> --to sent --commit
|
|
|
1845
1848
|
timestamps instead of the send date (SAST day start) — "what did a client
|
|
1846
1849
|
submit today", "what has moved since Friday", which `--since` cannot answer.
|
|
1847
1850
|
- **`show`** prints the plan the way the dialog does: message copy, the
|
|
1848
|
-
second message sent when a button is tapped
|
|
1849
|
-
creative (file name + link), audience,
|
|
1850
|
-
client, internal notes and the notes
|
|
1851
|
+
second message sent when a button is tapped (named by the button's text),
|
|
1852
|
+
buttons with their targets, the creative (file name + link), audience,
|
|
1853
|
+
template link, the response to the client, internal notes and the notes
|
|
1854
|
+
thread. A marketing plan carries 0-10 buttons in any mix WhatsApp allows:
|
|
1855
|
+
`quick_reply`, `url` (up to 2, `→ link`), `phone` (1, `→ call +27…`) and
|
|
1856
|
+
`copy_code` (1, `→ code SPRING20`); each quick reply may have its own
|
|
1857
|
+
follow-up message (`slide2a`, `slide2b`, `slide2c` … by button position).
|
|
1851
1858
|
- **`status`** is a dry run until `--commit`. `--comment` is the response the
|
|
1852
1859
|
**client reads**; `--internal` is staff-only. `approved` and `rejected`
|
|
1853
1860
|
record you as the reviewer; `rejected` requires `--comment`. Every commit is
|
|
@@ -1855,6 +1862,53 @@ flowiq plans status <plan_id> --to sent --commit
|
|
|
1855
1862
|
- Editing a plan's copy, buttons or links is not in the CLI yet; do that in
|
|
1856
1863
|
the app.
|
|
1857
1864
|
|
|
1865
|
+
### CLI gaps + requests — `flowiq gaps report|list|show|hit|note|status` (alias `requests`) (v0.9.7)
|
|
1866
|
+
|
|
1867
|
+
Found something the CLI cannot do, does wrong, or should do? File it here, from
|
|
1868
|
+
the terminal, the moment you hit it. This is the shared list of what the CLI
|
|
1869
|
+
is missing: anyone with a staff key can add to it, and so can a Claude session
|
|
1870
|
+
driving the CLI for you (it is marked `claude` automatically).
|
|
1871
|
+
|
|
1872
|
+
```bash
|
|
1873
|
+
flowiq gaps report "agent config cannot set tool_instructions" --command "agent config" \
|
|
1874
|
+
--error "error: unknown option '--tool-instruction'" --workaround "SQL on agents.settings" \
|
|
1875
|
+
--detail "Needed to add a per-tool instruction for Clara's get_product_info" --org <uuid>
|
|
1876
|
+
flowiq gaps report "links shorten should take a readable --slug" --kind idea --command "links shorten"
|
|
1877
|
+
flowiq gaps report "…" --dry-run # only check whether it is already on the list
|
|
1878
|
+
flowiq gaps list # open requests, most-hit first
|
|
1879
|
+
flowiq gaps list --command "bc" --status all --search slug
|
|
1880
|
+
flowiq gaps list --mine
|
|
1881
|
+
flowiq gaps show 12 # detail, verbatim error, every hit / note / status change
|
|
1882
|
+
flowiq gaps hit 12 --note "same on Barkyn IT while copying the escalation config"
|
|
1883
|
+
flowiq gaps note 12 "also needs --agent"
|
|
1884
|
+
flowiq gaps status 12 --to done --version 0.9.8 --note "agent config --tool-instruction name=@file.txt" # maintainers
|
|
1885
|
+
flowiq gaps status 14 --to duplicate --of 12 # hits move to #12
|
|
1886
|
+
```
|
|
1887
|
+
|
|
1888
|
+
- **`report`** takes a one-line title plus whatever context you have:
|
|
1889
|
+
`--command` (the command it concerns, or the one you expected to exist),
|
|
1890
|
+
`--kind gap|bug|idea` (default gap), `--error` (paste it verbatim),
|
|
1891
|
+
`--workaround` (what you did instead), `--detail` / `--detail-file`, `--org`,
|
|
1892
|
+
`--priority low|normal|high` (high = it blocked the job).
|
|
1893
|
+
- **Duplicates are caught before they are filed.** A report that closely
|
|
1894
|
+
matches an OPEN request is refused and names it; add yourself to that one
|
|
1895
|
+
with `hit` instead, or pass `--new` if yours is genuinely different.
|
|
1896
|
+
`--dry-run` shows the similar requests and files nothing.
|
|
1897
|
+
- **`hit`** is "this bit me too": it adds your case and counts it. `list` is
|
|
1898
|
+
ordered by hits, so the requests that cost the team most get built first.
|
|
1899
|
+
`note` adds context without counting.
|
|
1900
|
+
- **`status`** (open / planned / in_progress / done / declined / duplicate) is
|
|
1901
|
+
for the CLI maintainers (Matt and Gidon). You can withdraw your OWN request
|
|
1902
|
+
with `--to declined` or `--to duplicate --of <n>`. `done` and `declined` need
|
|
1903
|
+
`--note`: closing a request **emails everyone who reported or hit it** (from
|
|
1904
|
+
FlowIQ (CLI), reply-to the person who closed it), with the version to update
|
|
1905
|
+
to. `--no-notify` closes quietly.
|
|
1906
|
+
- **A command or flag that does not exist** now ends its error with the exact
|
|
1907
|
+
`flowiq gaps report …` line to file it, `--command` and `--error` filled in.
|
|
1908
|
+
- Every report / hit / note / status change is audited
|
|
1909
|
+
(`flowiq audit --endpoint gaps`). A report or hit from anyone but Matt also
|
|
1910
|
+
emails him through the standing CLI-write alert.
|
|
1911
|
+
|
|
1858
1912
|
### Chat export — `flowiq export chats <organization_id> [--out <path>]`
|
|
1859
1913
|
|
|
1860
1914
|
Full chat history → TXT, byte-identical to the in-app "Export Settings TXT"
|
|
@@ -1894,6 +1948,12 @@ flowiq test cleanup <organization_id> --confirm # delete the stress contact
|
|
|
1894
1948
|
`--agent <id>` targets a specific (non-active) agent. `test stress cleanup` is
|
|
1895
1949
|
destructive and requires `--confirm`.
|
|
1896
1950
|
|
|
1951
|
+
`test clear` (and `--clear` on `send`, and the clear between scenario turns) hides the
|
|
1952
|
+
messages (`memory_cutoff`) AND sets aside the agent's remembered tool results
|
|
1953
|
+
(`more_data.tool_usage_history`, which the agent reads as TOOL USAGE HISTORY) under
|
|
1954
|
+
`more_data.tool_usage_history_cleared`, so a cleared test really starts fresh. Since 0.9.6;
|
|
1955
|
+
before that earlier tool results and escalation reasons leaked into "cleared" tests.
|
|
1956
|
+
|
|
1897
1957
|
### Guide — `flowiq guide`
|
|
1898
1958
|
|
|
1899
1959
|
Read the bundled docs in the terminal — no digging through node_modules.
|
package/TEAM-GUIDE.md
CHANGED
|
@@ -70,6 +70,12 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
70
70
|
goes back to the agent you pulled — never "whatever is active now".
|
|
71
71
|
5. Run the CLI from the repo root when you can — `./.flowiq/` is gitignored
|
|
72
72
|
there.
|
|
73
|
+
6. **Hit a wall? File it.** Whenever the CLI cannot do something and you (or
|
|
74
|
+
your Claude session) fell back to the dashboard, SQL or a script, file it:
|
|
75
|
+
`flowiq gaps report "<what you needed>" --command "<topic>" --error "<the
|
|
76
|
+
exact error>"`. Already on the list? `flowiq gaps hit <number> --note "…"`
|
|
77
|
+
instead. The most-hit requests get built first, and you are emailed when
|
|
78
|
+
yours ships. Tell your Claude session to do this too.
|
|
73
79
|
|
|
74
80
|
## Everyday tasks
|
|
75
81
|
|
|
@@ -116,10 +122,14 @@ When you see it, run `npm i -g @flowapt/flowiq-cli` — a stale version also mea
|
|
|
116
122
|
| Agent can't quote ANY price / "I can't pull the live menu" | `flowiq agent config <org_id> --tool product_lookup=true` — name-based fuzzy lookup that works without embeddings or a live OpenAI key (semantic `get_product_info` needs both) |
|
|
117
123
|
| Client wants leads/requests emailed to their team instead of a human escalation | `flowiq agent config <org_id> --tool email_request_tool=true`, then set the recipient list in Agents → Tool Library → Email Request to Team (no CLI path for the addresses yet) |
|
|
118
124
|
| Talk to the live agent safely (no real WhatsApp ever sent) | `flowiq test send <org_id> "hi, do you sell X?"` |
|
|
125
|
+
| Start a test chat from scratch (agent forgets the earlier test messages AND its earlier tool results) | `flowiq test clear <org_id>` (or `flowiq test send <org_id> "…" --clear`) |
|
|
119
126
|
| **Tell the team what shipped** (the FlowIQ team update email) | `flowiq updates draft` → enrich the JSON (screenshots via `flowiq updates asset`, a *For the team* note per headline) → `flowiq updates preview <file> --open` → `flowiq updates send <file> --test` → `flowiq updates send <file> --yes` |
|
|
120
127
|
| Can I use `flowiq updates`? | Yes: `status`, `uncovered`, `draft`, `preview` and `send --test` (a copy to yourself) work for everyone. Only the real `send --yes` to the whole team is Matt's; anyone else gets a 403 there |
|
|
121
128
|
| Which changelog rows has nobody emailed the team about yet? | `flowiq updates status` / `flowiq updates uncovered` — anything left 20h+ goes out automatically at 08:30 SAST as a plain digest |
|
|
122
129
|
| Set which contact `flowiq test` uses (use a FAKE number!) | `flowiq agent config <org_id> --test-contact-number 27000000001 --test-contact-name "QA Bot"` |
|
|
130
|
+
| **The CLI can't do something I need** (or does it wrong, or should do more) | `flowiq gaps report "agent config cannot set tool_instructions" --command "agent config" --error "<exact error>" --workaround "<what you did instead>"`. If it is already on the list you are told its number: `flowiq gaps hit <number> --note "…"` |
|
|
131
|
+
| See what the team has asked the CLI to do | `flowiq gaps list` (open, most-hit first) · `flowiq gaps list --mine` · `flowiq gaps show <number>` |
|
|
132
|
+
| Close a request (Matt / Gidon) | `flowiq gaps status <number> --to done --version 0.9.8 --note "what shipped"` — emails everyone who reported or hit it |
|
|
123
133
|
> **Publishing the CLI (maintainers only):** publish from a clean clone, never
|
|
124
134
|
> from your working tree — `npm publish` packs whatever is on disk. A
|
|
125
135
|
> `prepublishOnly` check now stops you if `cli/` differs from `origin/main`.
|
|
@@ -220,6 +230,7 @@ several.
|
|
|
220
230
|
| Generate a client's monthly deck (metrics + copy) after the month has frozen | `flowiq report deck generate <org_id> 2026-08` — then review it at /reporting/deck |
|
|
221
231
|
| See whether a client deck can be approved, and what is still missing | `flowiq report deck status <org_id> 2026-08` |
|
|
222
232
|
| Send yourself a test copy of a client deck (PDF from flowiq@flowapt.com) | `flowiq report deck send <org_id> 2026-08 --test-to you@flowapt.com` — status untouched |
|
|
233
|
+
| Take a tile, pill or card off a client deck (and its PDF) for one month | On `/reporting/deck` open **Inputs** (the rail beside the slides) → Show or hide; or `flowiq report deck inputs <org_id> 2026-08 --file inputs.json` with `{ "hidden": { "tile:tail": true } }` |
|
|
223
234
|
| Approve a client deck / send it to the client | `flowiq report deck approve <org_id> 2026-08` then `… send … --client` (or let the 09:00 SAST schedule send it on the 5th) |
|
|
224
235
|
| Re-read this guide / the full command reference | `flowiq guide` / `flowiq guide --reference` |
|
|
225
236
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@flowapt/flowiq-cli",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.7",
|
|
4
4
|
"description": "Command-line tool for FlowIQ staff: round-trip agent prompts, questionnaires, fine-tuning, pin-board tasks, webhooks, templates, agent-updates, chat exports, and live agent testing without ever touching service-role credentials.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -146,7 +146,8 @@ export async function clear(orgId, opts = {}) {
|
|
|
146
146
|
let resp;
|
|
147
147
|
try { resp = await clearConv(orgId, opts.agent); }
|
|
148
148
|
catch (e) { console.error(`Clear failed: ${e.message}`); process.exit(1); }
|
|
149
|
-
|
|
149
|
+
const tools = Number(resp.tools_cleared || 0);
|
|
150
|
+
console.log(`Cleared web conversation for contact ${resp.contact_id} (history hidden via memory_cutoff; ${tools} remembered tool result(s) set aside in more_data.tool_usage_history_cleared; onboarding/disclaimer state NOT reset).`);
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
// ---------- phase 2: stress + extract ----------
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
// `flowiq gaps report|list|show|hit|note|status` — CLI gap reports and feature
|
|
2
|
+
// requests (alias `flowiq requests`), via /cli/gaps.
|
|
3
|
+
// report file one: something the CLI cannot do, does wrong, or should do
|
|
4
|
+
// list open requests, most-hit first (every status with --status all)
|
|
5
|
+
// show one request in full, with every hit, note and status change
|
|
6
|
+
// hit "this bit me too": +1 with what you were doing (the ranking signal)
|
|
7
|
+
// note add context without counting a hit
|
|
8
|
+
// status planned / in_progress / done / declined / duplicate (maintainers;
|
|
9
|
+
// closing emails everyone who reported or hit it)
|
|
10
|
+
// Anyone with a staff key can report, hit and note — including a Claude
|
|
11
|
+
// session driving the CLI, which is marked `claude` automatically.
|
|
12
|
+
|
|
13
|
+
import fs from "node:fs/promises";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { http } from "../http.js";
|
|
16
|
+
|
|
17
|
+
const KINDS = ["gap", "idea", "bug"];
|
|
18
|
+
const STATUSES = ["open", "planned", "in_progress", "done", "declined", "duplicate"];
|
|
19
|
+
const STATUS_ORDER = ["in_progress", "planned", "open", "done", "declined", "duplicate"];
|
|
20
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
21
|
+
|
|
22
|
+
/** Who is typing: a Claude session, a script/pipe, or a person at a terminal. */
|
|
23
|
+
export function filedVia(env = process.env, stdin = process.stdin, stdout = process.stdout) {
|
|
24
|
+
if (env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT) return "claude";
|
|
25
|
+
if (!stdin?.isTTY || !stdout?.isTTY) return "script";
|
|
26
|
+
return "human";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** "42", "#42" or a UUID; anything else is refused before a request is made. */
|
|
30
|
+
export function normaliseRef(ref) {
|
|
31
|
+
const raw = String(ref ?? "").trim().replace(/^#/, "");
|
|
32
|
+
if (/^\d{1,12}$/.test(raw)) return raw;
|
|
33
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) return raw;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sastParts(ts) {
|
|
38
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
39
|
+
timeZone: "Africa/Johannesburg",
|
|
40
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
41
|
+
hour: "2-digit", minute: "2-digit", hour12: false,
|
|
42
|
+
}).formatToParts(new Date(ts));
|
|
43
|
+
return Object.fromEntries(parts.map((p) => [p.type, p.value]));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fmtTs(ts) {
|
|
47
|
+
if (!ts) return "-";
|
|
48
|
+
const p = sastParts(ts);
|
|
49
|
+
const hour = p.hour === "24" ? "00" : p.hour;
|
|
50
|
+
return `${Number(p.day)} ${MONTHS[Number(p.month) - 1]} ${p.year} ${hour}:${p.minute}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function ago(ts, now = Date.now()) {
|
|
54
|
+
if (!ts) return "-";
|
|
55
|
+
const mins = Math.max(0, Math.round((now - new Date(ts).getTime()) / 60000));
|
|
56
|
+
if (mins < 60) return `${mins}m ago`;
|
|
57
|
+
const hrs = Math.round(mins / 60);
|
|
58
|
+
if (hrs < 48) return `${hrs}h ago`;
|
|
59
|
+
return `${Math.round(hrs / 24)}d ago`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function who(email) {
|
|
63
|
+
if (!email) return "?";
|
|
64
|
+
return String(email).split("@")[0];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function indent(text, pad = " ") {
|
|
68
|
+
return String(text ?? "").trim().split("\n").map((l) => `${pad}${l}`).join("\n");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function fail(prefix, e) {
|
|
72
|
+
console.error(`${prefix}: ${e.message}`);
|
|
73
|
+
if (e.body?.error && !String(e.message).includes(e.body.error)) console.error(` ${e.body.error}`);
|
|
74
|
+
process.exit(1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function requireRef(ref) {
|
|
78
|
+
const r = normaliseRef(ref);
|
|
79
|
+
if (!r) {
|
|
80
|
+
console.error(`Error: "${ref}" is not a request number (e.g. 42 or #42). Find it with \`flowiq gaps list\`.`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
83
|
+
return r;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printSimilar(similar, heading) {
|
|
87
|
+
if (!similar?.length) return;
|
|
88
|
+
console.log(heading);
|
|
89
|
+
for (const s of similar) {
|
|
90
|
+
const shipped = s.status === "done" && s.resolved_version ? ` in ${s.resolved_version}` : "";
|
|
91
|
+
console.log(` #${s.number} ${s.title} [${s.status}${shipped} · ${s.hit_count} hit(s) · match ${s.score}]`);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ── report ──────────────────────────────────────────────────────────────────
|
|
96
|
+
export async function report(titleWords, opts = {}) {
|
|
97
|
+
const title = (Array.isArray(titleWords) ? titleWords.join(" ") : String(titleWords || "")).trim();
|
|
98
|
+
if (title.length < 3) {
|
|
99
|
+
console.error('Error: give a one-line title, e.g. flowiq gaps report "agent config cannot set tool_instructions" --command "agent config"');
|
|
100
|
+
process.exit(1);
|
|
101
|
+
}
|
|
102
|
+
const kind = String(opts.kind || "gap").toLowerCase();
|
|
103
|
+
if (!KINDS.includes(kind)) {
|
|
104
|
+
console.error(`Error: --kind must be ${KINDS.join(" | ")} (gap = the CLI cannot do it, bug = it does it wrong, idea = it should).`);
|
|
105
|
+
process.exit(1);
|
|
106
|
+
}
|
|
107
|
+
let detail = opts.detail ?? null;
|
|
108
|
+
if (opts.detailFile) {
|
|
109
|
+
try {
|
|
110
|
+
detail = await fs.readFile(path.resolve(process.cwd(), opts.detailFile), "utf8");
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`Error: cannot read --detail-file ${opts.detailFile}: ${e.message}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let resp;
|
|
118
|
+
try {
|
|
119
|
+
resp = await http.post("gaps", {
|
|
120
|
+
action: "report",
|
|
121
|
+
title,
|
|
122
|
+
kind,
|
|
123
|
+
command: opts.command ?? null,
|
|
124
|
+
detail,
|
|
125
|
+
error_text: opts.error ?? null,
|
|
126
|
+
workaround: opts.workaround ?? null,
|
|
127
|
+
priority: opts.priority ?? "normal",
|
|
128
|
+
organization_id: opts.org ?? null,
|
|
129
|
+
filed_via: filedVia(),
|
|
130
|
+
force_new: Boolean(opts.new),
|
|
131
|
+
dry_run: Boolean(opts.dryRun),
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
if (e.status === 409) {
|
|
135
|
+
console.error(e.body?.error || e.message);
|
|
136
|
+
printSimilar(e.body?.similar, "\nAlready on the list:");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
fail("Report failed", e);
|
|
140
|
+
}
|
|
141
|
+
if (opts.json) {
|
|
142
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (resp.dry_run) {
|
|
146
|
+
console.log(`DRY RUN · nothing filed · "${title}"`);
|
|
147
|
+
if (!resp.similar?.length) console.log(" No similar requests on the list. File it by re-running without --dry-run.");
|
|
148
|
+
printSimilar(resp.similar, "Similar requests:");
|
|
149
|
+
if (resp.would_block) console.log("\nA real report would be held as a duplicate: hit the matching one, or pass --new if it is different.");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const r = resp.request;
|
|
153
|
+
console.log(`Filed #${r.number} (${r.kind}${r.command ? ` · flowiq ${r.command}` : ""}): ${r.title}`);
|
|
154
|
+
console.log(` by ${r.reporter_email ?? "?"} via ${r.filed_via}${r.organization_name ? ` · org ${r.organization_name}` : ""}`);
|
|
155
|
+
printSimilar(resp.similar, "\nPossibly related:");
|
|
156
|
+
console.log(`\nTrack it: flowiq gaps show ${r.number} · hit it again next time: flowiq gaps hit ${r.number} --note "…"`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── list ────────────────────────────────────────────────────────────────────
|
|
160
|
+
export async function list(opts = {}) {
|
|
161
|
+
const query = {};
|
|
162
|
+
if (opts.status) query.status = opts.status;
|
|
163
|
+
if (opts.command) query.command = opts.command;
|
|
164
|
+
if (opts.kind) query.kind = opts.kind;
|
|
165
|
+
if (opts.mine) query.mine = "1";
|
|
166
|
+
if (opts.search) query.search = opts.search;
|
|
167
|
+
if (opts.sort) query.sort = opts.sort;
|
|
168
|
+
if (opts.limit != null) query.limit = String(opts.limit);
|
|
169
|
+
|
|
170
|
+
let resp;
|
|
171
|
+
try {
|
|
172
|
+
resp = await http.get("gaps", query);
|
|
173
|
+
} catch (e) {
|
|
174
|
+
fail("List failed", e);
|
|
175
|
+
}
|
|
176
|
+
if (opts.json) {
|
|
177
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const totals = STATUS_ORDER.filter((s) => resp.counts?.[s]).map((s) => `${s} ${resp.counts[s]}`).join(" · ");
|
|
182
|
+
console.log(`CLI requests · status ${resp.status_filter} · ${resp.total} shown · every status: ${totals || "none yet"}`);
|
|
183
|
+
const rows = resp.requests || [];
|
|
184
|
+
if (!rows.length) {
|
|
185
|
+
console.log(" (nothing matches)");
|
|
186
|
+
console.log('\nFile one: flowiq gaps report "<what the CLI cannot do>" --command "<topic>"');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
for (const r of rows) {
|
|
190
|
+
const cmd = r.command ? `[${r.command}] ` : "";
|
|
191
|
+
const shipped = r.status === "done" && r.resolved_version ? ` ${r.resolved_version}` : "";
|
|
192
|
+
console.log(`\n#${r.number} ${r.kind.padEnd(4)} ×${r.hit_count} ${cmd}${r.title}`);
|
|
193
|
+
const bits = [
|
|
194
|
+
`${r.status}${shipped}`,
|
|
195
|
+
r.priority !== "normal" ? `${r.priority} priority` : null,
|
|
196
|
+
`by ${who(r.reporter_email)}${r.filed_via === "claude" ? " (Claude)" : ""} ${ago(r.created_at)}`,
|
|
197
|
+
r.hit_count > 1 ? `last hit ${ago(r.last_hit_at)}` : null,
|
|
198
|
+
r.organization_name ? `org ${r.organization_name}` : null,
|
|
199
|
+
].filter(Boolean);
|
|
200
|
+
console.log(` ${bits.join(" · ")}`);
|
|
201
|
+
}
|
|
202
|
+
if (resp.truncated) console.log(`\n⚠ the limit (${resp.limit}) was reached: pass --limit, or narrow with --status / --command / --search.`);
|
|
203
|
+
console.log("\nOne in full: flowiq gaps show <number> · same problem? flowiq gaps hit <number> --note \"…\"");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── show ────────────────────────────────────────────────────────────────────
|
|
207
|
+
export async function show(ref, opts = {}) {
|
|
208
|
+
const r0 = requireRef(ref);
|
|
209
|
+
let resp;
|
|
210
|
+
try {
|
|
211
|
+
resp = await http.get("gaps", { ref: r0 });
|
|
212
|
+
} catch (e) {
|
|
213
|
+
fail("Show failed", e);
|
|
214
|
+
}
|
|
215
|
+
if (opts.json) {
|
|
216
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const r = resp.request;
|
|
220
|
+
console.log(`#${r.number} ${r.title}`);
|
|
221
|
+
console.log(` kind: ${r.kind}${r.command ? ` · flowiq ${r.command}` : ""} · priority ${r.priority}`);
|
|
222
|
+
console.log(` status: ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}${resp.duplicate_of ? ` of #${resp.duplicate_of.number} "${resp.duplicate_of.title}"` : ""}`);
|
|
223
|
+
console.log(` hits: ${r.hit_count} · last ${fmtTs(r.last_hit_at)} SAST`);
|
|
224
|
+
console.log(` filed: ${fmtTs(r.created_at)} SAST by ${r.reporter_email ?? "?"} via ${r.filed_via ?? "?"}${r.cli_version ? ` (CLI ${r.cli_version})` : ""}`);
|
|
225
|
+
if (r.organization_name) console.log(` org: ${r.organization_name}`);
|
|
226
|
+
if (r.resolved_at) console.log(` closed: ${fmtTs(r.resolved_at)} SAST by ${r.resolved_by_email ?? "?"}`);
|
|
227
|
+
console.log(` id: ${r.id}`);
|
|
228
|
+
if (r.detail) console.log(`\nDetail\n${indent(r.detail, " ")}`);
|
|
229
|
+
if (r.error_text) console.log(`\nError (verbatim)\n${indent(r.error_text, " ")}`);
|
|
230
|
+
if (r.workaround) console.log(`\nWorkaround used\n${indent(r.workaround, " ")}`);
|
|
231
|
+
if (r.resolution_note) console.log(`\nResolution\n${indent(r.resolution_note, " ")}`);
|
|
232
|
+
|
|
233
|
+
const events = resp.events || [];
|
|
234
|
+
if (events.length) {
|
|
235
|
+
console.log(`\nHistory (${events.length})`);
|
|
236
|
+
for (const e of events) {
|
|
237
|
+
const head = e.kind === "status"
|
|
238
|
+
? `status ${e.from_status} → ${e.to_status}`
|
|
239
|
+
: e.kind === "hit" ? "hit again" : "note";
|
|
240
|
+
const org = e.organization_name ? ` · ${e.organization_name}` : "";
|
|
241
|
+
console.log(` ${fmtTs(e.created_at)} ${who(e.user_email)}${e.filed_via === "claude" ? " (Claude)" : ""}: ${head}${org}`);
|
|
242
|
+
if (e.body) console.log(indent(e.body, " "));
|
|
243
|
+
if (e.error_text) console.log(indent(`error: ${e.error_text}`, " "));
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
console.log(`\nSame problem? flowiq gaps hit ${r.number} --note "what you were doing"`);
|
|
247
|
+
console.log(`Maintainers (${(resp.editors || []).join(", ")}): flowiq gaps status ${r.number} --to done --version <x.y.z> --note "…"`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── hit ─────────────────────────────────────────────────────────────────────
|
|
251
|
+
export async function hit(ref, opts = {}) {
|
|
252
|
+
const r0 = requireRef(ref);
|
|
253
|
+
let resp;
|
|
254
|
+
try {
|
|
255
|
+
resp = await http.post("gaps", {
|
|
256
|
+
action: "hit",
|
|
257
|
+
ref: r0,
|
|
258
|
+
note: opts.note ?? null,
|
|
259
|
+
error_text: opts.error ?? null,
|
|
260
|
+
organization_id: opts.org ?? null,
|
|
261
|
+
filed_via: filedVia(),
|
|
262
|
+
});
|
|
263
|
+
} catch (e) {
|
|
264
|
+
fail("Hit failed", e);
|
|
265
|
+
}
|
|
266
|
+
if (opts.json) {
|
|
267
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
const r = resp.request;
|
|
271
|
+
console.log(`+1 on #${r.number} (${resp.hit_count} hits): ${r.title}`);
|
|
272
|
+
if (r.status === "done") {
|
|
273
|
+
console.log(` ⚠ this is marked DONE${r.resolved_version ? ` in ${r.resolved_version}` : ""}. Update first (npm i -g @flowapt/flowiq-cli@latest); if it still fails, say so with: flowiq gaps note ${r.number} "still fails on <version>: …"`);
|
|
274
|
+
} else if (r.status !== "open") {
|
|
275
|
+
console.log(` status: ${r.status}`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── note ────────────────────────────────────────────────────────────────────
|
|
280
|
+
export async function note(ref, textWords, opts = {}) {
|
|
281
|
+
const r0 = requireRef(ref);
|
|
282
|
+
const text = (Array.isArray(textWords) ? textWords.join(" ") : String(textWords || "")).trim();
|
|
283
|
+
if (!text) {
|
|
284
|
+
console.error("Error: give the note text, e.g. flowiq gaps note 42 \"also happens with --agent\"");
|
|
285
|
+
process.exit(1);
|
|
286
|
+
}
|
|
287
|
+
let resp;
|
|
288
|
+
try {
|
|
289
|
+
resp = await http.post("gaps", { action: "note", ref: r0, body: text });
|
|
290
|
+
} catch (e) {
|
|
291
|
+
fail("Note failed", e);
|
|
292
|
+
}
|
|
293
|
+
if (opts.json) {
|
|
294
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
console.log(`Noted on #${resp.request.number}: ${resp.request.title}`);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// ── status ──────────────────────────────────────────────────────────────────
|
|
301
|
+
export async function status(ref, opts = {}) {
|
|
302
|
+
const r0 = requireRef(ref);
|
|
303
|
+
const to = String(opts.to || "").toLowerCase();
|
|
304
|
+
if (!STATUSES.includes(to)) {
|
|
305
|
+
console.error(`Error: --to must be one of ${STATUSES.join(" | ")} (got "${opts.to ?? ""}").`);
|
|
306
|
+
process.exit(1);
|
|
307
|
+
}
|
|
308
|
+
if (["done", "declined"].includes(to) && !(opts.note && opts.note.trim())) {
|
|
309
|
+
console.error(`Error: --note is required for ${to}. The people who asked get it by email: what shipped, or why not.`);
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
let dupRef = null;
|
|
313
|
+
if (to === "duplicate") {
|
|
314
|
+
dupRef = normaliseRef(opts.of);
|
|
315
|
+
if (!dupRef) {
|
|
316
|
+
console.error("Error: --to duplicate needs --of <number>: the request this one duplicates.");
|
|
317
|
+
process.exit(1);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
let resp;
|
|
321
|
+
try {
|
|
322
|
+
resp = await http.post("gaps", {
|
|
323
|
+
action: "status",
|
|
324
|
+
ref: r0,
|
|
325
|
+
status: to,
|
|
326
|
+
note: opts.note ?? null,
|
|
327
|
+
version: opts.version ?? null,
|
|
328
|
+
duplicate_of: dupRef,
|
|
329
|
+
notify: opts.notify !== false,
|
|
330
|
+
});
|
|
331
|
+
} catch (e) {
|
|
332
|
+
fail("Status change failed", e);
|
|
333
|
+
}
|
|
334
|
+
if (opts.json) {
|
|
335
|
+
console.log(JSON.stringify(resp, null, 2));
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
const r = resp.request;
|
|
339
|
+
console.log(`#${r.number} ${resp.previous_status} → ${r.status}${r.resolved_version ? ` (${r.resolved_version})` : ""}: ${r.title}`);
|
|
340
|
+
if (resp.duplicate_of) console.log(` merged into #${resp.duplicate_of.number} "${resp.duplicate_of.title}" (hits carried over)`);
|
|
341
|
+
const n = resp.notified || {};
|
|
342
|
+
if (n.skipped) return;
|
|
343
|
+
if (n.sent?.length) console.log(` emailed: ${n.sent.join(", ")}`);
|
|
344
|
+
else console.log(" nobody else to email (you were the only reporter)");
|
|
345
|
+
for (const f of n.failed || []) console.log(` ⚠ email to ${f.to} failed: ${f.error}`);
|
|
346
|
+
}
|
package/src/commands/plans.js
CHANGED
|
@@ -182,7 +182,9 @@ export async function show(planId, opts = {}) {
|
|
|
182
182
|
if (more.slides && typeof more.slides === "object") {
|
|
183
183
|
for (const [key, slide] of Object.entries(more.slides)) {
|
|
184
184
|
if (!slide || typeof slide !== "object") continue;
|
|
185
|
-
|
|
185
|
+
const idx = /^button_(\d+)$/.exec(slide.triggered_by ?? "")?.[1];
|
|
186
|
+
const tapped = idx !== undefined && Array.isArray(more.buttons) ? more.buttons[Number(idx)]?.text : null;
|
|
187
|
+
console.log(`\n${key}${tapped ? ` (sent when "${tapped}" is tapped)` : slide.triggered_by ? ` (after ${slide.triggered_by})` : ""}`);
|
|
186
188
|
if (slide.message) console.log(indent(slide.message));
|
|
187
189
|
if (slide.media_url) console.log(` media: ${slide.media_url}`);
|
|
188
190
|
}
|
|
@@ -191,7 +193,11 @@ export async function show(planId, opts = {}) {
|
|
|
191
193
|
const buttons = Array.isArray(more.buttons) ? more.buttons : [];
|
|
192
194
|
if (buttons.length) {
|
|
193
195
|
console.log("\nButtons");
|
|
194
|
-
|
|
196
|
+
// Marketing plans carry 0-10 buttons: quick_reply, url, phone, copy_code (app src/lib/broadcastPlanButtons.ts).
|
|
197
|
+
for (const b of buttons) {
|
|
198
|
+
const target = b?.url ? ` → ${b.url}` : b?.phone_number ? ` → call ${b.phone_number}` : b?.code ? ` → code ${b.code}` : "";
|
|
199
|
+
console.log(` [${b?.type ?? "?"}] ${b?.text ?? ""}${target}`);
|
|
200
|
+
}
|
|
195
201
|
}
|
|
196
202
|
|
|
197
203
|
console.log("\nCreative");
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// node --test src/gaps.test.mjs
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import assert from "node:assert/strict";
|
|
4
|
+
import { filedVia, normaliseRef, ago } from "./commands/gaps.js";
|
|
5
|
+
import { gapHint } from "./index.js";
|
|
6
|
+
|
|
7
|
+
test("filedVia: a Claude Code session is marked claude, even on a TTY", () => {
|
|
8
|
+
const tty = { isTTY: true };
|
|
9
|
+
assert.equal(filedVia({ CLAUDECODE: "1" }, tty, tty), "claude");
|
|
10
|
+
assert.equal(filedVia({ CLAUDE_CODE_ENTRYPOINT: "cli" }, tty, tty), "claude");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("filedVia: a person at a terminal vs a pipe or script", () => {
|
|
14
|
+
assert.equal(filedVia({}, { isTTY: true }, { isTTY: true }), "human");
|
|
15
|
+
assert.equal(filedVia({}, { isTTY: undefined }, { isTTY: true }), "script");
|
|
16
|
+
assert.equal(filedVia({}, { isTTY: true }, { isTTY: false }), "script");
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("normaliseRef: number, #number and uuid pass; anything else is refused", () => {
|
|
20
|
+
assert.equal(normaliseRef("42"), "42");
|
|
21
|
+
assert.equal(normaliseRef("#42"), "42");
|
|
22
|
+
assert.equal(normaliseRef(" 7 "), "7");
|
|
23
|
+
assert.equal(normaliseRef("0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b"), "0f1e2d3c-4b5a-6978-8a9b-0c1d2e3f4a5b");
|
|
24
|
+
assert.equal(normaliseRef("abc"), null);
|
|
25
|
+
assert.equal(normaliseRef("42a"), null);
|
|
26
|
+
assert.equal(normaliseRef(""), null);
|
|
27
|
+
assert.equal(normaliseRef(undefined), null);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("ago: minutes, hours, days", () => {
|
|
31
|
+
const now = Date.parse("2026-09-30T12:00:00Z");
|
|
32
|
+
assert.equal(ago("2026-09-30T11:55:00Z", now), "5m ago");
|
|
33
|
+
assert.equal(ago("2026-09-30T09:00:00Z", now), "3h ago");
|
|
34
|
+
assert.equal(ago("2026-09-26T12:00:00Z", now), "4d ago");
|
|
35
|
+
assert.equal(ago(null, now), "-");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("gapHint: only for a missing command / flag, with the topic filled in", () => {
|
|
39
|
+
const argv = ["node", "flowiq", "agent", "config", "0689d0be-ba76-43d9-a0d5-f4d59c1db537", "--tool-instructions", "x"];
|
|
40
|
+
const hint = gapHint("error: unknown option '--tool-instructions'\n", argv);
|
|
41
|
+
assert.match(hint, /flowiq gaps report/);
|
|
42
|
+
assert.match(hint, /--command "agent config"/);
|
|
43
|
+
assert.match(hint, /--error "unknown option '--tool-instructions'"/);
|
|
44
|
+
assert.equal(gapHint("error: missing required argument 'organization_id'\n", argv), "");
|
|
45
|
+
assert.match(gapHint("error: unknown command 'widgets'\n", ["node", "flowiq", "widgets", "list"]), /--command "widgets list"/);
|
|
46
|
+
// an id in second place is not part of the topic
|
|
47
|
+
assert.match(gapHint("error: too many arguments\n", ["node", "flowiq", "messages", "0689d0be-ba76-43d9-a0d5-f4d59c1db537"]), /--command "messages"/);
|
|
48
|
+
});
|
package/src/index.js
CHANGED
|
@@ -27,6 +27,7 @@ import * as agentConfigCmd from "./commands/agent-config.js";
|
|
|
27
27
|
import * as agentsCmd from "./commands/agents.js";
|
|
28
28
|
import * as agentUpdatesCmd from "./commands/agent-updates.js";
|
|
29
29
|
import * as plansCmd from "./commands/plans.js";
|
|
30
|
+
import * as gapsCmd from "./commands/gaps.js";
|
|
30
31
|
import * as exportCmd from "./commands/export.js";
|
|
31
32
|
import * as testCmd from "./commands/agent-test.js";
|
|
32
33
|
import * as knowledgeCmd from "./commands/knowledge.js";
|
|
@@ -48,6 +49,19 @@ import * as doctorCmd from "./commands/doctor.js";
|
|
|
48
49
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
49
50
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
50
51
|
|
|
52
|
+
/** The one-line "file it" hint appended to a missing-command / missing-flag error. */
|
|
53
|
+
export function gapHint(errorText, argv = []) {
|
|
54
|
+
if (!/unknown option|unknown command|too many arguments/i.test(String(errorText))) return "";
|
|
55
|
+
const args = argv.slice(2).filter((a) => !a.startsWith("-"));
|
|
56
|
+
// "agent config", "bc send", "org flags": a verb-shaped second word belongs to the topic.
|
|
57
|
+
const topic = (args[1] && /^[a-z][a-z-]*$/.test(args[1]) ? args.slice(0, 2) : args.slice(0, 1)).join(" ") || "<topic>";
|
|
58
|
+
const firstLine = String(errorText).split("\n")[0].replace(/^error:\s*/i, "").replace(/"/g, "'").trim();
|
|
59
|
+
return (
|
|
60
|
+
` → If the CLI should be able to do this, file it so it gets built:\n` +
|
|
61
|
+
` flowiq gaps report "<what you needed>" --command "${topic}" --error "${firstLine}"\n`
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
51
65
|
export function run(argv) {
|
|
52
66
|
// Non-blocking "you're behind" hint (stderr; cached; detached refresh).
|
|
53
67
|
maybeNotifyUpdate(pkg.version);
|
|
@@ -56,7 +70,12 @@ export function run(argv) {
|
|
|
56
70
|
program
|
|
57
71
|
.name("flowiq")
|
|
58
72
|
.description("FlowIQ staff CLI — round-trip agent prompts, webhooks, and more, without service-role credentials.")
|
|
59
|
-
.version(pkg.version)
|
|
73
|
+
.version(pkg.version)
|
|
74
|
+
// Set before any subcommand exists so every subcommand inherits it. When a
|
|
75
|
+
// command or flag does not exist, that is usually a gap: say how to file it,
|
|
76
|
+
// so a teammate (or their Claude session) records it instead of quietly
|
|
77
|
+
// falling back to SQL.
|
|
78
|
+
.configureOutput({ outputError: (str, write) => write(str + gapHint(str, argv)) });
|
|
60
79
|
|
|
61
80
|
// auth
|
|
62
81
|
const auth = program.command("auth").description("Manage CLI authentication");
|
|
@@ -579,6 +598,60 @@ export function run(argv) {
|
|
|
579
598
|
.option("--commit", "apply the change (default is a dry run)")
|
|
580
599
|
.action((planId, opts) => plansCmd.status(planId, opts));
|
|
581
600
|
|
|
601
|
+
// gaps (CLI gap reports + feature requests, filed by anyone incl. a Claude session)
|
|
602
|
+
const gaps = program.command("gaps")
|
|
603
|
+
.alias("requests")
|
|
604
|
+
.description("Found something the CLI can't do (or does wrong)? `report` it; `list` what is asked for; `hit` one that bit you too");
|
|
605
|
+
gaps.command("report <title...>")
|
|
606
|
+
.description("File a gap / bug / idea (one-line title). Refused with the matching number if it is already on the list")
|
|
607
|
+
.option("--command <topic>", "the flowiq command it concerns, e.g. \"agent config\" or \"bc send\" (none yet? name the one you expected)")
|
|
608
|
+
.option("--kind <kind>", "gap (default: the CLI cannot do it) | bug (it does it wrong) | idea (it should)")
|
|
609
|
+
.option("--detail <text>", "what you were trying to do and what should happen")
|
|
610
|
+
.option("--detail-file <path>", "read --detail from a file")
|
|
611
|
+
.option("--error <text>", "the exact error the CLI printed, verbatim")
|
|
612
|
+
.option("--workaround <text>", "what you did instead (SQL, the dashboard, a script…)")
|
|
613
|
+
.option("--org <uuid>", "the org you were working on, for context")
|
|
614
|
+
.option("--priority <p>", "low | normal (default) | high — high = it blocked the job")
|
|
615
|
+
.option("--new", "file it even though a similar open request exists")
|
|
616
|
+
.option("--dry-run", "only show similar requests; file nothing")
|
|
617
|
+
.option("--json", "print the raw response")
|
|
618
|
+
.action((titleWords, opts) => gapsCmd.report(titleWords, opts));
|
|
619
|
+
gaps.command("list")
|
|
620
|
+
.description("Requests, most-hit first (default: open = open, planned, in_progress)")
|
|
621
|
+
.option("--status <list>", "open (default) | all | open | planned | in_progress | done | declined | duplicate (comma-separated allowed)")
|
|
622
|
+
.option("--command <topic>", "only requests about this command (contains)")
|
|
623
|
+
.option("--kind <kind>", "gap | bug | idea")
|
|
624
|
+
.option("--search <text>", "title / detail / error contains this text")
|
|
625
|
+
.option("--mine", "only requests you filed")
|
|
626
|
+
.option("--sort <how>", "hits (default) | new | old | touched")
|
|
627
|
+
.option("--limit <n>", "max rows (default 100, max 500)")
|
|
628
|
+
.option("--json", "print the raw response")
|
|
629
|
+
.action((opts) => gapsCmd.list(opts));
|
|
630
|
+
gaps.command("show <number>")
|
|
631
|
+
.description("One request in full: detail, verbatim error, every hit, note and status change")
|
|
632
|
+
.option("--json", "print the raw response")
|
|
633
|
+
.action((ref, opts) => gapsCmd.show(ref, opts));
|
|
634
|
+
gaps.command("hit <number>")
|
|
635
|
+
.description("It bit you too: +1 with what you were doing (the most-hit requests get built first)")
|
|
636
|
+
.option("--note <text>", "what you were doing when you hit it")
|
|
637
|
+
.option("--error <text>", "the exact error, if it differs")
|
|
638
|
+
.option("--org <uuid>", "the org you were working on")
|
|
639
|
+
.option("--json", "print the raw response")
|
|
640
|
+
.action((ref, opts) => gapsCmd.hit(ref, opts));
|
|
641
|
+
gaps.command("note <number> <text...>")
|
|
642
|
+
.description("Add context to a request without counting a hit")
|
|
643
|
+
.option("--json", "print the raw response")
|
|
644
|
+
.action((ref, textWords, opts) => gapsCmd.note(ref, textWords, opts));
|
|
645
|
+
gaps.command("status <number>")
|
|
646
|
+
.description("Maintainers: planned | in_progress | done | declined | duplicate. Closing emails everyone who reported or hit it")
|
|
647
|
+
.requiredOption("--to <status>", "open | planned | in_progress | done | declined | duplicate")
|
|
648
|
+
.option("--note <text>", "what shipped / why not (required for done + declined; the people who asked read it)")
|
|
649
|
+
.option("--version <x.y.z>", "the CLI version the fix shipped in (done)")
|
|
650
|
+
.option("--of <number>", "with --to duplicate: the request this one duplicates (hits move across)")
|
|
651
|
+
.option("--no-notify", "close without emailing the people who asked")
|
|
652
|
+
.option("--json", "print the raw response")
|
|
653
|
+
.action((ref, opts) => gapsCmd.status(ref, opts));
|
|
654
|
+
|
|
582
655
|
// audit (who did what, when — with the full before/after content)
|
|
583
656
|
const audit = program.command("audit")
|
|
584
657
|
.description("Staff-CLI audit trail: who changed what, when — with full before/after content")
|