baton-issue-tracker 1.5.0 → 1.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baton-issue-tracker",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "A CLI issue tracker for AI agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -37,6 +37,7 @@
37
37
  "node": ">=22.0.0"
38
38
  },
39
39
  "dependencies": {
40
+ "@inquirer/prompts": "^8.5.1",
40
41
  "better-sqlite3": "^12.10.0",
41
42
  "drizzle-orm": "^0.45.2"
42
43
  }
package/source/cli.js CHANGED
@@ -23,7 +23,9 @@ import { run as runView} from './commands/view.js';
23
23
  import { run as runSearch } from './commands/search.js';
24
24
  import { run as runList } from './commands/list.js';
25
25
  import { run as runCreate } from './commands/create.js'
26
- import { run as runUpdate } from './commands/update.js'
26
+ import { run as runUpdate } from './commands/update.js';
27
+ import { run as runPriority } from './commands/priority.js';
28
+ import { run as runLog } from './commands/log.js';
27
29
 
28
30
  const HELP = `baton — AI agent issue tracker CLI
29
31
 
@@ -40,7 +42,9 @@ Commands:
40
42
  list Lists issues filtered by status and priority
41
43
  create Creates an issue with specified fields
42
44
  approve Move an issue from in-review to closed
43
- update Updates an issue's specified fields
45
+ priority Set an issue's priority level
46
+ update Updates an issue's specified fields
47
+ log Show activity history for an issue
44
48
 
45
49
  Options:
46
50
  init --force Re-initialize an existing tracker database
@@ -66,13 +70,14 @@ Options:
66
70
  create --token-limit <n> Optional token budget for this issue
67
71
  create --json Output as JSON (for AI agents)
68
72
  approve <id> [--json]
73
+ priority <id> <level> [--json] low | medium | high
69
74
  update --title <text> New title
70
75
  update --description <text> New description
71
76
  update --token-limit <n> New token budget
72
77
  update --status <s> open | in-progress | closed
73
78
  update --priority <level> low | medium | high
74
79
  update --json Output as JSON (for AI agents)
75
-
80
+ log <id> [--json]
76
81
 
77
82
  Examples:
78
83
  baton init
@@ -90,8 +95,11 @@ Examples:
90
95
  baton create --title "Fix login bug" --priority high
91
96
  baton create --title "Refactor auth" --description "Clean up JWT logic" --token-limit 4000
92
97
  baton approve 5
98
+ baton priority 5 high
99
+ baton priority 3 low
93
100
  baton update 3 --title "Revised title"
94
101
  baton update 7 --status closed --priority medium
102
+ baton log 5
95
103
  `;
96
104
 
97
105
  /**
@@ -101,12 +109,6 @@ Examples:
101
109
  async function main() {
102
110
  const [, , command, ...args] = process.argv;
103
111
 
104
- if (!command || command === 'help' || wantsHelp(args) || command === '--help') {
105
- console.log(HELP);
106
- process.exit(command ? 0 : 1);
107
- return;
108
- }
109
-
110
112
  const handlers = {
111
113
  init: () => runInit(args),
112
114
  next: () => runNext(args),
@@ -116,12 +118,25 @@ async function main() {
116
118
  search: () => runSearch(args),
117
119
  list: () => runList(args),
118
120
  approve: () => runApprove(args),
121
+ priority: () => runPriority(args),
119
122
  create: () => runCreate(args),
120
- update: () => runUpdate(args)
123
+ update: () => runUpdate(args),
124
+ log: () => runLog(args),
121
125
  };
122
-
126
+
127
+ if (!command || command === 'help' || command === '--help') {
128
+ console.log(HELP);
129
+ process.exit(command ? 0 : 1);
130
+ return;
131
+ }
132
+
123
133
  const handler = handlers[command];
124
134
  if (!handler) {
135
+ if (wantsHelp(args)) {
136
+ console.log(HELP);
137
+ process.exit(0);
138
+ return;
139
+ }
125
140
  console.error(`Error: Unknown command "${command}".`);
126
141
  console.error('Run `baton --help` for usage.');
127
142
  process.exit(1);
@@ -1,52 +1,237 @@
1
1
  // create.js
2
2
  // AI was consulted for some portions of this file.
3
- // create command allows the user to create an issue
3
+ // create command allows the user to create an issue
4
4
  // Usage: baton create --title <text> --description <text> --priority <level> --token-limit <n>
5
- //
5
+ // baton create (launches interactive prompt mode)
6
+ //
6
7
  // Examples:
7
- // baton create --title "Fix login bug" --priority high
8
- // baton create --title "Refactor auth" --description "Clean up JWT logic" --token-limit 4000
8
+ // baton create --title "Fix login bug" --priority high
9
+ // baton create --title "Refactor auth" --description "Clean up JWT logic" --token-limit 4000
10
+ // baton create # interactive mode
11
+ //
9
12
  // Options:
10
- // --title <text> Issue title (defaults to "Issue #<id>" if omitted)
11
- // --description <text> Issue description
12
- // --priority <level> low | medium | high (default: low)
13
- // --token-limit <n> Optional token budget for this issue
14
- // -h, --help Show this help
13
+ // --title <text> Issue title (defaults to "Issue #<id>" if omitted)
14
+ // --description <text> Issue description
15
+ // --priority <level> low | medium | high (default: low)
16
+ // --token-limit <n> Optional token budget for this issue
17
+ // -h, --help Show this help
15
18
 
16
19
  import { createIssue } from "../services/issuesService.js";
17
- import { getFlagValue, getNumericFlag } from "../util.js";
18
- import { hasFlag, parseArgs, renderOutput, serializeIssue } from '../util.js';
20
+ import { hasFlag, parseArgs, renderOutput, serializeIssue } from "../util.js";
21
+ import { issueSchema } from "../models/schema.js";
22
+ import { input, select, confirm, editor } from "@inquirer/prompts";
23
+ import { spawnSync } from "child_process";
24
+ import { writeFileSync, readFileSync, unlinkSync } from "fs";
25
+ import { tmpdir } from "os";
26
+ import { join } from "path";
27
+
28
+ const ALLOWED_CREATE_FIELDS = ['title', 'priority', 'tokenLimit', 'description'];
29
+
30
+ const VALID_FLAGS = new Set([
31
+ ...ALLOWED_CREATE_FIELDS.map(key => issueSchema[key].flag),
32
+ "--json",
33
+ ]);
34
+
35
+ // Build select choices from issueSchema.priority.values so the list stays in
36
+ // sync with the Priority enum without any duplication.
37
+ const PRIORITY_HINTS = {
38
+ Low: "routine work, no urgency",
39
+ Medium: "should be resolved this week",
40
+ High: "blocking or time-sensitive",
41
+ };
42
+ const PRIORITY_CHOICES = issueSchema.priority.values.map((v) => ({
43
+ name: `${v.padEnd(6)} -- ${PRIORITY_HINTS[v] ?? v}`,
44
+ value: v,
45
+ }));
46
+ const DEFAULT_PRIORITY = issueSchema.priority.values[0];
47
+
48
+ const DESCRIPTION_PLACEHOLDER = [
49
+ "## What is the issue?",
50
+ "",
51
+ "## Steps to reproduce (if applicable)",
52
+ "",
53
+ "## Expected vs actual behaviour",
54
+ "",
55
+ "## Additional context",
56
+ "",
57
+ ].join("\n");
58
+
59
+ /**
60
+ * Opens the user's $EDITOR with an optional seed template and returns the
61
+ * saved contents, or null if the user left it unchanged / empty.
62
+ *
63
+ * Falls back gracefully: if no $EDITOR is set the inquirer `editor` prompt is
64
+ * used instead (which has its own built-in text area).
65
+ *
66
+ * @param {string} template Initial file content shown to the user.
67
+ * @returns {Promise<string|null>}
68
+ */
69
+ async function openEditorForDescription(template = DESCRIPTION_PLACEHOLDER) {
70
+ const editorBin = process.env.EDITOR || process.env.VISUAL;
71
+
72
+ if (!editorBin) {
73
+ // If no $EDITOR set, then fall back to @inquirer/prompts built-in editor widget
74
+ const result = await editor({
75
+ message:
76
+ "Description (opens in-terminal editor -- save & quit when done):",
77
+ default: template,
78
+ waitForUseInput: false,
79
+ });
80
+ const cleaned = result.trim();
81
+ return cleaned && cleaned !== template.trim() ? cleaned : null;
82
+ }
83
+
84
+ // Write template to a temp file, open $EDITOR, read back the result
85
+ const tmpPath = join(tmpdir(), `baton-issue-${Date.now()}.md`);
86
+ writeFileSync(tmpPath, template, "utf8");
87
+
88
+ const result = spawnSync(editorBin, [tmpPath], { stdio: "inherit" });
89
+
90
+ if (result.error) {
91
+ unlinkSync(tmpPath);
92
+ throw new Error(
93
+ `Could not open $EDITOR (${editorBin}): ${result.error.message}`,
94
+ );
95
+ }
96
+
97
+ const saved = readFileSync(tmpPath, "utf8");
98
+ unlinkSync(tmpPath);
99
+
100
+ const cleaned = saved.trim();
101
+ // Return null if the user saved without changing anything
102
+ return cleaned && cleaned !== template.trim() ? cleaned : null;
103
+ }
104
+
105
+ /**
106
+ * Guides the user through issue creation with interactive prompts.
107
+ * @returns {Promise<object>} Options object ready to pass to createIssue()
108
+ */
109
+ async function runInteractiveMode() {
110
+ console.log("\n Baton -- interactive issue creation\n");
111
+
112
+ // Title
113
+ const title = await input({
114
+ message: "Title:",
115
+ required: true,
116
+ validate: (val) => val.trim().length > 0 || "Title cannot be empty.",
117
+ });
118
+
119
+ // Priority
120
+ const priority = await select({
121
+ message: "Priority:",
122
+ choices: PRIORITY_CHOICES,
123
+ default: DEFAULT_PRIORITY,
124
+ });
125
+
126
+ // Token limit
127
+ const wantsTokenLimit = await confirm({
128
+ message: "Set a token limit for this issue?",
129
+ default: false,
130
+ });
131
+
132
+ let tokenLimit = null;
133
+ if (wantsTokenLimit) {
134
+ const raw = await input({
135
+ message: "Token limit (positive integer):",
136
+ validate: (val) => {
137
+ const n = Number(val);
138
+ return (Number.isInteger(n) && n > 0) || "Must be a positive integer.";
139
+ },
140
+ });
141
+ tokenLimit = Number(raw);
142
+ }
143
+
144
+ // Description
145
+ const wantsDescription = await confirm({
146
+ message: "Add a description?",
147
+ default: true,
148
+ });
149
+
150
+ let description = null;
151
+ if (wantsDescription) {
152
+ const editorBin = process.env.EDITOR || process.env.VISUAL;
153
+ const hint = editorBin ? `opens ${editorBin}` : "in-terminal editor";
154
+ console.log(
155
+ ` -> ${hint} -- fill in what's relevant, save and quit when done.\n`,
156
+ );
157
+ description = await openEditorForDescription();
158
+ }
159
+
160
+ // Preview & confirm
161
+ console.log("\n" + "-".repeat(48));
162
+ console.log(` Title : ${title}`);
163
+ console.log(` Priority : ${priority}`);
164
+ console.log(` Token limit: ${tokenLimit ?? "(none)"}`);
165
+ if (description) {
166
+ const preview = description.split("\n").slice(0, 3).join(" ").slice(0, 72);
167
+ console.log(
168
+ ` Description: ${preview}${description.length > 72 ? "..." : ""}`,
169
+ );
170
+ } else {
171
+ console.log(` Description: (none)`);
172
+ }
173
+ console.log("-".repeat(48) + "\n");
174
+
175
+ const confirmed = await confirm({
176
+ message: "Create this issue?",
177
+ default: true,
178
+ });
179
+ if (!confirmed) {
180
+ console.log("Aborted -- no issue created.");
181
+ process.exit(0);
182
+ }
183
+
184
+ return { title, priority, tokenLimit, description };
185
+ }
19
186
 
20
187
  /**
21
188
  * Initializes a new issue in the database with the specified fields
189
+ * Drops into interactive mode when no flags are provided
190
+ *
22
191
  * @param {string[]} args - The command line arguments
23
- * @returns {Promise<number>} The exit code: 0 is success, 1 is error.
192
+ * @returns {Promise<number>} The exit code: 0 is success, 1 is error
24
193
  */
25
194
  export async function run(args) {
26
- const isJson = hasFlag(args, '--json');
27
- const validFlags = ['--title', '--description', '--priority', '--token-limit', '--json'];
28
- // Check if user misspelled a flag
29
- for (const arg of args) {
30
- if (arg.startsWith('--')) {
31
- if (!validFlags.includes(arg)) {
32
- throw new Error(`Unknown flag provided: ${arg}. \nFlags: --title <text>, --description <text>, --priority <level>, --token-limit <n>`);
33
- }
34
- }
195
+ const isJson = hasFlag(args, "--json");
196
+
197
+ // Flag validation -- VALID_FLAGS is derived from issueSchema, so this stays
198
+ // current automatically when fields are added or renamed there
199
+ const providedFlags = args.filter((a) => a.startsWith("--"));
200
+ for (const flag of providedFlags) {
201
+ if (!VALID_FLAGS.has(flag)) {
202
+ const knownFlags = [...VALID_FLAGS].join(", ");
203
+ throw new Error(
204
+ `Unknown flag: ${flag}\nValid flags: ${knownFlags}\n` +
205
+ `Tip: run \`baton create\` with no flags to use interactive mode.`,
206
+ );
35
207
  }
208
+ }
209
+
210
+ try {
211
+ // Interactive mode: no flags provided (human at a keyboard)
212
+ // Flag mode: at least one flag present (scripts, CI, power users)
213
+ const nonJsonFlags = providedFlags.filter((f) => f !== "--json");
214
+ const isInteractive = nonJsonFlags.length === 0;
36
215
 
37
- try {
38
- const options = parseArgs(args);
216
+ const options = isInteractive
217
+ ? await runInteractiveMode()
218
+ : parseArgs(args);
39
219
 
40
- const issue = await createIssue(options);
41
- const envelope = { status: 'success', issue: serializeIssue(issue) };
220
+ const issue = await createIssue(options);
221
+ const envelope = { status: "success", issue: serializeIssue(issue) };
42
222
 
43
- renderOutput(isJson, envelope, () => {
44
- console.log(`Successfully created issue #${issue.id}: "${issue.title}"`);
45
- });
223
+ renderOutput(isJson, envelope, () => {
224
+ console.log(`\nCreated issue #${issue.id}: "${issue.title}"`);
225
+ });
46
226
 
47
- return 0;
48
- } catch (error) {
49
- console.error(`Failed to create issue: ${error.message}`);
50
- return 1;
227
+ return 0;
228
+ } catch (error) {
229
+ // when user hits Ctrl+C
230
+ if (error.name === "ExitPromptError") {
231
+ console.log("\nAborted.");
232
+ return 0;
51
233
  }
234
+ console.error(`Failed to create issue: ${error.message}`);
235
+ return 1;
236
+ }
52
237
  }
@@ -0,0 +1,85 @@
1
+ // AI was consulted for some portions of this file.
2
+ // log.js
3
+ // log command which displays the full activity history for an issue
4
+ // Usage: baton log <id>
5
+ //
6
+ // Options:
7
+ // -h, --help Show this help
8
+ // --json Output as JSON (for AI agents)
9
+ //
10
+ // Examples:
11
+ // baton log 5
12
+
13
+ import { getActivityLog } from '../services/issuesService.js';
14
+ import { formatTimestamp, hasFlag } from '../util.js';
15
+
16
+ /**
17
+ * Serializes an activity log entry for JSON output.
18
+ * @param {object} entry
19
+ * @returns {object}
20
+ */
21
+ function serializeLogEntry(entry) {
22
+ return {
23
+ log_id: entry.logId,
24
+ issue_id: entry.issueId,
25
+ action: entry.action,
26
+ details: entry.details ?? null,
27
+ created_at: entry.createdAt,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Displays the full activity history for a given issue ID.
33
+ * @param {string[]} args - The issue ID
34
+ * @returns {Promise<number>} The exit code: 0 is success, 1 is error.
35
+ */
36
+ export async function run(args) {
37
+ const isJson = hasFlag(args, '--json');
38
+ const idArgs = args.filter((arg) => arg !== '--json');
39
+
40
+ if (idArgs.length === 0) {
41
+ throw new Error('Invalid input: Missing Issue ID.\nUsage: baton log <id>');
42
+ }
43
+
44
+ const id = idArgs.join(' ');
45
+
46
+ if (isNaN(id)) {
47
+ throw new Error('Invalid input: ID must be a number.\nUsage: baton log <id>');
48
+ }
49
+
50
+ try {
51
+ const logs = getActivityLog(Number(id));
52
+ const entries = logs.map(serializeLogEntry);
53
+ const envelope = {
54
+ status: 'success',
55
+ issue_id: Number(id),
56
+ count: entries.length,
57
+ entries,
58
+ };
59
+
60
+ if (isJson) {
61
+ console.log(JSON.stringify(envelope, null, 2));
62
+ return 0;
63
+ }
64
+
65
+ if (entries.length === 0) {
66
+ console.log(`No activity history for issue #${id}.`);
67
+ return 0;
68
+ }
69
+
70
+ console.log(`Activity log for issue #${id}`);
71
+ console.log('──────────────────────────────────────────');
72
+ for (const entry of entries) {
73
+ const timestamp = formatTimestamp(entry.created_at);
74
+ const details = entry.details ?? '';
75
+ console.log(`${timestamp} ${entry.action} ${details}`);
76
+ }
77
+ console.log('');
78
+
79
+ return 0;
80
+ } catch (error) {
81
+ console.error('Error: Failed to retrieve activity log.');
82
+ console.error(error.message);
83
+ return 1;
84
+ }
85
+ }
@@ -0,0 +1,102 @@
1
+ // AI was consulted for some portions of this file.
2
+ // priority.js
3
+ // Sets the priority of an issue.
4
+ // Usage: baton priority <id> <priority>
5
+ //
6
+ // Options:
7
+ // -h, --help Show this help
8
+ // --json Output as JSON (for AI agents)
9
+ //
10
+ // Examples:
11
+ // baton priority 5 high
12
+ // baton priority 3 low
13
+
14
+ import { Priority } from '../models/issue.js';
15
+ import { setPriority } from '../services/issuesService.js';
16
+ import { hasFlag, renderOutput, serializeIssue, wantsHelp } from '../util.js';
17
+
18
+ const HELP = `Usage:
19
+ baton priority <id> <priority>
20
+
21
+ Options:
22
+ -h, --help Show this help
23
+ --json Output as JSON (for AI agents)
24
+
25
+ Examples:
26
+ baton priority 5 high
27
+ baton priority 3 low
28
+ `;
29
+
30
+ /**
31
+ * Normalize CLI priority input to a canonical Priority enum value.
32
+ * @param {string} input
33
+ * @returns {string | null}
34
+ */
35
+ function normalizePriority(input) {
36
+ const values = Object.values(Priority);
37
+ return values.find((v) => v.toLowerCase() === input.trim().toLowerCase()) ?? null;
38
+ }
39
+
40
+ /**
41
+ * Sets the priority of an issue.
42
+ * @param {string[]} args - The command line arguments.
43
+ * @returns {Promise<number>} The exit code: 0 is success, 1 is error.
44
+ */
45
+ export async function run(args) {
46
+ if (wantsHelp(args)) {
47
+ console.log(HELP);
48
+ return 0;
49
+ }
50
+
51
+ const isJson = hasFlag(args, '--json');
52
+ const cmdArgs = args.filter((arg) => arg !== '--json');
53
+
54
+ if (cmdArgs.length < 2) {
55
+ throw new Error(
56
+ 'Invalid input: Missing issue ID or priority.\nUsage: baton priority <id> <priority>'
57
+ );
58
+ }
59
+
60
+ if (cmdArgs.length > 2) {
61
+ throw new Error(
62
+ 'Invalid input: Too many arguments.\nUsage: baton priority <id> <priority>'
63
+ );
64
+ }
65
+
66
+ for (const arg of cmdArgs) {
67
+ if (arg.startsWith('--')) {
68
+ throw new Error(
69
+ 'Unknown flag provided.\nUsage: baton priority <id> <priority>\nPriority: low | medium | high'
70
+ );
71
+ }
72
+ }
73
+
74
+ const id = parseInt(cmdArgs[0], 10);
75
+ if (Number.isNaN(id)) {
76
+ throw new Error(
77
+ 'Invalid input: ID must be a number.\nUsage: baton priority <id> <priority>'
78
+ );
79
+ }
80
+
81
+ const priority = normalizePriority(cmdArgs[1]);
82
+ if (!priority) {
83
+ throw new Error(
84
+ `Invalid priority "${cmdArgs[1]}". Must be one of: low, medium, high.\nUsage: baton priority <id> <priority>`
85
+ );
86
+ }
87
+
88
+ try {
89
+ const issue = setPriority(id, priority);
90
+ const envelope = { status: 'success', issue: serializeIssue(issue) };
91
+
92
+ renderOutput(isJson, envelope, () => {
93
+ console.log(`Issue #${issue.id} priority set to ${issue.priority}.`);
94
+ });
95
+
96
+ return 0;
97
+ } catch (error) {
98
+ console.error('Error: Failed to set issue priority.');
99
+ console.error(error.message);
100
+ return 1;
101
+ }
102
+ }
@@ -1,7 +1,8 @@
1
1
  // update.js
2
2
  // AI was consulted for some portions of this file.
3
- // update command allows the user to update data fields for an issue
3
+ // update command allows the user to update data fields for an issue
4
4
  // Usage: baton update <id> [options]
5
+ // baton update <id> (launches interactive mode pre-filled with current values)
5
6
  //
6
7
  // Options:
7
8
  // --title <text> New title
@@ -12,70 +13,252 @@
12
13
  // -h, --help Show this help
13
14
  //
14
15
  // Examples:
15
- // baton update 3 --title "Revised title"
16
- // baton update 7 --status closed --priority medium
16
+ // baton update 3 # interactive mode
17
+ // baton update 3 --title "Revised title"
18
+ // baton update 7 --status closed --priority medium
17
19
 
18
20
  import { updateIssue, getIssue } from "../services/issuesService.js";
19
- import { hasFlag, parseArgs, renderOutput, serializeIssue } from '../util.js';
21
+ import { hasFlag, parseArgs, renderOutput, serializeIssue } from "../util.js";
22
+ import { issueSchema } from "../models/schema.js";
23
+ import { input, select, confirm, editor } from "@inquirer/prompts";
24
+ import { spawnSync } from "child_process";
25
+ import { writeFileSync, readFileSync, unlinkSync } from "fs";
26
+ import { tmpdir } from "os";
27
+ import { join } from "path";
20
28
 
29
+ const ALLOWED_UPDATE_FIELDS = ['title', 'status', 'priority', 'tokenLimit', 'description'];
30
+
31
+ const VALID_FLAGS = new Set([
32
+ ...ALLOWED_UPDATE_FIELDS.map(key => issueSchema[key].flag),
33
+ "--json",
34
+ ]);
35
+ const FLAGS_HINT = [...VALID_FLAGS].join(", ");
36
+ const USAGE = "Usage: baton update <id> [options]";
37
+
38
+ // Build select choices from issueSchema enums so they stay in sync automatically.
39
+ const PRIORITY_HINTS = {
40
+ Low: "routine work, no urgency",
41
+ Medium: "should be resolved this week",
42
+ High: "blocking or time-sensitive",
43
+ };
44
+
45
+ const PRIORITY_CHOICES = issueSchema.priority.values.map((v) => ({
46
+ name: `${v.padEnd(6)} -- ${PRIORITY_HINTS[v] ?? v}`,
47
+ value: v,
48
+ }));
49
+
50
+ const STATUS_CHOICES = issueSchema.status.values.map((v) => ({
51
+ name: v,
52
+ value: v,
53
+ }));
54
+
55
+ /**
56
+ * Opens the user's $EDITOR pre-filled with existing content.
57
+ * Falls back to the inquirer built-in editor widget if $EDITOR is not set.
58
+ * Returns null if the user saves without making any changes.
59
+ *
60
+ * @param {string} existing Current description to pre-fill.
61
+ * @returns {Promise<string|null>}
62
+ */
63
+ async function openEditorForDescription(existing = "") {
64
+ const editorBin = process.env.EDITOR || process.env.VISUAL;
65
+
66
+ if (!editorBin) {
67
+ const result = await editor({
68
+ message: "Description (save & quit when done):",
69
+ default: existing,
70
+ waitForUseInput: false,
71
+ });
72
+ const cleaned = result.trim();
73
+ return cleaned !== existing.trim() ? cleaned : null;
74
+ }
75
+
76
+ const tmpPath = join(tmpdir(), `baton-issue-${Date.now()}.md`);
77
+ writeFileSync(tmpPath, existing, "utf8");
78
+
79
+ const result = spawnSync(editorBin, [tmpPath], { stdio: "inherit" });
80
+ if (result.error) {
81
+ unlinkSync(tmpPath);
82
+ throw new Error(
83
+ `Could not open $EDITOR (${editorBin}): ${result.error.message}`,
84
+ );
85
+ }
86
+
87
+ const saved = readFileSync(tmpPath, "utf8");
88
+ unlinkSync(tmpPath);
89
+
90
+ const cleaned = saved.trim();
91
+ return cleaned !== existing.trim() ? cleaned : null;
92
+ }
93
+
94
+ /**
95
+ * Prompts the user to edit each field of an existing issue.
96
+ * Every prompt is pre-filled with the issue's current value so the user
97
+ * only needs to change what they care about.
98
+ *
99
+ * @param {object} issue The current issue object from the database.
100
+ * @returns {Promise<object>} Partial options object containing only changed fields.
101
+ */
102
+ async function runInteractiveMode(issue) {
103
+ console.log(`\n Baton -- editing issue #${issue.id}: "${issue.title}"\n`);
104
+
105
+ // Collect all prompt results into one object keyed by schema field name.
106
+ // The diff at the end loops over this -- no field names hardcoded there.
107
+ const results = {};
108
+
109
+ // Title
110
+ results.title = await input({
111
+ message: "Title:",
112
+ default: issue.title,
113
+ validate: (val) => val.trim().length > 0 || "Title cannot be empty.",
114
+ });
115
+
116
+ // Status
117
+ results.status = await select({
118
+ message: "Status:",
119
+ choices: STATUS_CHOICES,
120
+ default: issue.status,
121
+ });
122
+
123
+ // Priority
124
+ results.priority = await select({
125
+ message: "Priority:",
126
+ choices: PRIORITY_CHOICES,
127
+ default: issue.priority,
128
+ });
129
+
130
+ // Token limit -- confirm-gated since it is optional
131
+ const currentLimit = issue.tokenLimit ?? null;
132
+ const wantsTokenLimit = await confirm({
133
+ message: `Set a token limit?${currentLimit ? ` (currently ${currentLimit})` : ""}`,
134
+ default: currentLimit !== null,
135
+ });
136
+ if (wantsTokenLimit) {
137
+ const raw = await input({
138
+ message: "Token limit (positive integer):",
139
+ default: currentLimit ? String(currentLimit) : undefined,
140
+ validate: (val) => {
141
+ const n = Number(val);
142
+ return (Number.isInteger(n) && n > 0) || "Must be a positive integer.";
143
+ },
144
+ });
145
+ results.tokenLimit = Number(raw);
146
+ }
147
+
148
+ // Description - $EDITOR flow, keep original if the user makes no changes
149
+ const wantsDescription = await confirm({
150
+ message: "Edit description?",
151
+ default: true,
152
+ });
153
+ if (wantsDescription) {
154
+ const editorBin = process.env.EDITOR || process.env.VISUAL;
155
+ const hint = editorBin ? `opens ${editorBin}` : "in-terminal editor";
156
+ console.log(
157
+ ` -> ${hint} -- edit the description, save and quit when done.\n`,
158
+ );
159
+ const edited = await openEditorForDescription(issue.description ?? "");
160
+ // Only write to results if the user actually changed something
161
+ if (edited !== null) results.description = edited;
162
+ }
163
+
164
+ // Diff: loop over results and collect only what changed.
165
+ // Adding a new prompt above is all that is needed -- nothing to update here.
166
+ const pending = Object.fromEntries(
167
+ Object.entries(results).filter(([key, val]) => val !== issue[key]),
168
+ );
169
+
170
+ if (Object.keys(pending).length === 0) {
171
+ console.log("\nNo changes made.");
172
+ process.exit(0);
173
+ }
174
+
175
+ console.log("\n" + "-".repeat(48));
176
+ for (const [key, val] of Object.entries(pending)) {
177
+ const old = issue[key] ?? "(none)";
178
+ const preview = String(val).split("\n").slice(0, 2).join(" ").slice(0, 60);
179
+ console.log(
180
+ ` ${key}: "${old}" -> "${preview}${String(val).length > 60 ? "..." : ""}"`,
181
+ );
182
+ }
183
+ console.log("-".repeat(48) + "\n");
184
+
185
+ const confirmed = await confirm({ message: "Save changes?", default: true });
186
+ if (!confirmed) {
187
+ console.log("Aborted -- no changes saved.");
188
+ process.exit(0);
189
+ }
190
+
191
+ return pending;
192
+ }
21
193
 
22
194
  /**
23
- * Updates the specified fields for a given issue ID
195
+ * Updates the specified fields for a given issue ID.
196
+ * Drops into interactive mode when only an ID is provided and no field flags follow.
197
+ *
24
198
  * @param {string[]} args - The command line arguments
25
199
  * @returns {Promise<number>} The exit code: 0 is success, 1 is error.
26
200
  */
27
201
  export async function run(args) {
28
- const isJson = hasFlag(args, '--json');
29
- const cmdArgs = args.filter((arg) => arg !== '--json');
202
+ const isJson = hasFlag(args, "--json");
203
+ const cmdArgs = args.filter((arg) => arg !== "--json");
30
204
 
31
- if (cmdArgs.length === 0 || cmdArgs === '') {
32
- throw new Error("Invalid input: No arguments entered.\nUsage: baton update <id> [options] \nOptions: --title <text>, --description <text>, --token-limit <n>, --status <s>, --priority <level>");
33
- }
205
+ if (cmdArgs.length === 0 || cmdArgs === "") {
206
+ throw new Error(
207
+ `Invalid input: No arguments entered.\n${USAGE}\nOptions: ${FLAGS_HINT}`,
208
+ );
209
+ }
34
210
 
35
- // Convert id argument from string to base-10 integer
36
- const id = parseInt(cmdArgs[0], 10);
211
+ // Convert id argument from string to base-10 integer
212
+ const id = parseInt(cmdArgs[0], 10);
37
213
 
38
- if (isNaN(id)) {
39
- throw new Error ("Invalid input: No ID entered. \nUsage: baton update <id> [options]\nOptions: --title <text>, --description <text>, --token-limit <n>, --status <s>, --priority <level>");
214
+ if (isNaN(id)) {
215
+ throw new Error(
216
+ `Invalid input: No ID entered.\n${USAGE}\nOptions: ${FLAGS_HINT}`,
217
+ );
218
+ }
219
+
220
+ // Check if user misspelled a flag
221
+ for (const arg of cmdArgs) {
222
+ if (arg.startsWith("--") && !VALID_FLAGS.has(arg)) {
223
+ throw new Error(`Unknown flag: ${arg}\nValid flags: ${FLAGS_HINT}`);
40
224
  }
225
+ }
226
+
227
+ try {
228
+ const oldIssue = await getIssue(id);
41
229
 
42
- const validFlags = ['--title', '--description', '--token-limit', '--status', '--priority', '--json'];
43
- // Check if user misspelled a flag
44
- for (const arg of cmdArgs) {
45
- if (arg.startsWith('--')) {
46
- if (!validFlags.includes(arg)) {
47
- throw new Error(`Unknown flag provided: ${arg}. \nFlags: --title <text>, --description <text>, --token-limit <n>, --status <s>, --priority <level>`);
48
- }
230
+ // Interactive mode: only the ID was passed, no field flags.
231
+ // Flag mode: at least one flag follows the ID.
232
+ const providedFlags = cmdArgs.slice(1).filter((a) => a.startsWith("--"));
233
+ const isInteractive = providedFlags.length === 0;
234
+
235
+ const options = isInteractive
236
+ ? await runInteractiveMode(oldIssue)
237
+ : parseArgs(cmdArgs.slice(1));
238
+
239
+ const newIssue = await updateIssue(id, oldIssue, options);
240
+ const envelope = { status: "success", issue: serializeIssue(newIssue) };
241
+
242
+ renderOutput(isJson, envelope, () => {
243
+ console.log("");
244
+ console.log(`Successfully updated issue #${id}:`);
245
+ for (const key in options) {
246
+ if (oldIssue[key] !== newIssue[key]) {
247
+ console.log(` ${key}: "${oldIssue[key]}" -> "${newIssue[key]}"`);
248
+ } else {
249
+ console.log(` ${key}: No change (already set to "${newIssue[key]}")`);
49
250
  }
50
- }
251
+ }
252
+ console.log("");
253
+ });
51
254
 
52
- try {
53
- const options = parseArgs(cmdArgs.slice(1));
54
-
55
- // Store the old issue fields for displaying purposes:
56
- const oldIssue = getIssue(id);
57
-
58
- const newIssue = await updateIssue(id, oldIssue, options);
59
- const envelope = { status: 'success', issue: serializeIssue(newIssue) };
60
-
61
- renderOutput(isJson, envelope, () => {
62
- console.log('');
63
- // Compare and print the changes:
64
- console.log(`Successfully updated issue #${id}:`);
65
- for (const key in options) {
66
- if (oldIssue[key] !== newIssue[key]) {
67
- console.log(` ${key}: "${oldIssue[key]}" -> "${newIssue[key]}"`);
68
- } else {
69
- // If the entered argument matches the old data
70
- console.log(` ${key}: No change (already set to "${newIssue[key]}")`);
71
- }
72
- }
73
- console.log('');
74
- });
75
-
76
- return 0;
77
- } catch (error) {
78
- console.error(`Failed to update issue: ${error.message}`);
79
- return 1;
255
+ return 0;
256
+ } catch (error) {
257
+ if (error.name === "ExitPromptError") {
258
+ console.log("\nAborted.");
259
+ return 0;
80
260
  }
261
+ console.error(`Failed to update issue: ${error.message}`);
262
+ return 1;
263
+ }
81
264
  }