claudeck 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/LICENSE +21 -0
- package/README.md +233 -0
- package/cli.js +2 -0
- package/config/agent-chains.json +16 -0
- package/config/agent-dags.json +16 -0
- package/config/agents.json +46 -0
- package/config/bot-prompt.json +3 -0
- package/config/folders.json +66 -0
- package/config/prompts.json +92 -0
- package/config/repos.json +86 -0
- package/config/telegram-config.json +17 -0
- package/config/workflows.json +90 -0
- package/db.js +1198 -0
- package/package.json +55 -0
- package/plugins/claude-editor/client.css +171 -0
- package/plugins/claude-editor/client.js +183 -0
- package/plugins/event-stream/client.css +207 -0
- package/plugins/event-stream/client.js +271 -0
- package/plugins/linear/client.css +345 -0
- package/plugins/linear/client.js +380 -0
- package/plugins/linear/config.json +5 -0
- package/plugins/linear/server.js +312 -0
- package/plugins/repos/client.css +549 -0
- package/plugins/repos/client.js +663 -0
- package/plugins/repos/server.js +232 -0
- package/plugins/sudoku/client.css +196 -0
- package/plugins/sudoku/client.js +329 -0
- package/plugins/tasks/client.css +414 -0
- package/plugins/tasks/client.js +394 -0
- package/plugins/tasks/server.js +116 -0
- package/plugins/tic-tac-toe/client.css +167 -0
- package/plugins/tic-tac-toe/client.js +241 -0
- package/public/css/core/components.css +232 -0
- package/public/css/core/layout.css +330 -0
- package/public/css/core/print.css +18 -0
- package/public/css/core/reset.css +36 -0
- package/public/css/core/responsive.css +378 -0
- package/public/css/core/theme.css +116 -0
- package/public/css/core/variables.css +93 -0
- package/public/css/features/agent-monitor.css +297 -0
- package/public/css/features/agent-sidebar.css +525 -0
- package/public/css/features/agents.css +996 -0
- package/public/css/features/analytics.css +181 -0
- package/public/css/features/background-sessions.css +321 -0
- package/public/css/features/cost-dashboard.css +168 -0
- package/public/css/features/home.css +313 -0
- package/public/css/features/retro-terminal.css +88 -0
- package/public/css/features/telegram.css +127 -0
- package/public/css/features/tour.css +148 -0
- package/public/css/features/voice-input.css +60 -0
- package/public/css/features/welcome.css +241 -0
- package/public/css/panels/assistant-bot.css +442 -0
- package/public/css/panels/dev-docs.css +292 -0
- package/public/css/panels/file-explorer.css +322 -0
- package/public/css/panels/git-panel.css +221 -0
- package/public/css/panels/mcp-manager.css +199 -0
- package/public/css/panels/tips-feed.css +353 -0
- package/public/css/ui/commands.css +273 -0
- package/public/css/ui/context-gauge.css +76 -0
- package/public/css/ui/file-picker.css +69 -0
- package/public/css/ui/image-attachments.css +106 -0
- package/public/css/ui/messages.css +884 -0
- package/public/css/ui/modals.css +122 -0
- package/public/css/ui/parallel.css +217 -0
- package/public/css/ui/permissions.css +110 -0
- package/public/css/ui/right-panel.css +481 -0
- package/public/css/ui/sessions.css +689 -0
- package/public/css/ui/status-bar.css +425 -0
- package/public/css/ui/toolbox.css +206 -0
- package/public/data/tips.json +218 -0
- package/public/icons/favicon.png +0 -0
- package/public/icons/icon-192.png +0 -0
- package/public/icons/icon-512.png +0 -0
- package/public/icons/whaly.png +0 -0
- package/public/index.html +1140 -0
- package/public/js/core/api.js +591 -0
- package/public/js/core/constants.js +3 -0
- package/public/js/core/dom.js +270 -0
- package/public/js/core/events.js +10 -0
- package/public/js/core/plugin-loader.js +153 -0
- package/public/js/core/store.js +39 -0
- package/public/js/core/utils.js +25 -0
- package/public/js/core/ws.js +64 -0
- package/public/js/features/agent-monitor.js +222 -0
- package/public/js/features/agents.js +1209 -0
- package/public/js/features/analytics.js +397 -0
- package/public/js/features/attachments.js +251 -0
- package/public/js/features/background-sessions.js +475 -0
- package/public/js/features/chat.js +589 -0
- package/public/js/features/cost-dashboard.js +152 -0
- package/public/js/features/dag-editor.js +399 -0
- package/public/js/features/easter-egg.js +46 -0
- package/public/js/features/home.js +270 -0
- package/public/js/features/projects.js +372 -0
- package/public/js/features/prompts.js +228 -0
- package/public/js/features/sessions.js +332 -0
- package/public/js/features/telegram.js +131 -0
- package/public/js/features/tour.js +210 -0
- package/public/js/features/voice-input.js +185 -0
- package/public/js/features/welcome.js +43 -0
- package/public/js/features/workflows.js +277 -0
- package/public/js/main.js +51 -0
- package/public/js/panels/assistant-bot.js +445 -0
- package/public/js/panels/dev-docs.js +380 -0
- package/public/js/panels/file-explorer.js +486 -0
- package/public/js/panels/git-panel.js +285 -0
- package/public/js/panels/mcp-manager.js +311 -0
- package/public/js/panels/tips-feed.js +303 -0
- package/public/js/ui/commands.js +114 -0
- package/public/js/ui/context-gauge.js +100 -0
- package/public/js/ui/diff.js +124 -0
- package/public/js/ui/disabled-tools.js +36 -0
- package/public/js/ui/export.js +74 -0
- package/public/js/ui/formatting.js +206 -0
- package/public/js/ui/header-dropdowns.js +72 -0
- package/public/js/ui/input-meta.js +71 -0
- package/public/js/ui/max-turns.js +21 -0
- package/public/js/ui/messages.js +387 -0
- package/public/js/ui/model-selector.js +20 -0
- package/public/js/ui/notifications.js +232 -0
- package/public/js/ui/parallel.js +176 -0
- package/public/js/ui/permissions.js +168 -0
- package/public/js/ui/right-panel.js +173 -0
- package/public/js/ui/shortcuts.js +143 -0
- package/public/js/ui/sidebar-toggle.js +29 -0
- package/public/js/ui/status-bar.js +172 -0
- package/public/js/ui/tab-sdk.js +623 -0
- package/public/js/ui/theme.js +38 -0
- package/public/manifest.json +13 -0
- package/public/offline.html +190 -0
- package/public/style.css +42 -0
- package/public/sw.js +91 -0
- package/server/agent-loop.js +385 -0
- package/server/dag-executor.js +265 -0
- package/server/orchestrator.js +514 -0
- package/server/paths.js +61 -0
- package/server/plugin-mount.js +56 -0
- package/server/push-sender.js +31 -0
- package/server/routes/agents.js +294 -0
- package/server/routes/bot.js +45 -0
- package/server/routes/exec.js +35 -0
- package/server/routes/files.js +218 -0
- package/server/routes/mcp.js +82 -0
- package/server/routes/messages.js +36 -0
- package/server/routes/notifications.js +37 -0
- package/server/routes/projects.js +207 -0
- package/server/routes/prompts.js +53 -0
- package/server/routes/sessions.js +103 -0
- package/server/routes/stats.js +143 -0
- package/server/routes/telegram.js +71 -0
- package/server/routes/tips.js +135 -0
- package/server/routes/workflows.js +81 -0
- package/server/summarizer.js +55 -0
- package/server/telegram-poller.js +205 -0
- package/server/telegram-sender.js +304 -0
- package/server/ws-handler.js +926 -0
- package/server.js +179 -0
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
// Linear Tab — Tab SDK plugin combining Linear issues + settings
|
|
2
|
+
import { registerTab } from '/js/ui/tab-sdk.js';
|
|
3
|
+
|
|
4
|
+
function escapeHtml(str) {
|
|
5
|
+
const div = document.createElement('div');
|
|
6
|
+
div.textContent = str;
|
|
7
|
+
return div.innerHTML;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const ICONS = {
|
|
11
|
+
refresh: `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><polyline points="1 20 1 14 7 14"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>`,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
registerTab({
|
|
15
|
+
id: 'linear',
|
|
16
|
+
title: 'Linear',
|
|
17
|
+
icon: '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
|
|
18
|
+
lazy: true,
|
|
19
|
+
|
|
20
|
+
init(ctx) {
|
|
21
|
+
const CACHE_TTL = 60_000;
|
|
22
|
+
let cachedLinear = null;
|
|
23
|
+
let linearCacheTime = 0;
|
|
24
|
+
let linearLoading = false;
|
|
25
|
+
let currentView = 'issues'; // 'issues' | 'settings'
|
|
26
|
+
|
|
27
|
+
// ── Build DOM ──────────────────────────────────────
|
|
28
|
+
const root = document.createElement('div');
|
|
29
|
+
root.className = 'linear-tab';
|
|
30
|
+
|
|
31
|
+
root.innerHTML = `
|
|
32
|
+
<div class="linear-tab-header">
|
|
33
|
+
<div class="linear-tab-nav">
|
|
34
|
+
<button class="linear-nav-btn active" data-view="issues">Issues</button>
|
|
35
|
+
<button class="linear-nav-btn" data-view="settings">Settings</button>
|
|
36
|
+
</div>
|
|
37
|
+
<div class="linear-tab-actions">
|
|
38
|
+
<button class="linear-create-issue-btn" title="Create issue">+</button>
|
|
39
|
+
<button class="linear-refresh-btn" title="Refresh issues">${ICONS.refresh}</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<div class="linear-view linear-issues-view">
|
|
44
|
+
<div class="linear-issues"></div>
|
|
45
|
+
<div class="linear-panel-footer"></div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
<div class="linear-view linear-settings-view" style="display:none;">
|
|
49
|
+
<div class="ls-form">
|
|
50
|
+
<div class="ls-toggle-row">
|
|
51
|
+
<span class="ls-label">Enable Integration</span>
|
|
52
|
+
<label class="ls-switch">
|
|
53
|
+
<input type="checkbox" class="ls-enabled" />
|
|
54
|
+
<span class="ls-slider"></span>
|
|
55
|
+
</label>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<label class="ls-field">
|
|
59
|
+
<span class="ls-label">API Key</span>
|
|
60
|
+
<input type="password" class="ls-input ls-api-key" placeholder="lin_api_..." autocomplete="off" spellcheck="false" />
|
|
61
|
+
<span class="ls-hint">Generate at Linear → Settings → API → Personal API keys</span>
|
|
62
|
+
</label>
|
|
63
|
+
|
|
64
|
+
<label class="ls-field">
|
|
65
|
+
<span class="ls-label">Assignee Email</span>
|
|
66
|
+
<input type="email" class="ls-input ls-email" placeholder="you@company.com" autocomplete="off" spellcheck="false" />
|
|
67
|
+
<span class="ls-hint">New issues will be auto-assigned to this user</span>
|
|
68
|
+
</label>
|
|
69
|
+
|
|
70
|
+
<div class="ls-actions">
|
|
71
|
+
<button class="ls-btn ls-save-btn">Save</button>
|
|
72
|
+
<button class="ls-btn ls-test-btn ls-btn-secondary">Test Connection</button>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<div class="ls-status hidden"></div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
|
|
80
|
+
// ── Selectors ──────────────────────────────────────
|
|
81
|
+
const issuesView = root.querySelector('.linear-issues-view');
|
|
82
|
+
const settingsView = root.querySelector('.linear-settings-view');
|
|
83
|
+
const navBtns = root.querySelectorAll('.linear-nav-btn');
|
|
84
|
+
const actionsBar = root.querySelector('.linear-tab-actions');
|
|
85
|
+
const refreshBtn = root.querySelector('.linear-refresh-btn');
|
|
86
|
+
const createBtn = root.querySelector('.linear-create-issue-btn');
|
|
87
|
+
const issuesList = root.querySelector('.linear-issues');
|
|
88
|
+
const footer = root.querySelector('.linear-panel-footer');
|
|
89
|
+
|
|
90
|
+
// Settings selectors
|
|
91
|
+
const enabledEl = root.querySelector('.ls-enabled');
|
|
92
|
+
const apiKeyEl = root.querySelector('.ls-api-key');
|
|
93
|
+
const emailEl = root.querySelector('.ls-email');
|
|
94
|
+
const saveBtn = root.querySelector('.ls-save-btn');
|
|
95
|
+
const testBtn = root.querySelector('.ls-test-btn');
|
|
96
|
+
const statusEl = root.querySelector('.ls-status');
|
|
97
|
+
|
|
98
|
+
// ── View switching ─────────────────────────────────
|
|
99
|
+
navBtns.forEach(btn => {
|
|
100
|
+
btn.addEventListener('click', () => {
|
|
101
|
+
const view = btn.dataset.view;
|
|
102
|
+
if (view === currentView) return;
|
|
103
|
+
currentView = view;
|
|
104
|
+
navBtns.forEach(b => b.classList.toggle('active', b.dataset.view === view));
|
|
105
|
+
issuesView.style.display = view === 'issues' ? '' : 'none';
|
|
106
|
+
settingsView.style.display = view === 'settings' ? '' : 'none';
|
|
107
|
+
actionsBar.style.display = view === 'issues' ? '' : 'none';
|
|
108
|
+
if (view === 'settings') loadSettings();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// ══════════════════════════════════════════════════
|
|
113
|
+
// ISSUES VIEW
|
|
114
|
+
// ══════════════════════════════════════════════════
|
|
115
|
+
|
|
116
|
+
function priorityColor(priority) {
|
|
117
|
+
switch (priority) {
|
|
118
|
+
case 1: return 'var(--error)';
|
|
119
|
+
case 2: return 'var(--warning)';
|
|
120
|
+
case 3: return 'var(--accent)';
|
|
121
|
+
case 4: return 'var(--text-dim)';
|
|
122
|
+
default: return 'var(--border)';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function formatDate(dateStr) {
|
|
127
|
+
if (!dateStr) return null;
|
|
128
|
+
const d = new Date(dateStr);
|
|
129
|
+
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function loadIssues() {
|
|
133
|
+
if (linearLoading) return;
|
|
134
|
+
linearLoading = true;
|
|
135
|
+
refreshBtn.classList.add('spinning');
|
|
136
|
+
issuesList.innerHTML = '<div class="linear-empty"><span class="linear-empty-icon">⌛</span>Loading...</div>';
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const data = await ctx.api.fetchLinearIssues();
|
|
140
|
+
cachedLinear = data;
|
|
141
|
+
linearCacheTime = Date.now();
|
|
142
|
+
|
|
143
|
+
if (data.error && data.issues.length === 0) {
|
|
144
|
+
const isKeyError = data.error.includes('not configured');
|
|
145
|
+
const icon = isKeyError ? '🔑' : '📄';
|
|
146
|
+
const hint = isKeyError
|
|
147
|
+
? '<br><span style="font-size:10px;margin-top:4px;display:block;cursor:pointer;text-decoration:underline;" class="linear-go-settings">Configure in Settings</span>'
|
|
148
|
+
: '';
|
|
149
|
+
issuesList.innerHTML = `<div class="linear-empty"><span class="linear-empty-icon">${icon}</span>${data.error}${hint}</div>`;
|
|
150
|
+
footer.textContent = '';
|
|
151
|
+
|
|
152
|
+
const link = issuesList.querySelector('.linear-go-settings');
|
|
153
|
+
if (link) {
|
|
154
|
+
link.addEventListener('click', () => {
|
|
155
|
+
root.querySelector('.linear-nav-btn[data-view="settings"]').click();
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
renderIssues(data.issues);
|
|
160
|
+
footer.textContent = `\u2500\u2500\u2500 ${data.issues.length} issue${data.issues.length !== 1 ? 's' : ''} \u2500\u2500\u2500`;
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
issuesList.innerHTML = '<div class="linear-empty"><span class="linear-empty-icon">📄</span>Failed to fetch issues</div>';
|
|
164
|
+
footer.textContent = '';
|
|
165
|
+
} finally {
|
|
166
|
+
linearLoading = false;
|
|
167
|
+
refreshBtn.classList.remove('spinning');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function renderIssues(issues) {
|
|
172
|
+
issuesList.innerHTML = '';
|
|
173
|
+
for (const issue of issues) {
|
|
174
|
+
const a = document.createElement('a');
|
|
175
|
+
a.className = 'linear-issue';
|
|
176
|
+
a.href = issue.url;
|
|
177
|
+
a.target = '_blank';
|
|
178
|
+
a.rel = 'noopener';
|
|
179
|
+
|
|
180
|
+
const due = formatDate(issue.dueDate);
|
|
181
|
+
const labels = (issue.labels?.nodes || [])
|
|
182
|
+
.map(l => `<span class="linear-issue-label" style="background:${l.color}22;color:${l.color}">${l.name}</span>`)
|
|
183
|
+
.join('');
|
|
184
|
+
|
|
185
|
+
a.innerHTML = `
|
|
186
|
+
<div class="linear-issue-top">
|
|
187
|
+
<span class="linear-issue-priority" style="background:${priorityColor(issue.priority)}" title="${issue.priorityLabel}"></span>
|
|
188
|
+
<span class="linear-issue-id">${issue.identifier}</span>
|
|
189
|
+
<span class="linear-issue-title">${escapeHtml(issue.title)}</span>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="linear-issue-meta">
|
|
192
|
+
<span class="linear-issue-state">
|
|
193
|
+
<span class="linear-issue-state-dot" style="background:${issue.state?.color || 'var(--text-dim)'}"></span>
|
|
194
|
+
${escapeHtml(issue.state?.name || '')}
|
|
195
|
+
</span>
|
|
196
|
+
${due ? `<span class="linear-issue-due">Due ${due}</span>` : ''}
|
|
197
|
+
${labels}
|
|
198
|
+
</div>
|
|
199
|
+
`;
|
|
200
|
+
issuesList.appendChild(a);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Create Issue Modal (reuse from index.html) ────
|
|
205
|
+
const createModal = document.getElementById('linear-create-modal');
|
|
206
|
+
const createForm = document.getElementById('linear-create-form');
|
|
207
|
+
const createTitle = document.getElementById('linear-create-title');
|
|
208
|
+
const createDesc = document.getElementById('linear-create-desc');
|
|
209
|
+
const createTeam = document.getElementById('linear-create-team');
|
|
210
|
+
const createState = document.getElementById('linear-create-state');
|
|
211
|
+
const createClose = document.getElementById('linear-create-close');
|
|
212
|
+
const createCancel = document.getElementById('linear-create-cancel');
|
|
213
|
+
const createSubmit = document.getElementById('linear-create-submit');
|
|
214
|
+
|
|
215
|
+
function openCreateModal() {
|
|
216
|
+
if (!createModal) return;
|
|
217
|
+
createModal.classList.remove('hidden');
|
|
218
|
+
createForm.reset();
|
|
219
|
+
createState.disabled = true;
|
|
220
|
+
createState.innerHTML = '<option value="">Select a team first...</option>';
|
|
221
|
+
createSubmit.disabled = false;
|
|
222
|
+
createSubmit.textContent = 'Create';
|
|
223
|
+
createTitle.focus();
|
|
224
|
+
|
|
225
|
+
ctx.api.fetchLinearTeams().then((data) => {
|
|
226
|
+
const opts = (data.teams || [])
|
|
227
|
+
.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`)
|
|
228
|
+
.join('');
|
|
229
|
+
createTeam.innerHTML = `<option value="">Select a team...</option>${opts}`;
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function closeCreateModal() {
|
|
234
|
+
if (createModal) createModal.classList.add('hidden');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function handleTeamChange() {
|
|
238
|
+
const teamId = createTeam.value;
|
|
239
|
+
if (!teamId) {
|
|
240
|
+
createState.disabled = true;
|
|
241
|
+
createState.innerHTML = '<option value="">Select a team first...</option>';
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
createState.disabled = true;
|
|
245
|
+
createState.innerHTML = '<option value="">Loading...</option>';
|
|
246
|
+
|
|
247
|
+
ctx.api.fetchLinearTeamStates(teamId).then((data) => {
|
|
248
|
+
const states = data.states || [];
|
|
249
|
+
const opts = states
|
|
250
|
+
.map(s => `<option value="${s.id}">${escapeHtml(s.name)}</option>`)
|
|
251
|
+
.join('');
|
|
252
|
+
createState.innerHTML = `<option value="">Select state...</option>${opts}`;
|
|
253
|
+
createState.disabled = false;
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function handleCreateSubmit(e) {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
const title = createTitle.value.trim();
|
|
260
|
+
const teamId = createTeam.value;
|
|
261
|
+
if (!title || !teamId) return;
|
|
262
|
+
|
|
263
|
+
createSubmit.disabled = true;
|
|
264
|
+
createSubmit.textContent = 'Creating...';
|
|
265
|
+
|
|
266
|
+
try {
|
|
267
|
+
const result = await ctx.api.createLinearIssue({
|
|
268
|
+
title,
|
|
269
|
+
description: createDesc.value.trim() || undefined,
|
|
270
|
+
teamId,
|
|
271
|
+
stateId: createState.value || undefined,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (result.success) {
|
|
275
|
+
cachedLinear = null;
|
|
276
|
+
linearCacheTime = 0;
|
|
277
|
+
loadIssues();
|
|
278
|
+
closeCreateModal();
|
|
279
|
+
} else {
|
|
280
|
+
createSubmit.textContent = 'Failed \u2014 retry';
|
|
281
|
+
createSubmit.disabled = false;
|
|
282
|
+
}
|
|
283
|
+
} catch {
|
|
284
|
+
createSubmit.textContent = 'Failed \u2014 retry';
|
|
285
|
+
createSubmit.disabled = false;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
refreshBtn.addEventListener('click', () => loadIssues());
|
|
290
|
+
createBtn.addEventListener('click', () => openCreateModal());
|
|
291
|
+
|
|
292
|
+
if (createClose) createClose.addEventListener('click', closeCreateModal);
|
|
293
|
+
if (createCancel) createCancel.addEventListener('click', closeCreateModal);
|
|
294
|
+
if (createModal) createModal.addEventListener('click', (e) => {
|
|
295
|
+
if (e.target === createModal) closeCreateModal();
|
|
296
|
+
});
|
|
297
|
+
if (createTeam) createTeam.addEventListener('change', handleTeamChange);
|
|
298
|
+
if (createForm) createForm.addEventListener('submit', handleCreateSubmit);
|
|
299
|
+
|
|
300
|
+
// ══════════════════════════════════════════════════
|
|
301
|
+
// SETTINGS VIEW
|
|
302
|
+
// ══════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
function showStatus(msg, isError) {
|
|
305
|
+
statusEl.textContent = msg;
|
|
306
|
+
statusEl.className = `ls-status ${isError ? 'ls-error' : 'ls-success'}`;
|
|
307
|
+
setTimeout(() => { statusEl.className = 'ls-status hidden'; }, 4000);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function loadSettings() {
|
|
311
|
+
try {
|
|
312
|
+
const cfg = await ctx.api.fetchLinearConfig();
|
|
313
|
+
enabledEl.checked = cfg.enabled || false;
|
|
314
|
+
apiKeyEl.value = cfg.apiKey || '';
|
|
315
|
+
emailEl.value = cfg.assigneeEmail || '';
|
|
316
|
+
} catch {
|
|
317
|
+
showStatus('Failed to load config', true);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
saveBtn.addEventListener('click', async () => {
|
|
322
|
+
const enabled = enabledEl.checked;
|
|
323
|
+
const apiKey = apiKeyEl.value.trim();
|
|
324
|
+
const assigneeEmail = emailEl.value.trim();
|
|
325
|
+
|
|
326
|
+
if (enabled && !apiKey) {
|
|
327
|
+
showStatus('API key is required when enabled', true);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
saveBtn.disabled = true;
|
|
332
|
+
saveBtn.textContent = 'Saving...';
|
|
333
|
+
try {
|
|
334
|
+
const res = await ctx.api.saveLinearConfig({ enabled, apiKey, assigneeEmail });
|
|
335
|
+
if (res.error) throw new Error(res.error);
|
|
336
|
+
showStatus('Settings saved', false);
|
|
337
|
+
cachedLinear = null;
|
|
338
|
+
linearCacheTime = 0;
|
|
339
|
+
await loadSettings();
|
|
340
|
+
} catch (err) {
|
|
341
|
+
showStatus(`Save failed: ${err.message}`, true);
|
|
342
|
+
} finally {
|
|
343
|
+
saveBtn.disabled = false;
|
|
344
|
+
saveBtn.textContent = 'Save';
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
testBtn.addEventListener('click', async () => {
|
|
349
|
+
testBtn.disabled = true;
|
|
350
|
+
testBtn.textContent = 'Testing...';
|
|
351
|
+
try {
|
|
352
|
+
const res = await ctx.api.testLinearConnection();
|
|
353
|
+
if (!res.ok) throw new Error(res.error || 'Connection failed');
|
|
354
|
+
showStatus(`Connected as ${res.user?.name || res.user?.email || 'unknown'}`, false);
|
|
355
|
+
} catch (err) {
|
|
356
|
+
showStatus(`Test failed: ${err.message}`, true);
|
|
357
|
+
} finally {
|
|
358
|
+
testBtn.disabled = false;
|
|
359
|
+
testBtn.textContent = 'Test Connection';
|
|
360
|
+
}
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
// ── Initial load ───────────────────────────────────
|
|
364
|
+
function loadAll() {
|
|
365
|
+
if (!cachedLinear || Date.now() - linearCacheTime > CACHE_TTL) {
|
|
366
|
+
loadIssues();
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
loadAll();
|
|
371
|
+
|
|
372
|
+
root._loadAll = loadAll;
|
|
373
|
+
return root;
|
|
374
|
+
},
|
|
375
|
+
|
|
376
|
+
onActivate() {
|
|
377
|
+
const pane = document.querySelector('.right-panel-pane[data-tab="linear"] .linear-tab');
|
|
378
|
+
if (pane?._loadAll) pane._loadAll();
|
|
379
|
+
},
|
|
380
|
+
});
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
import { readFileSync, writeFileSync } from "fs";
|
|
3
|
+
import { configPath } from "../../server/paths.js";
|
|
4
|
+
|
|
5
|
+
const router = Router();
|
|
6
|
+
|
|
7
|
+
const LINEAR_API = "https://api.linear.app/graphql";
|
|
8
|
+
const CONFIG_FILE = configPath("linear-config.json");
|
|
9
|
+
|
|
10
|
+
// ── Config helpers ──────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function loadConfig() {
|
|
13
|
+
try {
|
|
14
|
+
return JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
15
|
+
} catch {
|
|
16
|
+
return { enabled: false, apiKey: "", assigneeEmail: "" };
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function saveConfig(cfg) {
|
|
21
|
+
writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + "\n");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getApiKey() {
|
|
25
|
+
const cfg = loadConfig();
|
|
26
|
+
return cfg.enabled ? cfg.apiKey : "";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getAssigneeEmail() {
|
|
30
|
+
return loadConfig().assigneeEmail || "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function maskKey(key) {
|
|
34
|
+
if (!key || key.length < 8) return key ? "****" : "";
|
|
35
|
+
return key.slice(0, 8) + "****" + key.slice(-4);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Config routes ───────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
router.get("/config", (req, res) => {
|
|
41
|
+
const cfg = loadConfig();
|
|
42
|
+
res.json({ ...cfg, apiKey: maskKey(cfg.apiKey) });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
router.put("/config", (req, res) => {
|
|
46
|
+
try {
|
|
47
|
+
const { enabled, apiKey, assigneeEmail } = req.body;
|
|
48
|
+
if (typeof enabled !== "boolean") {
|
|
49
|
+
return res.status(400).json({ error: "enabled must be a boolean" });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const existing = loadConfig();
|
|
53
|
+
const finalKey = apiKey && !apiKey.includes("****") ? apiKey : existing.apiKey;
|
|
54
|
+
|
|
55
|
+
saveConfig({
|
|
56
|
+
enabled,
|
|
57
|
+
apiKey: finalKey,
|
|
58
|
+
assigneeEmail: assigneeEmail || "",
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
res.json({ ok: true });
|
|
62
|
+
} catch (err) {
|
|
63
|
+
res.status(500).json({ error: err.message });
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
router.post("/test", async (req, res) => {
|
|
68
|
+
const cfg = loadConfig();
|
|
69
|
+
const apiKey = cfg.apiKey;
|
|
70
|
+
if (!apiKey) {
|
|
71
|
+
return res.status(400).json({ ok: false, error: "Linear API key not configured" });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const response = await fetch(LINEAR_API, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
78
|
+
body: JSON.stringify({ query: `query { viewer { id name email } }` }),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (!response.ok) {
|
|
82
|
+
const text = await response.text();
|
|
83
|
+
return res.status(response.status).json({ ok: false, error: text });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const data = await response.json();
|
|
87
|
+
if (data.errors) {
|
|
88
|
+
return res.json({ ok: false, error: data.errors[0].message });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const viewer = data.data?.viewer;
|
|
92
|
+
res.json({ ok: true, user: viewer });
|
|
93
|
+
} catch (err) {
|
|
94
|
+
res.status(500).json({ ok: false, error: err.message });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// ── GraphQL queries ─────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
const TEAMS_QUERY = `
|
|
101
|
+
query { teams { nodes { id name } } }
|
|
102
|
+
`;
|
|
103
|
+
|
|
104
|
+
const TEAM_STATES_QUERY = `
|
|
105
|
+
query($id: String!) {
|
|
106
|
+
team(id: $id) {
|
|
107
|
+
states {
|
|
108
|
+
nodes { id name type }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
const USER_BY_EMAIL_QUERY = `
|
|
115
|
+
query($email: String!) {
|
|
116
|
+
users(filter: { email: { eq: $email } }) {
|
|
117
|
+
nodes { id }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
`;
|
|
121
|
+
|
|
122
|
+
const CREATE_ISSUE_MUTATION = `
|
|
123
|
+
mutation($title: String!, $teamId: String!, $description: String, $stateId: String, $assigneeId: String) {
|
|
124
|
+
issueCreate(input: { title: $title, teamId: $teamId, description: $description, stateId: $stateId, assigneeId: $assigneeId }) {
|
|
125
|
+
success
|
|
126
|
+
issue { id identifier title url }
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
|
|
131
|
+
const ISSUES_QUERY = `
|
|
132
|
+
query MyIssues {
|
|
133
|
+
viewer {
|
|
134
|
+
assignedIssues(
|
|
135
|
+
filter: {
|
|
136
|
+
state: { type: { nin: ["completed", "canceled"] } }
|
|
137
|
+
}
|
|
138
|
+
orderBy: updatedAt
|
|
139
|
+
first: 50
|
|
140
|
+
) {
|
|
141
|
+
nodes {
|
|
142
|
+
id
|
|
143
|
+
identifier
|
|
144
|
+
title
|
|
145
|
+
url
|
|
146
|
+
priority
|
|
147
|
+
priorityLabel
|
|
148
|
+
dueDate
|
|
149
|
+
state { name type color }
|
|
150
|
+
labels { nodes { name color } }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
`;
|
|
156
|
+
|
|
157
|
+
router.get("/issues", async (req, res) => {
|
|
158
|
+
const apiKey = getApiKey();
|
|
159
|
+
if (!apiKey) {
|
|
160
|
+
return res.json({ issues: [], error: "Linear not configured" });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
try {
|
|
164
|
+
const response = await fetch(LINEAR_API, {
|
|
165
|
+
method: "POST",
|
|
166
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
167
|
+
body: JSON.stringify({ query: ISSUES_QUERY }),
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
if (!response.ok) {
|
|
171
|
+
const text = await response.text();
|
|
172
|
+
return res.status(response.status).json({ issues: [], error: text });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const data = await response.json();
|
|
176
|
+
if (data.errors) {
|
|
177
|
+
return res.json({ issues: [], error: data.errors[0].message });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const issues = data.data?.viewer?.assignedIssues?.nodes || [];
|
|
181
|
+
res.json({ issues });
|
|
182
|
+
} catch (err) {
|
|
183
|
+
console.error("Linear API error:", err.message);
|
|
184
|
+
res.status(500).json({ issues: [], error: err.message });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
router.get("/teams", async (req, res) => {
|
|
189
|
+
const apiKey = getApiKey();
|
|
190
|
+
if (!apiKey) {
|
|
191
|
+
return res.json({ teams: [], error: "Linear not configured" });
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const response = await fetch(LINEAR_API, {
|
|
196
|
+
method: "POST",
|
|
197
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
198
|
+
body: JSON.stringify({ query: TEAMS_QUERY }),
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (!response.ok) {
|
|
202
|
+
const text = await response.text();
|
|
203
|
+
return res.status(response.status).json({ teams: [], error: text });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const data = await response.json();
|
|
207
|
+
if (data.errors) {
|
|
208
|
+
return res.json({ teams: [], error: data.errors[0].message });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const teams = data.data?.teams?.nodes || [];
|
|
212
|
+
res.json({ teams });
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error("Linear API error:", err.message);
|
|
215
|
+
res.status(500).json({ teams: [], error: err.message });
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
router.get("/teams/:teamId/states", async (req, res) => {
|
|
220
|
+
const apiKey = getApiKey();
|
|
221
|
+
if (!apiKey) {
|
|
222
|
+
return res.json({ states: [], error: "Linear not configured" });
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
try {
|
|
226
|
+
const response = await fetch(LINEAR_API, {
|
|
227
|
+
method: "POST",
|
|
228
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
229
|
+
body: JSON.stringify({
|
|
230
|
+
query: TEAM_STATES_QUERY,
|
|
231
|
+
variables: { id: req.params.teamId },
|
|
232
|
+
}),
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
if (!response.ok) {
|
|
236
|
+
const text = await response.text();
|
|
237
|
+
return res.status(response.status).json({ states: [], error: text });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const data = await response.json();
|
|
241
|
+
if (data.errors) {
|
|
242
|
+
return res.json({ states: [], error: data.errors[0].message });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const allStates = data.data?.team?.states?.nodes || [];
|
|
246
|
+
const states = allStates.filter((s) =>
|
|
247
|
+
["unstarted", "started", "completed"].includes(s.type)
|
|
248
|
+
);
|
|
249
|
+
res.json({ states });
|
|
250
|
+
} catch (err) {
|
|
251
|
+
console.error("Linear API error:", err.message);
|
|
252
|
+
res.status(500).json({ states: [], error: err.message });
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
router.post("/issues", async (req, res) => {
|
|
257
|
+
const apiKey = getApiKey();
|
|
258
|
+
if (!apiKey) {
|
|
259
|
+
return res.status(400).json({ success: false, error: "Linear not configured" });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const { title, description, teamId, stateId } = req.body;
|
|
263
|
+
if (!title || !teamId) {
|
|
264
|
+
return res.status(400).json({ success: false, error: "title and teamId are required" });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
try {
|
|
268
|
+
let assigneeId;
|
|
269
|
+
const assigneeEmail = getAssigneeEmail();
|
|
270
|
+
if (assigneeEmail) {
|
|
271
|
+
const userRes = await fetch(LINEAR_API, {
|
|
272
|
+
method: "POST",
|
|
273
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
274
|
+
body: JSON.stringify({ query: USER_BY_EMAIL_QUERY, variables: { email: assigneeEmail } }),
|
|
275
|
+
});
|
|
276
|
+
if (userRes.ok) {
|
|
277
|
+
const userData = await userRes.json();
|
|
278
|
+
assigneeId = userData.data?.users?.nodes?.[0]?.id;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const response = await fetch(LINEAR_API, {
|
|
283
|
+
method: "POST",
|
|
284
|
+
headers: { "Content-Type": "application/json", Authorization: apiKey },
|
|
285
|
+
body: JSON.stringify({
|
|
286
|
+
query: CREATE_ISSUE_MUTATION,
|
|
287
|
+
variables: { title, teamId, description: description || undefined, stateId: stateId || undefined, assigneeId: assigneeId || undefined },
|
|
288
|
+
}),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
if (!response.ok) {
|
|
292
|
+
const text = await response.text();
|
|
293
|
+
return res.status(response.status).json({ success: false, error: text });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const data = await response.json();
|
|
297
|
+
if (data.errors) {
|
|
298
|
+
return res.json({ success: false, error: data.errors[0].message });
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const result = data.data?.issueCreate;
|
|
302
|
+
res.json({
|
|
303
|
+
success: result?.success || false,
|
|
304
|
+
issue: result?.issue || null,
|
|
305
|
+
});
|
|
306
|
+
} catch (err) {
|
|
307
|
+
console.error("Linear API error:", err.message);
|
|
308
|
+
res.status(500).json({ success: false, error: err.message });
|
|
309
|
+
}
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
export default router;
|