kanon-cli 0.1.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/LICENSE +21 -0
- package/bin/kanon.js +266 -0
- package/package.json +34 -0
- package/src/commands/attachment.js +98 -0
- package/src/commands/boards.js +39 -0
- package/src/commands/card.js +260 -0
- package/src/commands/cards.js +79 -0
- package/src/commands/checklist.js +129 -0
- package/src/commands/dashboard.js +24 -0
- package/src/commands/init.js +89 -0
- package/src/commands/label.js +61 -0
- package/src/commands/list.js +78 -0
- package/src/commands/login.js +91 -0
- package/src/commands/watch.js +224 -0
- package/src/dashboard/dist/assets/index-Dcbpx-Xz.js +186 -0
- package/src/dashboard/dist/assets/index-DhFfv70f.css +1 -0
- package/src/dashboard/dist/index.html +13 -0
- package/src/dashboard/dist/kanon.png +0 -0
- package/src/dashboard/package.json +26 -0
- package/src/dashboard/server/agent.js +201 -0
- package/src/dashboard/server/index.js +85 -0
- package/src/dashboard/server/proxy.js +54 -0
- package/src/dashboard/server/settings.js +236 -0
- package/src/lib/admin.js +330 -0
- package/src/lib/api.js +225 -0
- package/src/lib/claude.js +161 -0
- package/src/lib/config.js +112 -0
- package/src/lib/pipeline.js +133 -0
- package/src/lib/websocket.js +194 -0
- package/src/prompts/templates.js +127 -0
package/src/lib/admin.js
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import { getStatusPath } from './config.js';
|
|
4
|
+
import { getActiveWorkers, killWorker, spawnClaude } from './claude.js';
|
|
5
|
+
import { buildBundlePrompt } from '../prompts/templates.js';
|
|
6
|
+
|
|
7
|
+
export class AgentController {
|
|
8
|
+
constructor(config = {}) {
|
|
9
|
+
this.checkInterval = (config.check_interval_seconds || 60) * 1000;
|
|
10
|
+
this.maxSessionMs = (config.max_session_minutes || 30) * 60 * 1000;
|
|
11
|
+
this.stuckTimeoutMs = (config.stuck_timeout_minutes || 5) * 60 * 1000;
|
|
12
|
+
this.autoRestartStuck = config.auto_restart_stuck ?? true;
|
|
13
|
+
this.queueMaxSize = config.queue_max_size || 20;
|
|
14
|
+
this.bundleQueue = config.bundle_queue ?? true;
|
|
15
|
+
this.projectConfig = config._projectConfig || {};
|
|
16
|
+
|
|
17
|
+
// Per-board max_concurrent: build a map from boardId -> limit
|
|
18
|
+
this._boardMaxConcurrent = new Map();
|
|
19
|
+
for (const board of this.projectConfig.boards || []) {
|
|
20
|
+
this._boardMaxConcurrent.set(board.id, board.claude?.max_concurrent || 1);
|
|
21
|
+
}
|
|
22
|
+
// Total max concurrent is sum of all active board limits
|
|
23
|
+
this.maxConcurrent = this._getTotalMaxConcurrent();
|
|
24
|
+
|
|
25
|
+
this.queue = []; // { cardId, prompt, config, priority, addedAt, card?, extraPrompt?, boardId? }
|
|
26
|
+
this.eventLog = []; // { timestamp, type, cardId, message }
|
|
27
|
+
this.completedWorkers = []; // last N completed workers with their output
|
|
28
|
+
this._boardWorkerCounts = new Map(); // boardId -> number of active workers
|
|
29
|
+
this.timer = null;
|
|
30
|
+
this.paused = false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
_getTotalMaxConcurrent() {
|
|
34
|
+
if (this._boardMaxConcurrent.size === 0) return 1;
|
|
35
|
+
let total = 0;
|
|
36
|
+
for (const limit of this._boardMaxConcurrent.values()) total += limit;
|
|
37
|
+
return Math.max(1, total);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_getBoardMaxConcurrent(boardId) {
|
|
41
|
+
return this._boardMaxConcurrent.get(boardId) || 1;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
start() {
|
|
45
|
+
this.log('info', null, `Agent controller started (check every ${this.checkInterval / 1000}s)`);
|
|
46
|
+
this.tick(); // Run immediately
|
|
47
|
+
this.timer = setInterval(() => this.tick(), this.checkInterval);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
stop() {
|
|
51
|
+
if (this.timer) {
|
|
52
|
+
clearInterval(this.timer);
|
|
53
|
+
this.timer = null;
|
|
54
|
+
}
|
|
55
|
+
this.log('info', null, 'Agent controller stopped');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
tick() {
|
|
59
|
+
if (this.paused) return;
|
|
60
|
+
|
|
61
|
+
const workers = getActiveWorkers();
|
|
62
|
+
|
|
63
|
+
// Health checks
|
|
64
|
+
for (const [cardId, worker] of workers) {
|
|
65
|
+
const runtime = Date.now() - worker.startedAt;
|
|
66
|
+
const idleTime = Date.now() - worker.lastOutput;
|
|
67
|
+
|
|
68
|
+
// Max runtime exceeded
|
|
69
|
+
if (runtime > this.maxSessionMs) {
|
|
70
|
+
this.log('warn', cardId, `Worker exceeded max runtime (${Math.round(runtime / 60000)}min), killing`);
|
|
71
|
+
killWorker(cardId);
|
|
72
|
+
if (this.autoRestartStuck) {
|
|
73
|
+
this.log('info', cardId, 'Auto-restart queued');
|
|
74
|
+
// Re-queue with same context (if available)
|
|
75
|
+
}
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stuck detection: no output for too long
|
|
80
|
+
if (idleTime > this.stuckTimeoutMs) {
|
|
81
|
+
this.log('warn', cardId, `Worker stuck (no output for ${Math.round(idleTime / 60000)}min), killing`);
|
|
82
|
+
killWorker(cardId);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Process queue if slots available
|
|
88
|
+
this._processQueue();
|
|
89
|
+
|
|
90
|
+
// Write status snapshot
|
|
91
|
+
this._writeStatus();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Enqueue a new task. Returns true if queued, false if queue is full.
|
|
96
|
+
*/
|
|
97
|
+
enqueue(cardId, prompt, claudeConfig, { priority = 0, card = null, extraPrompt = '', boardId = null } = {}) {
|
|
98
|
+
// Don't double-queue
|
|
99
|
+
if (this.queue.some(q => q.cardId === cardId)) {
|
|
100
|
+
this.log('info', cardId, 'Already in queue, skipping');
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Don't queue if already running
|
|
105
|
+
if (getActiveWorkers().has(cardId)) {
|
|
106
|
+
this.log('info', cardId, 'Already running, skipping');
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (this.queue.length >= this.queueMaxSize) {
|
|
111
|
+
this.log('warn', cardId, 'Queue full, dropping event');
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
this.queue.push({ cardId, prompt, config: claudeConfig, priority, addedAt: Date.now(), card, extraPrompt, boardId });
|
|
116
|
+
// Sort by priority (higher first), then by time (older first)
|
|
117
|
+
this.queue.sort((a, b) => b.priority - a.priority || a.addedAt - b.addedAt);
|
|
118
|
+
|
|
119
|
+
this.log('info', cardId, `Queued (position ${this.queue.findIndex(q => q.cardId === cardId) + 1}/${this.queue.length})`);
|
|
120
|
+
|
|
121
|
+
// Try to process immediately
|
|
122
|
+
this._processQueue();
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_processQueue() {
|
|
127
|
+
const workers = getActiveWorkers();
|
|
128
|
+
if (this.queue.length === 0) return;
|
|
129
|
+
|
|
130
|
+
if (this.bundleQueue) {
|
|
131
|
+
// Bundle mode: group queue items by boardId, bundle within each board
|
|
132
|
+
const byBoard = new Map();
|
|
133
|
+
for (const task of this.queue) {
|
|
134
|
+
const key = task.boardId || '__default__';
|
|
135
|
+
if (!byBoard.has(key)) byBoard.set(key, []);
|
|
136
|
+
byBoard.get(key).push(task);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Process one board group per available slot, respecting per-board limits
|
|
140
|
+
for (const [boardKey, tasks] of byBoard) {
|
|
141
|
+
const boardLimit = this._getBoardMaxConcurrent(boardKey);
|
|
142
|
+
const boardActive = this._boardWorkerCounts.get(boardKey) || 0;
|
|
143
|
+
if (boardActive >= boardLimit) continue;
|
|
144
|
+
|
|
145
|
+
// Remove these tasks from the queue
|
|
146
|
+
for (const t of tasks) {
|
|
147
|
+
const idx = this.queue.indexOf(t);
|
|
148
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (tasks.length === 1) {
|
|
152
|
+
this._incBoard(boardKey);
|
|
153
|
+
this._startSingleWorker(tasks[0], boardKey);
|
|
154
|
+
} else {
|
|
155
|
+
this._incBoard(boardKey);
|
|
156
|
+
this._startBundledWorker(tasks, boardKey);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
// One task per worker, respecting per-board limits
|
|
161
|
+
const toProcess = [];
|
|
162
|
+
for (const task of this.queue) {
|
|
163
|
+
const boardKey = task.boardId || '__default__';
|
|
164
|
+
const boardLimit = this._getBoardMaxConcurrent(boardKey);
|
|
165
|
+
const boardActive = (this._boardWorkerCounts.get(boardKey) || 0) + toProcess.filter(t => (t.boardId || '__default__') === boardKey).length;
|
|
166
|
+
if (boardActive < boardLimit) {
|
|
167
|
+
toProcess.push(task);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const task of toProcess) {
|
|
171
|
+
const boardKey = task.boardId || '__default__';
|
|
172
|
+
const idx = this.queue.indexOf(task);
|
|
173
|
+
if (idx !== -1) this.queue.splice(idx, 1);
|
|
174
|
+
this._incBoard(boardKey);
|
|
175
|
+
this._startSingleWorker(task, boardKey);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_incBoard(boardKey) {
|
|
181
|
+
this._boardWorkerCounts.set(boardKey, (this._boardWorkerCounts.get(boardKey) || 0) + 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
_decBoard(boardKey) {
|
|
185
|
+
const count = this._boardWorkerCounts.get(boardKey) || 1;
|
|
186
|
+
this._boardWorkerCounts.set(boardKey, Math.max(0, count - 1));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
_startBundledWorker(tasks, boardKey) {
|
|
190
|
+
const cardIds = tasks.map(t => t.cardId);
|
|
191
|
+
const bundleId = `bundle-${Date.now()}`;
|
|
192
|
+
|
|
193
|
+
let combinedPrompt;
|
|
194
|
+
const hasCardData = tasks.every(t => t.card);
|
|
195
|
+
|
|
196
|
+
if (hasCardData) {
|
|
197
|
+
combinedPrompt = buildBundlePrompt(tasks, this.projectConfig);
|
|
198
|
+
} else {
|
|
199
|
+
combinedPrompt = tasks.map((t, i) =>
|
|
200
|
+
`## Task ${i + 1} (Card ${t.cardId})\n\n${t.prompt}`
|
|
201
|
+
).join('\n\n---\n\n');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
this.log('info', null, `Starting bundled worker for ${tasks.length} cards: ${cardIds.map(id => id.substring(0, 8)).join(', ')}`);
|
|
205
|
+
const worker = spawnClaude(bundleId, combinedPrompt, tasks[0].config);
|
|
206
|
+
if (worker?.process) {
|
|
207
|
+
worker.process.on('close', (code) => {
|
|
208
|
+
this._decBoard(boardKey);
|
|
209
|
+
this.completedWorkers.push({
|
|
210
|
+
cardId: bundleId,
|
|
211
|
+
cardIds,
|
|
212
|
+
bundled: true,
|
|
213
|
+
startedAt: worker.startedAt,
|
|
214
|
+
finishedAt: Date.now(),
|
|
215
|
+
runtimeMs: Date.now() - worker.startedAt,
|
|
216
|
+
exitCode: code,
|
|
217
|
+
output: worker.output.slice(-50),
|
|
218
|
+
});
|
|
219
|
+
if (this.completedWorkers.length > 10) this.completedWorkers.shift();
|
|
220
|
+
this.log(code === 0 ? 'info' : 'warn', null, `Bundled worker finished (exit ${code}, ${Math.round((Date.now() - worker.startedAt) / 1000)}s) — cards: ${cardIds.map(id => id.substring(0, 8)).join(', ')}`);
|
|
221
|
+
this._processQueue();
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
_startSingleWorker(task, boardKey) {
|
|
227
|
+
this.log('info', task.cardId, 'Starting worker from queue');
|
|
228
|
+
const worker = spawnClaude(task.cardId, task.prompt, task.config);
|
|
229
|
+
if (worker?.process) {
|
|
230
|
+
worker.process.on('close', (code) => {
|
|
231
|
+
if (boardKey) this._decBoard(boardKey);
|
|
232
|
+
this.completedWorkers.push({
|
|
233
|
+
cardId: task.cardId,
|
|
234
|
+
startedAt: worker.startedAt,
|
|
235
|
+
finishedAt: Date.now(),
|
|
236
|
+
runtimeMs: Date.now() - worker.startedAt,
|
|
237
|
+
exitCode: code,
|
|
238
|
+
output: worker.output.slice(-50),
|
|
239
|
+
});
|
|
240
|
+
if (this.completedWorkers.length > 10) this.completedWorkers.shift();
|
|
241
|
+
this.log(code === 0 ? 'info' : 'warn', task.cardId, `Worker finished (exit ${code}, ${Math.round((Date.now() - worker.startedAt) / 1000)}s)`);
|
|
242
|
+
this._processQueue();
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
_writeStatus() {
|
|
248
|
+
try {
|
|
249
|
+
const workers = getActiveWorkers();
|
|
250
|
+
const status = {
|
|
251
|
+
timestamp: new Date().toISOString(),
|
|
252
|
+
paused: this.paused,
|
|
253
|
+
workers: Array.from(workers.entries()).map(([cardId, w]) => ({
|
|
254
|
+
cardId,
|
|
255
|
+
startedAt: new Date(w.startedAt).toISOString(),
|
|
256
|
+
runtimeMs: Date.now() - w.startedAt,
|
|
257
|
+
lastOutputMs: Date.now() - w.lastOutput,
|
|
258
|
+
outputTail: w.output.slice(-30),
|
|
259
|
+
exitCode: w.exitCode,
|
|
260
|
+
})),
|
|
261
|
+
queue: this.queue.map(q => ({
|
|
262
|
+
cardId: q.cardId,
|
|
263
|
+
priority: q.priority,
|
|
264
|
+
waitingMs: Date.now() - q.addedAt,
|
|
265
|
+
})),
|
|
266
|
+
recentEvents: this.eventLog.slice(-20),
|
|
267
|
+
};
|
|
268
|
+
fs.writeFileSync(getStatusPath(), JSON.stringify(status, null, 2));
|
|
269
|
+
} catch {
|
|
270
|
+
// Non-critical, ignore
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
log(type, cardId, message, { triggered, boardId } = {}) {
|
|
275
|
+
const entry = {
|
|
276
|
+
timestamp: new Date().toISOString(),
|
|
277
|
+
type,
|
|
278
|
+
cardId,
|
|
279
|
+
message,
|
|
280
|
+
};
|
|
281
|
+
if (triggered !== undefined) entry.triggered = triggered;
|
|
282
|
+
if (boardId) entry.boardId = boardId;
|
|
283
|
+
this.eventLog.push(entry);
|
|
284
|
+
// Keep last 200 entries
|
|
285
|
+
if (this.eventLog.length > 200) this.eventLog.shift();
|
|
286
|
+
|
|
287
|
+
const prefix = cardId ? `[${cardId.substring(0, 8)}]` : '[admin]';
|
|
288
|
+
const color = type === 'warn' ? chalk.yellow : type === 'error' ? chalk.red : chalk.dim;
|
|
289
|
+
console.log(color(`${prefix} ${message}`));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
getStatus() {
|
|
293
|
+
const workers = getActiveWorkers();
|
|
294
|
+
return {
|
|
295
|
+
paused: this.paused,
|
|
296
|
+
activeWorkers: workers.size,
|
|
297
|
+
queueLength: this.queue.length,
|
|
298
|
+
maxConcurrent: this.maxConcurrent,
|
|
299
|
+
workers: Array.from(workers.entries()).map(([cardId, w]) => ({
|
|
300
|
+
cardId,
|
|
301
|
+
runtimeMs: Date.now() - w.startedAt,
|
|
302
|
+
lastOutputMs: Date.now() - w.lastOutput,
|
|
303
|
+
outputTail: w.output.slice(-30),
|
|
304
|
+
})),
|
|
305
|
+
queue: this.queue.map(q => ({
|
|
306
|
+
cardId: q.cardId,
|
|
307
|
+
priority: q.priority,
|
|
308
|
+
waitingMs: Date.now() - q.addedAt,
|
|
309
|
+
})),
|
|
310
|
+
completedWorkers: this.completedWorkers.slice(-10),
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
getEventLog(limit = 50) {
|
|
315
|
+
return this.eventLog.slice(-limit);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
pause() {
|
|
319
|
+
this.paused = true;
|
|
320
|
+
this.log('info', null, 'Agent controller paused');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
resume() {
|
|
324
|
+
this.paused = false;
|
|
325
|
+
this.log('info', null, 'Agent controller resumed');
|
|
326
|
+
this._processQueue();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export default AgentController;
|
package/src/lib/api.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { getToken, getServerUrl, loadGlobalConfig, saveGlobalConfig } from './config.js';
|
|
2
|
+
|
|
3
|
+
class KanonAPI {
|
|
4
|
+
constructor() {
|
|
5
|
+
this._token = null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
get token() {
|
|
9
|
+
if (!this._token) this._token = getToken();
|
|
10
|
+
return this._token;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
set token(val) {
|
|
14
|
+
this._token = val;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
get baseUrl() {
|
|
18
|
+
return getServerUrl() + '/api';
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async request(method, path, body = null, retried = false) {
|
|
22
|
+
const url = `${this.baseUrl}${path}`;
|
|
23
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
24
|
+
if (this.token) headers['Authorization'] = `Bearer ${this.token}`;
|
|
25
|
+
|
|
26
|
+
const opts = { method, headers };
|
|
27
|
+
if (body) opts.body = JSON.stringify(body);
|
|
28
|
+
|
|
29
|
+
const res = await fetch(url, opts);
|
|
30
|
+
|
|
31
|
+
// Auto re-login on 401
|
|
32
|
+
if (res.status === 401 && !retried) {
|
|
33
|
+
const refreshed = await this.tryRefreshToken();
|
|
34
|
+
if (refreshed) return this.request(method, path, body, true);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (!res.ok) {
|
|
38
|
+
const text = await res.text();
|
|
39
|
+
let message;
|
|
40
|
+
try { message = JSON.parse(text).error; } catch { message = text; }
|
|
41
|
+
throw new Error(`${method} ${path} failed (${res.status}): ${message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const text = await res.text();
|
|
45
|
+
if (!text) return {};
|
|
46
|
+
return JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async tryRefreshToken() {
|
|
50
|
+
const config = loadGlobalConfig();
|
|
51
|
+
if (!config.email || !config.password) return false;
|
|
52
|
+
try {
|
|
53
|
+
const res = await fetch(`${this.baseUrl}/auth/login`, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'Content-Type': 'application/json' },
|
|
56
|
+
body: JSON.stringify({ email: config.email, password: config.password }),
|
|
57
|
+
});
|
|
58
|
+
if (!res.ok) return false;
|
|
59
|
+
const data = await res.json();
|
|
60
|
+
this.token = data.token;
|
|
61
|
+
saveGlobalConfig({ ...config, token: data.token });
|
|
62
|
+
return true;
|
|
63
|
+
} catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- Auth ---
|
|
69
|
+
|
|
70
|
+
async login(email, password) {
|
|
71
|
+
const res = await fetch(`${this.baseUrl}/auth/login`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({ email, password }),
|
|
75
|
+
});
|
|
76
|
+
if (!res.ok) {
|
|
77
|
+
const data = await res.json().catch(() => ({}));
|
|
78
|
+
throw new Error(data.error || `Login failed (${res.status})`);
|
|
79
|
+
}
|
|
80
|
+
return res.json();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// --- Teams & Boards ---
|
|
84
|
+
|
|
85
|
+
async getTeams() {
|
|
86
|
+
return this.request('GET', '/teams');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async getBoard(boardId, { cardFields = 'essential', cardLimit = 20 } = {}) {
|
|
90
|
+
return this.request('GET', `/boards/${boardId}?card_fields=${cardFields}&card_limit=${cardLimit}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// --- Cards ---
|
|
94
|
+
|
|
95
|
+
async getCard(cardId) {
|
|
96
|
+
return this.request('GET', `/cards/${cardId}`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async updateCard(cardId, data) {
|
|
100
|
+
return this.request('PUT', `/cards/${cardId}?minimal=true`, data);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async moveCard(cardId, listId, position = 0) {
|
|
104
|
+
return this.request('PUT', `/cards/${cardId}/move`, { listId, position });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async createCard(listId, data) {
|
|
108
|
+
return this.request('POST', `/lists/${listId}/cards`, data);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --- Labels ---
|
|
112
|
+
|
|
113
|
+
async addLabel(cardId, labelId) {
|
|
114
|
+
return this.request('POST', `/cards/${cardId}/labels/${labelId}`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async removeLabel(cardId, labelId) {
|
|
118
|
+
return this.request('DELETE', `/cards/${cardId}/labels/${labelId}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// --- Comments ---
|
|
122
|
+
|
|
123
|
+
async addComment(cardId, text) {
|
|
124
|
+
return this.request('POST', `/cards/${cardId}/comments`, { text });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// --- Assignees ---
|
|
128
|
+
|
|
129
|
+
async addAssignee(cardId, userId) {
|
|
130
|
+
return this.request('POST', `/cards/${cardId}/assignees/${userId}`);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async removeAssignee(cardId, userId) {
|
|
134
|
+
return this.request('DELETE', `/cards/${cardId}/assignees/${userId}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// --- Checklists ---
|
|
138
|
+
|
|
139
|
+
async createChecklist(cardId, title) {
|
|
140
|
+
return this.request('POST', `/cards/${cardId}/checklists`, { title });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async addChecklistItem(checklistId, text) {
|
|
144
|
+
return this.request('POST', `/checklists/${checklistId}/items`, { text });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async toggleChecklistItem(itemId) {
|
|
148
|
+
return this.request('POST', `/checklist-items/${itemId}/toggle`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// --- Archive ---
|
|
152
|
+
async archiveCard(cardId) {
|
|
153
|
+
return this.request('PUT', `/cards/${cardId}/archive`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// --- Lists ---
|
|
157
|
+
async createList(boardId, title, position) {
|
|
158
|
+
const body = { title };
|
|
159
|
+
if (position !== undefined) body.position = position;
|
|
160
|
+
return this.request('POST', `/boards/${boardId}/lists`, body);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async updateList(listId, data) {
|
|
164
|
+
return this.request('PUT', `/lists/${listId}`, data);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// --- Board Labels ---
|
|
168
|
+
async createBoardLabel(boardId, name, color) {
|
|
169
|
+
return this.request('POST', `/boards/${boardId}/labels`, { name, color });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// --- Checklist Items ---
|
|
173
|
+
async updateChecklistItem(itemId, data) {
|
|
174
|
+
return this.request('PUT', `/checklist-items/${itemId}`, data);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async deleteChecklistItem(itemId) {
|
|
178
|
+
return this.request('DELETE', `/checklist-items/${itemId}`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Attachments (mounted under /boards/ on the server) ---
|
|
182
|
+
async getAttachments(cardId) {
|
|
183
|
+
return this.request('GET', `/boards/cards/${cardId}/attachments`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async getAttachmentUrl(cardId, attachmentId) {
|
|
187
|
+
return this.request('GET', `/boards/cards/${cardId}/attachments/${attachmentId}/url`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async deleteAttachment(cardId, attachmentId) {
|
|
191
|
+
return this.request('DELETE', `/boards/cards/${cardId}/attachments/${attachmentId}`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async uploadAttachment(cardId, filePath) {
|
|
195
|
+
const fs = await import('fs');
|
|
196
|
+
const path = await import('path');
|
|
197
|
+
const filename = path.default.basename(filePath);
|
|
198
|
+
const fileBuffer = fs.default.readFileSync(filePath);
|
|
199
|
+
|
|
200
|
+
const boundary = '----KanonCLI' + Date.now();
|
|
201
|
+
const body = Buffer.concat([
|
|
202
|
+
Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`),
|
|
203
|
+
fileBuffer,
|
|
204
|
+
Buffer.from(`\r\n--${boundary}--\r\n`),
|
|
205
|
+
]);
|
|
206
|
+
|
|
207
|
+
const url = `${this.baseUrl}/boards/cards/${cardId}/attachments`;
|
|
208
|
+
const headers = {
|
|
209
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
210
|
+
'Authorization': `Bearer ${this.token}`,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
const res = await fetch(url, { method: 'POST', headers, body });
|
|
214
|
+
if (!res.ok) {
|
|
215
|
+
const text = await res.text();
|
|
216
|
+
let msg;
|
|
217
|
+
try { msg = JSON.parse(text).error; } catch { msg = text; }
|
|
218
|
+
throw new Error(`Upload failed (${res.status}): ${msg}`);
|
|
219
|
+
}
|
|
220
|
+
return res.json();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export const api = new KanonAPI();
|
|
225
|
+
export default api;
|