open-wadah 1.2.2 → 1.3.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/README.md CHANGED
@@ -1,6 +1,15 @@
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
+ Command-line interface for Wadah task boards and autonomous agent workflows.
4
+
5
+ `open-wadah` gives teams and AI agents a fast, scriptable way to plan, assign, and deliver work from the terminal.
6
+
7
+ ## Why teams use Wadah CLI
8
+
9
+ - Manage tasks, assignees, and board flow without leaving your terminal.
10
+ - Automate workflows with stable JSON output for scripts and CI.
11
+ - Power autonomous agents with scoped agent tokens.
12
+ - Keep human and agent work on the same board and process.
4
13
 
5
14
  ## Install
6
15
 
@@ -8,83 +17,135 @@ Open WADAH CLI — shared task board for humans and agents. Use it from the term
8
17
  npm install -g open-wadah
9
18
  ```
10
19
 
11
- Commands: `wadah`, `tm`, `ow` (same CLI).
20
+ Available commands: `wadah`, `ow`, `tm`.
12
21
 
13
- ## Quick start
22
+ ## 60-second quick start
14
23
 
15
- 1. API must be reachable (default: `https://api.openwadah.com`). Override: `TASK_MANAGER_API_URL` or `--api <url>`.
16
- 2. Sign in: `wadah login` (browser) or `wadah login --password` (terminal).
17
- 3. Use the board:
24
+ 1. Sign in:
25
+
26
+ ```bash
27
+ wadah login
28
+ ```
29
+
30
+ 2. Open your assigned tasks:
18
31
 
19
32
  ```bash
20
33
  wadah open
21
- wadah add "Fix login issue"
34
+ ```
35
+
36
+ 3. Create and move work:
37
+
38
+ ```bash
39
+ wadah add "Fix login issue" --board "Main"
40
+ wadah move <task-id> <bucket-id>
22
41
  wadah complete <task-id>
23
42
  ```
24
43
 
25
- ## Commands
44
+ Default API URL is `https://api.openwadah.com`. Override with `--api <url>` or `WADAH_API_URL`.
26
45
 
27
- | Command | Description |
28
- |--------|-------------|
29
- | **Auth** | `login`, `signup`, `logout`, `whoami` |
30
- | **Tasks** | `open`, `list`, `search`, `requested`, `add`, `complete`, `reopen`, `view`, `update`, `move`, `assign`, `comment`, `delete` |
31
- | **Relationships** | `add --blocks <id>`, `add --blocked-by <id>`, `update --blocks <id>` |
32
- | **Board** | `boards --json`, `buckets --json`, `assignees --json`; `board create/delete`, `bucket create/update/delete`, `assignee create/update/delete` |
33
- | **Files** | `folders`, `files`, `folder create` / `mkdir`, `upload` |
34
- | **Calendar** | `calendar` (list; `--assignee`, `--task`, `--from`, `--to`), `calendar add`, `calendar update <id>`, `calendar delete <id>` |
35
- | **Docs** | `docs` (list), `doc create [title]`, `doc show <id>`, `doc update <id>`, `doc delete <id>` |
36
- | **Agent tokens** | `agent-tokens` (list), `agent-token create [name]`, `agent-token delete <id>` |
37
- | **Workspace** | `state`, `members`, `invite`, `config` |
38
- | **Other** | `do "<sentence>"` (natural language, needs `OPENAI_API_KEY`), `doctor`, `completion [bash\|zsh]` |
46
+ ## Core workflows
39
47
 
40
- Run `wadah --help` for full list and options.
48
+ ### Daily task operations
41
49
 
42
- ## Shell completion
50
+ ```bash
51
+ wadah list --board "Main"
52
+ wadah search "billing"
53
+ wadah view <task-id>
54
+ wadah update <task-id> --notes "Root cause + next steps"
55
+ wadah comment <task-id> "Investigating now"
56
+ ```
57
+
58
+ ### Dependencies and subtasks
59
+
60
+ ```bash
61
+ wadah add "Ship release notes" --blocks <task-id>
62
+ wadah subtask add <task-id> "Write tests"
63
+ wadah subtask list <task-id>
64
+ wadah subtask toggle <task-id> <subtask-id>
65
+ ```
43
66
 
44
- **Bash:**
67
+ ### Board administration
45
68
 
46
69
  ```bash
47
- wadah completion bash >> ~/.bashrc
48
- source ~/.bashrc
70
+ wadah board create "Backend"
71
+ wadah bucket create "In Review" --board <board-id>
72
+ wadah assignee create "Cursor Agent" --type agent
73
+ wadah buckets --board "Backend" --json
49
74
  ```
50
75
 
51
- **Zsh:**
76
+ ## Command surface
77
+
78
+ | Area | Commands |
79
+ |---|---|
80
+ | Auth | `login`, `signup`, `logout`, `whoami` |
81
+ | Tasks | `open`, `list`, `search`, `requested`, `add`, `view`, `update`, `move`, `assign`, `comment`, `complete`, `reopen`, `delete` |
82
+ | Subtasks | `subtask list`, `subtask add`, `subtask toggle`, `subtask delete` |
83
+ | Boards & members | `boards`, `buckets`, `assignees`, `board create/delete`, `bucket create/update/delete`, `assignee create/update/delete` |
84
+ | Docs | `docs`, `doc create/show/update/delete` |
85
+ | Calendar | `calendar`, `calendar add/update/delete` |
86
+ | Files | `folders`, `files`, `folder create`, `mkdir`, `upload` |
87
+ | Agent tokens | `agent-tokens`, `agent-token create/delete` |
88
+ | Utilities | `doctor`, `completion bash`, `completion zsh`, `state`, `members`, `invite`, `config`, `do` |
89
+
90
+ Use `wadah --help` or `wadah <command> --help` for full flags and examples.
91
+
92
+ ## AI agent mode
93
+
94
+ For autonomous workflows (Cursor, Claude Code, GitHub Actions):
95
+
96
+ - Create an agent token:
52
97
 
53
98
  ```bash
54
- wadah completion zsh >> ~/.zshrc
55
- source ~/.zshrc
99
+ wadah agent-token create "My Agent"
56
100
  ```
57
101
 
58
- ## Agent / AI use
102
+ - Set it as `WADAH_AGENT_TOKEN` in your environment.
103
+ - Use `--json` for deterministic machine output (`wadah open --json`).
104
+ - Optional natural-language command:
105
+
106
+ ```bash
107
+ wadah do "add a task to fix onboarding bug"
108
+ ```
59
109
 
60
- - Set `TASK_MANAGER_TOKEN` (create a token: `wadah agent-token create "My Agent"`).
61
- - Use `--json` for machine-readable output: `wadah open --json`, `wadah list --json`, `wadah buckets --json`.
62
- - Natural language: `wadah do "add a task to fix the bug"` (requires `OPENAI_API_KEY`).
63
- - In Cursor/Claude/Kimi: see **WADAH_CLI.md** in the repo root for a short reference.
64
- - For autonomous 24/7 agents with GitHub Actions: see **AGENTS.md** in the repo root.
110
+ `wadah do` requires `OPENAI_API_KEY`.
65
111
 
66
112
  ## Global flags
67
113
 
68
- - `--api <url>` API base URL
69
- - `--profile <name>` config profile
70
- - `--token <token>` — auth token for this run
71
- - `--json` JSON output
72
- - `--quiet` minimal output
114
+ - `--api <url>`: API base URL
115
+ - `--profile <name>`: config profile
116
+ - `--token <token>`: token for current command only
117
+ - `--json`: machine-readable output
118
+ - `--quiet`: reduce non-essential output
73
119
 
74
- ## Develop & test
120
+ ## Environment variables
75
121
 
76
- From the repo root:
122
+ - `WADAH_AGENT_TOKEN`: preferred auth token for automation
123
+ - `WADAH_API_URL`: override API base URL
124
+ - `OPENAI_API_KEY`: required for `wadah do`
125
+
126
+ ## Shell completion
127
+
128
+ ```bash
129
+ # Bash
130
+ wadah completion bash >> ~/.bashrc && source ~/.bashrc
131
+
132
+ # Zsh
133
+ wadah completion zsh >> ~/.zshrc && source ~/.zshrc
134
+ ```
135
+
136
+ ## Development
77
137
 
78
138
  ```bash
79
- npm run install:all
80
- npm run tm -- --help
81
- cd task-manager-cli && npm test
139
+ npm test
140
+ node cli.js --help
82
141
  ```
83
142
 
84
- ## Error codes (with `--json`)
143
+ ## JSON error codes
144
+
145
+ When running with `--json`, failures include structured error codes:
85
146
 
86
- - `auth_error` — not authenticated
87
- - `validation_error` — bad input
88
- - `network_error` — request failed
89
- - `api_error` — API returned an error
90
- - `config` — missing config (e.g. OPENAI_API_KEY for `do`)
147
+ - `auth_error`
148
+ - `validation_error`
149
+ - `network_error`
150
+ - `api_error`
151
+ - `config`
package/cli.js CHANGED
@@ -31,7 +31,7 @@ function normalizeApiUrl(url) {
31
31
  const conf = new Conf({ projectName: 'open-wadah' })
32
32
 
33
33
  function getProfile() {
34
- const profile = (process.env.TASK_MANAGER_PROFILE ?? runtimeProfile ?? 'default').trim()
34
+ const profile = (process.env.WADAH_PROFILE ?? runtimeProfile ?? 'default').trim()
35
35
  return profile || 'default'
36
36
  }
37
37
 
@@ -55,15 +55,28 @@ function confDelete(key) {
55
55
 
56
56
  function getApiBase() {
57
57
  return (
58
- (process.env.TASK_MANAGER_API_URL ?? '').trim() ||
58
+ (process.env.WADAH_API_URL ?? '').trim() ||
59
59
  runtimeApiBase ||
60
60
  confGet('api_url') ||
61
61
  DEFAULT_API_BASE
62
62
  )
63
63
  }
64
64
 
65
+ /** Agent token from env: reads WADAH_AGENT_TOKEN. */
66
+ function envAgentToken() {
67
+ const w = (process.env.WADAH_AGENT_TOKEN ?? '').trim()
68
+ return w || null
69
+ }
70
+
71
+ /** Which env var supplied the agent token (for status output). */
72
+ function envAgentTokenVarName() {
73
+ if ((process.env.WADAH_AGENT_TOKEN ?? '').trim()) return 'WADAH_AGENT_TOKEN'
74
+ return null
75
+ }
76
+
65
77
  function getAuthToken() {
66
- if (process.env.TASK_MANAGER_TOKEN) return process.env.TASK_MANAGER_TOKEN
78
+ const fromEnv = envAgentToken()
79
+ if (fromEnv) return fromEnv
67
80
  if (runtimeToken) return runtimeToken
68
81
  return confGet('access_token') ?? null
69
82
  }
@@ -99,7 +112,7 @@ async function refreshAccessToken() {
99
112
  }
100
113
 
101
114
  async function ensureAuth() {
102
- if (process.env.TASK_MANAGER_TOKEN) return // agent token, no refresh needed
115
+ if (envAgentToken()) return // agent token, no refresh needed
103
116
  const expires = confGet('token_expires')
104
117
  // Refresh if within 5 minutes of expiry or already expired
105
118
  if (expires && Date.now() > (expires * 1000) - 300_000) {
@@ -120,7 +133,7 @@ async function api(path, options = {}, retry = true) {
120
133
  }
121
134
 
122
135
  // If 401 and we have a refresh token, try once more
123
- if (res.status === 401 && retry && !process.env.TASK_MANAGER_TOKEN) {
136
+ if (res.status === 401 && retry && !envAgentToken()) {
124
137
  const refreshed = await refreshAccessToken()
125
138
  if (refreshed) return api(path, options, false)
126
139
  }
@@ -197,7 +210,7 @@ function handleError(err) {
197
210
  } else if (authLike) {
198
211
  console.error(chalk.red('\n✗ Not authenticated.'))
199
212
  console.error(chalk.gray(' Humans: wadah login'))
200
- console.error(chalk.gray(' Agents: TASK_MANAGER_TOKEN=<token> wadah open\n'))
213
+ console.error(chalk.gray(' Agents: WADAH_AGENT_TOKEN=<token> wadah open\n'))
201
214
  } else if (code === 'network_error') {
202
215
  console.error(chalk.red('\n✗ Network error — could not reach the API.'))
203
216
  console.error(chalk.gray(` Check your connection or API URL: ${getApiBase()}`))
@@ -279,6 +292,23 @@ function findBucket(buckets, query) {
279
292
  return buckets.find((b) => b.id === query || b.title.toLowerCase().includes(q)) ?? null
280
293
  }
281
294
 
295
+ function findSubtask(subtasks, query) {
296
+ const raw = String(query ?? '').trim()
297
+ if (!raw) return null
298
+ const q = raw.toLowerCase()
299
+ const byId = subtasks.filter((s) => s.id === raw || s.id.startsWith(raw))
300
+ if (byId.length === 1) return byId[0]
301
+ if (byId.length > 1) {
302
+ throw new CliError('validation_error', `Ambiguous subtask id prefix: ${raw}`)
303
+ }
304
+ const byTitle = subtasks.filter((s) => String(s.title ?? '').toLowerCase() === q)
305
+ if (byTitle.length === 1) return byTitle[0]
306
+ if (byTitle.length > 1) {
307
+ throw new CliError('validation_error', `Multiple subtasks have title: ${raw}`)
308
+ }
309
+ return null
310
+ }
311
+
282
312
  function parseCompletedFlag(value) {
283
313
  if (typeof value !== 'string') return null
284
314
  const v = value.trim().toLowerCase()
@@ -410,6 +440,8 @@ Available commands and their args (use these exact command names):
410
440
  - repo add: args = [github url or owner/repo]. Link a GitHub repository to your workspace.
411
441
  - repo list (or repos): args = []. List linked GitHub repositories.
412
442
  - repo remove: args = [repo id]. Unlink a repository.
443
+ - pr link: args = [task id, pr url]. Attach a PR URL to a task.
444
+ - pr open: args = [task id]. Open the linked PR URL for a task.
413
445
 
414
446
  If the request is unclear or not a valid CLI action, return {"command": "", "args": []}. Output only the JSON object.`
415
447
 
@@ -719,7 +751,7 @@ program
719
751
  .action(async () => {
720
752
  if (isMachineOutput()) {
721
753
  try {
722
- if (process.env.TASK_MANAGER_TOKEN || runtimeToken) {
754
+ if (envAgentToken() || runtimeToken) {
723
755
  const me = await api('/api/auth/me')
724
756
  const ws = me.workspaces?.[0] ?? null
725
757
  console.log(JSON.stringify({
@@ -752,8 +784,8 @@ program
752
784
  return
753
785
  }
754
786
 
755
- if (process.env.TASK_MANAGER_TOKEN) {
756
- console.log(chalk.bold('\nAgent') + ' (TASK_MANAGER_TOKEN)')
787
+ if (envAgentToken()) {
788
+ console.log(chalk.bold('\nAgent') + ` (${envAgentTokenVarName()})`)
757
789
  } else {
758
790
  const email = confGet('user_email')
759
791
  if (!email) {
@@ -1233,7 +1265,16 @@ program
1233
1265
  if (requester) console.log(chalk.gray(` Requested: ${requester}`))
1234
1266
  if (task.dueDate) console.log(chalk.gray(` Due: ${formatDate(task.dueDate)}`))
1235
1267
  if (task.repo) console.log(chalk.gray(` Repo: ${task.repo}`))
1236
- if (task.contextUrl) console.log(chalk.gray(` Link: ${task.contextUrl}`))
1268
+ if (Array.isArray(task.contextLinks) && task.contextLinks.length > 0) {
1269
+ console.log(chalk.gray('\n Links:'))
1270
+ task.contextLinks.forEach((l) => {
1271
+ const lab = l.label ? `${l.label}: ` : ''
1272
+ console.log(chalk.gray(` ${lab}${l.url}`))
1273
+ })
1274
+ if (task.contextUrl) console.log(chalk.gray(` Primary: ${task.contextUrl}`))
1275
+ } else if (task.contextUrl) {
1276
+ console.log(chalk.gray(` Link: ${task.contextUrl}`))
1277
+ }
1237
1278
  if (task.tags?.length) {
1238
1279
  const relTags = task.tags.filter((t) => t.startsWith('blocks:') || t.startsWith('blocked-by:'))
1239
1280
  const plainTags = task.tags.filter((t) => !t.startsWith('blocks:') && !t.startsWith('blocked-by:'))
@@ -1280,6 +1321,87 @@ program
1280
1321
  } catch (err) { handleError(err) }
1281
1322
  })
1282
1323
 
1324
+ const subtaskCmd = program.command('subtask').description('Manage task subtasks')
1325
+
1326
+ subtaskCmd
1327
+ .command('list <taskId>')
1328
+ .description('List subtasks on a task')
1329
+ .action(async (taskId) => {
1330
+ try {
1331
+ const task = await api(`/api/tasks/${taskId}`)
1332
+ const subtasks = task.subtasks ?? []
1333
+ if (isMachineOutput()) {
1334
+ console.log(JSON.stringify({ taskId, subtasks }, null, runtimeJsonOutput ? 2 : 0))
1335
+ return
1336
+ }
1337
+ console.log()
1338
+ if (subtasks.length === 0) {
1339
+ console.log(chalk.gray(' No subtasks.\n'))
1340
+ return
1341
+ }
1342
+ console.log(chalk.bold(task.title))
1343
+ subtasks.forEach((s) => {
1344
+ const status = s.completed ? chalk.green('✓') : '○'
1345
+ console.log(` ${status} ${s.title} ${chalk.gray(s.id.slice(0, 8))}`)
1346
+ })
1347
+ console.log()
1348
+ } catch (err) { handleError(err) }
1349
+ })
1350
+
1351
+ subtaskCmd
1352
+ .command('add <taskId> <title>')
1353
+ .description('Add a subtask to a task')
1354
+ .action(async (taskId, title) => {
1355
+ try {
1356
+ const trimmedTitle = String(title ?? '').trim()
1357
+ if (!trimmedTitle) return fail('Subtask title is required')
1358
+ const task = await api(`/api/tasks/${taskId}`)
1359
+ const nextSubtask = { id: randomUUID(), title: trimmedTitle, completed: false }
1360
+ await api(`/api/tasks/${taskId}`, {
1361
+ method: 'PATCH',
1362
+ body: JSON.stringify({ subtasks: [...(task.subtasks ?? []), nextSubtask] }),
1363
+ })
1364
+ console.log(chalk.green('\n✓ Subtask added') + ` ${chalk.bold(nextSubtask.title)} ${chalk.gray(`(${nextSubtask.id.slice(0, 8)})`)}\n`)
1365
+ } catch (err) { handleError(err) }
1366
+ })
1367
+
1368
+ subtaskCmd
1369
+ .command('toggle <taskId> <subtaskId>')
1370
+ .description('Toggle subtask completion')
1371
+ .action(async (taskId, subtaskId) => {
1372
+ try {
1373
+ const task = await api(`/api/tasks/${taskId}`)
1374
+ const subtasks = task.subtasks ?? []
1375
+ const target = findSubtask(subtasks, subtaskId)
1376
+ if (!target) return fail(`Subtask not found: ${subtaskId}`)
1377
+ const nextSubtasks = subtasks.map((s) => s.id === target.id ? { ...s, completed: !s.completed } : s)
1378
+ await api(`/api/tasks/${taskId}`, {
1379
+ method: 'PATCH',
1380
+ body: JSON.stringify({ subtasks: nextSubtasks }),
1381
+ })
1382
+ const marker = !target.completed ? chalk.green('✓') : '○'
1383
+ console.log(chalk.green('\n✓ Subtask updated') + ` ${marker} ${chalk.bold(target.title)}\n`)
1384
+ } catch (err) { handleError(err) }
1385
+ })
1386
+
1387
+ subtaskCmd
1388
+ .command('delete <taskId> <subtaskId>')
1389
+ .description('Delete a subtask from a task')
1390
+ .action(async (taskId, subtaskId) => {
1391
+ try {
1392
+ const task = await api(`/api/tasks/${taskId}`)
1393
+ const subtasks = task.subtasks ?? []
1394
+ const target = findSubtask(subtasks, subtaskId)
1395
+ if (!target) return fail(`Subtask not found: ${subtaskId}`)
1396
+ const nextSubtasks = subtasks.filter((s) => s.id !== target.id)
1397
+ await api(`/api/tasks/${taskId}`, {
1398
+ method: 'PATCH',
1399
+ body: JSON.stringify({ subtasks: nextSubtasks }),
1400
+ })
1401
+ console.log(chalk.green('\n✓ Subtask deleted') + ` ${chalk.bold(target.title)}\n`)
1402
+ } catch (err) { handleError(err) }
1403
+ })
1404
+
1283
1405
  // ── tm boards ─────────────────────────────────────────────────────────────────
1284
1406
 
1285
1407
  program
@@ -1371,13 +1493,65 @@ const boardCmd = program.command('board').description('Create or delete a board
1371
1493
  boardCmd
1372
1494
  .command('create <name>')
1373
1495
  .description('Create a new board')
1374
- .action(async (name) => {
1496
+ .option('--flow <columns>', 'Comma-separated column flow, e.g. "Backlog,Ready,Doing,QA,Done"')
1497
+ .action(async function (name) {
1375
1498
  try {
1499
+ const opts = this.opts()
1376
1500
  const board = await api('/api/boards', { method: 'POST', body: JSON.stringify({ name: String(name).trim() }) })
1501
+ const createdBuckets = []
1502
+ if (opts.flow) {
1503
+ const flowColumns = String(opts.flow)
1504
+ .split(',')
1505
+ .map((c) => c.trim())
1506
+ .filter(Boolean)
1507
+ if (flowColumns.length === 0) {
1508
+ return fail('Flow is empty. Provide comma-separated columns, e.g. --flow "Backlog,Doing,Done".')
1509
+ }
1510
+ for (const column of flowColumns) {
1511
+ if (!column) continue
1512
+ const bucket = await api('/api/buckets', {
1513
+ method: 'POST',
1514
+ body: JSON.stringify({ title: column, boardId: board.id }),
1515
+ })
1516
+ createdBuckets.push(bucket)
1517
+ }
1518
+ }
1377
1519
  if (isMachineOutput()) {
1378
- console.log(JSON.stringify(board, null, runtimeJsonOutput ? 2 : 0))
1520
+ console.log(JSON.stringify(
1521
+ { ...board, flow: createdBuckets.map((b) => ({ id: b.id, title: b.title })) },
1522
+ null,
1523
+ runtimeJsonOutput ? 2 : 0
1524
+ ))
1379
1525
  } else {
1380
1526
  console.log(chalk.green('\n✓ Board created') + ` ${chalk.bold(board.name)} ${chalk.gray(board.id?.slice(0, 8))}\n`)
1527
+ if (createdBuckets.length > 0) {
1528
+ console.log(chalk.gray(' Flow columns:'))
1529
+ createdBuckets.forEach((b) => console.log(` - ${b.title} ${chalk.gray(b.id?.slice(0, 8))}`))
1530
+ console.log()
1531
+ }
1532
+ }
1533
+ } catch (err) { handleError(err) }
1534
+ })
1535
+
1536
+ boardCmd
1537
+ .command('rename <id> <name>')
1538
+ .description('Rename a board')
1539
+ .action(async (id, name) => {
1540
+ try {
1541
+ const payload = JSON.stringify({ name: String(name).trim() })
1542
+ let board
1543
+ try {
1544
+ board = await api(`/api/boards/${id}`, { method: 'PATCH', body: payload })
1545
+ } catch (err) {
1546
+ // Backward compatibility: some deployments only support PUT for board rename.
1547
+ const notFound = err instanceof CliError && err.code === 'api_error' && String(err.message).toLowerCase().includes('not found')
1548
+ if (!notFound) throw err
1549
+ board = await api(`/api/boards/${id}`, { method: 'PUT', body: payload })
1550
+ }
1551
+ if (isMachineOutput()) {
1552
+ console.log(JSON.stringify(board, null, runtimeJsonOutput ? 2 : 0))
1553
+ } else {
1554
+ console.log(chalk.green('\n✓ Board renamed') + ` ${chalk.bold(board.name)} ${chalk.gray(board.id?.slice(0, 8))}\n`)
1381
1555
  }
1382
1556
  } catch (err) { handleError(err) }
1383
1557
  })
@@ -1630,6 +1804,179 @@ repoCmd
1630
1804
  } catch (err) { handleError(err) }
1631
1805
  })
1632
1806
 
1807
+ const prCmd = program.command('pr').description('Attach and open pull requests on tasks')
1808
+
1809
+ prCmd
1810
+ .command('link <taskId> <prUrl>')
1811
+ .description('Attach a PR URL to a task')
1812
+ .option('--comment', 'Add a task comment with the PR link')
1813
+ .action(async function (taskId, prUrl) {
1814
+ const opts = this.opts()
1815
+ try {
1816
+ let parsed
1817
+ try {
1818
+ parsed = new URL(String(prUrl).trim())
1819
+ } catch {
1820
+ return fail('Provide a valid PR URL (e.g. https://github.com/owner/repo/pull/123)')
1821
+ }
1822
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
1823
+ return fail('PR URL must start with http:// or https://')
1824
+ }
1825
+ const normalizedUrl = parsed.toString()
1826
+
1827
+ const task = await api(`/api/tasks/${taskId}`)
1828
+ await api(`/api/tasks/${taskId}`, {
1829
+ method: 'PATCH',
1830
+ body: JSON.stringify({ contextUrl: normalizedUrl }),
1831
+ })
1832
+ if (opts.comment) {
1833
+ const comment = {
1834
+ id: randomUUID(),
1835
+ text: `Linked PR: ${normalizedUrl}`,
1836
+ createdAt: Date.now(),
1837
+ }
1838
+ await api(`/api/tasks/${taskId}`, {
1839
+ method: 'PATCH',
1840
+ body: JSON.stringify({ comments: [...(task.comments ?? []), comment] }),
1841
+ })
1842
+ }
1843
+
1844
+ if (isMachineOutput()) {
1845
+ console.log(JSON.stringify({ taskId, prUrl: normalizedUrl, linked: true, commented: Boolean(opts.comment) }, null, runtimeJsonOutput ? 2 : 0))
1846
+ } else {
1847
+ console.log(chalk.green('\n✓ PR linked') + ` ${chalk.bold(task.title)}`)
1848
+ console.log(chalk.gray(` ${normalizedUrl}${opts.comment ? ' · comment added' : ''}\n`))
1849
+ }
1850
+ } catch (err) { handleError(err) }
1851
+ })
1852
+
1853
+ prCmd
1854
+ .command('open <taskId>')
1855
+ .description('Open the linked PR URL for a task')
1856
+ .option('--no-browser', "Don't auto-open browser; print URL only")
1857
+ .action(async function (taskId) {
1858
+ const opts = this.opts()
1859
+ try {
1860
+ const task = await api(`/api/tasks/${taskId}`)
1861
+ const url = task?.contextUrl ? String(task.contextUrl).trim() : ''
1862
+ if (!url) return fail('This task has no linked PR URL. Use: wadah pr link <task-id> <pr-url>')
1863
+
1864
+ if (isMachineOutput()) {
1865
+ console.log(JSON.stringify({ taskId, prUrl: url }, null, runtimeJsonOutput ? 2 : 0))
1866
+ return
1867
+ }
1868
+
1869
+ console.log(chalk.gray(`\nPR: ${url}`))
1870
+ if (opts.browser) {
1871
+ const opened = await openBrowser(url)
1872
+ if (opened) console.log(chalk.green('✓ Opened in browser\n'))
1873
+ else console.log(chalk.yellow('Could not open browser automatically. Open the URL manually.\n'))
1874
+ } else {
1875
+ console.log()
1876
+ }
1877
+ } catch (err) { handleError(err) }
1878
+ })
1879
+
1880
+ const linkCmd = program.command('link').description('Attach labeled URLs to tasks (Vercel, Figma, docs — without replacing the PR)')
1881
+
1882
+ function normalizeHttpUrlForLink(input) {
1883
+ let parsed
1884
+ try {
1885
+ parsed = new URL(String(input).trim())
1886
+ } catch {
1887
+ return null
1888
+ }
1889
+ if (!['http:', 'https:'].includes(parsed.protocol)) return null
1890
+ return parsed.toString()
1891
+ }
1892
+
1893
+ const MAX_TASK_LINKS = 20
1894
+
1895
+ linkCmd
1896
+ .command('add <taskId> <url>')
1897
+ .description('Append a URL to a task (use --label for display)')
1898
+ .option('--label <text>', 'Short label, e.g. Vercel, Figma', '')
1899
+ .action(async function (taskId, url) {
1900
+ const opts = this.opts()
1901
+ try {
1902
+ const normalizedUrl = normalizeHttpUrlForLink(url)
1903
+ if (!normalizedUrl) return fail('Provide a valid http(s) URL')
1904
+
1905
+ const task = await api(`/api/tasks/${taskId}`)
1906
+ const existing = Array.isArray(task.contextLinks) ? task.contextLinks : []
1907
+ if (existing.length >= MAX_TASK_LINKS) {
1908
+ return fail(`A task can have at most ${MAX_TASK_LINKS} links. Remove one with: wadah link remove <task-id> <link-id>`)
1909
+ }
1910
+ const label = String(opts.label ?? '').trim().slice(0, 64)
1911
+ const next = [...existing, { id: randomUUID(), label, url: normalizedUrl }]
1912
+
1913
+ await api(`/api/tasks/${taskId}`, {
1914
+ method: 'PATCH',
1915
+ body: JSON.stringify({ contextLinks: next }),
1916
+ })
1917
+
1918
+ if (isMachineOutput()) {
1919
+ console.log(JSON.stringify({ taskId, url: normalizedUrl, label, added: true, links: next }, null, runtimeJsonOutput ? 2 : 0))
1920
+ } else {
1921
+ console.log(chalk.green('\n✓ Link added') + ` ${chalk.bold(task.title)}`)
1922
+ console.log(chalk.gray(` ${label ? `${label} · ` : ''}${normalizedUrl}\n`))
1923
+ }
1924
+ } catch (err) { handleError(err) }
1925
+ })
1926
+
1927
+ linkCmd
1928
+ .command('list <taskId>')
1929
+ .description('List URLs attached to a task')
1930
+ .action(async (taskId) => {
1931
+ try {
1932
+ const task = await api(`/api/tasks/${taskId}`)
1933
+ const links = Array.isArray(task.contextLinks) ? task.contextLinks : []
1934
+ if (isMachineOutput()) {
1935
+ console.log(JSON.stringify({ taskId, links, contextUrl: task.contextUrl ?? null }, null, runtimeJsonOutput ? 2 : 0))
1936
+ return
1937
+ }
1938
+ if (links.length === 0) {
1939
+ console.log(chalk.gray('\nNo links on this task. Add one: wadah link add <task-id> <url> --label Vercel\n'))
1940
+ return
1941
+ }
1942
+ console.log()
1943
+ links.forEach((l) => {
1944
+ const lab = l.label ? chalk.cyan(l.label) : chalk.gray('link')
1945
+ console.log(` ${chalk.dim(l.id)} ${lab}`)
1946
+ console.log(chalk.gray(` ${l.url}`))
1947
+ })
1948
+ if (task.contextUrl) {
1949
+ console.log(chalk.gray(`\n Primary (e.g. wadah pr open): ${task.contextUrl}\n`))
1950
+ } else {
1951
+ console.log()
1952
+ }
1953
+ } catch (err) { handleError(err) }
1954
+ })
1955
+
1956
+ linkCmd
1957
+ .command('remove <taskId> <linkId>')
1958
+ .description('Remove a link by id (from wadah link list --json)')
1959
+ .action(async (taskId, linkId) => {
1960
+ try {
1961
+ const task = await api(`/api/tasks/${taskId}`)
1962
+ const existing = Array.isArray(task.contextLinks) ? task.contextLinks : []
1963
+ const needle = String(linkId).trim()
1964
+ const next = existing.filter((l) => l.id !== needle)
1965
+ if (next.length === existing.length) {
1966
+ return fail(`No link with id matching "${needle}". Use: wadah link list ${taskId} --json`)
1967
+ }
1968
+ await api(`/api/tasks/${taskId}`, {
1969
+ method: 'PATCH',
1970
+ body: JSON.stringify({ contextLinks: next }),
1971
+ })
1972
+ if (isMachineOutput()) {
1973
+ console.log(JSON.stringify({ taskId, removed: needle, links: next }, null, runtimeJsonOutput ? 2 : 0))
1974
+ } else {
1975
+ console.log(chalk.green('\n✓ Link removed') + ` ${chalk.bold(task.title)}\n`)
1976
+ }
1977
+ } catch (err) { handleError(err) }
1978
+ })
1979
+
1633
1980
  // ── agent tokens ──────────────────────────────────────────────────────────────
1634
1981
 
1635
1982
  program
@@ -1662,7 +2009,7 @@ const agentTokenCmd = program.command('agent-token').description('Create or dele
1662
2009
 
1663
2010
  agentTokenCmd
1664
2011
  .command('create [name]')
1665
- .description('Create an agent token; token is shown once — store it as TASK_MANAGER_TOKEN')
2012
+ .description('Create an agent token; token is shown once — store it as WADAH_AGENT_TOKEN')
1666
2013
  .option('--name <text>', 'Token name (if not passed as argument)')
1667
2014
  .option('--assignee-name <text>', 'Display name for the agent (default: same as name)')
1668
2015
  .action(async function (nameArg) {
@@ -1680,9 +2027,9 @@ agentTokenCmd
1680
2027
  console.log(chalk.yellow('\n Token (store it now — shown once):'))
1681
2028
  console.log(` ${result.token}`)
1682
2029
  console.log(chalk.gray('\n Store it so you don’t lose it:'))
1683
- console.log(chalk.gray(' · GitHub Actions: Repo → Settings → Secrets and variables → Actions → New secret → TASK_MANAGER_TOKEN'))
1684
- console.log(chalk.gray(' · Cursor: Settings → Environment variables → TASK_MANAGER_TOKEN'))
1685
- console.log(chalk.gray(' · Shell: export TASK_MANAGER_TOKEN="<paste token here>"\n'))
2030
+ console.log(chalk.gray(' · GitHub Actions: Repo → Settings → Secrets and variables → Actions → New secret → WADAH_AGENT_TOKEN'))
2031
+ console.log(chalk.gray(' · Cursor: Settings → Environment variables → WADAH_AGENT_TOKEN'))
2032
+ console.log(chalk.gray(' · Shell: export WADAH_AGENT_TOKEN="<paste token here>"\n'))
1686
2033
  }
1687
2034
  } catch (err) { handleError(err) }
1688
2035
  })
@@ -2006,12 +2353,12 @@ program
2006
2353
  addCheck('api_ping', 'fail', `API ping failed: ${err.message}`)
2007
2354
  }
2008
2355
 
2009
- const hasEnvToken = Boolean(process.env.TASK_MANAGER_TOKEN || runtimeToken)
2356
+ const hasEnvToken = Boolean(envAgentToken() || runtimeToken)
2010
2357
  const hasSavedToken = Boolean(confGet('access_token'))
2011
2358
  if (!hasEnvToken && !hasSavedToken) {
2012
2359
  addCheck('auth_token', 'warn', 'No token available. Run wadah login.')
2013
2360
  } else if (hasEnvToken) {
2014
- addCheck('auth_token', 'pass', 'Using agent token from env/flag (TASK_MANAGER_TOKEN)')
2361
+ addCheck('auth_token', 'pass', `Using agent token from env/flag (${envAgentTokenVarName()})`)
2015
2362
  } else {
2016
2363
  addCheck('auth_token', 'pass', 'Using token from profile config')
2017
2364
  }
@@ -2037,7 +2384,7 @@ program
2037
2384
  addCheck('auth_me', 'warn', `Auth check failed: ${err.message}`)
2038
2385
  }
2039
2386
 
2040
- // Agent-specific checks (only when TASK_MANAGER_TOKEN is set)
2387
+ // Agent-specific checks (only when an env agent token is set)
2041
2388
  if (hasEnvToken && meBody) {
2042
2389
  const isAgent = meBody.workspaces?.[0]?.role === 'agent'
2043
2390
  if (!isAgent) {
@@ -2163,7 +2510,7 @@ program
2163
2510
  const config = {
2164
2511
  api_url: getApiBase(),
2165
2512
  signed_in_as: confGet('user_email') ?? null,
2166
- agent_mode: Boolean(process.env.TASK_MANAGER_TOKEN || runtimeToken),
2513
+ agent_mode: Boolean(envAgentToken() || runtimeToken),
2167
2514
  profile: getProfile(),
2168
2515
  default_board: defaultBoard ?? null,
2169
2516
  config_file: conf.path,
@@ -2176,7 +2523,7 @@ program
2176
2523
  console.log(chalk.bold('CLI config'))
2177
2524
  console.log(chalk.gray(` API URL: ${config.api_url}`))
2178
2525
  console.log(chalk.gray(` Signed in as: ${config.signed_in_as ?? '—'}`))
2179
- console.log(chalk.gray(` Agent mode: ${config.agent_mode ? 'yes (TASK_MANAGER_TOKEN set)' : 'no'}`))
2526
+ console.log(chalk.gray(` Agent mode: ${config.agent_mode ? `yes (${envAgentTokenVarName() ?? 'token flag'} set)` : 'no'}`))
2180
2527
  console.log(chalk.gray(` Default board: ${defaultBoard ?? '— (not set, use: wadah config --default-board "Name")'}`))
2181
2528
  console.log(chalk.gray(` Profile: ${getProfile()}`))
2182
2529
  console.log(chalk.gray(` Config file: ${config.config_file}`))
@@ -2255,7 +2602,7 @@ const CLI_COMMANDS = [
2255
2602
  'add', 'agent-token', 'agent-tokens', 'assign', 'assignee', 'assignees', 'board', 'board-view', 'boards',
2256
2603
  'bucket', 'buckets', 'calendar', 'comment', 'complete', 'config', 'delete', 'doc', 'docs',
2257
2604
  'do', 'doctor', 'files', 'folder', 'folders', 'invite', 'list', 'login', 'members', 'mkdir',
2258
- 'forget', 'memory', 'memory-export', 'memory-log', 'move', 'open', 'repo', 'repos', 'reopen', 'remember', 'requested', 'search', 'setup-agent', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
2605
+ 'forget', 'memory', 'memory-export', 'memory-log', 'move', 'open', 'pr', 'repo', 'repos', 'reopen', 'remember', 'requested', 'search', 'setup-agent', 'signup', 'state', 'update', 'upload', 'view', 'whoami',
2259
2606
  ]
2260
2607
 
2261
2608
  // ── wadah setup-agent ─────────────────────────────────────────────────────────
@@ -2265,7 +2612,7 @@ program
2265
2612
  .description('Interactive wizard: create an agent token, write it to your shell profile, and verify everything works')
2266
2613
  .option('--name <name>', 'Agent name (skip prompt)')
2267
2614
  .option('--board <board>', 'Default board name (skip prompt)')
2268
- .option('--skip-env', 'Skip writing TASK_MANAGER_TOKEN to shell profile (print instructions only)')
2615
+ .option('--skip-env', 'Skip writing WADAH_AGENT_TOKEN to shell profile (print instructions only)')
2269
2616
  .action(async (opts) => {
2270
2617
  const readline = await import('node:readline/promises')
2271
2618
  const os = await import('node:os')
@@ -2329,18 +2676,18 @@ program
2329
2676
 
2330
2677
  // Step 4: write env var to shell profile
2331
2678
  console.log()
2332
- console.log(chalk.bold('Step 4/5 Setting up TASK_MANAGER_TOKEN…'))
2679
+ console.log(chalk.bold('Step 4/5 Setting up WADAH_AGENT_TOKEN…'))
2333
2680
 
2334
2681
  const shell = process.env.SHELL ?? ''
2335
2682
  const profileMap = { zsh: '.zshrc', bash: '.bashrc', fish: '.config/fish/config.fish' }
2336
2683
  const profileKey = Object.keys(profileMap).find((k) => shell.includes(k))
2337
2684
  const profileFile = profileKey ? path.join(os.homedir(), profileMap[profileKey]) : null
2338
2685
  const marker = '# added by wadah setup-agent'
2339
- const exportLine = `\nexport TASK_MANAGER_TOKEN="${rawToken}" ${marker}\n`
2686
+ const exportLine = `\nexport WADAH_AGENT_TOKEN="${rawToken}" ${marker}\n`
2340
2687
 
2341
2688
  if (opts.skipEnv || !profileFile) {
2342
2689
  console.log(chalk.gray(' Skipping automatic profile write. Add this line manually:'))
2343
- console.log(chalk.gray(` export TASK_MANAGER_TOKEN="${rawToken}"`))
2690
+ console.log(chalk.gray(` export WADAH_AGENT_TOKEN="${rawToken}"`))
2344
2691
  const rcGuess = shell.includes('zsh') ? '~/.zshrc' : shell.includes('fish') ? '~/.config/fish/config.fish' : '~/.bashrc'
2345
2692
  console.log(chalk.gray(` Then reload: source ${rcGuess}`))
2346
2693
  } else {
@@ -2350,7 +2697,7 @@ program
2350
2697
  const existing = fs.existsSync(profileFile) ? fs.readFileSync(profileFile, 'utf8') : ''
2351
2698
  const filtered = existing
2352
2699
  .split('\n')
2353
- .filter((line) => !line.includes('TASK_MANAGER_TOKEN=') && !line.includes(marker))
2700
+ .filter((line) => !line.includes('WADAH_AGENT_TOKEN=') && !line.includes(marker))
2354
2701
  .join('\n')
2355
2702
  const next = `${filtered.trimEnd()}${exportLine}`
2356
2703
  fs.writeFileSync(profileFile, `${next.endsWith('\n') ? next : `${next}\n`}`)
@@ -2358,10 +2705,10 @@ program
2358
2705
  console.log(chalk.gray(` Reload now: source ${profileFile}`))
2359
2706
  } catch (e) {
2360
2707
  console.log(chalk.yellow(` ⚠ Could not write to ${profileFile}: ${e.message}`))
2361
- console.log(chalk.gray(` Add manually: export TASK_MANAGER_TOKEN="${rawToken}"`))
2708
+ console.log(chalk.gray(` Add manually: export WADAH_AGENT_TOKEN="${rawToken}"`))
2362
2709
  }
2363
2710
  } else {
2364
- console.log(chalk.gray(` Skipped. Add manually: export TASK_MANAGER_TOKEN="${rawToken}"`))
2711
+ console.log(chalk.gray(` Skipped. Add manually: export WADAH_AGENT_TOKEN="${rawToken}"`))
2365
2712
  }
2366
2713
  }
2367
2714
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-wadah",
3
- "version": "1.2.2",
3
+ "version": "1.3.0",
4
4
  "description": "Open Wadah CLI — shared task board for humans and agents",
5
5
  "type": "module",
6
6
  "bin": {
package/test/cli.test.js CHANGED
@@ -11,7 +11,7 @@ function runCli(args) {
11
11
  return new Promise((resolvePromise, reject) => {
12
12
  const child = spawn(process.execPath, [cliPath, ...args], {
13
13
  stdio: ['ignore', 'pipe', 'pipe'],
14
- env: { ...process.env, TASK_MANAGER_TOKEN: '' },
14
+ env: { ...process.env, WADAH_AGENT_TOKEN: '' },
15
15
  })
16
16
  let stdout = ''
17
17
  let stderr = ''
@@ -47,6 +47,12 @@ test('wadah open --help shows open command help', async () => {
47
47
  assert.match(stdout, /open|List/)
48
48
  })
49
49
 
50
+ test('wadah link --help shows link subcommands', async () => {
51
+ const { code, stdout } = await runCli(['link', '--help'])
52
+ assert.equal(code, 0)
53
+ assert.match(stdout, /add|list|remove/)
54
+ })
55
+
50
56
  test('wadah doctor runs without crashing', async () => {
51
57
  const { code } = await runCli(['doctor'])
52
58
  // Doctor may exit 0 or 1 depending on API/token; we only assert it doesn't throw