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
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import api from '../lib/api.js';
|
|
3
|
+
import { getToken, loadProjectConfig } from '../lib/config.js';
|
|
4
|
+
|
|
5
|
+
function requireAuth() {
|
|
6
|
+
if (!getToken()) {
|
|
7
|
+
console.error(chalk.red('Not logged in. Run: kanon login'));
|
|
8
|
+
process.exit(1);
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function getBoardId() {
|
|
13
|
+
const config = loadProjectConfig();
|
|
14
|
+
const boardId = config?.board_id;
|
|
15
|
+
if (!boardId) throw new Error('No board configured. Run: kanon init');
|
|
16
|
+
return boardId;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function getBoardContext() {
|
|
20
|
+
const boardId = getBoardId();
|
|
21
|
+
const data = await api.getBoard(boardId, { cardLimit: 0 });
|
|
22
|
+
return data.board || data;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function resolveList(board, name) {
|
|
26
|
+
const lower = name.toLowerCase();
|
|
27
|
+
const list = (board.lists || []).find(l => l.title.toLowerCase() === lower);
|
|
28
|
+
if (!list) {
|
|
29
|
+
const available = (board.lists || []).map(l => l.title).join(', ');
|
|
30
|
+
throw new Error(`List "${name}" not found. Available: ${available}`);
|
|
31
|
+
}
|
|
32
|
+
return list;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// --- kanon list create "title" ---
|
|
36
|
+
|
|
37
|
+
export async function listCreateCommand(title) {
|
|
38
|
+
requireAuth();
|
|
39
|
+
try {
|
|
40
|
+
const boardId = getBoardId();
|
|
41
|
+
const result = await api.createList(boardId, title);
|
|
42
|
+
const id = result.list?.id || result.id;
|
|
43
|
+
console.log(chalk.green(`List "${title}" created (${id}).`));
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error(chalk.red(`Failed to create list: ${err.message}`));
|
|
46
|
+
process.exit(1);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// --- kanon list rename "old" "new" ---
|
|
51
|
+
|
|
52
|
+
export async function listRenameCommand(oldName, newName) {
|
|
53
|
+
requireAuth();
|
|
54
|
+
try {
|
|
55
|
+
const board = await getBoardContext();
|
|
56
|
+
const list = resolveList(board, oldName);
|
|
57
|
+
await api.updateList(list.id, { title: newName });
|
|
58
|
+
console.log(chalk.green(`List renamed: "${oldName}" -> "${newName}".`));
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error(chalk.red(`Failed to rename list: ${err.message}`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// --- kanon list delete "name" (archives) ---
|
|
66
|
+
|
|
67
|
+
export async function listDeleteCommand(name) {
|
|
68
|
+
requireAuth();
|
|
69
|
+
try {
|
|
70
|
+
const board = await getBoardContext();
|
|
71
|
+
const list = resolveList(board, name);
|
|
72
|
+
await api.updateList(list.id, { archived: true });
|
|
73
|
+
console.log(chalk.green(`List "${name}" archived.`));
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.error(chalk.red(`Failed to archive list: ${err.message}`));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import readline from 'readline';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { loadGlobalConfig, saveGlobalConfig, getServerUrl } from '../lib/config.js';
|
|
4
|
+
import api from '../lib/api.js';
|
|
5
|
+
|
|
6
|
+
function prompt(rl, question, defaultVal = '') {
|
|
7
|
+
const suffix = defaultVal ? ` (${defaultVal})` : '';
|
|
8
|
+
return new Promise(resolve => {
|
|
9
|
+
rl.question(`${question}${suffix}: `, answer => {
|
|
10
|
+
resolve(answer.trim() || defaultVal);
|
|
11
|
+
});
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function promptSecret(rl, question) {
|
|
16
|
+
return new Promise(resolve => {
|
|
17
|
+
// Use raw mode to hide password input
|
|
18
|
+
const stdin = process.stdin;
|
|
19
|
+
const wasRaw = stdin.isRaw;
|
|
20
|
+
|
|
21
|
+
process.stdout.write(`${question}: `);
|
|
22
|
+
stdin.setRawMode(true);
|
|
23
|
+
stdin.resume();
|
|
24
|
+
|
|
25
|
+
let password = '';
|
|
26
|
+
const onData = (char) => {
|
|
27
|
+
const c = char.toString();
|
|
28
|
+
if (c === '\n' || c === '\r') {
|
|
29
|
+
stdin.setRawMode(wasRaw || false);
|
|
30
|
+
stdin.removeListener('data', onData);
|
|
31
|
+
process.stdout.write('\n');
|
|
32
|
+
resolve(password);
|
|
33
|
+
} else if (c === '\u0003') {
|
|
34
|
+
// Ctrl+C
|
|
35
|
+
process.exit(1);
|
|
36
|
+
} else if (c === '\u007f' || c === '\b') {
|
|
37
|
+
// Backspace
|
|
38
|
+
if (password.length > 0) {
|
|
39
|
+
password = password.slice(0, -1);
|
|
40
|
+
process.stdout.write('\b \b');
|
|
41
|
+
}
|
|
42
|
+
} else {
|
|
43
|
+
password += c;
|
|
44
|
+
process.stdout.write('*');
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
stdin.on('data', onData);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function loginCommand() {
|
|
53
|
+
const rl = readline.createInterface({
|
|
54
|
+
input: process.stdin,
|
|
55
|
+
output: process.stdout,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
const config = loadGlobalConfig();
|
|
60
|
+
|
|
61
|
+
console.log(chalk.bold('\nKanon CLI Login\n'));
|
|
62
|
+
|
|
63
|
+
const serverUrl = await prompt(rl, 'Server URL', config.server_url || 'https://kanones.com');
|
|
64
|
+
const email = await prompt(rl, 'Email', config.email || '');
|
|
65
|
+
|
|
66
|
+
// Close readline before prompting password (raw mode conflict)
|
|
67
|
+
rl.close();
|
|
68
|
+
|
|
69
|
+
const password = await promptSecret(readline, 'Password');
|
|
70
|
+
|
|
71
|
+
console.log(chalk.dim('\nAuthenticating...'));
|
|
72
|
+
|
|
73
|
+
const data = await api.login(email, password);
|
|
74
|
+
|
|
75
|
+
saveGlobalConfig({
|
|
76
|
+
server_url: serverUrl,
|
|
77
|
+
token: data.token,
|
|
78
|
+
user_id: data.user.id,
|
|
79
|
+
user_name: data.user.name,
|
|
80
|
+
email,
|
|
81
|
+
password, // Stored for auto-refresh
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
console.log(chalk.green(`\nLogged in as ${chalk.bold(data.user.name)} (${data.user.email})`));
|
|
85
|
+
console.log(chalk.dim(`Token saved to ~/.kanon/config.yaml`));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
rl.close();
|
|
88
|
+
console.error(chalk.red(`\nLogin failed: ${err.message}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { KanonWebSocket } from '../lib/websocket.js';
|
|
3
|
+
import { AgentController } from '../lib/admin.js';
|
|
4
|
+
import { shouldTrigger, getContextFromLabels } from '../lib/pipeline.js';
|
|
5
|
+
import { buildPrompt } from '../prompts/templates.js';
|
|
6
|
+
import api from '../lib/api.js';
|
|
7
|
+
import { killWorker } from '../lib/claude.js';
|
|
8
|
+
import { getToken, getUserId, getServerUrl, loadProjectConfig, getAgentLockPath, getActiveBoardIds, getBoardConfig } from '../lib/config.js';
|
|
9
|
+
import http from 'http';
|
|
10
|
+
import fs from 'fs';
|
|
11
|
+
|
|
12
|
+
export async function watchCommand(options) {
|
|
13
|
+
const token = getToken();
|
|
14
|
+
if (!token) {
|
|
15
|
+
console.error(chalk.red('Not logged in. Run: kanon login'));
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const config = loadProjectConfig();
|
|
20
|
+
const boardIds = getActiveBoardIds(config);
|
|
21
|
+
if (boardIds.length === 0) {
|
|
22
|
+
console.error(chalk.red('No active boards configured. Run: kanon init'));
|
|
23
|
+
process.exit(1);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const ownUserId = getUserId();
|
|
27
|
+
const serverUrl = getServerUrl();
|
|
28
|
+
const dryRun = options.dryRun || false;
|
|
29
|
+
|
|
30
|
+
// Cooldown tracking
|
|
31
|
+
const cooldowns = new Map();
|
|
32
|
+
|
|
33
|
+
// Agent controller (global settings, per-board max_concurrent from boards config)
|
|
34
|
+
const admin = new AgentController({
|
|
35
|
+
...config.admin,
|
|
36
|
+
bundle_queue: config.admin?.bundle_queue ?? true,
|
|
37
|
+
_projectConfig: config,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
console.log(chalk.bold('\nKanon Watch Daemon\n'));
|
|
41
|
+
console.log(` Server: ${chalk.cyan(serverUrl)}`);
|
|
42
|
+
console.log(` Boards: ${chalk.cyan(boardIds.length)} active`);
|
|
43
|
+
for (const bid of boardIds) {
|
|
44
|
+
const bc = getBoardConfig(config, bid);
|
|
45
|
+
const labelRules = (bc?.rules?.labels || []).filter(l => l.enabled !== false);
|
|
46
|
+
const eventRules = Object.entries(bc?.rules?.events || {}).filter(([, v]) => v.enabled);
|
|
47
|
+
console.log(` ${chalk.dim(bid.substring(0, 8))} ${bc?.name || 'unnamed'}: ${labelRules.length} labels, ${eventRules.length} events`);
|
|
48
|
+
}
|
|
49
|
+
console.log(` Dry run: ${dryRun ? chalk.yellow('yes') : 'no'}`);
|
|
50
|
+
console.log();
|
|
51
|
+
|
|
52
|
+
// Connect WebSocket
|
|
53
|
+
const ws = new KanonWebSocket(serverUrl, token, {
|
|
54
|
+
name: 'Kanon CLI',
|
|
55
|
+
color: '#6366f1',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
console.log(chalk.dim('Connecting to WebSocket...'));
|
|
60
|
+
await ws.connect();
|
|
61
|
+
console.log(chalk.green('Connected and authenticated.\n'));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error(chalk.red(`Failed to connect: ${err.message}`));
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Join boards
|
|
68
|
+
for (const boardId of boardIds) {
|
|
69
|
+
ws.joinBoard(boardId);
|
|
70
|
+
console.log(chalk.dim(`Joined board ${boardId}`));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Start agent controller
|
|
74
|
+
admin.start();
|
|
75
|
+
|
|
76
|
+
// Start IPC server for dashboard
|
|
77
|
+
const ipcServer = startIPCServer(admin);
|
|
78
|
+
|
|
79
|
+
// Listen for card events
|
|
80
|
+
ws.on('card_event', async (event) => {
|
|
81
|
+
const { cardId, eventType, userId, eventData, boardId } = event;
|
|
82
|
+
|
|
83
|
+
// Look up board-specific config
|
|
84
|
+
const boardConfig = getBoardConfig(config, boardId);
|
|
85
|
+
if (!boardConfig) {
|
|
86
|
+
admin.log('event', cardId, `${eventType}: no config for board ${boardId}`, { triggered: false, boardId });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Build a compat config object for shouldTrigger (it expects top-level rules/watch)
|
|
91
|
+
const compatConfig = {
|
|
92
|
+
watch: boardConfig.watch,
|
|
93
|
+
rules: boardConfig.rules,
|
|
94
|
+
claude: boardConfig.claude,
|
|
95
|
+
prompts: boardConfig.prompts,
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// Check if this event should trigger (includes own-event filtering)
|
|
99
|
+
const result = shouldTrigger(event, compatConfig, ownUserId);
|
|
100
|
+
|
|
101
|
+
// Log all incoming events for dashboard visibility
|
|
102
|
+
const eventSummary = eventData?.label_name || eventData?.member_name || eventType;
|
|
103
|
+
admin.log('event', cardId, `${eventType}: ${eventSummary}`, { triggered: result.trigger, boardId });
|
|
104
|
+
|
|
105
|
+
if (!result.trigger) return;
|
|
106
|
+
|
|
107
|
+
// Cooldown check (per-board cooldown)
|
|
108
|
+
const cooldownSeconds = boardConfig.watch?.cooldown_seconds || 30;
|
|
109
|
+
const cooldownKey = `${boardId}:${cardId}`;
|
|
110
|
+
const lastTrigger = cooldowns.get(cooldownKey);
|
|
111
|
+
if (lastTrigger && (Date.now() - lastTrigger) < cooldownSeconds * 1000) {
|
|
112
|
+
console.log(chalk.dim(`[cooldown] Skipping card ${cardId} (${eventData?.label_name || eventType})`));
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
cooldowns.set(cooldownKey, Date.now());
|
|
116
|
+
|
|
117
|
+
const labelName = eventData?.label_name || eventType;
|
|
118
|
+
console.log(chalk.cyan(`\n[event] ${eventType}: ${labelName} on card ${cardId} (board: ${boardConfig.name || boardId.substring(0, 8)})`));
|
|
119
|
+
|
|
120
|
+
if (dryRun) {
|
|
121
|
+
console.log(chalk.yellow(`[dry-run] Would spawn Claude for card ${cardId}`));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Fetch full card
|
|
126
|
+
try {
|
|
127
|
+
const cardData = await api.getCard(cardId);
|
|
128
|
+
const card = cardData.card || cardData;
|
|
129
|
+
|
|
130
|
+
console.log(chalk.dim(` Card: "${card.title}"`));
|
|
131
|
+
|
|
132
|
+
// Scope check: if trigger is scoped to 'assigned', verify agent is assigned to this card
|
|
133
|
+
if (result.scope === 'assigned') {
|
|
134
|
+
const isAssigned = card.assignees?.some(a => a.id === ownUserId || a.user_id === ownUserId);
|
|
135
|
+
if (!isAssigned) {
|
|
136
|
+
admin.log('event', cardId, `Skipped (not assigned)`, { triggered: false, boardId });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Collect context from context-only labels on the card
|
|
142
|
+
const context = getContextFromLabels(card, compatConfig);
|
|
143
|
+
const combinedPrompt = [result.extraPrompt, context.extraPrompt].filter(Boolean).join('\n');
|
|
144
|
+
const combinedArgs = [result.extraArgs, context.extraArgs].filter(Boolean).join(' ');
|
|
145
|
+
|
|
146
|
+
// Build unified prompt from board config + trigger + context prompts
|
|
147
|
+
const prompt = buildPrompt(card, compatConfig, combinedPrompt);
|
|
148
|
+
|
|
149
|
+
// Merge extra args into claude config
|
|
150
|
+
const claudeConfig = { ...boardConfig.claude };
|
|
151
|
+
if (combinedArgs) {
|
|
152
|
+
claudeConfig.args = [claudeConfig.args || '', combinedArgs].filter(Boolean).join('\n');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Enqueue via agent controller (pass card + extraPrompt for bundle mode)
|
|
156
|
+
admin.enqueue(cardId, prompt, claudeConfig, { card, extraPrompt: combinedPrompt, boardId });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.error(chalk.red(` Failed to process card ${cardId}: ${err.message}`));
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Graceful shutdown
|
|
163
|
+
const shutdown = () => {
|
|
164
|
+
console.log(chalk.dim('\nShutting down...'));
|
|
165
|
+
admin.stop();
|
|
166
|
+
ws.disconnect();
|
|
167
|
+
ipcServer?.close();
|
|
168
|
+
cleanupLockFile();
|
|
169
|
+
process.exit(0);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
process.on('SIGINT', shutdown);
|
|
173
|
+
process.on('SIGTERM', shutdown);
|
|
174
|
+
|
|
175
|
+
console.log(chalk.green('\nWatching for events. Press Ctrl+C to stop.\n'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function startIPCServer(admin) {
|
|
179
|
+
const server = http.createServer((req, res) => {
|
|
180
|
+
res.setHeader('Content-Type', 'application/json');
|
|
181
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
182
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST');
|
|
183
|
+
|
|
184
|
+
if (req.method === 'GET' && req.url === '/status') {
|
|
185
|
+
res.end(JSON.stringify(admin.getStatus()));
|
|
186
|
+
} else if (req.method === 'GET' && req.url === '/events') {
|
|
187
|
+
res.end(JSON.stringify(admin.getEventLog()));
|
|
188
|
+
} else if (req.method === 'POST' && req.url === '/pause') {
|
|
189
|
+
admin.pause();
|
|
190
|
+
res.end(JSON.stringify({ ok: true }));
|
|
191
|
+
} else if (req.method === 'POST' && req.url === '/resume') {
|
|
192
|
+
admin.resume();
|
|
193
|
+
res.end(JSON.stringify({ ok: true }));
|
|
194
|
+
} else if (req.method === 'POST' && req.url?.startsWith('/kill/')) {
|
|
195
|
+
const cardId = req.url.split('/kill/')[1];
|
|
196
|
+
killWorker(cardId);
|
|
197
|
+
res.end(JSON.stringify({ ok: true }));
|
|
198
|
+
} else {
|
|
199
|
+
res.statusCode = 404;
|
|
200
|
+
res.end(JSON.stringify({ error: 'not found' }));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Listen on random port
|
|
205
|
+
server.listen(0, '127.0.0.1', () => {
|
|
206
|
+
const port = server.address().port;
|
|
207
|
+
const lockPath = getAgentLockPath();
|
|
208
|
+
fs.writeFileSync(lockPath, JSON.stringify({
|
|
209
|
+
pid: process.pid,
|
|
210
|
+
port,
|
|
211
|
+
startedAt: new Date().toISOString(),
|
|
212
|
+
}));
|
|
213
|
+
console.log(chalk.dim(`IPC server on port ${port}`));
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return server;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function cleanupLockFile() {
|
|
220
|
+
try {
|
|
221
|
+
const lockPath = getAgentLockPath();
|
|
222
|
+
if (fs.existsSync(lockPath)) fs.unlinkSync(lockPath);
|
|
223
|
+
} catch {}
|
|
224
|
+
}
|