open-wadah 1.2.1 → 1.2.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.
Files changed (3) hide show
  1. package/README.md +11 -2
  2. package/cli.js +882 -37
  3. package/package.json +4 -4
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # open-wadah
2
2
 
3
- Open Wadah CLI — shared task board for humans and agents. Use it from the terminal or from Cursor, Claude Code, and Kimi.
3
+ Open WADAH CLI — shared task board for humans and agents. Use it from the terminal or from Cursor, Claude Code, and Kimi.
4
4
 
5
5
  ## Install
6
6
 
@@ -27,7 +27,7 @@ wadah complete <task-id>
27
27
  | Command | Description |
28
28
  |--------|-------------|
29
29
  | **Auth** | `login`, `signup`, `logout`, `whoami` |
30
- | **Tasks** | `open`, `list`, `search`, `requested`, `add`, `complete`, `reopen`, `view`, `update`, `move`, `assign`, `comment`, `delete` |
30
+ | **Tasks** | `open`, `list`, `search`, `requested`, `add`, `complete`, `reopen`, `view`, `update`, `move`, `assign`, `comment`, `subtask list/add/toggle/delete`, `delete` |
31
31
  | **Relationships** | `add --blocks <id>`, `add --blocked-by <id>`, `update --blocks <id>` |
32
32
  | **Board** | `boards --json`, `buckets --json`, `assignees --json`; `board create/delete`, `bucket create/update/delete`, `assignee create/update/delete` |
33
33
  | **Files** | `folders`, `files`, `folder create` / `mkdir`, `upload` |
@@ -39,6 +39,15 @@ wadah complete <task-id>
39
39
 
40
40
  Run `wadah --help` for full list and options.
41
41
 
42
+ Subtask examples:
43
+
44
+ ```bash
45
+ wadah subtask add <task-id> "Write tests"
46
+ wadah subtask list <task-id>
47
+ wadah subtask toggle <task-id> <subtask-id>
48
+ wadah subtask delete <task-id> <subtask-id>
49
+ ```
50
+
42
51
  ## Shell completion
43
52
 
44
53
  **Bash:**
package/cli.js CHANGED
@@ -164,7 +164,10 @@ function printTaskList(tasks, state) {
164
164
  }
165
165
  tasks.forEach((t) => {
166
166
  const bucket = bucketMap[t.bucketId] ?? '—'
167
- const assignee = assigneeMap[t.assigneeId] ?? 'Unassigned'
167
+ const assignedIds = taskAssigneeIds(t)
168
+ const assignee = assignedIds.length === 0
169
+ ? 'Unassigned'
170
+ : assignedIds.map((id) => assigneeMap[id] ?? id).join(', ')
168
171
  const requester = t.requestedById ? assigneeMap[t.requestedById] : null
169
172
  const board = boardMap[t.boardId] ?? '—'
170
173
  const id = t.id.slice(0, 8)
@@ -216,6 +219,12 @@ async function resolveMyAssigneeId(state) {
216
219
  try {
217
220
  const me = await api('/api/auth/me')
218
221
  if (me.assigneeId) return me.assigneeId
222
+ // For agent tokens the API always returns assigneeId if the token is linked
223
+ // to an assignee. If it's missing, don't fall back to the human "Me" —
224
+ // return null so callers know the agent has no assignee configured.
225
+ if (me.workspaces?.[0]?.role === 'agent') {
226
+ return null
227
+ }
219
228
  const userId = me.user?.id
220
229
  const byUserId = state.assignees.find((a) => a.user_id === userId)
221
230
  if (byUserId) return byUserId.id
@@ -228,6 +237,38 @@ function findAssignee(assignees, name) {
228
237
  return assignees.find((a) => a.name.toLowerCase().includes(q)) ?? null
229
238
  }
230
239
 
240
+ function taskAssigneeIds(task) {
241
+ if (Array.isArray(task.assigneeIds) && task.assigneeIds.length > 0) return task.assigneeIds
242
+ if (task.assigneeId) return [task.assigneeId]
243
+ return []
244
+ }
245
+
246
+ function taskHasAssignee(task, assigneeId) {
247
+ return taskAssigneeIds(task).includes(assigneeId)
248
+ }
249
+
250
+ function resolveAssigneeIdsByNames(assignees, namesCsv) {
251
+ const parts = String(namesCsv ?? '')
252
+ .split(',')
253
+ .map((x) => x.trim())
254
+ .filter(Boolean)
255
+ const ids = []
256
+ for (const name of parts) {
257
+ const a = findAssignee(assignees, name)
258
+ if (!a) throw new CliError('validation_error', `Assignee not found: ${name}`)
259
+ ids.push(a.id)
260
+ }
261
+ const uniqueIds = Array.from(new Set(ids))
262
+ const selected = uniqueIds
263
+ .map((id) => assignees.find((a) => a.id === id))
264
+ .filter(Boolean)
265
+ const agentCount = selected.filter((a) => a.type === 'agent').length
266
+ if (agentCount > 1) {
267
+ throw new CliError('validation_error', 'Only one agent can be assigned to a task.')
268
+ }
269
+ return uniqueIds
270
+ }
271
+
231
272
  function findBoard(boards, query) {
232
273
  const q = query.toLowerCase()
233
274
  return boards.find((b) => b.id === query || b.name.toLowerCase().includes(q)) ?? null
@@ -238,6 +279,23 @@ function findBucket(buckets, query) {
238
279
  return buckets.find((b) => b.id === query || b.title.toLowerCase().includes(q)) ?? null
239
280
  }
240
281
 
282
+ function findSubtask(subtasks, query) {
283
+ const raw = String(query ?? '').trim()
284
+ if (!raw) return null
285
+ const q = raw.toLowerCase()
286
+ const byId = subtasks.filter((s) => s.id === raw || s.id.startsWith(raw))
287
+ if (byId.length === 1) return byId[0]
288
+ if (byId.length > 1) {
289
+ throw new CliError('validation_error', `Ambiguous subtask id prefix: ${raw}`)
290
+ }
291
+ const byTitle = subtasks.filter((s) => String(s.title ?? '').toLowerCase() === q)
292
+ if (byTitle.length === 1) return byTitle[0]
293
+ if (byTitle.length > 1) {
294
+ throw new CliError('validation_error', `Multiple subtasks have title: ${raw}`)
295
+ }
296
+ return null
297
+ }
298
+
241
299
  function parseCompletedFlag(value) {
242
300
  if (typeof value !== 'string') return null
243
301
  const v = value.trim().toLowerCase()
@@ -246,6 +304,46 @@ function parseCompletedFlag(value) {
246
304
  throw new CliError('validation_error', '--completed must be true or false')
247
305
  }
248
306
 
307
+ function parseGitHubRepoInput(input) {
308
+ const raw = String(input ?? '').trim()
309
+ if (!raw) return null
310
+ const bare = raw.replace(/^git\+/, '').replace(/\.git$/i, '').trim()
311
+ let owner = ''
312
+ let name = ''
313
+
314
+ const sshMatch = bare.match(/^git@github\.com:([^/]+)\/([^/]+)$/i)
315
+ if (sshMatch) {
316
+ owner = sshMatch[1]
317
+ name = sshMatch[2]
318
+ } else if (/^https?:\/\//i.test(bare)) {
319
+ try {
320
+ const u = new URL(bare)
321
+ if (!/^github\.com$/i.test(u.hostname)) return null
322
+ const parts = u.pathname.split('/').filter(Boolean)
323
+ if (parts.length < 2) return null
324
+ owner = parts[0]
325
+ name = parts[1]
326
+ } catch {
327
+ return null
328
+ }
329
+ } else {
330
+ const parts = bare.split('/').filter(Boolean)
331
+ if (parts.length !== 2) return null
332
+ owner = parts[0]
333
+ name = parts[1]
334
+ }
335
+
336
+ owner = owner.replace(/[^a-zA-Z0-9-]/g, '')
337
+ name = name.replace(/[^a-zA-Z0-9._-]/g, '')
338
+ if (!owner || !name) return null
339
+ return {
340
+ owner: owner.toLowerCase(),
341
+ name: name.toLowerCase(),
342
+ fullName: `${owner.toLowerCase()}/${name.toLowerCase()}`,
343
+ url: `https://github.com/${owner.toLowerCase()}/${name.toLowerCase()}`,
344
+ }
345
+ }
346
+
249
347
  function fail(message) {
250
348
  throw new CliError('validation_error', message)
251
349
  }
@@ -303,7 +401,7 @@ Available commands and their args (use these exact command names):
303
401
  - assignees: args = []. List assignees.
304
402
  - state: args = []. Full state JSON.
305
403
  - move: args = [task id, bucket name or id]. Move task to another column.
306
- - assign: args = [task id, assignee name]. Assign a task.
404
+ - assign: args = [task id, "--to", "assignee[,assignee2]", "--mode", "set|add|remove"]. Assign one or more people.
307
405
  - whoami: args = []. Show current user/workspace.
308
406
  - folders: args = []. List folders.
309
407
  - files: args = []. List files; optionally include "--folder", "<id>" in args.
@@ -326,6 +424,11 @@ Available commands and their args (use these exact command names):
326
424
  - assignee create: args = [name]. Optional "--type", "human" or "agent".
327
425
  - assignee update: args = [assignee id]. Optional "--name", "--type".
328
426
  - assignee delete: args = [assignee id]. Delete assignee.
427
+ - repo add: args = [github url or owner/repo]. Link a GitHub repository to your workspace.
428
+ - repo list (or repos): args = []. List linked GitHub repositories.
429
+ - repo remove: args = [repo id]. Unlink a repository.
430
+ - pr link: args = [task id, pr url]. Attach a PR URL to a task.
431
+ - pr open: args = [task id]. Open the linked PR URL for a task.
329
432
 
330
433
  If the request is unclear or not a valid CLI action, return {"command": "", "args": []}. Output only the JSON object.`
331
434
 
@@ -707,12 +810,12 @@ program
707
810
 
708
811
  if (!opts.all && !opts.assignee) {
709
812
  const myId = await resolveMyAssigneeId(state)
710
- if (myId) tasks = tasks.filter((t) => t.assigneeId === myId)
813
+ if (myId) tasks = tasks.filter((t) => taskHasAssignee(t, myId))
711
814
  }
712
815
  if (opts.assignee) {
713
816
  const a = findAssignee(state.assignees, opts.assignee)
714
817
  if (!a) return fail(`Assignee not found: ${opts.assignee}`)
715
- tasks = tasks.filter((t) => t.assigneeId === a.id)
818
+ tasks = tasks.filter((t) => taskHasAssignee(t, a.id))
716
819
  }
717
820
  if (opts.requestedBy) {
718
821
  const a = findAssignee(state.assignees, opts.requestedBy)
@@ -749,7 +852,7 @@ program
749
852
  if (opts.assignee) {
750
853
  const a = findAssignee(state.assignees, opts.assignee)
751
854
  if (!a) return fail(`Assignee not found: ${opts.assignee}`)
752
- tasks = tasks.filter((t) => t.assigneeId === a.id)
855
+ tasks = tasks.filter((t) => taskHasAssignee(t, a.id))
753
856
  }
754
857
  if (opts.requestedBy) {
755
858
  const a = findAssignee(state.assignees, opts.requestedBy)
@@ -790,7 +893,7 @@ program
790
893
  if (opts.assignee) {
791
894
  const a = findAssignee(state.assignees, opts.assignee)
792
895
  if (!a) return fail(`Assignee not found: ${opts.assignee}`)
793
- tasks = tasks.filter((t) => t.assigneeId === a.id)
896
+ tasks = tasks.filter((t) => taskHasAssignee(t, a.id))
794
897
  }
795
898
 
796
899
  tasks = tasks.filter((t) =>
@@ -828,9 +931,11 @@ program
828
931
  .command('add <title>')
829
932
  .description('Create a new task')
830
933
  .option('--assignee <name>', 'Assign to a person or agent')
934
+ .option('--assignees <names>', 'Assign to multiple people (comma-separated names)')
831
935
  .option('--bucket <name>', 'Column name (default: first column)')
832
936
  .option('--board <name>', 'Board name (default: current board)')
833
937
  .option('--due <YYYY-MM-DD>', 'Due date')
938
+ .option('--priority <level>', 'Priority: urgent, high, medium, low')
834
939
  .option('--repo <owner/repo>','GitHub repo')
835
940
  .option('--url <url>', 'Issue / PR URL')
836
941
  .option('--notes <text>', 'Notes')
@@ -842,20 +947,31 @@ program
842
947
  const myId = await resolveMyAssigneeId(state)
843
948
 
844
949
  let assigneeId = myId
950
+ let assigneeIds = assigneeId ? [assigneeId] : []
845
951
  let requestedById = null
846
952
 
953
+ if (opts.assignee && opts.assignees) {
954
+ return fail('Use either --assignee or --assignees, not both.')
955
+ }
847
956
  if (opts.assignee) {
848
957
  const a = findAssignee(state.assignees, opts.assignee)
849
958
  if (!a) return fail(`Assignee not found: ${opts.assignee}`)
850
959
  assigneeId = a.id
960
+ assigneeIds = [a.id]
961
+ requestedById = myId
962
+ } else if (opts.assignees) {
963
+ assigneeIds = resolveAssigneeIdsByNames(state.assignees, opts.assignees)
964
+ assigneeId = assigneeIds[0] ?? null
851
965
  requestedById = myId
852
966
  }
853
967
 
854
- const board = opts.board
855
- ? state.boards.find((b) => b.name.toLowerCase().includes(opts.board.toLowerCase()))
968
+ const defaultBoardName = opts.board ?? confGet('default_board')
969
+ const board = defaultBoardName
970
+ ? state.boards.find((b) => b.name.toLowerCase().includes(defaultBoardName.toLowerCase()))
856
971
  : state.boards.find((b) => b.id === state.currentBoardId) ?? state.boards[0]
857
972
 
858
973
  if (!board && opts.board) return fail(`Board not found: ${opts.board}`)
974
+ if (!board && defaultBoardName && !opts.board) return fail(`Default board not found: "${defaultBoardName}". Update with: wadah config --default-board "<name>"`)
859
975
  if (!board) console.warn(chalk.yellow(` ⚠ No board specified — defaulting to "${state.boards[0]?.name}". Set a default with: wadah config --default-board "<name>"`))
860
976
 
861
977
  const boardBuckets = board ? state.buckets.filter((b) => b.boardId === board.id) : state.buckets
@@ -871,8 +987,10 @@ program
871
987
  bucketId: bucket?.id,
872
988
  boardId: board?.id,
873
989
  assigneeId,
990
+ assigneeIds,
874
991
  requestedById,
875
992
  dueDate: opts.due ? new Date(opts.due).getTime() : null,
993
+ priority: opts.priority ?? null,
876
994
  repo: opts.repo ?? null,
877
995
  contextUrl: opts.url ?? null,
878
996
  notes: opts.notes ?? '',
@@ -897,7 +1015,8 @@ program
897
1015
  } catch {}
898
1016
  }
899
1017
 
900
- const assigneeName = state.assignees.find((a) => a.id === task.assigneeId)?.name ?? 'Unassigned'
1018
+ const taskAssigneeNames = taskAssigneeIds(task).map((id) => state.assignees.find((a) => a.id === id)?.name ?? id)
1019
+ const assigneeName = taskAssigneeNames.length ? taskAssigneeNames.join(', ') : 'Unassigned'
901
1020
  console.log(chalk.green('\n✓ Created') + ` ${chalk.bold(task.title)}`)
902
1021
  console.log(chalk.gray(` ID: ${task.id} · Assigned to: ${assigneeName}\n`))
903
1022
  } catch (err) { handleError(err) }
@@ -937,20 +1056,35 @@ program
937
1056
 
938
1057
  program
939
1058
  .command('assign <id>')
940
- .description('Reassign a task to another person or agent')
941
- .requiredOption('--to <name>', 'Assignee name')
1059
+ .description('Assign task assignees (set/add/remove)')
1060
+ .requiredOption('--to <names>', 'Assignee name(s), comma-separated')
1061
+ .option('--mode <mode>', 'set | add | remove (default: set)', 'set')
942
1062
  .action(async (id, opts) => {
943
1063
  try {
944
1064
  const state = await getState()
945
- const a = findAssignee(state.assignees, opts.to)
946
- if (!a) return fail(`Assignee not found: ${opts.to}`)
1065
+ const mode = String(opts.mode || 'set').toLowerCase()
1066
+ if (!['set', 'add', 'remove'].includes(mode)) return fail('--mode must be one of: set, add, remove')
1067
+ const ids = resolveAssigneeIdsByNames(state.assignees, opts.to)
1068
+ if (ids.length === 0) return fail('Provide at least one assignee in --to')
1069
+ const currentTask = await api(`/api/tasks/${id}`)
1070
+ const current = taskAssigneeIds(currentTask)
1071
+ const nextIds = mode === 'set'
1072
+ ? ids
1073
+ : mode === 'add'
1074
+ ? Array.from(new Set([...current, ...ids]))
1075
+ : current.filter((x) => !ids.includes(x))
947
1076
 
948
1077
  const myId = await resolveMyAssigneeId(state)
949
- const task = await api(`/api/tasks/${id}`, {
1078
+ const task = await api(`/api/tasks/${id}`, {
950
1079
  method: 'PATCH',
951
- body: JSON.stringify({ assigneeId: a.id, requestedById: myId }),
1080
+ body: JSON.stringify({
1081
+ assigneeIds: nextIds,
1082
+ assigneeId: nextIds[0] ?? null,
1083
+ requestedById: myId,
1084
+ }),
952
1085
  })
953
- console.log(chalk.green('\n✓ Assigned') + ` ${chalk.bold(task.title)} ${a.name}\n`)
1086
+ const names = taskAssigneeIds(task).map((aid) => state.assignees.find((a) => a.id === aid)?.name ?? aid).join(', ') || 'Unassigned'
1087
+ console.log(chalk.green('\n✓ Assigned') + ` ${chalk.bold(task.title)} → ${names}\n`)
954
1088
  } catch (err) { handleError(err) }
955
1089
  })
956
1090
 
@@ -984,7 +1118,9 @@ program
984
1118
  .option('--url <url>', 'Set issue/PR URL')
985
1119
  .option('--due <YYYY-MM-DD>', 'Set due date')
986
1120
  .option('--clear-due', 'Clear due date')
1121
+ .option('--priority <level>', 'Set priority: urgent, high, medium, low (or clear)')
987
1122
  .option('--assignee <name>', 'Set assignee')
1123
+ .option('--assignees <names>', 'Set all assignees (comma-separated names)')
988
1124
  .option('--board <name>', 'Set board')
989
1125
  .option('--bucket <name>', 'Set bucket')
990
1126
  .option('--completed <true|false>', 'Set completion state')
@@ -1002,6 +1138,12 @@ program
1002
1138
  if (opts.notes !== undefined) patch.notes = opts.notes
1003
1139
  if (opts.repo !== undefined) patch.repo = opts.repo
1004
1140
  if (opts.url !== undefined) patch.contextUrl = opts.url
1141
+ if (opts.priority !== undefined) {
1142
+ const VALID_PRIORITIES = ['urgent', 'high', 'medium', 'low', 'clear', 'none']
1143
+ const p = opts.priority.toLowerCase()
1144
+ if (!VALID_PRIORITIES.includes(p)) return fail(`Invalid priority. Use: urgent, high, medium, low (or "clear" to remove)`)
1145
+ patch.priority = (p === 'clear' || p === 'none') ? null : p
1146
+ }
1005
1147
 
1006
1148
  if (opts.due && opts.clearDue) {
1007
1149
  return fail('Use either --due or --clear-due, not both.')
@@ -1013,14 +1155,22 @@ program
1013
1155
  }
1014
1156
  if (opts.clearDue) patch.dueDate = null
1015
1157
 
1016
- const needsStateLookup = !!(opts.assignee || opts.board || opts.bucket)
1158
+ const needsStateLookup = !!(opts.assignee || opts.assignees || opts.board || opts.bucket)
1017
1159
  const state = needsStateLookup ? await getState() : null
1018
1160
 
1019
-
1161
+ if (opts.assignee && opts.assignees) {
1162
+ return fail('Use either --assignee or --assignees, not both.')
1163
+ }
1020
1164
  if (opts.assignee) {
1021
1165
  const a = findAssignee(state.assignees, opts.assignee)
1022
1166
  if (!a) return fail(`Assignee not found: ${opts.assignee}`)
1023
1167
  patch.assigneeId = a.id
1168
+ patch.assigneeIds = [a.id]
1169
+ }
1170
+ if (opts.assignees) {
1171
+ const ids = resolveAssigneeIdsByNames(state.assignees, opts.assignees)
1172
+ patch.assigneeIds = ids
1173
+ patch.assigneeId = ids[0] ?? null
1024
1174
  }
1025
1175
  if (opts.board) {
1026
1176
  const b = findBoard(state.boards, opts.board)
@@ -1087,7 +1237,9 @@ program
1087
1237
  .action(async (id) => {
1088
1238
  try {
1089
1239
  const [task, state] = await Promise.all([api(`/api/tasks/${id}`), api('/api/state')])
1090
- const assignee = state.assignees.find((a) => a.id === task.assigneeId)?.name ?? 'Unassigned'
1240
+ const assignee = taskAssigneeIds(task)
1241
+ .map((id) => state.assignees.find((a) => a.id === id)?.name ?? id)
1242
+ .join(', ') || 'Unassigned'
1091
1243
  const requester = state.assignees.find((a) => a.id === task.requestedById)?.name ?? null
1092
1244
  const bucket = state.buckets.find((b) => b.id === task.bucketId)?.title ?? '—'
1093
1245
  const board = state.boards.find((b) => b.id === task.boardId)?.name ?? '—'
@@ -1147,6 +1299,87 @@ program
1147
1299
  } catch (err) { handleError(err) }
1148
1300
  })
1149
1301
 
1302
+ const subtaskCmd = program.command('subtask').description('Manage task subtasks')
1303
+
1304
+ subtaskCmd
1305
+ .command('list <taskId>')
1306
+ .description('List subtasks on a task')
1307
+ .action(async (taskId) => {
1308
+ try {
1309
+ const task = await api(`/api/tasks/${taskId}`)
1310
+ const subtasks = task.subtasks ?? []
1311
+ if (isMachineOutput()) {
1312
+ console.log(JSON.stringify({ taskId, subtasks }, null, runtimeJsonOutput ? 2 : 0))
1313
+ return
1314
+ }
1315
+ console.log()
1316
+ if (subtasks.length === 0) {
1317
+ console.log(chalk.gray(' No subtasks.\n'))
1318
+ return
1319
+ }
1320
+ console.log(chalk.bold(task.title))
1321
+ subtasks.forEach((s) => {
1322
+ const status = s.completed ? chalk.green('✓') : '○'
1323
+ console.log(` ${status} ${s.title} ${chalk.gray(s.id.slice(0, 8))}`)
1324
+ })
1325
+ console.log()
1326
+ } catch (err) { handleError(err) }
1327
+ })
1328
+
1329
+ subtaskCmd
1330
+ .command('add <taskId> <title>')
1331
+ .description('Add a subtask to a task')
1332
+ .action(async (taskId, title) => {
1333
+ try {
1334
+ const trimmedTitle = String(title ?? '').trim()
1335
+ if (!trimmedTitle) return fail('Subtask title is required')
1336
+ const task = await api(`/api/tasks/${taskId}`)
1337
+ const nextSubtask = { id: randomUUID(), title: trimmedTitle, completed: false }
1338
+ await api(`/api/tasks/${taskId}`, {
1339
+ method: 'PATCH',
1340
+ body: JSON.stringify({ subtasks: [...(task.subtasks ?? []), nextSubtask] }),
1341
+ })
1342
+ console.log(chalk.green('\n✓ Subtask added') + ` ${chalk.bold(nextSubtask.title)} ${chalk.gray(`(${nextSubtask.id.slice(0, 8)})`)}\n`)
1343
+ } catch (err) { handleError(err) }
1344
+ })
1345
+
1346
+ subtaskCmd
1347
+ .command('toggle <taskId> <subtaskId>')
1348
+ .description('Toggle subtask completion')
1349
+ .action(async (taskId, subtaskId) => {
1350
+ try {
1351
+ const task = await api(`/api/tasks/${taskId}`)
1352
+ const subtasks = task.subtasks ?? []
1353
+ const target = findSubtask(subtasks, subtaskId)
1354
+ if (!target) return fail(`Subtask not found: ${subtaskId}`)
1355
+ const nextSubtasks = subtasks.map((s) => s.id === target.id ? { ...s, completed: !s.completed } : s)
1356
+ await api(`/api/tasks/${taskId}`, {
1357
+ method: 'PATCH',
1358
+ body: JSON.stringify({ subtasks: nextSubtasks }),
1359
+ })
1360
+ const marker = !target.completed ? chalk.green('✓') : '○'
1361
+ console.log(chalk.green('\n✓ Subtask updated') + ` ${marker} ${chalk.bold(target.title)}\n`)
1362
+ } catch (err) { handleError(err) }
1363
+ })
1364
+
1365
+ subtaskCmd
1366
+ .command('delete <taskId> <subtaskId>')
1367
+ .description('Delete a subtask from a task')
1368
+ .action(async (taskId, subtaskId) => {
1369
+ try {
1370
+ const task = await api(`/api/tasks/${taskId}`)
1371
+ const subtasks = task.subtasks ?? []
1372
+ const target = findSubtask(subtasks, subtaskId)
1373
+ if (!target) return fail(`Subtask not found: ${subtaskId}`)
1374
+ const nextSubtasks = subtasks.filter((s) => s.id !== target.id)
1375
+ await api(`/api/tasks/${taskId}`, {
1376
+ method: 'PATCH',
1377
+ body: JSON.stringify({ subtasks: nextSubtasks }),
1378
+ })
1379
+ console.log(chalk.green('\n✓ Subtask deleted') + ` ${chalk.bold(target.title)}\n`)
1380
+ } catch (err) { handleError(err) }
1381
+ })
1382
+
1150
1383
  // ── tm boards ─────────────────────────────────────────────────────────────────
1151
1384
 
1152
1385
  program
@@ -1215,16 +1448,16 @@ program
1215
1448
  id: a.id,
1216
1449
  name: a.name,
1217
1450
  type: a.type ?? 'human',
1218
- openCount: state.tasks.filter((t) => t.assigneeId === a.id && !t.completed).length,
1219
- doneCount: state.tasks.filter((t) => t.assigneeId === a.id && t.completed).length,
1451
+ openCount: state.tasks.filter((t) => taskHasAssignee(t, a.id) && !t.completed).length,
1452
+ doneCount: state.tasks.filter((t) => taskHasAssignee(t, a.id) && t.completed).length,
1220
1453
  }))
1221
1454
  console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0))
1222
1455
  return
1223
1456
  }
1224
1457
  console.log()
1225
1458
  state.assignees.forEach((a) => {
1226
- const open = state.tasks.filter((t) => t.assigneeId === a.id && !t.completed).length
1227
- const done = state.tasks.filter((t) => t.assigneeId === a.id && t.completed).length
1459
+ const open = state.tasks.filter((t) => taskHasAssignee(t, a.id) && !t.completed).length
1460
+ const done = state.tasks.filter((t) => taskHasAssignee(t, a.id) && t.completed).length
1228
1461
  console.log(` ${chalk.bold(a.name)} ${chalk.gray(`${a.type ?? 'human'} · ${open} open · ${done} done`)}`)
1229
1462
  })
1230
1463
  console.log()
@@ -1249,6 +1482,29 @@ boardCmd
1249
1482
  } catch (err) { handleError(err) }
1250
1483
  })
1251
1484
 
1485
+ boardCmd
1486
+ .command('rename <id> <name>')
1487
+ .description('Rename a board')
1488
+ .action(async (id, name) => {
1489
+ try {
1490
+ const payload = JSON.stringify({ name: String(name).trim() })
1491
+ let board
1492
+ try {
1493
+ board = await api(`/api/boards/${id}`, { method: 'PATCH', body: payload })
1494
+ } catch (err) {
1495
+ // Backward compatibility: some deployments only support PUT for board rename.
1496
+ const notFound = err instanceof CliError && err.code === 'api_error' && String(err.message).toLowerCase().includes('not found')
1497
+ if (!notFound) throw err
1498
+ board = await api(`/api/boards/${id}`, { method: 'PUT', body: payload })
1499
+ }
1500
+ if (isMachineOutput()) {
1501
+ console.log(JSON.stringify(board, null, runtimeJsonOutput ? 2 : 0))
1502
+ } else {
1503
+ console.log(chalk.green('\n✓ Board renamed') + ` ${chalk.bold(board.name)} ${chalk.gray(board.id?.slice(0, 8))}\n`)
1504
+ }
1505
+ } catch (err) { handleError(err) }
1506
+ })
1507
+
1252
1508
  boardCmd
1253
1509
  .command('delete <id>')
1254
1510
  .description('Delete a board (cannot delete the last one)')
@@ -1418,6 +1674,158 @@ program
1418
1674
  } catch (err) { handleError(err) }
1419
1675
  })
1420
1676
 
1677
+ program
1678
+ .command('repos')
1679
+ .description('List linked GitHub repositories')
1680
+ .action(async () => {
1681
+ try {
1682
+ const list = await api('/api/github-repos').catch(() => [])
1683
+ const arr = Array.isArray(list) ? list : []
1684
+ if (isMachineOutput()) {
1685
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1686
+ } else if (arr.length === 0) {
1687
+ console.log(chalk.gray('\nNo linked repositories. Add one: wadah repo add https://github.com/owner/repo\n'))
1688
+ } else {
1689
+ console.log()
1690
+ arr.forEach((r) => {
1691
+ const full = r.fullName ?? `${r.owner}/${r.name}`
1692
+ console.log(` ${chalk.bold(full)} ${chalk.gray(`id: ${r.id?.slice(0, 8)}`)}`)
1693
+ console.log(chalk.gray(` ${r.url}`))
1694
+ })
1695
+ console.log()
1696
+ }
1697
+ } catch (err) { handleError(err) }
1698
+ })
1699
+
1700
+ const repoCmd = program.command('repo').description('Link and manage GitHub repositories')
1701
+
1702
+ repoCmd
1703
+ .command('add <repo>')
1704
+ .description('Link a GitHub repo (URL or owner/repo)')
1705
+ .action(async (repoInput) => {
1706
+ try {
1707
+ const parsed = parseGitHubRepoInput(repoInput)
1708
+ if (!parsed) return fail('Provide a valid GitHub repo URL or owner/repo')
1709
+ const linked = await api('/api/github-repos', {
1710
+ method: 'POST',
1711
+ body: JSON.stringify({ url: parsed.url }),
1712
+ })
1713
+ if (isMachineOutput()) {
1714
+ console.log(JSON.stringify(linked, null, runtimeJsonOutput ? 2 : 0))
1715
+ } else {
1716
+ const full = linked.fullName ?? `${linked.owner}/${linked.name}`
1717
+ console.log(chalk.green('\n✓ Repo linked') + ` ${chalk.bold(full)}`)
1718
+ console.log(chalk.gray(` ${linked.url}\n`))
1719
+ }
1720
+ } catch (err) { handleError(err) }
1721
+ })
1722
+
1723
+ repoCmd
1724
+ .command('list')
1725
+ .description('List linked GitHub repositories')
1726
+ .action(async () => {
1727
+ try {
1728
+ const list = await api('/api/github-repos').catch(() => [])
1729
+ const arr = Array.isArray(list) ? list : []
1730
+ if (isMachineOutput()) {
1731
+ console.log(JSON.stringify(arr, null, runtimeJsonOutput ? 2 : 0))
1732
+ } else if (arr.length === 0) {
1733
+ console.log(chalk.gray('\nNo linked repositories. Add one: wadah repo add https://github.com/owner/repo\n'))
1734
+ } else {
1735
+ console.log()
1736
+ arr.forEach((r) => {
1737
+ const full = r.fullName ?? `${r.owner}/${r.name}`
1738
+ console.log(` ${chalk.bold(full)} ${chalk.gray(`id: ${r.id?.slice(0, 8)}`)}`)
1739
+ console.log(chalk.gray(` ${r.url}`))
1740
+ })
1741
+ console.log()
1742
+ }
1743
+ } catch (err) { handleError(err) }
1744
+ })
1745
+
1746
+ repoCmd
1747
+ .command('remove <id>')
1748
+ .description('Unlink a repository by id (use: wadah repos)')
1749
+ .action(async (id) => {
1750
+ try {
1751
+ await api(`/api/github-repos/${encodeURIComponent(id)}`, { method: 'DELETE' })
1752
+ if (!runtimeQuietOutput) console.log(chalk.green('\n✓ Repo unlinked\n'))
1753
+ } catch (err) { handleError(err) }
1754
+ })
1755
+
1756
+ const prCmd = program.command('pr').description('Attach and open pull requests on tasks')
1757
+
1758
+ prCmd
1759
+ .command('link <taskId> <prUrl>')
1760
+ .description('Attach a PR URL to a task')
1761
+ .option('--comment', 'Add a task comment with the PR link')
1762
+ .action(async function (taskId, prUrl) {
1763
+ const opts = this.opts()
1764
+ try {
1765
+ let parsed
1766
+ try {
1767
+ parsed = new URL(String(prUrl).trim())
1768
+ } catch {
1769
+ return fail('Provide a valid PR URL (e.g. https://github.com/owner/repo/pull/123)')
1770
+ }
1771
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
1772
+ return fail('PR URL must start with http:// or https://')
1773
+ }
1774
+ const normalizedUrl = parsed.toString()
1775
+
1776
+ const task = await api(`/api/tasks/${taskId}`)
1777
+ await api(`/api/tasks/${taskId}`, {
1778
+ method: 'PATCH',
1779
+ body: JSON.stringify({ contextUrl: normalizedUrl }),
1780
+ })
1781
+ if (opts.comment) {
1782
+ const comment = {
1783
+ id: randomUUID(),
1784
+ text: `Linked PR: ${normalizedUrl}`,
1785
+ createdAt: Date.now(),
1786
+ }
1787
+ await api(`/api/tasks/${taskId}`, {
1788
+ method: 'PATCH',
1789
+ body: JSON.stringify({ comments: [...(task.comments ?? []), comment] }),
1790
+ })
1791
+ }
1792
+
1793
+ if (isMachineOutput()) {
1794
+ console.log(JSON.stringify({ taskId, prUrl: normalizedUrl, linked: true, commented: Boolean(opts.comment) }, null, runtimeJsonOutput ? 2 : 0))
1795
+ } else {
1796
+ console.log(chalk.green('\n✓ PR linked') + ` ${chalk.bold(task.title)}`)
1797
+ console.log(chalk.gray(` ${normalizedUrl}${opts.comment ? ' · comment added' : ''}\n`))
1798
+ }
1799
+ } catch (err) { handleError(err) }
1800
+ })
1801
+
1802
+ prCmd
1803
+ .command('open <taskId>')
1804
+ .description('Open the linked PR URL for a task')
1805
+ .option('--no-browser', "Don't auto-open browser; print URL only")
1806
+ .action(async function (taskId) {
1807
+ const opts = this.opts()
1808
+ try {
1809
+ const task = await api(`/api/tasks/${taskId}`)
1810
+ const url = task?.contextUrl ? String(task.contextUrl).trim() : ''
1811
+ if (!url) return fail('This task has no linked PR URL. Use: wadah pr link <task-id> <pr-url>')
1812
+
1813
+ if (isMachineOutput()) {
1814
+ console.log(JSON.stringify({ taskId, prUrl: url }, null, runtimeJsonOutput ? 2 : 0))
1815
+ return
1816
+ }
1817
+
1818
+ console.log(chalk.gray(`\nPR: ${url}`))
1819
+ if (opts.browser) {
1820
+ const opened = await openBrowser(url)
1821
+ if (opened) console.log(chalk.green('✓ Opened in browser\n'))
1822
+ else console.log(chalk.yellow('Could not open browser automatically. Open the URL manually.\n'))
1823
+ } else {
1824
+ console.log()
1825
+ }
1826
+ } catch (err) { handleError(err) }
1827
+ })
1828
+
1421
1829
  // ── agent tokens ──────────────────────────────────────────────────────────────
1422
1830
 
1423
1831
  program
@@ -1434,8 +1842,12 @@ program
1434
1842
  } else {
1435
1843
  console.log()
1436
1844
  arr.forEach((t) => {
1437
- const as = t.assigneeName ? chalk.gray(` · acts as ${t.assigneeName}`) : ''
1438
- console.log(` ${chalk.bold(t.name)} ${chalk.gray(`id: ${t.id?.slice(0, 8)}${as}`)}`)
1845
+ const as = t.assigneeName ? ` · ${chalk.cyan(t.assigneeName)}` : ''
1846
+ const creator = t.createdBy ? chalk.gray(` · created by ${t.createdBy}`) : ''
1847
+ const used = t.last_used_at ? chalk.gray(` · last used ${new Date(t.last_used_at).toLocaleDateString()}`) : chalk.gray(' · never used')
1848
+ const usage = t.usageLast30d > 0 ? chalk.gray(` · ${t.usageLast30d} call${t.usageLast30d === 1 ? '' : 's'} (30d)`) : ''
1849
+ console.log(` ${chalk.bold(t.name)}${as}`)
1850
+ console.log(chalk.gray(` id: ${t.id?.slice(0, 8)}${used}${usage}${creator}`))
1439
1851
  })
1440
1852
  console.log()
1441
1853
  }
@@ -1461,9 +1873,12 @@ agentTokenCmd
1461
1873
  } else {
1462
1874
  console.log(chalk.green('\n✓ Agent token created'))
1463
1875
  console.log(chalk.gray(` Name: ${result.name} Id: ${result.id?.slice(0, 8)} Acts as: ${result.assigneeName ?? result.name}`))
1464
- console.log(chalk.yellow('\n Token (save it — shown once):'))
1876
+ console.log(chalk.yellow('\n Token (store it now — shown once):'))
1465
1877
  console.log(` ${result.token}`)
1466
- console.log(chalk.gray('\n Use: export TASK_MANAGER_TOKEN="<token>"\n'))
1878
+ console.log(chalk.gray('\n Store it so you don’t lose it:'))
1879
+ console.log(chalk.gray(' · GitHub Actions: Repo → Settings → Secrets and variables → Actions → New secret → TASK_MANAGER_TOKEN'))
1880
+ console.log(chalk.gray(' · Cursor: Settings → Environment variables → TASK_MANAGER_TOKEN'))
1881
+ console.log(chalk.gray(' · Shell: export TASK_MANAGER_TOKEN="<paste token here>"\n'))
1467
1882
  }
1468
1883
  } catch (err) { handleError(err) }
1469
1884
  })
@@ -1478,6 +1893,32 @@ agentTokenCmd
1478
1893
  } catch (err) { handleError(err) }
1479
1894
  })
1480
1895
 
1896
+ agentTokenCmd
1897
+ .command('events <id>')
1898
+ .description('Show audit events for an agent token (created / used / deleted)')
1899
+ .action(async (id) => {
1900
+ try {
1901
+ const events = await api(`/api/agent-tokens/${id}/events`)
1902
+ if (isMachineOutput()) {
1903
+ console.log(JSON.stringify(events, null, runtimeJsonOutput ? 2 : 0))
1904
+ return
1905
+ }
1906
+ if (!events?.length) {
1907
+ console.log(chalk.gray('\nNo events recorded for this token.\n'))
1908
+ return
1909
+ }
1910
+ console.log()
1911
+ events.forEach((e) => {
1912
+ const when = new Date(e.created_at).toLocaleString()
1913
+ const ep = e.endpoint ? chalk.gray(` ${e.endpoint}`) : ''
1914
+ const ip = e.ip_address ? chalk.gray(` from ${e.ip_address}`) : ''
1915
+ const mark = e.event_type === 'created' ? chalk.green('●') : e.event_type === 'deleted' ? chalk.red('●') : chalk.blue('●')
1916
+ console.log(` ${mark} ${chalk.bold(e.event_type.padEnd(8))} ${chalk.gray(when)}${ep}${ip}`)
1917
+ })
1918
+ console.log()
1919
+ } catch (err) { handleError(err) }
1920
+ })
1921
+
1481
1922
  // ── calendar events ──────────────────────────────────────────────────────────
1482
1923
 
1483
1924
  const calendarCmd = program
@@ -1766,17 +2207,23 @@ program
1766
2207
  if (!hasEnvToken && !hasSavedToken) {
1767
2208
  addCheck('auth_token', 'warn', 'No token available. Run wadah login.')
1768
2209
  } else if (hasEnvToken) {
1769
- addCheck('auth_token', 'pass', 'Using token from env/flag')
2210
+ addCheck('auth_token', 'pass', 'Using agent token from env/flag (TASK_MANAGER_TOKEN)')
1770
2211
  } else {
1771
2212
  addCheck('auth_token', 'pass', 'Using token from profile config')
1772
2213
  }
1773
2214
 
1774
2215
  if (pingOk) {
2216
+ let meBody = null
1775
2217
  try {
1776
2218
  const meRes = await fetch(`${getApiBase()}/api/auth/me`, { headers: authHeaders() })
1777
2219
  if (meRes.ok) {
1778
- const meBody = await meRes.json().catch(() => ({}))
1779
- addCheck('auth_me', 'pass', `Authenticated as ${meBody.user?.email ?? 'unknown user'}`)
2220
+ meBody = await meRes.json().catch(() => ({}))
2221
+ const isAgent = meBody.workspaces?.[0]?.role === 'agent'
2222
+ if (isAgent) {
2223
+ addCheck('auth_me', 'pass', `Authenticated as agent (workspace: ${meBody.workspaces?.[0]?.name ?? '?'})`)
2224
+ } else {
2225
+ addCheck('auth_me', 'pass', `Authenticated as ${meBody.user?.email ?? 'unknown user'}`)
2226
+ }
1780
2227
  } else if (meRes.status === 401) {
1781
2228
  addCheck('auth_me', 'warn', 'Token is missing/expired. Run wadah login.')
1782
2229
  } else {
@@ -1786,6 +2233,41 @@ program
1786
2233
  addCheck('auth_me', 'warn', `Auth check failed: ${err.message}`)
1787
2234
  }
1788
2235
 
2236
+ // Agent-specific checks (only when TASK_MANAGER_TOKEN is set)
2237
+ if (hasEnvToken && meBody) {
2238
+ const isAgent = meBody.workspaces?.[0]?.role === 'agent'
2239
+ if (!isAgent) {
2240
+ addCheck('agent_identity', 'warn', 'Token is not an agent token — it authenticated as a human user')
2241
+ } else {
2242
+ addCheck('agent_identity', 'pass', `Token resolves to agent role`)
2243
+
2244
+ if (meBody.assigneeId) {
2245
+ addCheck('agent_assignee', 'pass', `Assignee: ${meBody.assigneeName ?? meBody.assigneeId}`)
2246
+ } else {
2247
+ addCheck('agent_assignee', 'fail', 'No assignee linked to this token. Create a new token via the app (Agent tokens) so it auto-creates an assignee.')
2248
+ }
2249
+
2250
+ const defaultBoard = confGet('default_board')
2251
+ if (!defaultBoard) {
2252
+ addCheck('agent_default_board', 'warn', 'No default board set — wadah add will guess. Fix: wadah config --default-board "<name>"')
2253
+ } else {
2254
+ // Verify board exists
2255
+ try {
2256
+ const state = await getState()
2257
+ const board = state.boards.find((b) => b.name.toLowerCase().includes(defaultBoard.toLowerCase()))
2258
+ if (board) {
2259
+ const buckets = state.buckets.filter((b) => b.boardId === board.id)
2260
+ addCheck('agent_default_board', 'pass', `Default board: "${board.name}" (${buckets.length} bucket${buckets.length === 1 ? '' : 's'})`)
2261
+ } else {
2262
+ addCheck('agent_default_board', 'fail', `Default board "${defaultBoard}" not found. Update: wadah config --default-board "<name>"`)
2263
+ }
2264
+ } catch {
2265
+ addCheck('agent_default_board', 'warn', `Could not verify default board "${defaultBoard}"`)
2266
+ }
2267
+ }
2268
+ }
2269
+ }
2270
+
1789
2271
  try {
1790
2272
  const deviceRes = await fetch(`${getApiBase()}/api/auth/device/start`, {
1791
2273
  method: 'POST',
@@ -1861,17 +2343,25 @@ program
1861
2343
  .command('config')
1862
2344
  .description('Show or set CLI configuration')
1863
2345
  .option('--api-url <url>', 'Set API base URL')
2346
+ .option('--default-board <name>', 'Set default board for wadah add (agents: avoids landing tasks on wrong board)')
1864
2347
  .action((opts) => {
1865
2348
  if (opts.apiUrl) {
1866
2349
  confSet('api_url', opts.apiUrl.trim().replace(/\/$/, ''))
1867
2350
  if (!runtimeQuietOutput) console.log(chalk.green(`\n✓ API URL set to ${opts.apiUrl}\n`))
1868
2351
  return
1869
2352
  }
2353
+ if (opts.defaultBoard) {
2354
+ confSet('default_board', opts.defaultBoard.trim())
2355
+ if (!runtimeQuietOutput) console.log(chalk.green(`\n✓ Default board set to "${opts.defaultBoard.trim()}"\n wadah add will now use this board unless --board is specified.\n`))
2356
+ return
2357
+ }
2358
+ const defaultBoard = confGet('default_board')
1870
2359
  const config = {
1871
2360
  api_url: getApiBase(),
1872
2361
  signed_in_as: confGet('user_email') ?? null,
1873
2362
  agent_mode: Boolean(process.env.TASK_MANAGER_TOKEN || runtimeToken),
1874
2363
  profile: getProfile(),
2364
+ default_board: defaultBoard ?? null,
1875
2365
  config_file: conf.path,
1876
2366
  }
1877
2367
  if (isMachineOutput()) {
@@ -1880,23 +2370,378 @@ program
1880
2370
  }
1881
2371
  console.log()
1882
2372
  console.log(chalk.bold('CLI config'))
1883
- console.log(chalk.gray(` API URL: ${config.api_url}`))
1884
- console.log(chalk.gray(` Signed in as: ${config.signed_in_as ?? '—'}`))
1885
- console.log(chalk.gray(` Agent mode: ${config.agent_mode ? 'yes (TASK_MANAGER_TOKEN set)' : 'no'}`))
1886
- console.log(chalk.gray(` Profile: ${getProfile()}`))
1887
- console.log(chalk.gray(` Config file: ${config.config_file}`))
2373
+ console.log(chalk.gray(` API URL: ${config.api_url}`))
2374
+ console.log(chalk.gray(` Signed in as: ${config.signed_in_as ?? '—'}`))
2375
+ console.log(chalk.gray(` Agent mode: ${config.agent_mode ? 'yes (TASK_MANAGER_TOKEN set)' : 'no'}`))
2376
+ console.log(chalk.gray(` Default board: ${defaultBoard ?? '— (not set, use: wadah config --default-board "Name")'}`))
2377
+ console.log(chalk.gray(` Profile: ${getProfile()}`))
2378
+ console.log(chalk.gray(` Config file: ${config.config_file}`))
1888
2379
  console.log()
1889
2380
  })
1890
2381
 
2382
+ // ── wadah board-view ──────────────────────────────────────────────────────────
2383
+
2384
+ program
2385
+ .command('board-view')
2386
+ .description('Show a structured view of a board grouped by bucket — lets agents verify task placement')
2387
+ .option('--board <name>', 'Board name (defaults to current/default board)')
2388
+ .option('--assignee <name>', 'Filter to tasks for a specific assignee')
2389
+ .action(async (opts) => {
2390
+ try {
2391
+ const state = await getState()
2392
+ const defaultBoardName = opts.board ?? confGet('default_board')
2393
+ const board = defaultBoardName
2394
+ ? state.boards.find((b) => b.name.toLowerCase().includes(defaultBoardName.toLowerCase()))
2395
+ : state.boards.find((b) => b.id === state.currentBoardId) ?? state.boards[0]
2396
+
2397
+ if (!board) return fail(opts.board ? `Board not found: ${opts.board}` : 'No boards found')
2398
+
2399
+ const buckets = state.buckets
2400
+ .filter((b) => b.boardId === board.id)
2401
+ .sort((a, b) => a.order - b.order)
2402
+
2403
+ let tasks = state.tasks.filter((t) => t.boardId === board.id && !t.completed)
2404
+ if (opts.assignee) {
2405
+ const a = findAssignee(state.assignees, opts.assignee)
2406
+ if (a) tasks = tasks.filter((t) => taskHasAssignee(t, a.id))
2407
+ }
2408
+
2409
+ const view = {
2410
+ board: { id: board.id, name: board.name },
2411
+ buckets: buckets.map((bucket) => ({
2412
+ id: bucket.id,
2413
+ name: bucket.title,
2414
+ tasks: tasks
2415
+ .filter((t) => t.bucketId === bucket.id)
2416
+ .sort((a, b) => a.order - b.order)
2417
+ .map((t) => ({
2418
+ id: t.id,
2419
+ title: t.title,
2420
+ assignee: taskAssigneeIds(t).map((aid) => state.assignees.find((a) => a.id === aid)?.name ?? aid).join(', ') || null,
2421
+ priority: t.priority ?? null,
2422
+ })),
2423
+ })),
2424
+ }
2425
+
2426
+ if (isMachineOutput()) {
2427
+ console.log(JSON.stringify(view, null, runtimeJsonOutput ? 2 : 0))
2428
+ return
2429
+ }
2430
+
2431
+ console.log()
2432
+ console.log(chalk.bold(`Board: ${board.name}`))
2433
+ for (const bucket of view.buckets) {
2434
+ console.log(chalk.gray(`\n ${bucket.name} (${bucket.tasks.length})`))
2435
+ if (bucket.tasks.length === 0) {
2436
+ console.log(chalk.gray(' — empty'))
2437
+ } else {
2438
+ bucket.tasks.forEach((t) => {
2439
+ const who = t.assignee ? chalk.gray(` @${t.assignee}`) : ''
2440
+ console.log(` ${t.id.slice(0, 8)} ${t.title.slice(0, 60)}${who}`)
2441
+ })
2442
+ }
2443
+ }
2444
+ console.log()
2445
+ } catch (err) { handleError(err) }
2446
+ })
2447
+
1891
2448
  // ── shell completion ──────────────────────────────────────────────────────────
1892
2449
 
1893
2450
  const CLI_COMMANDS = [
1894
- 'add', 'agent-token', 'agent-tokens', 'assign', 'assignee', 'assignees', 'board', 'boards',
2451
+ 'add', 'agent-token', 'agent-tokens', 'assign', 'assignee', 'assignees', 'board', 'board-view', 'boards',
1895
2452
  'bucket', 'buckets', 'calendar', 'comment', 'complete', 'config', 'delete', 'doc', 'docs',
1896
2453
  'do', 'doctor', 'files', 'folder', 'folders', 'invite', 'list', 'login', 'members', 'mkdir',
1897
- 'move', 'open', 'reopen', 'requested', 'search', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
2454
+ 'forget', 'memory', 'memory-export', 'memory-log', 'move', 'open', 'pr', 'repo', 'repos', 'reopen', 'remember', 'requested', 'search', 'setup-agent', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
1898
2455
  ]
1899
2456
 
2457
+ // ── wadah setup-agent ─────────────────────────────────────────────────────────
2458
+
2459
+ program
2460
+ .command('setup-agent')
2461
+ .description('Interactive wizard: create an agent token, write it to your shell profile, and verify everything works')
2462
+ .option('--name <name>', 'Agent name (skip prompt)')
2463
+ .option('--board <board>', 'Default board name (skip prompt)')
2464
+ .option('--skip-env', 'Skip writing TASK_MANAGER_TOKEN to shell profile (print instructions only)')
2465
+ .action(async (opts) => {
2466
+ const readline = await import('node:readline/promises')
2467
+ const os = await import('node:os')
2468
+ const fs = await import('node:fs')
2469
+ const path = await import('node:path')
2470
+
2471
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
2472
+ const ask = (q) => rl.question(q)
2473
+
2474
+ console.log()
2475
+ console.log(chalk.bold('Open Wadah — Agent Setup Wizard'))
2476
+ console.log(chalk.gray(' This wizard will create an agent token and configure your environment.\n'))
2477
+
2478
+ // Step 1: verify auth
2479
+ console.log(chalk.bold('Step 1/5 Checking authentication…'))
2480
+ let me
2481
+ try {
2482
+ me = await api('/api/auth/me')
2483
+ if (me.workspaces?.[0]?.role === 'agent') {
2484
+ console.log(chalk.yellow(' ⚠ You are currently authenticated as an agent, not a human user.'))
2485
+ console.log(chalk.gray(' Run: wadah login then rerun: wadah setup-agent\n'))
2486
+ rl.close(); return
2487
+ }
2488
+ console.log(chalk.green(` ✓ Signed in as ${me.user?.email ?? 'unknown'}`))
2489
+ } catch {
2490
+ console.log(chalk.red(' ✗ Not authenticated. Run: wadah login'))
2491
+ console.log(chalk.gray(' Then rerun: wadah setup-agent\n'))
2492
+ rl.close(); return
2493
+ }
2494
+
2495
+ // Step 2: agent name
2496
+ console.log()
2497
+ console.log(chalk.bold('Step 2/5 Name your agent'))
2498
+ let agentName = opts.name
2499
+ if (!agentName) {
2500
+ agentName = (await ask(chalk.gray(' What should your agent be called? (e.g. "Cursor", "Claude") > '))).trim()
2501
+ } else {
2502
+ console.log(chalk.gray(` Using: ${agentName}`))
2503
+ }
2504
+ if (!agentName) agentName = 'My Agent'
2505
+
2506
+ // Step 3: create token
2507
+ console.log()
2508
+ console.log(chalk.bold('Step 3/5 Creating agent token…'))
2509
+ let tokenResult
2510
+ try {
2511
+ tokenResult = await api('/api/agent-tokens', {
2512
+ method: 'POST',
2513
+ body: JSON.stringify({ name: agentName, assigneeName: agentName }),
2514
+ })
2515
+ console.log(chalk.green(` ✓ Token created (assignee: ${tokenResult.assigneeName ?? agentName})`))
2516
+ } catch (err) {
2517
+ console.log(chalk.red(` ✗ Failed to create token: ${err.message}`))
2518
+ rl.close(); return
2519
+ }
2520
+
2521
+ const rawToken = tokenResult.token
2522
+ console.log()
2523
+ console.log(chalk.yellow(' Token (shown once — copy it now):'))
2524
+ console.log(` ${chalk.bold(rawToken)}`)
2525
+
2526
+ // Step 4: write env var to shell profile
2527
+ console.log()
2528
+ console.log(chalk.bold('Step 4/5 Setting up TASK_MANAGER_TOKEN…'))
2529
+
2530
+ const shell = process.env.SHELL ?? ''
2531
+ const profileMap = { zsh: '.zshrc', bash: '.bashrc', fish: '.config/fish/config.fish' }
2532
+ const profileKey = Object.keys(profileMap).find((k) => shell.includes(k))
2533
+ const profileFile = profileKey ? path.join(os.homedir(), profileMap[profileKey]) : null
2534
+ const marker = '# added by wadah setup-agent'
2535
+ const exportLine = `\nexport TASK_MANAGER_TOKEN="${rawToken}" ${marker}\n`
2536
+
2537
+ if (opts.skipEnv || !profileFile) {
2538
+ console.log(chalk.gray(' Skipping automatic profile write. Add this line manually:'))
2539
+ console.log(chalk.gray(` export TASK_MANAGER_TOKEN="${rawToken}"`))
2540
+ const rcGuess = shell.includes('zsh') ? '~/.zshrc' : shell.includes('fish') ? '~/.config/fish/config.fish' : '~/.bashrc'
2541
+ console.log(chalk.gray(` Then reload: source ${rcGuess}`))
2542
+ } else {
2543
+ const confirm = await ask(chalk.gray(` Write to ${profileFile}? (Y/n) > `))
2544
+ if (confirm.trim().toLowerCase() !== 'n') {
2545
+ try {
2546
+ const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : ''
2547
+ const filtered = existing
2548
+ .split('\n')
2549
+ .filter((line) => !line.includes('TASK_MANAGER_TOKEN=') && !line.includes(marker))
2550
+ .join('\n')
2551
+ const next = `${filtered.trimEnd()}${exportLine}`
2552
+ fs.writeFileSync(profileFile, `${next.endsWith('\n') ? next : `${next}\n`}`)
2553
+ console.log(chalk.green(` ✓ Written to ${profileFile}`))
2554
+ console.log(chalk.gray(` Reload now: source ${profileFile}`))
2555
+ } catch (e) {
2556
+ console.log(chalk.yellow(` ⚠ Could not write to ${profileFile}: ${e.message}`))
2557
+ console.log(chalk.gray(` Add manually: export TASK_MANAGER_TOKEN="${rawToken}"`))
2558
+ }
2559
+ } else {
2560
+ console.log(chalk.gray(` Skipped. Add manually: export TASK_MANAGER_TOKEN="${rawToken}"`))
2561
+ }
2562
+ }
2563
+
2564
+ // Step 4.5: verify the created token works (without requiring shell reload)
2565
+ console.log()
2566
+ console.log(chalk.bold('Step 4.5/5 Verifying token…'))
2567
+ try {
2568
+ const verifyRes = await fetch(`${getApiBase()}/api/auth/me`, {
2569
+ headers: { Authorization: `Bearer ${rawToken}`, 'Content-Type': 'application/json' },
2570
+ })
2571
+ if (!verifyRes.ok) {
2572
+ console.log(chalk.yellow(` ⚠ Token verification returned ${verifyRes.status}. Continue and run: wadah doctor`))
2573
+ } else {
2574
+ const verifyBody = await verifyRes.json().catch(() => ({}))
2575
+ const role = verifyBody?.workspaces?.[0]?.role ?? '?'
2576
+ const actsAs = verifyBody?.assigneeName ?? tokenResult.assigneeName ?? agentName
2577
+ console.log(chalk.green(` ✓ Token valid (role: ${role}, assignee: ${actsAs})`))
2578
+ }
2579
+ } catch (e) {
2580
+ console.log(chalk.yellow(` ⚠ Could not verify token now: ${e.message}`))
2581
+ }
2582
+
2583
+ // Step 5: default board
2584
+ console.log()
2585
+ console.log(chalk.bold('Step 5/5 Default board'))
2586
+ let boardName = opts.board
2587
+ if (!boardName) {
2588
+ try {
2589
+ const state = await getState()
2590
+ if (state.boards?.length > 0) {
2591
+ console.log(chalk.gray(' Available boards:'))
2592
+ state.boards.forEach((b, i) => console.log(chalk.gray(` ${i + 1}. ${b.name}`)))
2593
+ const boardInput = (await ask(chalk.gray(' Which board should new tasks land on by default? (name or number, Enter to skip) > '))).trim()
2594
+ if (boardInput) {
2595
+ const byNum = parseInt(boardInput, 10)
2596
+ const picked = !isNaN(byNum) ? state.boards[byNum - 1] : state.boards.find((b) => b.name.toLowerCase().includes(boardInput.toLowerCase()))
2597
+ if (picked) {
2598
+ confSet('default_board', picked.name)
2599
+ boardName = picked.name
2600
+ console.log(chalk.green(` ✓ Default board: "${picked.name}"`))
2601
+ } else {
2602
+ console.log(chalk.yellow(` ⚠ Board not found — skipping. Set later: wadah config --default-board "<name>"`))
2603
+ }
2604
+ } else {
2605
+ console.log(chalk.gray(' Skipped. Set later: wadah config --default-board "<name>"'))
2606
+ }
2607
+ }
2608
+ } catch { /* board step is optional */ }
2609
+ } else {
2610
+ confSet('default_board', boardName)
2611
+ console.log(chalk.green(` ✓ Default board: "${boardName}"`))
2612
+ }
2613
+
2614
+ rl.close()
2615
+
2616
+ console.log()
2617
+ console.log(chalk.bold.green('✓ Setup complete!'))
2618
+ console.log()
2619
+ console.log(chalk.gray(' Next steps:'))
2620
+ console.log(chalk.gray(` 1. Reload your shell profile (or open a new terminal)`))
2621
+ console.log(chalk.gray(` 2. Verify everything works: wadah doctor`))
2622
+ console.log(chalk.gray(` 3. Create your first task: wadah add "My first task"`))
2623
+ console.log()
2624
+ })
2625
+
2626
+ // ── agent memory ─────────────────────────────────────────────────────────────
2627
+
2628
+ program
2629
+ .command('memory')
2630
+ .description('Show all memory for this agent, grouped by category')
2631
+ .option('--category <cat>', 'Filter to a specific category')
2632
+ .option('--log', 'Show memory change history instead of current values')
2633
+ .option('--export', 'Export all memory as a JSON blob (for backup or handoff)')
2634
+ .action(async (opts) => {
2635
+ try {
2636
+ if (opts.export) {
2637
+ const data = await api('/api/agent-memory/export')
2638
+ console.log(JSON.stringify(data, null, runtimeJsonOutput ? 2 : 0))
2639
+ return
2640
+ }
2641
+ if (opts.log) {
2642
+ const log = await api('/api/agent-memory/log')
2643
+ if (isMachineOutput()) { console.log(JSON.stringify(log, null, runtimeJsonOutput ? 2 : 0)); return }
2644
+ if (!log?.length) { console.log(chalk.gray('\nNo memory changes recorded yet.\n')); return }
2645
+ console.log()
2646
+ log.forEach((e) => {
2647
+ const when = new Date(e.changed_at).toLocaleString()
2648
+ const key = e.agent_memory?.key ?? '?'
2649
+ console.log(` ${chalk.gray(when)} ${chalk.bold(key)}`)
2650
+ if (e.old_value !== null) console.log(chalk.gray(` was: ${String(e.old_value).slice(0, 80)}`))
2651
+ console.log(chalk.gray(` now: ${String(e.new_value).slice(0, 80)}`))
2652
+ })
2653
+ console.log()
2654
+ return
2655
+ }
2656
+
2657
+ const url = opts.category ? `/api/agent-memory?category=${encodeURIComponent(opts.category)}` : '/api/agent-memory'
2658
+ const entries = await api(url)
2659
+ if (isMachineOutput()) { console.log(JSON.stringify(entries, null, runtimeJsonOutput ? 2 : 0)); return }
2660
+ if (!entries?.length) { console.log(chalk.gray('\nNo memory stored yet. Use: wadah remember <key> <value>\n')); return }
2661
+
2662
+ console.log()
2663
+ const byCategory = {}
2664
+ entries.forEach((e) => {
2665
+ const cat = e.category ?? 'general'
2666
+ if (!byCategory[cat]) byCategory[cat] = []
2667
+ byCategory[cat].push(e)
2668
+ })
2669
+ for (const [cat, items] of Object.entries(byCategory)) {
2670
+ console.log(chalk.bold(`${cat.charAt(0).toUpperCase() + cat.slice(1)}`))
2671
+ items.forEach((e) => {
2672
+ const conf = e.confidence < 1 ? chalk.yellow(` (${Math.round(e.confidence * 100)}% confidence)`) : ''
2673
+ const val = String(e.value).slice(0, 80)
2674
+ console.log(` ${e.key.padEnd(28)} ${val}${conf}`)
2675
+ })
2676
+ console.log()
2677
+ }
2678
+ } catch (err) { handleError(err) }
2679
+ })
2680
+
2681
+ program
2682
+ .command('remember <key> <value>')
2683
+ .description('Write or update a memory entry for this agent')
2684
+ .option('--category <cat>', 'Category (config, preferences, workspace, session, learned)', 'general')
2685
+ .option('--confidence <0-1>', 'Confidence level (default 1.0)', '1.0')
2686
+ .option('--source <text>', 'How this was learned (e.g. user_confirmed, inferred)')
2687
+ .option('--session <id>', 'Session identifier for audit log')
2688
+ .action(async (key, value, opts) => {
2689
+ try {
2690
+ const body = {
2691
+ value,
2692
+ category: opts.category,
2693
+ confidence: parseFloat(opts.confidence || '1'),
2694
+ source: opts.source ?? null,
2695
+ sessionId: opts.session ?? null,
2696
+ }
2697
+ const result = await api(`/api/agent-memory/${encodeURIComponent(key)}`, { method: 'PUT', body: JSON.stringify(body) })
2698
+ if (isMachineOutput()) { console.log(JSON.stringify(result, null, runtimeJsonOutput ? 2 : 0)); return }
2699
+ console.log(chalk.green(`\n✓ Remembered ${chalk.bold(key)} = ${value}\n`))
2700
+ } catch (err) { handleError(err) }
2701
+ })
2702
+
2703
+ program
2704
+ .command('forget <key>')
2705
+ .description('Delete a memory entry')
2706
+ .action(async (key) => {
2707
+ try {
2708
+ await api(`/api/agent-memory/${encodeURIComponent(key)}`, { method: 'DELETE' })
2709
+ if (!runtimeQuietOutput) console.log(chalk.green(`\n✓ Forgotten ${key}\n`))
2710
+ } catch (err) { handleError(err) }
2711
+ })
2712
+
2713
+ program
2714
+ .command('memory-log')
2715
+ .description('Alias for: wadah memory --log')
2716
+ .action(async () => {
2717
+ try {
2718
+ const log = await api('/api/agent-memory/log')
2719
+ if (isMachineOutput()) { console.log(JSON.stringify(log, null, runtimeJsonOutput ? 2 : 0)); return }
2720
+ if (!log?.length) { console.log(chalk.gray('\nNo memory changes recorded yet.\n')); return }
2721
+ console.log()
2722
+ log.forEach((e) => {
2723
+ const when = new Date(e.changed_at).toLocaleString()
2724
+ const key = e.agent_memory?.key ?? '?'
2725
+ console.log(` ${chalk.gray(when)} ${chalk.bold(key)}`)
2726
+ if (e.old_value !== null) console.log(chalk.gray(` was: ${String(e.old_value).slice(0, 80)}`))
2727
+ console.log(chalk.gray(` now: ${String(e.new_value).slice(0, 80)}`))
2728
+ })
2729
+ console.log()
2730
+ } catch (err) { handleError(err) }
2731
+ })
2732
+
2733
+ program
2734
+ .command('memory-export')
2735
+ .description('Alias for: wadah memory --export')
2736
+ .action(async () => {
2737
+ try {
2738
+ const data = await api('/api/agent-memory/export')
2739
+ console.log(JSON.stringify(data, null, runtimeJsonOutput ? 2 : 0))
2740
+ } catch (err) { handleError(err) }
2741
+ })
2742
+
2743
+ // ── shell completion ──────────────────────────────────────────────────────────
2744
+
1900
2745
  program
1901
2746
  .command('completion [shell]')
1902
2747
  .description('Print shell completion script (bash or zsh). Add to your profile: wadah completion bash >> ~/.bashrc')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-wadah",
3
- "version": "1.2.1",
3
+ "version": "1.2.3",
4
4
  "description": "Open Wadah CLI — shared task board for humans and agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -13,10 +13,10 @@
13
13
  "test": "node --test test/cli.test.js"
14
14
  },
15
15
  "dependencies": {
16
- "@inquirer/prompts": "^8.3.0",
16
+ "@inquirer/prompts": "^8.3.2",
17
17
  "chalk": "^5.4.1",
18
- "commander": "^13.1.0",
19
- "conf": "^13.1.0",
18
+ "commander": "^14.0.3",
19
+ "conf": "^15.1.0",
20
20
  "form-data": "^4.0.0",
21
21
  "node-fetch": "^3.3.2"
22
22
  }