@devflow-one/cli 0.1.1 → 0.1.3

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 CHANGED
@@ -11,16 +11,89 @@ provider API logic lives here — the owning browser profile executes the action
11
11
 
12
12
  ## How it works
13
13
 
14
- - Each command maps to one gateway action; global flags (`--issue` / `--project`
15
- / `--profile` / `--repo`) supply routing, the command supplies its own args.
14
+ - Each command maps to one gateway action. **Routing is inferred from the
15
+ current directory** — no flags needed inside a configured repo; the command
16
+ supplies its own args.
16
17
  - The gateway routes the action to the connected browser profile that owns the
17
18
  project key, waits for the profile's result, and returns it.
18
19
  - Discovery: `list-actions` and `list-profiles` read straight from the live
19
20
  gateway registry (advertised actions; connected profiles + owned keys).
21
+ `where` shows what the current directory resolves to.
20
22
  - Failures come back as structured errors (`NO_PROFILE`, `AMBIGUOUS_PROFILE`,
21
23
  `TIMEOUT`, `DESTRUCTIVE_BLOCKED`, or an unreachable daemon), printed with their
22
24
  code (`--json` for the machine-readable envelope).
23
25
 
26
+ ## Routing: inferred, flags override
27
+
28
+ Run a command inside a repo you've configured in the extension and it routes
29
+ itself — the CLI asks the desktop daemon which project owns this checkout:
30
+
31
+ ```sh
32
+ cd ~/work/acme-web # a configured repo, on branch ABC-123-login
33
+ devflow mr find # → finds the PR for ABC-123, no flags at all
34
+ ```
35
+
36
+ Inference goes through these sources, highest priority first. A lower one never
37
+ overrides a value a higher one already supplied:
38
+
39
+ | # | Source | Supplies |
40
+ | - | ------ | -------- |
41
+ | 1 | Flags — `--project` / `--issue` / `--repo` / `--profile` | whichever you pass |
42
+ | 2 | Env — `DEVFLOW_PROJECT` / `DEVFLOW_ISSUE` / `DEVFLOW_REPO` / `DEVFLOW_PROFILE` | whichever are set |
43
+ | 3 | Git remote `origin`, matched against your configured repos | project, repo, profile |
44
+ | 4 | The checkout path, matched against a repo's configured local path | project, repo, profile |
45
+ | 5 | The current branch name (`feature/ABC-123-login` → `ABC-123`) | issue key |
46
+ | 6 | A Jira-key-shaped ancestor directory (`~/work/ABC-123/src`) | issue key |
47
+
48
+ Every flag is therefore an **override**, never a requirement:
49
+
50
+ ```sh
51
+ devflow mr find # fully inferred
52
+ devflow mr find --issue ZZ-9 # the flag wins over the branch
53
+ devflow branch create x --project OTHER # act on a project other than this repo's
54
+ ```
55
+
56
+ Ambiguity is never guessed. If two configured repos match the checkout, the
57
+ command **fails naming both** and tells you which flag settles it:
58
+
59
+ ```
60
+ error [UsageError]: "branch create" could not be routed — this checkout matches
61
+ several configured projects (ABC (web via p1), XYZ (web-fork via p2)).
62
+ Pass --project (or --repo) to choose one.
63
+ ```
64
+
65
+ ### `devflow where`
66
+
67
+ Shows what this directory resolves to, where each value came from, and whether
68
+ the owning Chrome profile is actually connected — so you can see *why* a call
69
+ would fail before making it:
70
+
71
+ ```sh
72
+ $ devflow where
73
+ projectKey ABC (git remote)
74
+ issueKey ABC-123 (branch name)
75
+ repo repo-1 (git remote)
76
+ profile profile-a (git remote) — connected
77
+
78
+ $ devflow where --json # same thing, machine-readable
79
+ ```
80
+
81
+ It never routes and never acts, so it still reports when nothing matches (or
82
+ when the desktop app isn't running) instead of failing.
83
+
84
+ ### The owning Chrome profile must be open
85
+
86
+ Provider credentials live in the browser extension, so the profile that owns a
87
+ project is what actually executes the action. When the daemon knows the profile
88
+ but it isn't connected, the CLI says so by name rather than a bare `NO_PROFILE`:
89
+
90
+ ```
91
+ error [NO_PROFILE]: Chrome profile "profile-a" owns ABC but is not connected,
92
+ so this action cannot run. Open that Chrome profile and retry.
93
+ ```
94
+
95
+ `devflow where` flags the same condition up front.
96
+
24
97
  ## Prerequisites
25
98
 
26
99
  - The **DevFlow desktop app must be running** — it hosts the gateway and writes
@@ -69,19 +142,28 @@ The file is written owner-only (`0600`). Override for CI / non-standard installs
69
142
  | `DEVFLOW_GATEWAY_TOKEN` | Provide the token directly (skips the file lookup). |
70
143
  | `DEVFLOW_GATEWAY_PORT` | Gateway port (default `8787`). |
71
144
 
145
+ Routing defaults can also come from the environment — handy in CI or a shell
146
+ profile. They lose to the equivalent flag and win over anything inferred:
147
+ `DEVFLOW_PROJECT`, `DEVFLOW_ISSUE`, `DEVFLOW_REPO`, `DEVFLOW_PROFILE`.
148
+
72
149
  ## Usage
73
150
 
74
151
  ```sh
75
152
  # discovery
76
153
  devflow list-actions
77
154
  devflow list-profiles # connected profiles + owned keys
155
+ devflow where # what this directory resolves to
156
+
157
+ # inside a configured repo, routing is inferred — no flags
158
+ devflow mr find # issue key from the branch
159
+ devflow branch create feat/x
78
160
 
79
- # routed by project key
161
+ # or route explicitly, overriding whatever the directory implies
80
162
  devflow branch create feat/x --project ABC
81
163
  devflow mr create "Title" feat/x --body "desc" --project ABC
82
164
 
83
165
  # routed by issue key (project key = prefix before the last '-')
84
- devflow mr find ABC-123 --issue ABC-123
166
+ devflow mr find ABC-123
85
167
  devflow pipeline status 42 deadbeef --issue ABC-9
86
168
 
87
169
  # commit a managed worktree with the project's commit-message template
@@ -89,7 +171,7 @@ devflow commit template /wt/ABC-123 --values '{"summary":"fix login"}' --project
89
171
  devflow commit template /wt/ABC-123 --no-push --project ABC # commit locally, skip push
90
172
 
91
173
  # assembled fix-review-comments prompt (read-only; runs no AI, makes no writes)
92
- devflow mr fix-prompt 42 --issue ABC-123
174
+ devflow mr fix-prompt 42 # issue key inferred from the branch
93
175
 
94
176
  # notify the project's configured Slack channel(s)
95
177
  devflow slack notify "Review fixes ready on {pr}" --values '{"pr":"#42"}' --project ABC
@@ -109,6 +191,10 @@ devflow branch delete old --project ABC # refused
109
191
  devflow branch delete old --project ABC --force # executes
110
192
  ```
111
193
 
194
+ Inference never softens this gate — a destructive command run with fully
195
+ inferred routing still refuses without `--force`. Confirmation is always yours
196
+ to give explicitly, precisely because inference makes mis-targeting easier.
197
+
112
198
  ## Verify
113
199
 
114
200
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devflow-one/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Terminal CLI wrapping the local DevFlow gateway so shell scripts can trigger repo actions.",
5
5
  "keywords": [
6
6
  "devflow",
@@ -42,7 +42,9 @@
42
42
  "node": ">=20"
43
43
  },
44
44
  "scripts": {
45
- "test": "node --test \"src/tests/**/*.test.js\""
45
+ "sync:shared": "node ../scripts/sync-context-resolver.mjs",
46
+ "test": "node --test \"src/tests/**/*.test.js\"",
47
+ "prepublishOnly": "npm run sync:shared"
46
48
  },
47
49
  "devDependencies": {
48
50
  "release-it": "^20.0.1"
package/src/cli.js CHANGED
@@ -6,60 +6,60 @@ import { parseArgs } from "./args.js";
6
6
  import { resolveConfig, ConfigError } from "./config.js";
7
7
  import { gatewayRequest, GatewayError } from "./client.js";
8
8
  import { resolveCommand, listCommands, UsageError } from "./commands.js";
9
+ import {
10
+ createContextQuery,
11
+ resolveCliContext,
12
+ projectKeyFrom,
13
+ assertRoutable,
14
+ offlineProfileError,
15
+ formatWhere,
16
+ } from "./context.js";
9
17
 
10
18
  const USAGE = `devflow — trigger DevFlow repo actions via the local gateway
11
19
 
12
20
  Usage:
13
- devflow <command> [args] [--issue KEY | --project KEY] [--profile ID] [--json]
21
+ devflow <command> [args] [--issue KEY] [--project KEY] [--profile ID] [--json]
14
22
 
15
23
  Commands:
16
24
  ${listCommands()
17
25
  .map((c) => ` ${c}`)
18
26
  .join("\n")}
19
27
 
20
- Routing flags (one of --issue / --project required for action commands):
28
+ Routing is inferred from the current directory — git remote, checkout path and
29
+ branch name — so inside a configured repo no routing flag is needed. Every flag
30
+ below is an override, applied ahead of anything inferred:
21
31
  --issue ABC-123 Route by Jira issue key (project key = prefix before last '-').
22
32
  --project ABC Route by project key directly.
23
33
  --profile <id> Force a specific Chrome profile (disambiguates ambiguity).
24
34
  --repo <id|url> Select a repo when the project owns several.
25
35
 
36
+ Run \`devflow where\` to see what the current directory resolves to.
37
+
26
38
  Destructive actions (branch delete, mr close):
27
39
  --force, --yes Confirm a destructive action; required or it is refused.
40
+ Never inferred — inference makes mis-targeting easier.
28
41
 
29
42
  Output:
30
43
  --json Print machine-readable JSON (envelope) for scripts.
31
44
 
32
45
  Environment:
33
46
  DEVFLOW_GATEWAY_PORT Gateway port (default 8787).
34
- DEVFLOW_GATEWAY_TOKEN Override the auto-read token file.`;
35
-
36
- /** Attach the routing fields from global flags to an action request body. */
37
- function buildRoutingBody(flags) {
38
- const body = {};
39
-
40
- if (flags.issue) body.issueId = String(flags.issue);
41
- if (flags.project) body.projectKey = String(flags.project);
42
- if (flags.profile) body.profile = String(flags.profile);
43
-
44
- return body;
45
- }
47
+ DEVFLOW_GATEWAY_TOKEN Override the auto-read token file.
48
+ DEVFLOW_PROJECT / DEVFLOW_ISSUE / DEVFLOW_REPO / DEVFLOW_PROFILE
49
+ Routing defaults, overridden by the flags above.`;
46
50
 
47
51
  /**
48
- * The project key a payload should carry (from --project, or derived from
49
- * --issue). Uppercased to match how Jira keys are stored in the extension config
50
- * and the gateway routing rule (`issueKeyToProjectKey`).
52
+ * Attach the routing fields to an action request body. Built from the resolved
53
+ * context, so an inferred issue/project routes exactly like an explicit one.
51
54
  */
52
- function payloadProjectKey(flags) {
53
- if (flags.project) return String(flags.project).toUpperCase();
54
-
55
- if (flags.issue) {
56
- const issue = String(flags.issue);
57
- const lastDash = issue.lastIndexOf("-");
55
+ function buildRoutingBody(context) {
56
+ const body = {};
58
57
 
59
- return (lastDash === -1 ? issue : issue.slice(0, lastDash)).toUpperCase();
60
- }
58
+ if (context.issueKey) body.issueId = context.issueKey;
59
+ if (context.projectKey) body.projectKey = context.projectKey;
60
+ if (context.profile) body.profile = context.profile;
61
61
 
62
- return undefined;
62
+ return body;
63
63
  }
64
64
 
65
65
  /** Print the result of a GET/POST call, honoring --json. */
@@ -93,7 +93,11 @@ function printError(err, json, stderr) {
93
93
  /**
94
94
  * Run the CLI once.
95
95
  * @param {string[]} argv args after node/script (i.e. `process.argv.slice(2)`)
96
- * @param {{ stdout?: (s: string) => void, stderr?: (s: string) => void, request?: typeof gatewayRequest }} [io]
96
+ * @param {{
97
+ * stdout?: (s: string) => void, stderr?: (s: string) => void,
98
+ * request?: typeof gatewayRequest, cwd?: string,
99
+ * env?: Record<string, string | undefined>,
100
+ * }} [io] `cwd`/`env` are injectable so inference is testable without chdir.
97
101
  * @returns {Promise<number>} exit code
98
102
  */
99
103
  export async function run(argv, io = {}) {
@@ -121,17 +125,51 @@ export async function run(argv, io = {}) {
121
125
  const { key, command, rest } = match;
122
126
 
123
127
  let config;
128
+ let configError = null;
124
129
 
125
130
  try {
126
131
  config = resolveConfig();
127
132
  } catch (err) {
128
- if (err instanceof ConfigError) {
133
+ if (!(err instanceof ConfigError)) throw err;
134
+
135
+ configError = err;
136
+
137
+ // `where` is a diagnostic: it must still report what the checkout implies
138
+ // when there is no daemon to ask, since that is exactly when it is consulted.
139
+ if (command.method !== "LOCAL") {
129
140
  printError(err, Boolean(flags.json), stderr);
130
141
 
131
142
  return 1;
132
143
  }
133
144
 
134
- throw err;
145
+ config = null;
146
+ }
147
+
148
+ const resolveRouting = () =>
149
+ resolveCliContext(flags, {
150
+ cwd: io.cwd ?? process.cwd(),
151
+ env: io.env ?? process.env,
152
+ queryContext: config
153
+ ? createContextQuery(config, request)
154
+ : async () => {
155
+ // Report only what was actually checked — the token file. Whether the
156
+ // app is running is unknown here, since the gateway was never called.
157
+ throw new Error(configError?.message ?? "no gateway token available.");
158
+ },
159
+ });
160
+
161
+ // `where` explains the resolution instead of acting on it, so it never routes
162
+ // and never fails on an unroutable directory.
163
+ if (command.method === "LOCAL") {
164
+ const context = await resolveRouting();
165
+
166
+ if (flags.json) {
167
+ printResult(context, true, stdout);
168
+ } else {
169
+ stdout(formatWhere(context));
170
+ }
171
+
172
+ return 0;
135
173
  }
136
174
 
137
175
  try {
@@ -143,16 +181,13 @@ export async function run(argv, io = {}) {
143
181
  return 0;
144
182
  }
145
183
 
146
- // POST action: routing must be provided.
147
- const projectKey = payloadProjectKey(flags);
184
+ // POST action: routing comes from flags where given, inference otherwise.
185
+ const context = await resolveRouting();
148
186
 
149
- if (!projectKey && !flags.profile) {
150
- throw new UsageError(
151
- `"${key}" needs a target: pass --issue, --project, or --profile. Usage: ${command.usage}`,
152
- );
153
- }
187
+ assertRoutable(key, command, context, flags);
154
188
 
155
189
  // Destructive gate: refuse before touching the gateway unless confirmed.
190
+ // Deliberately independent of inference — a confirmed target is never guessed.
156
191
  const confirmed = Boolean(flags.force || flags.yes);
157
192
 
158
193
  if (command.destructive && !confirmed) {
@@ -161,14 +196,20 @@ export async function run(argv, io = {}) {
161
196
  );
162
197
  }
163
198
 
199
+ // The owning profile is known but closed: say so by name rather than letting
200
+ // the gateway answer with a bare NO_PROFILE.
201
+ const offline = offlineProfileError(context);
202
+
203
+ if (offline) throw offline;
204
+
164
205
  const action = command.resolveAction ? command.resolveAction(flags) : command.action;
165
- const args = command.buildArgs ? command.buildArgs(rest, flags) : [];
206
+ const args = command.buildArgs ? command.buildArgs(rest, flags, context) : [];
166
207
  const body = {
167
- ...buildRoutingBody(flags),
208
+ ...buildRoutingBody(context),
168
209
  ...(command.destructive ? { allowDestructive: true } : {}),
169
210
  payload: {
170
- projectKey,
171
- ...(flags.repo ? { repo: String(flags.repo) } : {}),
211
+ projectKey: projectKeyFrom(context),
212
+ ...(context.repo ? { repo: context.repo } : {}),
172
213
  args,
173
214
  },
174
215
  };
package/src/client.js CHANGED
@@ -4,11 +4,17 @@
4
4
 
5
5
  /** A gateway call failure carrying the server's error `code` (when present). */
6
6
  export class GatewayError extends Error {
7
- /** @param {string} message @param {string} [code] */
8
- constructor(message, code) {
7
+ /**
8
+ * @param {string} message
9
+ * @param {string} [code]
10
+ * @param {unknown} [result] the envelope's `result`, which some failures still
11
+ * carry — `/context` reports its ambiguity candidates there.
12
+ */
13
+ constructor(message, code, result) {
9
14
  super(message);
10
15
  this.name = "GatewayError";
11
16
  this.code = code;
17
+ this.result = result;
12
18
  }
13
19
  }
14
20
 
@@ -73,5 +79,9 @@ export async function gatewayRequest(config, method, path, body) {
73
79
 
74
80
  const error = envelope?.error ?? {};
75
81
 
76
- throw new GatewayError(error.message ?? `Gateway error (HTTP ${response.status}).`, error.code);
82
+ throw new GatewayError(
83
+ error.message ?? `Gateway error (HTTP ${response.status}).`,
84
+ error.code,
85
+ envelope?.result,
86
+ );
77
87
  }
package/src/commands.js CHANGED
@@ -2,8 +2,12 @@
2
2
  // discovery route or POSTs an action whose `payload.args` mirrors the positional
3
3
  // signature of the matching Repo store action (see extension gatewayActions.ts).
4
4
  //
5
- // Routing (projectKey / issueKey / profile) is attached by cli.js from the global
6
- // flags; a command only builds its `args`.
5
+ // Routing (projectKey / issueKey / profile) is attached by cli.js from the
6
+ // resolved context — flags where given, inferred from the checkout otherwise; a
7
+ // command only builds its `args`. `buildArgs` receives that context as its third
8
+ // parameter so a command whose own positional IS an issue key can fall back to it.
9
+ //
10
+ // Usage strings show routing in `[…]`: it is optional inside a configured repo.
7
11
 
8
12
  /** Parse a positional as an integer, or fail with a clear message. */
9
13
  function toInt(name, value) {
@@ -45,11 +49,11 @@ export class UsageError extends Error {
45
49
 
46
50
  /**
47
51
  * @typedef {Object} Command
48
- * @property {"GET" | "POST"} method
52
+ * @property {"GET" | "POST" | "LOCAL"} method `LOCAL` runs in-process, no action call.
49
53
  * @property {string} [path] For GET commands.
50
54
  * @property {string} [action] Gateway action name for POST commands.
51
55
  * @property {boolean} [destructive] Requires --force/--yes (gated by cli.js).
52
- * @property {(pos: string[], flags: Record<string, unknown>) => unknown[]} [buildArgs]
56
+ * @property {(pos: string[], flags: Record<string, unknown>, context?: object) => unknown[]} [buildArgs]
53
57
  * @property {string} usage One-line usage string.
54
58
  */
55
59
 
@@ -65,11 +69,17 @@ const COMMANDS = {
65
69
  path: "/profiles",
66
70
  usage: "list-profiles",
67
71
  },
72
+ // Reports the resolved routing context rather than acting on it, so it stays
73
+ // useful precisely when a command failed to route.
74
+ where: {
75
+ method: "LOCAL",
76
+ usage: "where [--json]",
77
+ },
68
78
 
69
79
  "branch create": {
70
80
  method: "POST",
71
81
  action: "createBranch",
72
- usage: "branch create <branchName> (--issue|--project)",
82
+ usage: "branch create <branchName> [--issue KEY] [--project KEY]",
73
83
  buildArgs: ([branchName]) => {
74
84
  if (!branchName) throw new UsageError("branch create requires <branchName>.");
75
85
 
@@ -80,7 +90,7 @@ const COMMANDS = {
80
90
  method: "POST",
81
91
  action: "deleteBranch",
82
92
  destructive: true,
83
- usage: "branch delete <branchName> (--issue|--project) --force",
93
+ usage: "branch delete <branchName> [--issue KEY] [--project KEY] --force",
84
94
  buildArgs: ([branchName]) => {
85
95
  if (!branchName) throw new UsageError("branch delete requires <branchName>.");
86
96
 
@@ -92,7 +102,7 @@ const COMMANDS = {
92
102
  method: "POST",
93
103
  action: "commitByTemplate",
94
104
  usage:
95
- 'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push] (--issue|--project)',
105
+ 'commit template <worktreePath> [--values \'{"key":"val"}\'] [--no-push] [--issue KEY] [--project KEY]',
96
106
  buildArgs: ([worktreePath], flags) => {
97
107
  if (!worktreePath) throw new UsageError("commit template requires <worktreePath>.");
98
108
 
@@ -106,7 +116,7 @@ const COMMANDS = {
106
116
  "mr create": {
107
117
  method: "POST",
108
118
  action: "createPullRequest",
109
- usage: "mr create <title> <headBranch> [--body <text>] (--issue|--project)",
119
+ usage: "mr create <title> <headBranch> [--body <text>] [--issue KEY] [--project KEY]",
110
120
  buildArgs: ([title, headBranch], flags) => {
111
121
  if (!title || !headBranch) {
112
122
  throw new UsageError("mr create requires <title> and <headBranch>.");
@@ -118,26 +128,26 @@ const COMMANDS = {
118
128
  "mr ready": {
119
129
  method: "POST",
120
130
  action: "markPrReadyForReview",
121
- usage: "mr ready <prId> (--issue|--project)",
131
+ usage: "mr ready <prId> [--issue KEY] [--project KEY]",
122
132
  buildArgs: ([prId]) => [toInt("prId", prId)],
123
133
  },
124
134
  "mr draft": {
125
135
  method: "POST",
126
136
  action: "markPrAsDraft",
127
- usage: "mr draft <prId> (--issue|--project)",
137
+ usage: "mr draft <prId> [--issue KEY] [--project KEY]",
128
138
  buildArgs: ([prId]) => [toInt("prId", prId)],
129
139
  },
130
140
  "mr close": {
131
141
  method: "POST",
132
142
  action: "closePullRequest",
133
143
  destructive: true,
134
- usage: "mr close <prId> (--issue|--project) --force",
144
+ usage: "mr close <prId> [--issue KEY] [--project KEY] --force",
135
145
  buildArgs: ([prId]) => [toInt("prId", prId)],
136
146
  },
137
147
  "mr assign": {
138
148
  method: "POST",
139
149
  action: "assignPr",
140
- usage: "mr assign <prId> <assignee> (--issue|--project)",
150
+ usage: "mr assign <prId> <assignee> [--issue KEY] [--project KEY]",
141
151
  buildArgs: ([prId, assignee]) => {
142
152
  if (!assignee) throw new UsageError("mr assign requires <prId> and <assignee>.");
143
153
 
@@ -147,7 +157,7 @@ const COMMANDS = {
147
157
  "mr labels": {
148
158
  method: "POST",
149
159
  action: "addPrLabels",
150
- usage: "mr labels <prId> <label...> (--issue|--project)",
160
+ usage: "mr labels <prId> <label...> [--issue KEY] [--project KEY]",
151
161
  buildArgs: ([prId, ...labels]) => {
152
162
  if (labels.length === 0) {
153
163
  throw new UsageError("mr labels requires <prId> and at least one <label>.");
@@ -159,7 +169,7 @@ const COMMANDS = {
159
169
  "mr reviewers": {
160
170
  method: "POST",
161
171
  action: "requestPrReviewers",
162
- usage: "mr reviewers <prId> <reviewer...> (--issue|--project)",
172
+ usage: "mr reviewers <prId> <reviewer...> [--issue KEY] [--project KEY]",
163
173
  buildArgs: ([prId, ...reviewers]) => {
164
174
  if (reviewers.length === 0) {
165
175
  throw new UsageError("mr reviewers requires <prId> and at least one <reviewer>.");
@@ -171,11 +181,18 @@ const COMMANDS = {
171
181
  "mr find": {
172
182
  method: "POST",
173
183
  action: "findPrByIssueKey",
174
- usage: "mr find <issueKey> (--issue|--project)",
175
- buildArgs: ([issueKey], flags) => {
176
- const key = issueKey ?? flags.issue;
177
-
178
- if (!key) throw new UsageError("mr find requires <issueKey> (or --issue).");
184
+ usage: "mr find [issueKey] [--issue KEY] [--project KEY]",
185
+ // The positional wins, then --issue, then the issue key inferred from the
186
+ // branch — so `devflow mr find` on `ABC-123-login` needs no argument at all.
187
+ buildArgs: ([issueKey], _flags, context = {}) => {
188
+ const key = issueKey ?? context.issueKey;
189
+
190
+ if (!key) {
191
+ throw new UsageError(
192
+ "mr find needs an issue key: pass it positionally or with --issue " +
193
+ "(none could be inferred from the current branch or directory).",
194
+ );
195
+ }
179
196
 
180
197
  return [key];
181
198
  },
@@ -183,18 +200,17 @@ const COMMANDS = {
183
200
  "mr fix-prompt": {
184
201
  method: "POST",
185
202
  action: "getFixCommentsPrompt",
186
- usage: "mr fix-prompt [prId] [--issue KEY] (--issue|--project)",
187
- buildArgs: ([prId], flags) => {
203
+ usage: "mr fix-prompt [prId] [--issue KEY] [--project KEY]",
204
+ buildArgs: ([prId], _flags, context = {}) => {
188
205
  const parsedPrId = prId === undefined ? undefined : toInt("prId", prId);
189
- const issueKey = flags.issue ? String(flags.issue) : undefined;
190
206
 
191
- return [parsedPrId, issueKey];
207
+ return [parsedPrId, context.issueKey];
192
208
  },
193
209
  },
194
210
  "pipeline status": {
195
211
  method: "POST",
196
212
  action: "getPipelineStatus",
197
- usage: "pipeline status <prId> <headSha> (--issue|--project)",
213
+ usage: "pipeline status <prId> <headSha> [--issue KEY] [--project KEY]",
198
214
  buildArgs: ([prId, headSha]) => {
199
215
  if (!headSha) throw new UsageError("pipeline status requires <prId> and <headSha>.");
200
216
 
@@ -204,7 +220,7 @@ const COMMANDS = {
204
220
  "pipeline jobs": {
205
221
  method: "POST",
206
222
  action: "listPipelineJobs",
207
- usage: "pipeline jobs <prId> <headSha> (--issue|--project)",
223
+ usage: "pipeline jobs <prId> <headSha> [--issue KEY] [--project KEY]",
208
224
  buildArgs: ([prId, headSha]) => {
209
225
  if (!headSha) throw new UsageError("pipeline jobs requires <prId> and <headSha>.");
210
226
 
@@ -214,7 +230,7 @@ const COMMANDS = {
214
230
  "pipeline retry": {
215
231
  method: "POST",
216
232
  action: "restartFailedJobs",
217
- usage: "pipeline retry <pipelineOrRunId> [--all] [--job <jobId>] (--issue|--project)",
233
+ usage: "pipeline retry <pipelineOrRunId> [--all] [--job <jobId>] [--issue KEY] [--project KEY]",
218
234
  buildArgs: ([id], flags) => {
219
235
  if (flags.job !== undefined) return [toInt("job", String(flags.job))];
220
236
 
@@ -234,7 +250,8 @@ const COMMANDS = {
234
250
  "slack notify": {
235
251
  method: "POST",
236
252
  action: "notifySlack",
237
- usage: 'slack notify <message> [--channel ID] [--values \'{"key":"val"}\'] (--issue|--project)',
253
+ usage:
254
+ 'slack notify <message> [--channel ID] [--values \'{"key":"val"}\'] [--issue KEY] [--project KEY]',
238
255
  buildArgs: ([message], flags) => {
239
256
  if (!message) throw new UsageError("slack notify requires <message>.");
240
257