open-wadah 1.0.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/cli.js +579 -0
- package/package.json +20 -0
package/cli.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander'
|
|
3
|
+
import chalk from 'chalk'
|
|
4
|
+
import fetch from 'node-fetch'
|
|
5
|
+
import Conf from 'conf'
|
|
6
|
+
import { input, password as promptPassword } from '@inquirer/prompts'
|
|
7
|
+
import { webcrypto } from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
const randomUUID = () => webcrypto.randomUUID()
|
|
10
|
+
|
|
11
|
+
// ── config ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
const conf = new Conf({ projectName: 'open-wadah' })
|
|
14
|
+
|
|
15
|
+
function getApiBase() {
|
|
16
|
+
return (
|
|
17
|
+
(process.env.TASK_MANAGER_API_URL ?? '').trim() ||
|
|
18
|
+
conf.get('api_url') ||
|
|
19
|
+
'http://localhost:3001'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function getAuthToken() {
|
|
24
|
+
if (process.env.TASK_MANAGER_TOKEN) return process.env.TASK_MANAGER_TOKEN
|
|
25
|
+
return conf.get('access_token') ?? null
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function authHeaders() {
|
|
29
|
+
const token = getAuthToken()
|
|
30
|
+
return {
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ── token refresh ─────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
async function refreshAccessToken() {
|
|
39
|
+
const refreshToken = conf.get('refresh_token')
|
|
40
|
+
if (!refreshToken) return false
|
|
41
|
+
try {
|
|
42
|
+
const res = await fetch(`${getApiBase()}/api/auth/refresh`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
headers: { 'Content-Type': 'application/json' },
|
|
45
|
+
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
46
|
+
})
|
|
47
|
+
if (!res.ok) return false
|
|
48
|
+
const data = await res.json()
|
|
49
|
+
conf.set('access_token', data.access_token)
|
|
50
|
+
conf.set('refresh_token', data.refresh_token)
|
|
51
|
+
conf.set('token_expires', data.expires_at)
|
|
52
|
+
return true
|
|
53
|
+
} catch {
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function ensureAuth() {
|
|
59
|
+
if (process.env.TASK_MANAGER_TOKEN) return // agent token, no refresh needed
|
|
60
|
+
const expires = conf.get('token_expires')
|
|
61
|
+
// Refresh if within 5 minutes of expiry or already expired
|
|
62
|
+
if (expires && Date.now() > (expires * 1000) - 300_000) {
|
|
63
|
+
await refreshAccessToken()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── http ──────────────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
async function api(path, options = {}, retry = true) {
|
|
70
|
+
await ensureAuth()
|
|
71
|
+
const url = `${getApiBase()}${path}`
|
|
72
|
+
const res = await fetch(url, { headers: authHeaders(), ...options })
|
|
73
|
+
|
|
74
|
+
// If 401 and we have a refresh token, try once more
|
|
75
|
+
if (res.status === 401 && retry && !process.env.TASK_MANAGER_TOKEN) {
|
|
76
|
+
const refreshed = await refreshAccessToken()
|
|
77
|
+
if (refreshed) return api(path, options, false)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const body = await res.json().catch(() => ({}))
|
|
81
|
+
if (!res.ok) throw new Error(body.error ?? body.message ?? res.statusText)
|
|
82
|
+
return body
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── formatting ────────────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
function formatDate(ts) {
|
|
88
|
+
if (!ts) return '—'
|
|
89
|
+
const d = new Date(ts)
|
|
90
|
+
const today = new Date(); today.setHours(0, 0, 0, 0)
|
|
91
|
+
const day = new Date(d); day.setHours(0, 0, 0, 0)
|
|
92
|
+
const diff = Math.round((day - today) / 86400000)
|
|
93
|
+
if (diff === 0) return chalk.yellow('Today')
|
|
94
|
+
if (diff < 0) return chalk.red(`${Math.abs(diff)}d overdue`)
|
|
95
|
+
if (diff === 1) return chalk.green('Tomorrow')
|
|
96
|
+
if (diff <= 7) return chalk.green(`${diff}d`)
|
|
97
|
+
return d.toLocaleDateString()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function printTaskList(tasks, state) {
|
|
101
|
+
const bucketMap = Object.fromEntries(state.buckets.map((b) => [b.id, b.title]))
|
|
102
|
+
const assigneeMap = Object.fromEntries(state.assignees.map((a) => [a.id, a.name]))
|
|
103
|
+
const boardMap = Object.fromEntries(state.boards.map((b) => [b.id, b.name]))
|
|
104
|
+
|
|
105
|
+
console.log()
|
|
106
|
+
if (tasks.length === 0) {
|
|
107
|
+
console.log(chalk.gray(' No tasks.\n'))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
tasks.forEach((t) => {
|
|
111
|
+
const bucket = bucketMap[t.bucketId] ?? '—'
|
|
112
|
+
const assignee = assigneeMap[t.assigneeId] ?? 'Unassigned'
|
|
113
|
+
const requester = t.requestedById ? assigneeMap[t.requestedById] : null
|
|
114
|
+
const board = boardMap[t.boardId] ?? '—'
|
|
115
|
+
const id = t.id.slice(0, 8)
|
|
116
|
+
|
|
117
|
+
console.log(` ${chalk.bold(t.title)}`)
|
|
118
|
+
console.log(chalk.gray(
|
|
119
|
+
` ${id} · ${board} / ${bucket} · ${assignee}` +
|
|
120
|
+
(requester ? ` ← ${requester}` : '') +
|
|
121
|
+
(t.dueDate ? ` · ${formatDate(t.dueDate)}` : '') +
|
|
122
|
+
(t.tags?.length ? ` · #${t.tags.join(' #')}` : '')
|
|
123
|
+
))
|
|
124
|
+
console.log()
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function handleError(err) {
|
|
129
|
+
const msg = err.message ?? ''
|
|
130
|
+
if (
|
|
131
|
+
msg.includes('401') ||
|
|
132
|
+
msg.toLowerCase().includes('missing token') ||
|
|
133
|
+
msg.toLowerCase().includes('invalid token') ||
|
|
134
|
+
msg.toLowerCase().includes('not authenticated')
|
|
135
|
+
) {
|
|
136
|
+
console.error(chalk.red('\n✗ Not authenticated.'))
|
|
137
|
+
console.error(chalk.gray(' Humans: wadah login'))
|
|
138
|
+
console.error(chalk.gray(' Agents: TASK_MANAGER_TOKEN=<token> wadah open\n'))
|
|
139
|
+
} else {
|
|
140
|
+
console.error(chalk.red(`\n✗ ${msg}\n`))
|
|
141
|
+
}
|
|
142
|
+
process.exit(1)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function resolveMyAssigneeId(state) {
|
|
146
|
+
try {
|
|
147
|
+
const me = await api('/api/auth/me')
|
|
148
|
+
const userId = me.user?.id
|
|
149
|
+
// Match by user_id field on assignee (if backend returns it)
|
|
150
|
+
const byUserId = state.assignees.find((a) => a.user_id === userId)
|
|
151
|
+
if (byUserId) return byUserId.id
|
|
152
|
+
} catch {}
|
|
153
|
+
// Fallback: 'Me' assignee
|
|
154
|
+
return state.assignees.find((a) => a.name === 'Me')?.id ?? null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function findAssignee(assignees, name) {
|
|
158
|
+
const q = name.toLowerCase()
|
|
159
|
+
return assignees.find((a) => a.name.toLowerCase().includes(q)) ?? null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── program ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
program
|
|
165
|
+
.name('tm')
|
|
166
|
+
.description('Open Wadah CLI — shared task board for humans and agents')
|
|
167
|
+
.version('1.0.0')
|
|
168
|
+
|
|
169
|
+
// ── tm login ──────────────────────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
program
|
|
172
|
+
.command('login')
|
|
173
|
+
.description('Sign in as a human user (email + password)')
|
|
174
|
+
.option('--api-url <url>', 'Override API base URL and save it')
|
|
175
|
+
.action(async (opts) => {
|
|
176
|
+
if (opts.apiUrl) conf.set('api_url', opts.apiUrl.trim().replace(/\/$/, ''))
|
|
177
|
+
|
|
178
|
+
console.log(chalk.bold('\nOpen Wadah — Sign in\n'))
|
|
179
|
+
const email = await input({ message: 'Email:' })
|
|
180
|
+
const pass = await promptPassword({ message: 'Password:' })
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const data = await fetch(`${getApiBase()}/api/auth/login`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: { 'Content-Type': 'application/json' },
|
|
186
|
+
body: JSON.stringify({ email, password: pass }),
|
|
187
|
+
})
|
|
188
|
+
const body = await data.json()
|
|
189
|
+
if (!data.ok) throw new Error(body.error ?? 'Login failed')
|
|
190
|
+
|
|
191
|
+
conf.set('access_token', body.access_token)
|
|
192
|
+
conf.set('refresh_token', body.refresh_token)
|
|
193
|
+
conf.set('token_expires', body.expires_at)
|
|
194
|
+
conf.set('user_email', body.user.email)
|
|
195
|
+
conf.set('user_id', body.user.id)
|
|
196
|
+
console.log(chalk.green(`\n✓ Signed in as ${body.user.email}\n`))
|
|
197
|
+
} catch (err) {
|
|
198
|
+
console.error(chalk.red(`\n✗ ${err.message}\n`))
|
|
199
|
+
process.exit(1)
|
|
200
|
+
}
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
// ── tm signup ─────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
program
|
|
206
|
+
.command('signup')
|
|
207
|
+
.description('Create a new account')
|
|
208
|
+
.option('--api-url <url>', 'Override API base URL and save it')
|
|
209
|
+
.action(async (opts) => {
|
|
210
|
+
if (opts.apiUrl) conf.set('api_url', opts.apiUrl.trim().replace(/\/$/, ''))
|
|
211
|
+
|
|
212
|
+
console.log(chalk.bold('\nOpen Wadah — Create account\n'))
|
|
213
|
+
const name = await input({ message: 'Name:' })
|
|
214
|
+
const email = await input({ message: 'Email:' })
|
|
215
|
+
const pass = await promptPassword({ message: 'Password (min 6 chars):' })
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
// Sign up
|
|
219
|
+
const signupRes = await fetch(`${getApiBase()}/api/auth/signup`, {
|
|
220
|
+
method: 'POST',
|
|
221
|
+
headers: { 'Content-Type': 'application/json' },
|
|
222
|
+
body: JSON.stringify({ name, email, password: pass }),
|
|
223
|
+
})
|
|
224
|
+
const signupBody = await signupRes.json()
|
|
225
|
+
if (!signupRes.ok) throw new Error(signupBody.error ?? 'Signup failed')
|
|
226
|
+
|
|
227
|
+
// Auto login
|
|
228
|
+
const loginRes = await fetch(`${getApiBase()}/api/auth/login`, {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify({ email, password: pass }),
|
|
232
|
+
})
|
|
233
|
+
const loginBody = await loginRes.json()
|
|
234
|
+
if (!loginRes.ok) throw new Error(loginBody.error ?? 'Login after signup failed')
|
|
235
|
+
|
|
236
|
+
conf.set('access_token', loginBody.access_token)
|
|
237
|
+
conf.set('refresh_token', loginBody.refresh_token)
|
|
238
|
+
conf.set('token_expires', loginBody.expires_at)
|
|
239
|
+
conf.set('user_email', loginBody.user.email)
|
|
240
|
+
conf.set('user_id', loginBody.user.id)
|
|
241
|
+
console.log(chalk.green(`\n✓ Account created and signed in as ${email}\n`))
|
|
242
|
+
} catch (err) {
|
|
243
|
+
console.error(chalk.red(`\n✗ ${err.message}\n`))
|
|
244
|
+
process.exit(1)
|
|
245
|
+
}
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
// ── tm logout ─────────────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
program
|
|
251
|
+
.command('logout')
|
|
252
|
+
.description('Sign out and clear stored credentials')
|
|
253
|
+
.action(() => {
|
|
254
|
+
conf.delete('access_token')
|
|
255
|
+
conf.delete('refresh_token')
|
|
256
|
+
conf.delete('token_expires')
|
|
257
|
+
conf.delete('user_email')
|
|
258
|
+
conf.delete('user_id')
|
|
259
|
+
console.log(chalk.green('✓ Signed out\n'))
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
// ── tm whoami ─────────────────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
program
|
|
265
|
+
.command('whoami')
|
|
266
|
+
.description('Show current identity and workspace')
|
|
267
|
+
.action(async () => {
|
|
268
|
+
if (process.env.TASK_MANAGER_TOKEN) {
|
|
269
|
+
console.log(chalk.bold('\nAgent') + ' (TASK_MANAGER_TOKEN)')
|
|
270
|
+
} else {
|
|
271
|
+
const email = conf.get('user_email')
|
|
272
|
+
if (!email) {
|
|
273
|
+
console.log(chalk.gray('\nNot signed in. Run: tm login\n'))
|
|
274
|
+
return
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const me = await api('/api/auth/me')
|
|
278
|
+
const ws = me.workspaces?.[0]
|
|
279
|
+
console.log()
|
|
280
|
+
console.log(chalk.bold(me.user.email))
|
|
281
|
+
if (ws) console.log(chalk.gray(`Workspace: ${ws.name} (${ws.role})`))
|
|
282
|
+
console.log(chalk.gray(`API: ${getApiBase()}`))
|
|
283
|
+
console.log()
|
|
284
|
+
} catch {
|
|
285
|
+
console.log(chalk.gray(`\n${email} (token may be expired — run: tm login)\n`))
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
// ── tm open ───────────────────────────────────────────────────────────────────
|
|
291
|
+
|
|
292
|
+
program
|
|
293
|
+
.command('open')
|
|
294
|
+
.description('List open tasks assigned to me')
|
|
295
|
+
.option('--all', 'Show all tasks in the workspace')
|
|
296
|
+
.option('--assignee <name>', 'Filter by assignee name')
|
|
297
|
+
.option('--requested-by <name>', 'Filter by requester name')
|
|
298
|
+
.option('--board <name>', 'Filter by board name')
|
|
299
|
+
.option('--completed', 'Show completed tasks instead')
|
|
300
|
+
.action(async (opts) => {
|
|
301
|
+
try {
|
|
302
|
+
const state = await api('/api/state')
|
|
303
|
+
let tasks = state.tasks.filter((t) => !!t.completed === !!opts.completed)
|
|
304
|
+
|
|
305
|
+
if (!opts.all && !opts.assignee) {
|
|
306
|
+
const myId = await resolveMyAssigneeId(state)
|
|
307
|
+
if (myId) tasks = tasks.filter((t) => t.assigneeId === myId)
|
|
308
|
+
}
|
|
309
|
+
if (opts.assignee) {
|
|
310
|
+
const a = findAssignee(state.assignees, opts.assignee)
|
|
311
|
+
if (!a) return console.error(chalk.red(`\n✗ Assignee not found: ${opts.assignee}\n`))
|
|
312
|
+
tasks = tasks.filter((t) => t.assigneeId === a.id)
|
|
313
|
+
}
|
|
314
|
+
if (opts.requestedBy) {
|
|
315
|
+
const a = findAssignee(state.assignees, opts.requestedBy)
|
|
316
|
+
if (!a) return console.error(chalk.red(`\n✗ Assignee not found: ${opts.requestedBy}\n`))
|
|
317
|
+
tasks = tasks.filter((t) => t.requestedById === a.id)
|
|
318
|
+
}
|
|
319
|
+
if (opts.board) {
|
|
320
|
+
const b = state.boards.find((b) => b.name.toLowerCase().includes(opts.board.toLowerCase()))
|
|
321
|
+
if (b) tasks = tasks.filter((t) => t.boardId === b.id)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
printTaskList(tasks, state)
|
|
325
|
+
} catch (err) { handleError(err) }
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
// ── tm requested ──────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
program
|
|
331
|
+
.command('requested')
|
|
332
|
+
.description('List open tasks you requested (assigned to others / agents)')
|
|
333
|
+
.action(async () => {
|
|
334
|
+
try {
|
|
335
|
+
const state = await api('/api/state')
|
|
336
|
+
const myId = await resolveMyAssigneeId(state)
|
|
337
|
+
const tasks = state.tasks.filter((t) => t.requestedById === myId && !t.completed)
|
|
338
|
+
printTaskList(tasks, state)
|
|
339
|
+
} catch (err) { handleError(err) }
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
// ── tm add ────────────────────────────────────────────────────────────────────
|
|
343
|
+
|
|
344
|
+
program
|
|
345
|
+
.command('add <title>')
|
|
346
|
+
.description('Create a new task')
|
|
347
|
+
.option('--assignee <name>', 'Assign to a person or agent')
|
|
348
|
+
.option('--bucket <name>', 'Column name (default: first column)')
|
|
349
|
+
.option('--board <name>', 'Board name (default: current board)')
|
|
350
|
+
.option('--due <YYYY-MM-DD>', 'Due date')
|
|
351
|
+
.option('--repo <owner/repo>','GitHub repo')
|
|
352
|
+
.option('--url <url>', 'Issue / PR URL')
|
|
353
|
+
.option('--notes <text>', 'Notes')
|
|
354
|
+
.action(async (title, opts) => {
|
|
355
|
+
try {
|
|
356
|
+
const state = await api('/api/state')
|
|
357
|
+
const myId = await resolveMyAssigneeId(state)
|
|
358
|
+
|
|
359
|
+
let assigneeId = myId
|
|
360
|
+
let requestedById = null
|
|
361
|
+
|
|
362
|
+
if (opts.assignee) {
|
|
363
|
+
const a = findAssignee(state.assignees, opts.assignee)
|
|
364
|
+
if (!a) return console.error(chalk.red(`\n✗ Assignee not found: ${opts.assignee}\n`))
|
|
365
|
+
assigneeId = a.id
|
|
366
|
+
requestedById = myId
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const bucket = opts.bucket
|
|
370
|
+
? state.buckets.find((b) => b.title.toLowerCase().includes(opts.bucket.toLowerCase()))
|
|
371
|
+
: state.buckets[0]
|
|
372
|
+
|
|
373
|
+
const board = opts.board
|
|
374
|
+
? state.boards.find((b) => b.name.toLowerCase().includes(opts.board.toLowerCase()))
|
|
375
|
+
: state.boards.find((b) => b.id === state.currentBoardId) ?? state.boards[0]
|
|
376
|
+
|
|
377
|
+
const task = await api('/api/tasks', {
|
|
378
|
+
method: 'POST',
|
|
379
|
+
body: JSON.stringify({
|
|
380
|
+
title,
|
|
381
|
+
bucketId: bucket?.id,
|
|
382
|
+
boardId: board?.id,
|
|
383
|
+
assigneeId,
|
|
384
|
+
requestedById,
|
|
385
|
+
dueDate: opts.due ? new Date(opts.due).getTime() : null,
|
|
386
|
+
repo: opts.repo ?? null,
|
|
387
|
+
contextUrl: opts.url ?? null,
|
|
388
|
+
notes: opts.notes ?? '',
|
|
389
|
+
}),
|
|
390
|
+
})
|
|
391
|
+
|
|
392
|
+
const assigneeName = state.assignees.find((a) => a.id === task.assigneeId)?.name ?? 'Unassigned'
|
|
393
|
+
console.log(chalk.green('\n✓ Created') + ` ${chalk.bold(task.title)}`)
|
|
394
|
+
console.log(chalk.gray(` ID: ${task.id} · Assigned to: ${assigneeName}\n`))
|
|
395
|
+
} catch (err) { handleError(err) }
|
|
396
|
+
})
|
|
397
|
+
|
|
398
|
+
// ── tm complete ───────────────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
program
|
|
401
|
+
.command('complete <id>')
|
|
402
|
+
.description('Mark a task as complete')
|
|
403
|
+
.action(async (id) => {
|
|
404
|
+
try {
|
|
405
|
+
const task = await api(`/api/tasks/${id}`, {
|
|
406
|
+
method: 'PATCH',
|
|
407
|
+
body: JSON.stringify({ completed: true }),
|
|
408
|
+
})
|
|
409
|
+
console.log(chalk.green('\n✓ Completed') + ` ${chalk.bold(task.title)}\n`)
|
|
410
|
+
} catch (err) { handleError(err) }
|
|
411
|
+
})
|
|
412
|
+
|
|
413
|
+
// ── tm reopen ─────────────────────────────────────────────────────────────────
|
|
414
|
+
|
|
415
|
+
program
|
|
416
|
+
.command('reopen <id>')
|
|
417
|
+
.description('Reopen a completed task')
|
|
418
|
+
.action(async (id) => {
|
|
419
|
+
try {
|
|
420
|
+
const task = await api(`/api/tasks/${id}`, {
|
|
421
|
+
method: 'PATCH',
|
|
422
|
+
body: JSON.stringify({ completed: false }),
|
|
423
|
+
})
|
|
424
|
+
console.log(chalk.green('\n✓ Reopened') + ` ${chalk.bold(task.title)}\n`)
|
|
425
|
+
} catch (err) { handleError(err) }
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
// ── tm assign ─────────────────────────────────────────────────────────────────
|
|
429
|
+
|
|
430
|
+
program
|
|
431
|
+
.command('assign <id>')
|
|
432
|
+
.description('Reassign a task to another person or agent')
|
|
433
|
+
.requiredOption('--to <name>', 'Assignee name')
|
|
434
|
+
.action(async (id, opts) => {
|
|
435
|
+
try {
|
|
436
|
+
const state = await api('/api/state')
|
|
437
|
+
const a = findAssignee(state.assignees, opts.to)
|
|
438
|
+
if (!a) return console.error(chalk.red(`\n✗ Assignee not found: ${opts.to}\n`))
|
|
439
|
+
|
|
440
|
+
const myId = await resolveMyAssigneeId(state)
|
|
441
|
+
const task = await api(`/api/tasks/${id}`, {
|
|
442
|
+
method: 'PATCH',
|
|
443
|
+
body: JSON.stringify({ assigneeId: a.id, requestedById: myId }),
|
|
444
|
+
})
|
|
445
|
+
console.log(chalk.green('\n✓ Assigned') + ` ${chalk.bold(task.title)} → ${a.name}\n`)
|
|
446
|
+
} catch (err) { handleError(err) }
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
// ── tm delete ─────────────────────────────────────────────────────────────────
|
|
450
|
+
|
|
451
|
+
program
|
|
452
|
+
.command('delete <id>')
|
|
453
|
+
.description('Delete a task')
|
|
454
|
+
.action(async (id) => {
|
|
455
|
+
try {
|
|
456
|
+
const task = await api(`/api/tasks/${id}`)
|
|
457
|
+
await api(`/api/tasks/${id}`, { method: 'DELETE' })
|
|
458
|
+
console.log(chalk.green('\n✓ Deleted') + ` ${chalk.bold(task.title)}\n`)
|
|
459
|
+
} catch (err) { handleError(err) }
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
// ── tm view ───────────────────────────────────────────────────────────────────
|
|
463
|
+
|
|
464
|
+
program
|
|
465
|
+
.command('view <id>')
|
|
466
|
+
.description('View full details of a task')
|
|
467
|
+
.action(async (id) => {
|
|
468
|
+
try {
|
|
469
|
+
const [task, state] = await Promise.all([api(`/api/tasks/${id}`), api('/api/state')])
|
|
470
|
+
const assignee = state.assignees.find((a) => a.id === task.assigneeId)?.name ?? 'Unassigned'
|
|
471
|
+
const requester = state.assignees.find((a) => a.id === task.requestedById)?.name ?? null
|
|
472
|
+
const bucket = state.buckets.find((b) => b.id === task.bucketId)?.title ?? '—'
|
|
473
|
+
const board = state.boards.find((b) => b.id === task.boardId)?.name ?? '—'
|
|
474
|
+
|
|
475
|
+
console.log()
|
|
476
|
+
console.log(chalk.bold(task.title) + (task.completed ? chalk.green(' ✓ done') : ''))
|
|
477
|
+
console.log(chalk.gray(` ID: ${task.id}`))
|
|
478
|
+
console.log(chalk.gray(` Board: ${board} / ${bucket}`))
|
|
479
|
+
console.log(chalk.gray(` Assigned: ${assignee}`))
|
|
480
|
+
if (requester) console.log(chalk.gray(` Requested: ${requester}`))
|
|
481
|
+
if (task.dueDate) console.log(chalk.gray(` Due: ${formatDate(task.dueDate)}`))
|
|
482
|
+
if (task.repo) console.log(chalk.gray(` Repo: ${task.repo}`))
|
|
483
|
+
if (task.contextUrl) console.log(chalk.gray(` Link: ${task.contextUrl}`))
|
|
484
|
+
if (task.tags?.length) console.log(chalk.gray(` Tags: #${task.tags.join(' #')}`))
|
|
485
|
+
if (task.description) { console.log(); console.log(` ${task.description}`) }
|
|
486
|
+
if (task.notes) { console.log(chalk.gray('\n Notes:')); console.log(` ${task.notes}`) }
|
|
487
|
+
if (task.subtasks?.length) {
|
|
488
|
+
console.log(chalk.gray('\n Subtasks:'))
|
|
489
|
+
task.subtasks.forEach((s) => {
|
|
490
|
+
console.log(` ${s.completed ? chalk.green('✓') : '○'} ${s.title}`)
|
|
491
|
+
})
|
|
492
|
+
}
|
|
493
|
+
if (task.comments?.length) {
|
|
494
|
+
console.log(chalk.gray('\n Comments:'))
|
|
495
|
+
task.comments.forEach((c) => {
|
|
496
|
+
console.log(` ${chalk.gray(new Date(c.createdAt).toLocaleString())} ${c.text}`)
|
|
497
|
+
})
|
|
498
|
+
}
|
|
499
|
+
console.log()
|
|
500
|
+
} catch (err) { handleError(err) }
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
// ── tm comment ────────────────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
program
|
|
506
|
+
.command('comment <id> <text>')
|
|
507
|
+
.description('Add a comment to a task')
|
|
508
|
+
.action(async (id, text) => {
|
|
509
|
+
try {
|
|
510
|
+
const task = await api(`/api/tasks/${id}`)
|
|
511
|
+
const comment = { id: randomUUID(), text, createdAt: Date.now() }
|
|
512
|
+
await api(`/api/tasks/${id}`, {
|
|
513
|
+
method: 'PATCH',
|
|
514
|
+
body: JSON.stringify({ comments: [...(task.comments ?? []), comment] }),
|
|
515
|
+
})
|
|
516
|
+
console.log(chalk.green('\n✓ Comment added\n'))
|
|
517
|
+
} catch (err) { handleError(err) }
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
// ── tm boards ─────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
program
|
|
523
|
+
.command('boards')
|
|
524
|
+
.description('List all boards')
|
|
525
|
+
.action(async () => {
|
|
526
|
+
try {
|
|
527
|
+
const state = await api('/api/state')
|
|
528
|
+
console.log()
|
|
529
|
+
state.boards.forEach((b) => {
|
|
530
|
+
const open = state.tasks.filter((t) => t.boardId === b.id && !t.completed).length
|
|
531
|
+
const done = state.tasks.filter((t) => t.boardId === b.id && t.completed).length
|
|
532
|
+
console.log(` ${chalk.bold(b.name)} ${chalk.gray(`${open} open · ${done} done`)}`)
|
|
533
|
+
})
|
|
534
|
+
console.log()
|
|
535
|
+
} catch (err) { handleError(err) }
|
|
536
|
+
})
|
|
537
|
+
|
|
538
|
+
// ── tm invite ─────────────────────────────────────────────────────────────────
|
|
539
|
+
|
|
540
|
+
program
|
|
541
|
+
.command('invite')
|
|
542
|
+
.description('Generate a workspace invite link')
|
|
543
|
+
.option('--email <email>', 'Email address to invite')
|
|
544
|
+
.option('--role <role>', 'Role: member or agent (default: member)', 'member')
|
|
545
|
+
.action(async (opts) => {
|
|
546
|
+
try {
|
|
547
|
+
const data = await api('/api/workspace/invite', {
|
|
548
|
+
method: 'POST',
|
|
549
|
+
body: JSON.stringify({ email: opts.email, role: opts.role }),
|
|
550
|
+
})
|
|
551
|
+
console.log(chalk.green('\n✓ Invite created'))
|
|
552
|
+
console.log(` ${chalk.bold(data.link)}`)
|
|
553
|
+
if (opts.email) console.log(chalk.gray(` For: ${opts.email} (${opts.role})`))
|
|
554
|
+
console.log(chalk.gray(' Expires in 7 days\n'))
|
|
555
|
+
} catch (err) { handleError(err) }
|
|
556
|
+
})
|
|
557
|
+
|
|
558
|
+
// ── tm config ─────────────────────────────────────────────────────────────────
|
|
559
|
+
|
|
560
|
+
program
|
|
561
|
+
.command('config')
|
|
562
|
+
.description('Show or set CLI configuration')
|
|
563
|
+
.option('--api-url <url>', 'Set API base URL')
|
|
564
|
+
.action((opts) => {
|
|
565
|
+
if (opts.apiUrl) {
|
|
566
|
+
conf.set('api_url', opts.apiUrl.trim().replace(/\/$/, ''))
|
|
567
|
+
console.log(chalk.green(`\n✓ API URL set to ${opts.apiUrl}\n`))
|
|
568
|
+
return
|
|
569
|
+
}
|
|
570
|
+
console.log()
|
|
571
|
+
console.log(chalk.bold('CLI config'))
|
|
572
|
+
console.log(chalk.gray(` API URL: ${getApiBase()}`))
|
|
573
|
+
console.log(chalk.gray(` Signed in as: ${conf.get('user_email') ?? '—'}`))
|
|
574
|
+
console.log(chalk.gray(` Agent mode: ${process.env.TASK_MANAGER_TOKEN ? 'yes (TASK_MANAGER_TOKEN set)' : 'no'}`))
|
|
575
|
+
console.log(chalk.gray(` Config file: ${conf.path}`))
|
|
576
|
+
console.log()
|
|
577
|
+
})
|
|
578
|
+
|
|
579
|
+
program.parse()
|
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "open-wadah",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Open Wadah CLI — shared task board for humans and agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"wadah": "cli.js",
|
|
8
|
+
"ow": "cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "node cli.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"chalk": "^5.4.1",
|
|
15
|
+
"commander": "^13.1.0",
|
|
16
|
+
"conf": "^13.1.0",
|
|
17
|
+
"inquirer": "^12.5.0",
|
|
18
|
+
"node-fetch": "^3.3.2"
|
|
19
|
+
}
|
|
20
|
+
}
|