claudit 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/README.md +139 -0
- package/bin/claudit-mcp.js +3 -0
- package/bin/claudit.js +11 -0
- package/client/dist/assets/index-DhjH_2Wd.css +32 -0
- package/client/dist/assets/index-Dwom-XdC.js +98 -0
- package/client/dist/index.html +13 -0
- package/package.json +40 -0
- package/server/dist/server/src/index.js +170 -0
- package/server/dist/server/src/mcp-server.js +144 -0
- package/server/dist/server/src/routes/cron.js +101 -0
- package/server/dist/server/src/routes/filesystem.js +71 -0
- package/server/dist/server/src/routes/groups.js +60 -0
- package/server/dist/server/src/routes/sessions.js +206 -0
- package/server/dist/server/src/routes/todo.js +93 -0
- package/server/dist/server/src/routes/todoProviders.js +179 -0
- package/server/dist/server/src/services/claudeProcess.js +220 -0
- package/server/dist/server/src/services/cronScheduler.js +163 -0
- package/server/dist/server/src/services/cronStorage.js +154 -0
- package/server/dist/server/src/services/database.js +103 -0
- package/server/dist/server/src/services/eventBus.js +11 -0
- package/server/dist/server/src/services/groupStorage.js +52 -0
- package/server/dist/server/src/services/historyIndex.js +100 -0
- package/server/dist/server/src/services/jsonStore.js +41 -0
- package/server/dist/server/src/services/managedSessions.js +96 -0
- package/server/dist/server/src/services/providerConfigStorage.js +80 -0
- package/server/dist/server/src/services/providers/TodoProvider.js +1 -0
- package/server/dist/server/src/services/providers/larkDocsProvider.js +75 -0
- package/server/dist/server/src/services/providers/mcpClient.js +151 -0
- package/server/dist/server/src/services/providers/meegoProvider.js +99 -0
- package/server/dist/server/src/services/providers/registry.js +17 -0
- package/server/dist/server/src/services/providers/supabaseProvider.js +172 -0
- package/server/dist/server/src/services/ptyManager.js +263 -0
- package/server/dist/server/src/services/sessionIndexCache.js +24 -0
- package/server/dist/server/src/services/sessionParser.js +98 -0
- package/server/dist/server/src/services/sessionScanner.js +244 -0
- package/server/dist/server/src/services/todoStorage.js +112 -0
- package/server/dist/server/src/services/todoSyncEngine.js +170 -0
- package/server/dist/server/src/types.js +1 -0
- package/server/dist/shared/src/index.js +1 -0
- package/server/dist/shared/src/types.js +2 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { execSync } from 'child_process';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { getSessionIndex, invalidateSessionCache } from '../services/historyIndex.js';
|
|
7
|
+
import { parseSession } from '../services/sessionParser.js';
|
|
8
|
+
import { addManagedSession, renameManagedSession, archiveManagedSession, removeManagedSession, pinManagedSession } from '../services/managedSessions.js';
|
|
9
|
+
import { eventBus } from '../services/eventBus.js';
|
|
10
|
+
const router = Router();
|
|
11
|
+
// GET /api/sessions?q=keyword&hideEmpty=true&managedOnly=true
|
|
12
|
+
router.get('/', (req, res) => {
|
|
13
|
+
try {
|
|
14
|
+
const query = req.query.q;
|
|
15
|
+
const hideEmpty = req.query.hideEmpty === 'true';
|
|
16
|
+
const managedOnly = req.query.managedOnly === 'true';
|
|
17
|
+
const groups = getSessionIndex(query, hideEmpty, managedOnly);
|
|
18
|
+
res.json(groups);
|
|
19
|
+
}
|
|
20
|
+
catch (err) {
|
|
21
|
+
console.error('Error fetching sessions:', err);
|
|
22
|
+
res.status(500).json({ error: 'Failed to fetch sessions' });
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
// POST /api/sessions/new — create a fresh claude session
|
|
26
|
+
router.post('/new', (req, res) => {
|
|
27
|
+
try {
|
|
28
|
+
const { projectPath, worktree, displayName, initialPrompt } = req.body;
|
|
29
|
+
if (!projectPath || typeof projectPath !== 'string') {
|
|
30
|
+
res.status(400).json({ error: 'projectPath is required' });
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (!fs.existsSync(projectPath)) {
|
|
34
|
+
res.status(400).json({ error: 'projectPath does not exist' });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
let actualProjectPath = projectPath;
|
|
38
|
+
// Handle git worktree creation
|
|
39
|
+
if (worktree?.branchName) {
|
|
40
|
+
const gitDir = path.join(projectPath, '.git');
|
|
41
|
+
if (!fs.existsSync(gitDir)) {
|
|
42
|
+
res.status(400).json({ error: 'Source path is not a git repository' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const worktreePath = path.join(path.dirname(projectPath), `${path.basename(projectPath)}-${worktree.branchName}`);
|
|
46
|
+
try {
|
|
47
|
+
execSync(`git worktree add ${JSON.stringify(worktreePath)} -b ${JSON.stringify(worktree.branchName)}`, {
|
|
48
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 15_000,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Branch may already exist — try without -b
|
|
53
|
+
try {
|
|
54
|
+
execSync(`git worktree add ${JSON.stringify(worktreePath)} ${JSON.stringify(worktree.branchName)}`, {
|
|
55
|
+
cwd: projectPath, encoding: 'utf-8', timeout: 15_000,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
res.status(500).json({ error: `Failed to create worktree: ${e.message}` });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
actualProjectPath = worktreePath;
|
|
64
|
+
}
|
|
65
|
+
// Run claude with the initial prompt to create a session
|
|
66
|
+
const prompt = (typeof initialPrompt === 'string' && initialPrompt.trim())
|
|
67
|
+
? initialPrompt.trim()
|
|
68
|
+
: 'hello';
|
|
69
|
+
const cleanEnv = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE'));
|
|
70
|
+
const escapedPrompt = prompt.replace(/'/g, "'\\''");
|
|
71
|
+
const result = execSync(`claude -p --output-format json --max-turns 1 '${escapedPrompt}'`, { cwd: actualProjectPath, encoding: 'utf-8', timeout: 60_000, env: cleanEnv });
|
|
72
|
+
// Parse the JSON result to get the session ID
|
|
73
|
+
const parsed = JSON.parse(result);
|
|
74
|
+
const sessionId = parsed.session_id;
|
|
75
|
+
if (!sessionId) {
|
|
76
|
+
res.status(500).json({ error: 'Failed to get session ID from claude output' });
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Compute project hash
|
|
80
|
+
const projectHash = actualProjectPath.replace(/\//g, '-');
|
|
81
|
+
// Record in managed store
|
|
82
|
+
addManagedSession(sessionId, actualProjectPath);
|
|
83
|
+
if (displayName) {
|
|
84
|
+
renameManagedSession(sessionId, displayName);
|
|
85
|
+
}
|
|
86
|
+
// Invalidate cache so the new session shows up
|
|
87
|
+
invalidateSessionCache();
|
|
88
|
+
eventBus.emitSessionEvent({ type: 'session:created', sessionId });
|
|
89
|
+
res.json({ sessionId, projectPath: actualProjectPath, projectHash });
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
console.error('Error creating session:', err);
|
|
93
|
+
res.status(500).json({ error: err.message || 'Failed to create session' });
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
// GET /api/sessions/archived — return archived sessions
|
|
97
|
+
router.get('/archived', (req, res) => {
|
|
98
|
+
try {
|
|
99
|
+
const groups = getSessionIndex(undefined, false, false, true);
|
|
100
|
+
const count = groups.reduce((sum, g) => sum + g.sessions.length, 0);
|
|
101
|
+
res.json({ count, groups });
|
|
102
|
+
}
|
|
103
|
+
catch (err) {
|
|
104
|
+
console.error('Error fetching archived sessions:', err);
|
|
105
|
+
res.status(500).json({ error: 'Failed to fetch archived sessions' });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
// PATCH /api/sessions/:sessionId/pin — pin or unpin a session
|
|
109
|
+
router.patch('/:sessionId/pin', (req, res) => {
|
|
110
|
+
try {
|
|
111
|
+
const { sessionId } = req.params;
|
|
112
|
+
const { pinned } = req.body;
|
|
113
|
+
if (typeof pinned !== 'boolean') {
|
|
114
|
+
res.status(400).json({ error: 'pinned (boolean) is required' });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
pinManagedSession(sessionId, pinned);
|
|
118
|
+
invalidateSessionCache();
|
|
119
|
+
eventBus.emitSessionEvent({ type: 'session:updated', sessionId });
|
|
120
|
+
res.json({ ok: true });
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.error('Error pinning session:', err);
|
|
124
|
+
res.status(500).json({ error: err.message || 'Failed to pin session' });
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
// PATCH /api/sessions/:sessionId/archive — archive or unarchive a session
|
|
128
|
+
router.patch('/:sessionId/archive', (req, res) => {
|
|
129
|
+
try {
|
|
130
|
+
const { sessionId } = req.params;
|
|
131
|
+
const { archived } = req.body;
|
|
132
|
+
if (typeof archived !== 'boolean') {
|
|
133
|
+
res.status(400).json({ error: 'archived (boolean) is required' });
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
archiveManagedSession(sessionId, archived);
|
|
137
|
+
invalidateSessionCache();
|
|
138
|
+
eventBus.emitSessionEvent({ type: 'session:archived', sessionId });
|
|
139
|
+
res.json({ ok: true });
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error('Error archiving session:', err);
|
|
143
|
+
res.status(500).json({ error: err.message || 'Failed to archive session' });
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
// PATCH /api/sessions/:sessionId/name — rename a session
|
|
147
|
+
router.patch('/:sessionId/name', (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const { sessionId } = req.params;
|
|
150
|
+
const { name } = req.body;
|
|
151
|
+
if (typeof name !== 'string') {
|
|
152
|
+
res.status(400).json({ error: 'name is required' });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
let entry = renameManagedSession(sessionId, name);
|
|
156
|
+
if (!entry) {
|
|
157
|
+
// Session not in managed store — add it so we can still rename
|
|
158
|
+
addManagedSession(sessionId, '');
|
|
159
|
+
entry = renameManagedSession(sessionId, name);
|
|
160
|
+
}
|
|
161
|
+
// Invalidate cache so the new name shows up
|
|
162
|
+
invalidateSessionCache();
|
|
163
|
+
eventBus.emitSessionEvent({ type: 'session:updated', sessionId });
|
|
164
|
+
res.json({ ok: true });
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
console.error('Error renaming session:', err);
|
|
168
|
+
res.status(500).json({ error: err.message || 'Failed to rename session' });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
// DELETE /api/sessions/:projectHash/:sessionId — delete session file and managed entry
|
|
172
|
+
router.delete('/:projectHash/:sessionId', (req, res) => {
|
|
173
|
+
try {
|
|
174
|
+
const { projectHash, sessionId } = req.params;
|
|
175
|
+
const sessionFile = path.join(os.homedir(), '.claude', 'projects', projectHash, `${sessionId}.jsonl`);
|
|
176
|
+
if (fs.existsSync(sessionFile)) {
|
|
177
|
+
fs.unlinkSync(sessionFile);
|
|
178
|
+
}
|
|
179
|
+
removeManagedSession(sessionId);
|
|
180
|
+
invalidateSessionCache();
|
|
181
|
+
eventBus.emitSessionEvent({ type: 'session:deleted', sessionId });
|
|
182
|
+
res.json({ ok: true });
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.error('Error deleting session:', err);
|
|
186
|
+
res.status(500).json({ error: err.message || 'Failed to delete session' });
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
// GET /api/sessions/:projectHash/:sessionId
|
|
190
|
+
router.get('/:projectHash/:sessionId', (req, res) => {
|
|
191
|
+
try {
|
|
192
|
+
const { projectHash, sessionId } = req.params;
|
|
193
|
+
const detail = parseSession(projectHash, sessionId);
|
|
194
|
+
res.json(detail);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
console.error('Error parsing session:', err);
|
|
198
|
+
if (err.message?.includes('not found')) {
|
|
199
|
+
res.status(404).json({ error: 'Session not found' });
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
res.status(500).json({ error: 'Failed to parse session' });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
export default router;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getAllTodos, getTodo, createTodo, updateTodo, deleteTodo, reorderTodos, } from '../services/todoStorage.js';
|
|
3
|
+
import { pushCompletion } from '../services/todoSyncEngine.js';
|
|
4
|
+
const router = Router();
|
|
5
|
+
// GET /api/todo — list all todos
|
|
6
|
+
router.get('/', (_req, res) => {
|
|
7
|
+
try {
|
|
8
|
+
res.json(getAllTodos());
|
|
9
|
+
}
|
|
10
|
+
catch (err) {
|
|
11
|
+
console.error('Error fetching todos:', err);
|
|
12
|
+
res.status(500).json({ error: 'Failed to fetch todos' });
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
// POST /api/todo — create todo
|
|
16
|
+
router.post('/', (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const { title, description, priority, sessionId, sessionLabel, groupId } = req.body;
|
|
19
|
+
if (!title) {
|
|
20
|
+
res.status(400).json({ error: 'title is required' });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
const todo = createTodo({
|
|
24
|
+
title,
|
|
25
|
+
description,
|
|
26
|
+
completed: false,
|
|
27
|
+
priority: priority ?? 'medium',
|
|
28
|
+
sessionId,
|
|
29
|
+
sessionLabel,
|
|
30
|
+
groupId,
|
|
31
|
+
position: 0, // will be auto-computed by createTodo
|
|
32
|
+
});
|
|
33
|
+
res.status(201).json(todo);
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
console.error('Error creating todo:', err);
|
|
37
|
+
res.status(500).json({ error: 'Failed to create todo' });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
// PUT /api/todo/reorder — batch reorder todos
|
|
41
|
+
router.put('/reorder', (req, res) => {
|
|
42
|
+
try {
|
|
43
|
+
const { items } = req.body;
|
|
44
|
+
if (!Array.isArray(items)) {
|
|
45
|
+
res.status(400).json({ error: 'items array is required' });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
reorderTodos(items);
|
|
49
|
+
res.json({ success: true });
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
console.error('Error reordering todos:', err);
|
|
53
|
+
res.status(500).json({ error: 'Failed to reorder todos' });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// PUT /api/todo/:id — update todo
|
|
57
|
+
router.put('/:id', (req, res) => {
|
|
58
|
+
try {
|
|
59
|
+
const before = getTodo(req.params.id);
|
|
60
|
+
const todo = updateTodo(req.params.id, req.body);
|
|
61
|
+
if (!todo) {
|
|
62
|
+
res.status(404).json({ error: 'Todo not found' });
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
// If a provider-linked todo was just completed, push completion async
|
|
66
|
+
if (todo.provider && todo.completed && before && !before.completed) {
|
|
67
|
+
pushCompletion(todo).catch(err => {
|
|
68
|
+
console.error('Failed to push completion:', err);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
res.json(todo);
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
console.error('Error updating todo:', err);
|
|
75
|
+
res.status(500).json({ error: 'Failed to update todo' });
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
// DELETE /api/todo/:id — delete todo
|
|
79
|
+
router.delete('/:id', (req, res) => {
|
|
80
|
+
try {
|
|
81
|
+
const ok = deleteTodo(req.params.id);
|
|
82
|
+
if (!ok) {
|
|
83
|
+
res.status(404).json({ error: 'Todo not found' });
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
res.json({ success: true });
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
console.error('Error deleting todo:', err);
|
|
90
|
+
res.status(500).json({ error: 'Failed to delete todo' });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
export default router;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import { getAllProviders, getProvider } from '../services/providers/registry.js';
|
|
3
|
+
import { getAllConfigs, getConfig, createConfig, updateConfig, deleteConfig, } from '../services/providerConfigStorage.js';
|
|
4
|
+
import { syncProvider, syncAllProviders } from '../services/todoSyncEngine.js';
|
|
5
|
+
const router = Router();
|
|
6
|
+
// GET /api/todo/providers — list provider types
|
|
7
|
+
router.get('/', (_req, res) => {
|
|
8
|
+
try {
|
|
9
|
+
const providers = getAllProviders().map(p => ({
|
|
10
|
+
id: p.id,
|
|
11
|
+
displayName: p.displayName,
|
|
12
|
+
configSchema: p.configSchema,
|
|
13
|
+
}));
|
|
14
|
+
res.json(providers);
|
|
15
|
+
}
|
|
16
|
+
catch (err) {
|
|
17
|
+
console.error('Error listing providers:', err);
|
|
18
|
+
res.status(500).json({ error: 'Failed to list providers' });
|
|
19
|
+
}
|
|
20
|
+
});
|
|
21
|
+
/**
|
|
22
|
+
* Redact secret fields from a config object.
|
|
23
|
+
*/
|
|
24
|
+
function redactSecrets(providerId, config) {
|
|
25
|
+
const provider = getProvider(providerId);
|
|
26
|
+
if (!provider)
|
|
27
|
+
return config;
|
|
28
|
+
const secretKeys = new Set(provider.configSchema.filter(f => f.secret).map(f => f.key));
|
|
29
|
+
const redacted = {};
|
|
30
|
+
for (const [key, value] of Object.entries(config)) {
|
|
31
|
+
redacted[key] = secretKeys.has(key) && typeof value === 'string' && value
|
|
32
|
+
? '••••••••'
|
|
33
|
+
: value;
|
|
34
|
+
}
|
|
35
|
+
return redacted;
|
|
36
|
+
}
|
|
37
|
+
// GET /api/todo/providers/configs — list all configs (secrets redacted)
|
|
38
|
+
router.get('/configs', (_req, res) => {
|
|
39
|
+
try {
|
|
40
|
+
const configs = getAllConfigs().map(c => ({
|
|
41
|
+
...c,
|
|
42
|
+
config: redactSecrets(c.providerId, c.config),
|
|
43
|
+
}));
|
|
44
|
+
res.json(configs);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
console.error('Error listing configs:', err);
|
|
48
|
+
res.status(500).json({ error: 'Failed to list configs' });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
// POST /api/todo/providers/configs — create config
|
|
52
|
+
router.post('/configs', async (req, res) => {
|
|
53
|
+
try {
|
|
54
|
+
const { providerId, name, enabled, config: providerConfig, syncIntervalMinutes } = req.body;
|
|
55
|
+
if (!providerId || !name) {
|
|
56
|
+
res.status(400).json({ error: 'providerId and name are required' });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const provider = getProvider(providerId);
|
|
60
|
+
if (!provider) {
|
|
61
|
+
res.status(400).json({ error: `Unknown provider: ${providerId}` });
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Validate config if provider supports it
|
|
65
|
+
if (provider.validateConfig) {
|
|
66
|
+
const validationError = await provider.validateConfig(providerConfig || {});
|
|
67
|
+
if (validationError) {
|
|
68
|
+
res.status(400).json({ error: validationError });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const created = createConfig({
|
|
73
|
+
providerId,
|
|
74
|
+
name,
|
|
75
|
+
enabled: enabled ?? true,
|
|
76
|
+
config: providerConfig || {},
|
|
77
|
+
syncIntervalMinutes: syncIntervalMinutes ?? 0,
|
|
78
|
+
});
|
|
79
|
+
res.status(201).json({
|
|
80
|
+
...created,
|
|
81
|
+
config: redactSecrets(created.providerId, created.config),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
console.error('Error creating config:', err);
|
|
86
|
+
res.status(500).json({ error: 'Failed to create config' });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
// PUT /api/todo/providers/configs/:id — update config
|
|
90
|
+
router.put('/configs/:id', async (req, res) => {
|
|
91
|
+
try {
|
|
92
|
+
const existing = getConfig(req.params.id);
|
|
93
|
+
if (!existing) {
|
|
94
|
+
res.status(404).json({ error: 'Config not found' });
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// If config fields are being updated, merge with existing (don't overwrite secrets with redacted values)
|
|
98
|
+
let newConfig = req.body.config;
|
|
99
|
+
if (newConfig) {
|
|
100
|
+
const provider = getProvider(existing.providerId);
|
|
101
|
+
if (provider) {
|
|
102
|
+
const secretKeys = new Set(provider.configSchema.filter(f => f.secret).map(f => f.key));
|
|
103
|
+
const merged = { ...existing.config };
|
|
104
|
+
for (const [key, value] of Object.entries(newConfig)) {
|
|
105
|
+
// Skip redacted placeholder values for secret fields
|
|
106
|
+
if (secretKeys.has(key) && value === '••••••••')
|
|
107
|
+
continue;
|
|
108
|
+
merged[key] = value;
|
|
109
|
+
}
|
|
110
|
+
newConfig = merged;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const updates = {};
|
|
114
|
+
if (req.body.name !== undefined)
|
|
115
|
+
updates.name = req.body.name;
|
|
116
|
+
if (req.body.enabled !== undefined)
|
|
117
|
+
updates.enabled = req.body.enabled;
|
|
118
|
+
if (newConfig !== undefined)
|
|
119
|
+
updates.config = newConfig;
|
|
120
|
+
if (req.body.syncIntervalMinutes !== undefined)
|
|
121
|
+
updates.syncIntervalMinutes = req.body.syncIntervalMinutes;
|
|
122
|
+
const updated = updateConfig(req.params.id, updates);
|
|
123
|
+
if (!updated) {
|
|
124
|
+
res.status(404).json({ error: 'Config not found' });
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
res.json({
|
|
128
|
+
...updated,
|
|
129
|
+
config: redactSecrets(updated.providerId, updated.config),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
console.error('Error updating config:', err);
|
|
134
|
+
res.status(500).json({ error: 'Failed to update config' });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
// DELETE /api/todo/providers/configs/:id — delete config
|
|
138
|
+
router.delete('/configs/:id', (req, res) => {
|
|
139
|
+
try {
|
|
140
|
+
const ok = deleteConfig(req.params.id);
|
|
141
|
+
if (!ok) {
|
|
142
|
+
res.status(404).json({ error: 'Config not found' });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
res.json({ success: true });
|
|
146
|
+
}
|
|
147
|
+
catch (err) {
|
|
148
|
+
console.error('Error deleting config:', err);
|
|
149
|
+
res.status(500).json({ error: 'Failed to delete config' });
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
// POST /api/todo/providers/configs/:id/sync — manual sync one config
|
|
153
|
+
router.post('/configs/:id/sync', async (req, res) => {
|
|
154
|
+
try {
|
|
155
|
+
const config = getConfig(req.params.id);
|
|
156
|
+
if (!config) {
|
|
157
|
+
res.status(404).json({ error: 'Config not found' });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const result = await syncProvider(config);
|
|
161
|
+
res.json(result);
|
|
162
|
+
}
|
|
163
|
+
catch (err) {
|
|
164
|
+
console.error('Error syncing provider:', err);
|
|
165
|
+
res.status(500).json({ error: 'Failed to sync provider' });
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
// POST /api/todo/providers/sync-all — manual sync all enabled configs
|
|
169
|
+
router.post('/sync-all', async (_req, res) => {
|
|
170
|
+
try {
|
|
171
|
+
const results = await syncAllProviders();
|
|
172
|
+
res.json(results);
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
console.error('Error syncing all providers:', err);
|
|
176
|
+
res.status(500).json({ error: 'Failed to sync providers' });
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
export default router;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
// Resolve claude binary path at startup so spawn can find it
|
|
6
|
+
const CLAUDE_BIN = (() => {
|
|
7
|
+
try {
|
|
8
|
+
return execSync('which claude', { encoding: 'utf-8' }).trim();
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return 'claude'; // fallback
|
|
12
|
+
}
|
|
13
|
+
})();
|
|
14
|
+
console.log(`[claude] Binary path: ${CLAUDE_BIN}`);
|
|
15
|
+
export class ClaudeProcess extends EventEmitter {
|
|
16
|
+
proc = null;
|
|
17
|
+
sessionId;
|
|
18
|
+
projectPath;
|
|
19
|
+
stdoutBuffer = '';
|
|
20
|
+
stderrBuffer = '';
|
|
21
|
+
lastTextLength = 0;
|
|
22
|
+
lastThinkingLength = 0;
|
|
23
|
+
lastMessageId = '';
|
|
24
|
+
sentToolUseIds = new Set();
|
|
25
|
+
userMessageSent = false;
|
|
26
|
+
pendingMessage = null;
|
|
27
|
+
constructor(sessionId, projectPath) {
|
|
28
|
+
super();
|
|
29
|
+
this.sessionId = sessionId;
|
|
30
|
+
this.projectPath = projectPath;
|
|
31
|
+
}
|
|
32
|
+
resetTracking() {
|
|
33
|
+
this.lastTextLength = 0;
|
|
34
|
+
this.lastThinkingLength = 0;
|
|
35
|
+
this.lastMessageId = '';
|
|
36
|
+
this.sentToolUseIds.clear();
|
|
37
|
+
this.stdoutBuffer = '';
|
|
38
|
+
this.stderrBuffer = '';
|
|
39
|
+
}
|
|
40
|
+
_spawnProcess(onClose) {
|
|
41
|
+
const cwd = fs.existsSync(this.projectPath) ? this.projectPath : os.homedir();
|
|
42
|
+
console.log(`[claude] Spawning CLI: resume=${this.sessionId} cwd=${cwd}`);
|
|
43
|
+
const proc = spawn(CLAUDE_BIN, [
|
|
44
|
+
'--resume', this.sessionId,
|
|
45
|
+
'-p',
|
|
46
|
+
'--output-format', 'stream-json',
|
|
47
|
+
'--input-format', 'stream-json',
|
|
48
|
+
'--verbose',
|
|
49
|
+
], {
|
|
50
|
+
cwd,
|
|
51
|
+
env: Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== 'CLAUDECODE')),
|
|
52
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
53
|
+
});
|
|
54
|
+
proc.stdout?.on('data', (data) => {
|
|
55
|
+
this.processStreamData(data, 'stdout');
|
|
56
|
+
});
|
|
57
|
+
proc.stderr?.on('data', (data) => {
|
|
58
|
+
this.processStreamData(data, 'stderr');
|
|
59
|
+
});
|
|
60
|
+
proc.on('close', onClose);
|
|
61
|
+
proc.on('error', (err) => {
|
|
62
|
+
console.error(`[claude] Process error: ${err.message}`);
|
|
63
|
+
this.emit('error', err.message);
|
|
64
|
+
this.proc = null;
|
|
65
|
+
});
|
|
66
|
+
return proc;
|
|
67
|
+
}
|
|
68
|
+
start() {
|
|
69
|
+
this.proc = this._spawnProcess((code) => {
|
|
70
|
+
console.log(`[claude] Process exited with code ${code}`);
|
|
71
|
+
this.proc = null;
|
|
72
|
+
if (this.userMessageSent) {
|
|
73
|
+
this.emit('done');
|
|
74
|
+
}
|
|
75
|
+
else if (this.pendingMessage) {
|
|
76
|
+
console.log('[claude] Process exited during resume, restarting for pending message...');
|
|
77
|
+
const msg = this.pendingMessage;
|
|
78
|
+
this.pendingMessage = null;
|
|
79
|
+
this.restart(msg);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
/** Restart the process and send a message once ready */
|
|
84
|
+
restart(content) {
|
|
85
|
+
this.userMessageSent = true;
|
|
86
|
+
this.resetTracking();
|
|
87
|
+
this.proc = this._spawnProcess((code) => {
|
|
88
|
+
console.log(`[claude] Restarted process exited with code ${code}`);
|
|
89
|
+
this.emit('done');
|
|
90
|
+
this.proc = null;
|
|
91
|
+
});
|
|
92
|
+
// Write the user message to stdin
|
|
93
|
+
const msg = JSON.stringify({
|
|
94
|
+
type: 'user',
|
|
95
|
+
message: { role: 'user', content },
|
|
96
|
+
}) + '\n';
|
|
97
|
+
console.log(`[claude] Sending to restarted stdin: ${msg.trim().slice(0, 200)}`);
|
|
98
|
+
this.proc.stdin?.write(msg);
|
|
99
|
+
}
|
|
100
|
+
processStreamData(data, source) {
|
|
101
|
+
const bufferKey = source === 'stdout' ? 'stdoutBuffer' : 'stderrBuffer';
|
|
102
|
+
this[bufferKey] += data.toString();
|
|
103
|
+
const lines = this[bufferKey].split('\n');
|
|
104
|
+
this[bufferKey] = lines.pop() || '';
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
if (!line.trim())
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(line);
|
|
110
|
+
this.handleStreamEvent(parsed);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
if (source === 'stderr') {
|
|
114
|
+
const text = line.trim();
|
|
115
|
+
if (text.includes('Error') || text.includes('error')) {
|
|
116
|
+
console.error(`[claude] stderr text: ${text.slice(0, 300)}`);
|
|
117
|
+
this.emit('error', text);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
handleStreamEvent(event) {
|
|
124
|
+
switch (event.type) {
|
|
125
|
+
case 'system':
|
|
126
|
+
this.emit('ready');
|
|
127
|
+
break;
|
|
128
|
+
case 'assistant': {
|
|
129
|
+
if (!this.userMessageSent)
|
|
130
|
+
break;
|
|
131
|
+
const msg = event.message;
|
|
132
|
+
if (!msg?.content)
|
|
133
|
+
break;
|
|
134
|
+
const messageId = msg.id || '';
|
|
135
|
+
if (messageId !== this.lastMessageId) {
|
|
136
|
+
this.lastMessageId = messageId;
|
|
137
|
+
this.lastTextLength = 0;
|
|
138
|
+
this.lastThinkingLength = 0;
|
|
139
|
+
}
|
|
140
|
+
let fullText = '';
|
|
141
|
+
let fullThinking = '';
|
|
142
|
+
for (const block of msg.content) {
|
|
143
|
+
if (block.type === 'text' && block.text) {
|
|
144
|
+
fullText += block.text;
|
|
145
|
+
}
|
|
146
|
+
else if (block.type === 'thinking' && block.thinking) {
|
|
147
|
+
fullThinking += block.thinking;
|
|
148
|
+
}
|
|
149
|
+
else if (block.type === 'tool_use' && !this.sentToolUseIds.has(block.id)) {
|
|
150
|
+
this.sentToolUseIds.add(block.id);
|
|
151
|
+
this.emit('tool_use', {
|
|
152
|
+
name: block.name,
|
|
153
|
+
id: block.id,
|
|
154
|
+
input: block.input || {},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (fullThinking.length > this.lastThinkingLength) {
|
|
159
|
+
const delta = fullThinking.slice(this.lastThinkingLength);
|
|
160
|
+
this.lastThinkingLength = fullThinking.length;
|
|
161
|
+
this.emit('assistant_thinking', delta);
|
|
162
|
+
}
|
|
163
|
+
if (fullText.length > this.lastTextLength) {
|
|
164
|
+
const delta = fullText.slice(this.lastTextLength);
|
|
165
|
+
this.lastTextLength = fullText.length;
|
|
166
|
+
this.emit('assistant_text', delta);
|
|
167
|
+
}
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
case 'result':
|
|
171
|
+
if (!this.userMessageSent) {
|
|
172
|
+
console.log('[claude] Ignoring initial resume result');
|
|
173
|
+
break;
|
|
174
|
+
}
|
|
175
|
+
if (event.result && typeof event.result === 'string' && this.lastTextLength === 0) {
|
|
176
|
+
this.emit('assistant_text', event.result);
|
|
177
|
+
}
|
|
178
|
+
console.log(`[claude] Result: subtype=${event.subtype} result=${String(event.result).slice(0, 100)}`);
|
|
179
|
+
this.emit('done');
|
|
180
|
+
break;
|
|
181
|
+
case 'user':
|
|
182
|
+
break;
|
|
183
|
+
default:
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
sendMessage(content) {
|
|
188
|
+
if (!this.proc?.stdin?.writable) {
|
|
189
|
+
console.log('[claude] Process not running, restarting for message...');
|
|
190
|
+
this.restart(content);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.userMessageSent = true;
|
|
194
|
+
this.lastTextLength = 0;
|
|
195
|
+
this.lastThinkingLength = 0;
|
|
196
|
+
this.lastMessageId = '';
|
|
197
|
+
this.sentToolUseIds.clear();
|
|
198
|
+
const msg = JSON.stringify({
|
|
199
|
+
type: 'user',
|
|
200
|
+
message: { role: 'user', content },
|
|
201
|
+
}) + '\n';
|
|
202
|
+
console.log(`[claude] Sending to stdin: ${msg.trim().slice(0, 200)}`);
|
|
203
|
+
this.proc.stdin.write(msg);
|
|
204
|
+
}
|
|
205
|
+
isAlive() {
|
|
206
|
+
return this.proc !== null && !this.proc.killed;
|
|
207
|
+
}
|
|
208
|
+
stop() {
|
|
209
|
+
if (this.proc) {
|
|
210
|
+
console.log('[claude] Stopping process');
|
|
211
|
+
this.proc.kill('SIGTERM');
|
|
212
|
+
setTimeout(() => {
|
|
213
|
+
if (this.proc) {
|
|
214
|
+
this.proc.kill('SIGKILL');
|
|
215
|
+
this.proc = null;
|
|
216
|
+
}
|
|
217
|
+
}, 3000);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|