diff --git a/packages/editor/css/main.css b/packages/editor/css/main.css
deleted file mode 100644
index cdd7782..0000000
--- a/packages/editor/css/main.css
+++ /dev/null
@@ -1,531 +0,0 @@
-/* ==========================================
- Armature — Editor Surface (v0.25.0)
- ==========================================
- Replaces editor-mode.css for the pane-based editor.
- Covers: topbar, bootstrap, and component-specific
- overrides within the editor context.
- ========================================== */
-
-/* ── Surface Shell ─────────────────────────── */
-
-.ext-editor {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
- background: var(--bg);
-}
-
-/* ── Topbar ────────────────────────────────── */
-
-.ext-editor-topbar {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: 0 var(--sp-3);
- height: 40px;
- flex-shrink: 0;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- font-size: 13px;
- position: relative;
- z-index: 20;
-}
-
-.ext-editor-topbar-back {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- color: var(--text-3);
- text-decoration: none;
- font-size: 12px;
- font-weight: 500;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--radius-sm);
- transition: color 0.15s, background 0.15s;
-}
-
-.ext-editor-topbar-back:hover {
- color: var(--text);
- background: var(--bg-hover);
-}
-
-.ext-editor-topbar-sep {
- width: 1px;
- height: 18px;
- background: var(--border);
-}
-
-.ext-editor-topbar-name {
- font-size: 13px;
- font-weight: 600;
- color: var(--text);
-}
-
-/* ── Workspace Selector ────────────────────── */
-
-.ext-editor-ws-selector {
- position: relative;
-}
-
-.ext-editor-ws-selector-btn {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- background: none;
- border: 1px solid transparent;
- color: var(--text);
- font-size: 13px;
- font-weight: 600;
- font-family: inherit;
- padding: var(--sp-1) var(--sp-2);
- border-radius: var(--radius-sm);
- cursor: pointer;
- transition: border-color 0.15s, background 0.15s;
-}
-
-.ext-editor-ws-selector-btn:hover {
- border-color: var(--border);
- background: var(--bg-hover);
-}
-
-.ext-editor-ws-dropdown {
- display: none;
- position: absolute;
- top: 100%;
- left: 0;
- margin-top: var(--sp-1);
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- min-width: 220px;
- max-height: 320px;
- overflow-y: auto;
- z-index: 1000;
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
- padding: var(--sp-1) 0;
-}
-
-.ext-editor-ws-dropdown.open { display: block; }
-
-.ext-editor-ws-list {
- max-height: 240px;
- overflow-y: auto;
-}
-
-.ext-editor-ws-dropdown-item {
- display: block;
- width: 100%;
- text-align: left;
- background: none;
- border: none;
- color: var(--text);
- font-size: 12px;
- font-family: inherit;
- padding: var(--sp-2) var(--sp-3);
- cursor: pointer;
- transition: background 0.1s;
-}
-
-.ext-editor-ws-dropdown-item:hover {
- background: var(--bg-hover);
-}
-
-.ext-editor-ws-dropdown-item.active {
- color: var(--accent);
- font-weight: 600;
-}
-
-.ext-editor-ws-dropdown-divider {
- height: 1px;
- background: var(--border);
- margin: var(--sp-1) 0;
-}
-
-.ext-editor-ws-new {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- color: var(--accent);
-}
-
-.ext-editor-topbar-branch {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- background: var(--purple-dim);
- padding: 2px var(--sp-2);
- border-radius: var(--radius-sm);
-}
-
-.ext-editor-topbar-branch-text {
- font-size: 11px;
- font-weight: 600;
- color: var(--purple);
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-/* ── Body ──────────────────────────────────── */
-
-.ext-editor-body {
- flex: 1;
- min-height: 0;
- overflow: hidden;
-}
-
-/* ── Bootstrap (no workspace) ──────────────── */
-
-.ext-editor-bootstrap {
- flex: 1;
- display: flex;
- align-items: center;
- justify-content: center;
-}
-
-.ext-editor-bootstrap-card {
- text-align: center;
- padding: var(--sp-10);
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- max-width: 360px;
-}
-
-.ext-editor-bootstrap-input {
- width: 100%;
- padding: var(--sp-2) var(--sp-3);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-size: 13px;
- font-family: inherit;
- outline: none;
- margin-bottom: var(--sp-3);
- box-sizing: border-box;
-}
-
-.ext-editor-bootstrap-input:focus {
- border-color: var(--accent);
-}
-
-.ext-editor-bootstrap-btn {
- width: 100%;
- padding: var(--sp-3) var(--sp-4);
- background: var(--accent);
- color: #fff;
- border: none;
- border-radius: var(--radius);
- font-size: 13px;
- font-weight: 600;
- font-family: inherit;
- cursor: pointer;
- transition: opacity 0.15s;
-}
-
-.ext-editor-bootstrap-btn:hover { opacity: 0.9; }
-.ext-editor-bootstrap-btn:disabled { opacity: 0.5; cursor: not-allowed; }
-
-/* Workspace list in bootstrap */
-.ext-editor-bootstrap-ws-item {
- display: flex;
- align-items: center;
- justify-content: space-between;
- width: 100%;
- padding: var(--sp-3) var(--sp-3);
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-size: 13px;
- font-family: inherit;
- cursor: pointer;
- transition: border-color 0.15s, background 0.15s;
- margin-bottom: var(--sp-2);
- text-align: left;
-}
-
-.ext-editor-bootstrap-ws-item:hover {
- border-color: var(--accent);
- background: var(--bg-hover);
-}
-
-.ext-editor-bootstrap-ws-name {
- font-weight: 600;
-}
-
-.ext-editor-bootstrap-ws-date {
- font-size: 11px;
- color: var(--text-3);
-}
-
-/* ── FileTree overrides (in editor context) ── */
-
-.ext-editor .file-tree {
- height: 100%;
- display: flex;
- flex-direction: column;
- border-right: 1px solid var(--border);
-}
-
-.ext-editor .file-tree-header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- padding: var(--sp-2) var(--sp-3);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-editor .file-tree-title {
- font-size: 11px;
- font-weight: 600;
- color: var(--text-2);
- text-transform: uppercase;
- letter-spacing: 0.4px;
- flex: 1;
-}
-
-.ext-editor .file-tree-items {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-1) 0;
-}
-
-.ext-editor .file-tree-row {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 3px var(--sp-2);
- cursor: pointer;
- font-size: 12px;
- color: var(--text-2);
- transition: background 0.1s;
-}
-
-.ext-editor .file-tree-row:hover {
- background: var(--bg-hover);
-}
-
-.ext-editor .file-tree-row.active {
- background: var(--accent-dim);
- color: var(--text);
-}
-
-.ext-editor .file-tree-arrow {
- width: 12px;
- font-size: 10px;
- color: var(--text-3);
- text-align: center;
-}
-
-.ext-editor .file-tree-icon {
- font-size: 13px;
- width: 18px;
- text-align: center;
-}
-
-.ext-editor .file-tree-name {
- flex: 1;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-/* Git status indicators */
-.ext-editor .file-tree-row.git-modified .file-tree-name { color: var(--warning); }
-.ext-editor .file-tree-row.git-added .file-tree-name { color: var(--success); }
-.ext-editor .file-tree-row.git-untracked .file-tree-name { color: var(--text-3); font-style: italic; }
-.ext-editor .file-tree-row.git-deleted .file-tree-name { color: var(--danger); text-decoration: line-through; }
-
-/* Context menu */
-.ext-editor-file-tree-ctx-menu {
- position: fixed;
- background: var(--bg-secondary);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-1) 0;
- min-width: 120px;
- z-index: 1000;
- box-shadow: 0 4px 12px rgba(0,0,0,0.4);
-}
-
-.ext-editor-file-tree-ctx-item {
- padding: var(--sp-2) var(--sp-3);
- font-size: 12px;
- color: var(--text);
- cursor: pointer;
-}
-
-.ext-editor-file-tree-ctx-item:hover {
- background: var(--bg-hover);
-}
-
-/* ── CodeEditor overrides ──────────────────── */
-
-.ext-editor .code-editor {
- height: 100%;
- display: flex;
- flex-direction: column;
-}
-
-.ext-editor .code-editor-tabs {
- display: flex;
- align-items: center;
- gap: 0;
- background: var(--bg-secondary);
- border-bottom: 1px solid var(--border);
- height: 32px;
- overflow-x: auto;
- flex-shrink: 0;
-}
-
-.ext-editor .code-editor-tabs::-webkit-scrollbar { height: 0; }
-
-.ext-editor .code-editor-tab {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- padding: 0 var(--sp-3);
- height: 100%;
- font-size: 12px;
- color: var(--text-3);
- cursor: pointer;
- border-right: 1px solid var(--border);
- transition: background 0.1s;
- white-space: nowrap;
-}
-
-.ext-editor .code-editor-tab:hover { background: var(--bg-hover); }
-.ext-editor .code-editor-tab.active { color: var(--text); background: var(--bg); }
-.ext-editor .code-editor-tab.modified .code-editor-tab-modified { color: var(--warning); }
-
-.ext-editor .code-editor-tab-icon { font-size: 12px; }
-.ext-editor .code-editor-tab-modified { font-size: 10px; color: var(--text-3); }
-
-.ext-editor .code-editor-tab-close {
- background: none;
- border: none;
- color: var(--text-3);
- font-size: 12px;
- cursor: pointer;
- padding: 0 2px;
- margin-left: var(--sp-1);
- border-radius: var(--radius-sm);
- line-height: 1;
-}
-
-.ext-editor .code-editor-tab-close:hover {
- background: var(--danger-dim);
- color: var(--danger);
-}
-
-.ext-editor .code-editor-content {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- position: relative;
-}
-
-.ext-editor .code-editor-welcome {
- height: 100%;
-}
-
-.ext-editor .code-editor-cm-wrap {
- height: 100%;
- overflow: auto;
-}
-
-.ext-editor .code-editor-cm-wrap .cm-editor {
- height: 100%;
-}
-
-.ext-editor .code-editor-statusbar {
- display: flex;
- align-items: center;
- gap: var(--sp-4);
- padding: 0 var(--sp-3);
- height: 24px;
- flex-shrink: 0;
- background: var(--bg-secondary);
- border-top: 1px solid var(--border);
- font-size: 11px;
- color: var(--text-3);
- font-family: var(--mono, 'SF Mono', monospace);
-}
-
-.ext-editor .code-editor-textarea-fallback {
- width: 100%;
- height: 100%;
- background: var(--bg);
- color: var(--text);
- border: none;
- padding: var(--sp-3);
- font-family: var(--mono, 'SF Mono', monospace);
- font-size: 13px;
- resize: none;
- outline: none;
- box-sizing: border-box;
-}
-
-/* ── Tabbed assist pane overrides ──────────── */
-
-.ext-editor .pane-tabbed {
- border-left: 1px solid var(--border);
-}
-
-/* ChatPane in editor tabbed pane */
-.ext-editor .chat-pane {
- position: absolute;
- inset: 0;
-}
-
-/* Notes in editor pane */
-.ext-editor .ext-notes-editor {
- position: absolute;
- inset: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.ext-editor .ext-notes-editor-list-view {
- flex: 1;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-.ext-editor .ext-notes-list {
- flex: 1;
- overflow-y: auto;
-}
-
-/* Compact notes toolbar for narrow pane */
-.ext-editor .ext-notes-toolbar {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-1);
- padding: var(--sp-2) var(--sp-2);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-editor .ext-notes-toolbar .sw-btn--sm {
- font-size: 11px;
- padding: 3px var(--sp-2);
-}
-
-.ext-editor .ext-notes-search-row {
- padding: var(--sp-1) var(--sp-2);
-}
-
-.ext-editor .ext-notes-filter-row {
- padding: 2px var(--sp-2) var(--sp-1);
- display: flex;
- gap: var(--sp-1);
-}
-
-.ext-editor .ext-notes-filter-select {
- font-size: 11px;
- flex: 1;
- min-width: 0;
-}
diff --git a/packages/editor/js/main.js b/packages/editor/js/main.js
deleted file mode 100644
index 79a8594..0000000
--- a/packages/editor/js/main.js
+++ /dev/null
@@ -1,473 +0,0 @@
-// ==========================================
-// Armature — Editor Package (v0.31.0)
-// ==========================================
-// Installable .pkg that provides the code editor surface.
-// Mounts into #extension-mount (surface-extension template).
-//
-// Uses Component.mount() for all sub-components — single source
-// of truth for DOM structure. No duplicated template partials.
-//
-// Dependencies (loaded by base.html):
-// FileTree, CodeEditor, ChatPane, NotePanel, note-graph, PaneContainer
-// API, UI, App, sw, esc (ui-primitives)
-//
-// Dynamically loaded:
-// codemirror.bundle.js
-// ==========================================
-
-(function () {
- 'use strict';
-
- const SURFACE_ID = 'editor';
- const PREFIX = 'ed';
- const NOTES_PREFIX = 'edNotes';
- const STATE_DEBOUNCE_MS = 2000;
-
- if (window.__SURFACE__ !== SURFACE_ID) return;
-
- const base = window.__BASE__ || '';
-
- // ── Dynamic Script Loader ────────────────────
-
- function _loadScript(src) {
- return new Promise((resolve, reject) => {
- if (document.querySelector('script[src*="' + src.split('?')[0] + '"]')) {
- resolve();
- return;
- }
- const s = document.createElement('script');
- s.src = base + src;
- s.type = 'module';
- s.onload = resolve;
- s.onerror = () => { resolve(); }; // Non-fatal
- document.head.appendChild(s);
- });
- }
-
- // ── Init ─────────────────────────────────────
-
- async function _init() {
- const mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- // Hide server-rendered user menu (we mount our own in the topbar)
- const serverMenu = document.getElementById('userMenuWrap');
- if (serverMenu) serverMenu.style.display = 'none';
-
- // Wrap in surface container for CSS scoping
- const surface = document.createElement('div');
- surface.className = 'ext-editor';
- surface.id = 'editorSurface';
- mount.appendChild(surface);
-
- // Read workspace ID from query param
- const params = new URL(window.location.href).searchParams;
- const wsId = params.get('ws') || '';
-
- // Load workspace name
- let wsName = 'Editor';
- if (wsId && typeof API !== 'undefined') {
- try {
- const ws = await API._get('/api/v1/workspaces/' + wsId);
- wsName = ws?.name || ws?.data?.name || wsName;
- } catch (_) {}
- }
-
- // Build topbar
- const topbar = _buildTopbar(wsName);
- surface.appendChild(topbar);
-
- // Mount user menu via SDK (flyout drops down from topbar)
- if (typeof sw !== 'undefined' && sw.userMenu) {
- sw.userMenu(topbar, { flyout: 'down' });
- }
-
- // Build body + bootstrap
- const body = document.createElement('div');
- body.className = 'ext-editor-body';
- body.id = 'editorBody';
- surface.appendChild(body);
-
- const bootstrap = _buildBootstrap();
- surface.appendChild(bootstrap);
-
- // Load dynamic dependencies (codemirror only — ChatPane, NotePanel, note-graph are platform scripts in base.html)
- const ver = window.__VERSION__ || '';
- const verQ = ver ? '?v=' + ver : '';
- await _loadScript('/vendor/codemirror/codemirror.bundle.js' + verQ);
-
- _initWsSelector(wsId);
-
- if (!wsId) {
- body.style.display = 'none';
- bootstrap.style.display = '';
- _loadBootstrapList();
- _initBootstrapCreate();
- return;
- }
-
- _mountEditor(wsId, wsName);
- }
-
- // ── Topbar ──────────────────────────────────
-
- function _buildTopbar(wsName) {
- const el = document.createElement('div');
- el.className = 'ext-editor-topbar';
- el.id = 'editorTopbar';
- el.innerHTML =
- '' +
- '' +
- 'Back' +
- '' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
' +
- '' +
- '' +
- 'main' +
- '
' +
- '' +
- '';
- return el;
- }
-
- // ── Bootstrap (no workspace) ────────────────
-
- function _buildBootstrap() {
- const el = document.createElement('div');
- el.className = 'ext-editor-bootstrap';
- el.id = 'editorBootstrap';
- el.style.display = 'none';
- el.innerHTML =
- '' +
- '
' +
- '
Open a Workspace
' +
- '
' +
- '
Loading workspaces\u2026
' +
- '
' +
- '
' +
- '
' +
- '
or create new' +
- '
' +
- '
' +
- '
' +
- '
' +
- '
';
- return el;
- }
-
- // ── Workspace Selector ──────────────────────
-
- function _initWsSelector(currentWsId) {
- const btn = document.getElementById('editorWsSelectorBtn');
- const dropdown = document.getElementById('editorWsDropdown');
- if (!btn || !dropdown) return;
-
- btn.addEventListener('click', (e) => {
- e.stopPropagation();
- if (dropdown.classList.toggle('open')) _loadWsDropdown(currentWsId);
- });
- document.addEventListener('click', (e) => {
- if (!e.target.closest('#editorWsSelector')) dropdown.classList.remove('open');
- });
- document.getElementById('editorWsNewBtn')?.addEventListener('click', async () => {
- dropdown.classList.remove('open');
- const name = window.prompt('Workspace name:');
- if (!name) return;
- try {
- const userId = sw.user?.id;
- if (!userId) throw new Error('Not authenticated');
- const resp = await API.createWorkspace({ name: name.trim(), owner_type: 'user', owner_id: userId });
- const newId = resp.id || resp.data?.id;
- if (newId) window.location.href = base + '/s/editor?ws=' + newId;
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- });
- }
-
- async function _loadWsDropdown(currentWsId) {
- const listEl = document.getElementById('editorWsList');
- if (!listEl) return;
- listEl.innerHTML = 'Loading\u2026
';
- try {
- const resp = await API._get('/api/v1/workspaces');
- const workspaces = resp.data || resp || [];
- listEl.innerHTML = '';
- if (!workspaces.length) {
- listEl.innerHTML = 'No workspaces
';
- return;
- }
- workspaces.forEach(ws => {
- const item = document.createElement('button');
- item.className = 'ext-editor-ws-dropdown-item' + (ws.id === currentWsId ? ' active' : '');
- item.textContent = ws.name || ws.id?.slice(0, 8);
- item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
- listEl.appendChild(item);
- });
- } catch (_) {
- listEl.innerHTML = 'Failed to load
';
- }
- }
-
- // ── Bootstrap ────────────────────────────────
-
- async function _loadBootstrapList() {
- const listEl = document.getElementById('editorBootstrapList');
- if (!listEl) return;
- try {
- const resp = await API._get('/api/v1/workspaces');
- const workspaces = resp.data || resp || [];
- if (!workspaces.length) {
- listEl.innerHTML = 'No workspaces yet
';
- return;
- }
- listEl.innerHTML = '';
- workspaces.forEach(ws => {
- const item = document.createElement('button');
- item.className = 'ext-editor-bootstrap-ws-item';
- item.innerHTML =
- '' + esc(ws.name || ws.id?.slice(0, 8)) + '' +
- '' + esc(ws.created_at ? new Date(ws.created_at).toLocaleDateString() : '') + '';
- item.addEventListener('click', () => { window.location.href = base + '/s/editor?ws=' + ws.id; });
- listEl.appendChild(item);
- });
- } catch (_) {
- listEl.innerHTML = 'Failed to load workspaces
';
- }
- }
-
- function _initBootstrapCreate() {
- const btn = document.getElementById('editorBootstrapBtn');
- const input = document.getElementById('editorBootstrapName');
- if (!btn || !input) return;
- btn.addEventListener('click', async () => {
- const name = input.value.trim() || 'workspace';
- btn.disabled = true;
- btn.textContent = 'Creating\u2026';
- try {
- const userId = sw.user?.id;
- if (!userId) throw new Error('Not authenticated');
- const resp = await API.createWorkspace({ name, owner_type: 'user', owner_id: userId });
- const newId = resp.id || resp.data?.id;
- if (!newId) throw new Error('No workspace ID returned');
- window.location.href = base + '/s/editor?ws=' + newId;
- } catch (e) {
- btn.disabled = false;
- btn.textContent = 'Create Workspace';
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- });
- }
-
- // ── Mount Pane Layout ───────────────────────
-
- function _mountEditor(wsId, wsName) {
- const body = document.getElementById('editorBody');
- if (!body) return;
-
- // ── Layout via SDK ──
- const layout = sw.layout(body, 'editor', { workspaceId: wsId });
- if (!layout) return;
-
- const filesPaneEl = layout._panes.get('files')?.el;
- const editorPaneEl = layout._panes.get('editor')?.el;
- const assistPaneInfo = layout._panes.get('assist');
-
- // ── FileTree via SDK ──
- let fileTree;
- if (filesPaneEl) {
- fileTree = sw.fileTree(filesPaneEl, {
- id: PREFIX, workspaceId: wsId,
- onSelect: (path) => { codeEditor?.openFile(path); _saveState(wsId, codeEditor); },
- onDelete: (path) => _deleteFile(wsId, path, fileTree, codeEditor),
- onNewFile: () => _createNewFile(wsId, fileTree),
- });
- layout._panes.get('files').component = fileTree;
- }
-
- // ── CodeEditor via SDK ──
- let codeEditor;
- if (editorPaneEl) {
- codeEditor = sw.codeEditor(editorPaneEl, {
- id: PREFIX, workspaceId: wsId,
- onSave: () => fileTree?.refresh(),
- onActivate: (path) => { fileTree?.setActiveFile(path); _saveState(wsId, codeEditor); },
- });
- layout._panes.get('editor').component = codeEditor;
- }
-
- // ── Assist pane (tabbed: chat + notes) via SDK ──
- if (assistPaneInfo?.tabs) {
- // Chat tab — standalone mode handles everything (streaming, model selector, history)
- const chatPanel = assistPaneInfo.getTabPanel('chat');
- if (chatPanel) {
- const chatPane = sw.chat(chatPanel, {
- id: PREFIX,
- standalone: true,
- getContext: () => _getFileContext(codeEditor),
- });
- if (chatPane) {
- const chatTab = assistPaneInfo.tabs.find(t => t.id === 'chat');
- if (chatTab) chatTab.instance = chatPane;
- }
- }
-
- // Notes tab
- const notesPanel = assistPaneInfo.getTabPanel('notes');
- if (notesPanel) {
- const notePanel = sw.notes(notesPanel, { projectId: null });
- if (notePanel) {
- const notesTab = assistPaneInfo.tabs.find(t => t.id === 'notes');
- if (notesTab) {
- notesTab.instance = notePanel;
- const _loadNotes = () => {
- if (!notesTab._loaded) {
- notesTab._loaded = true;
- notePanel.loadNotesList();
- notePanel.loadNoteFolders();
- }
- };
- notesTab.btn.addEventListener('click', _loadNotes);
- if (notesTab._activated) _loadNotes();
- }
- }
- }
- }
-
- // Git branch
- _refreshGitBranch(wsId, codeEditor);
-
- // Toolbar
- document.getElementById('editorRefreshBtn')?.addEventListener('click', () => {
- fileTree?.refresh();
- _refreshGitBranch(wsId, codeEditor);
- });
-
- // Initial load
- fileTree?.refresh();
- _restoreState(wsId, codeEditor);
-
- console.log('[EditorPkg] Mounted for workspace', wsId);
- }
-
- // ── File Operations ─────────────────────────
-
- async function _deleteFile(wsId, path, fileTree, codeEditor) {
- const ok = typeof sw !== 'undefined' && sw.confirm
- ? await sw.confirm('Delete ' + path + '?', { destructive: true })
- : window.confirm('Delete ' + path + '?');
- if (!ok) return;
- try {
- await API.deleteWorkspaceFile(wsId, path);
- if (codeEditor?.getOpenFiles().includes(path)) await codeEditor.closeFile(path);
- fileTree?.refresh();
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Delete failed: ' + e.message, 'error');
- }
- }
-
- async function _createNewFile(wsId, fileTree) {
- const name = window.prompt('File name (e.g. src/main.go):');
- if (!name) return;
- try {
- await API.writeWorkspaceFile(wsId, name.trim(), '');
- fileTree?.refresh();
- if (typeof UI !== 'undefined') UI.toast('Created ' + name.trim(), 'success');
- } catch (e) {
- if (typeof UI !== 'undefined') UI.toast('Failed: ' + e.message, 'error');
- }
- }
-
- async function _refreshGitBranch(wsId, codeEditor) {
- try {
- const resp = await API.getWorkspaceGitBranches(wsId);
- const branch = resp.current || null;
- if (branch) {
- const badge = document.getElementById('editorBranchBadge');
- const name = document.getElementById('editorBranchName');
- if (badge) badge.style.display = '';
- if (name) name.textContent = branch;
- }
- if (codeEditor) codeEditor.setBranch(branch);
- } catch (_) {}
- }
-
- // ── File Context (editor-specific, passed to ChatPane via getContext) ──
-
- function _getFileContext(editor) {
- if (!editor) return null;
- try {
- const openFiles = editor.getOpenFiles();
- if (!openFiles.length) return null;
- const activeTab = document.querySelector('.code-editor-tab.active');
- const path = activeTab?.dataset?.path || openFiles[0];
- const inst = CodeEditor._instances?.get(PREFIX);
- if (inst?._files) {
- const file = inst._files.get(path);
- if (file?.view) return { path, content: file.view.state.doc.toString().slice(0, 4000) };
- }
- } catch (_) {}
- return null;
- }
-
- // ── State Persistence (localStorage) ─────────
-
- const STATE_KEY_PREFIX = 'sb:editor:state:';
- let _stateSaveTimer = null;
-
- function _stateKey(wsId) {
- return STATE_KEY_PREFIX + (sw?.user?.id || '') + ':' + wsId;
- }
-
- function _restoreState(wsId, codeEditor) {
- if (!wsId) return;
- try {
- const raw = localStorage.getItem(_stateKey(wsId));
- if (!raw) return;
- const state = JSON.parse(raw);
- if (state.open_tabs && Array.isArray(state.open_tabs)) {
- for (const path of state.open_tabs) codeEditor.openFile(path);
- if (state.active_tab) codeEditor.activateFile(state.active_tab);
- }
- } catch (_) {}
- }
-
- function _saveState(wsId, codeEditor) {
- if (_stateSaveTimer) clearTimeout(_stateSaveTimer);
- _stateSaveTimer = setTimeout(() => {
- if (!wsId) return;
- try {
- const openFiles = codeEditor.getOpenFiles();
- const activeTab = document.querySelector('.code-editor-tab.active');
- localStorage.setItem(_stateKey(wsId), JSON.stringify({
- open_tabs: openFiles,
- active_tab: activeTab?.dataset?.path || '',
- updated_at: new Date().toISOString(),
- }));
- } catch (_) {}
- }, STATE_DEBOUNCE_MS);
- }
-
- // ── Boot ─────────────────────────────────────
-
- document.addEventListener('DOMContentLoaded', _init);
-})();
diff --git a/packages/editor/manifest.json b/packages/editor/manifest.json
deleted file mode 100644
index a641068..0000000
--- a/packages/editor/manifest.json
+++ /dev/null
@@ -1,19 +0,0 @@
-{
- "id": "editor",
- "title": "Editor",
- "type": "full",
- "version": "0.31.0",
- "tier": "browser",
- "author": "Armature",
- "icon": "✏️",
- "description": "Code editor with workspace management, file tree, and AI assist (requires legacy sw.* SDK — dormant until rewritten)",
- "requires": ["legacy-sdk"],
- "route": "/s/editor",
- "layout": "editor",
- "permissions": [],
- "settings": [
- { "key": "font_size", "label": "Font Size", "type": "number", "default": 13 },
- { "key": "tab_size", "label": "Tab Size", "type": "number", "default": 4 },
- { "key": "word_wrap", "label": "Word Wrap", "type": "boolean", "default": false }
- ]
-}
diff --git a/packages/git-board/css/main.css b/packages/git-board/css/main.css
deleted file mode 100644
index 4fa0cfa..0000000
--- a/packages/git-board/css/main.css
+++ /dev/null
@@ -1,450 +0,0 @@
-/* Git Board — Surface Styles */
-
-.ext-git-board-shell {
- display: flex;
- flex-direction: column;
- height: 100%;
- overflow: hidden;
-}
-
-/* ── Header ──────────────────────────────── */
-
-.ext-git-board-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-4);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-
-.ext-git-board-header__left,
-.ext-git-board-header__right {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
-}
-
-.ext-git-board-title {
- font-size: 18px;
- font-weight: 600;
- color: var(--text);
- margin: 0;
-}
-
-.ext-git-board-repo-picker {
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-family: var(--mono);
- font-size: 13px;
- padding: 5px var(--sp-2);
- max-width: 260px;
-}
-
-/* ── Connection Setup ─────────────────────── */
-
-.ext-git-board-setup {
- max-width: 480px;
- margin: 60px auto;
- text-align: center;
- padding: 0 var(--sp-4);
-}
-
-.ext-git-board-setup h2 {
- color: var(--text);
- font-size: 20px;
- margin: 0 0 var(--sp-2);
-}
-
-.ext-git-board-setup p {
- color: var(--text-2);
- font-size: 14px;
- line-height: 1.5;
- margin: 0 0 var(--sp-5);
-}
-
-
-.ext-git-board-setup__hint {
- font-size: 12px;
- color: var(--text-3);
-}
-
-/* ── Kanban Board ────────────────────────── */
-
-.ext-git-board-board {
- display: flex;
- gap: var(--sp-3);
- padding: var(--sp-3) var(--sp-4);
- flex: 1;
- overflow-x: auto;
- overflow-y: hidden;
-}
-
-.ext-git-board-column {
- min-width: 260px;
- max-width: 320px;
- flex: 1;
- display: flex;
- flex-direction: column;
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- overflow: hidden;
-}
-
-.ext-git-board-column__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-3);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-git-board-column__title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text);
- text-transform: uppercase;
- letter-spacing: 0.03em;
-}
-
-.ext-git-board-column__count {
- background: var(--bg-raised);
- color: var(--text-2);
- font-size: 11px;
- font-weight: 600;
- padding: 2px 7px;
- border-radius: var(--radius-lg);
-}
-
-.ext-git-board-column__cards {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-2);
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-
-/* ── Cards ───────────────────────────────── */
-
-.ext-git-board-card {
- display: block;
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3);
- text-decoration: none;
- color: var(--text);
- transition: border-color var(--transition), background var(--transition);
- cursor: pointer;
-}
-
-.ext-git-board-card:hover {
- border-color: var(--accent);
- background: var(--bg-hover);
-}
-
-.ext-git-board-card--pr {
- border-left: 3px solid var(--accent);
-}
-
-.ext-git-board-card__header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- margin-bottom: var(--sp-1);
-}
-
-.ext-git-board-card__number {
- font-family: var(--mono);
- font-size: 12px;
- color: var(--text-3);
-}
-
-.ext-git-board-card__assignee {
- font-size: 11px;
- color: var(--accent);
- margin-left: auto;
-}
-
-.ext-git-board-card__branch {
- font-family: var(--mono);
- font-size: 11px;
- color: var(--text-3);
- margin-left: auto;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- max-width: 150px;
-}
-
-.ext-git-board-card__title {
- font-size: 13px;
- font-weight: 500;
- line-height: 1.4;
- color: var(--text);
-}
-
-.ext-git-board-card__labels {
- display: flex;
- flex-wrap: wrap;
- gap: var(--sp-1);
- margin-top: var(--sp-2);
-}
-
-.ext-git-board-card__labels .ext-git-board-badge {
- font-size: 10px;
- padding: 1px var(--sp-2);
-}
-
-.ext-git-board-card__meta {
- display: flex;
- justify-content: space-between;
- margin-top: var(--sp-2);
- font-size: 11px;
- color: var(--text-3);
-}
-
-/* ── DnD States ─────────────────────────── */
-
-.ext-git-board-card[draggable="true"] {
- cursor: grab;
- user-select: none;
-}
-.ext-git-board-card[draggable="true"]:active {
- cursor: grabbing;
- opacity: 0.6;
-}
-.ext-git-board-column--dragover {
- border-color: var(--accent);
- background: color-mix(in srgb, var(--accent) 6%, var(--bg-surface));
-}
-.ext-git-board-column--dragover .ext-git-board-column__header {
- border-bottom-color: var(--accent);
-}
-
-/* ── Empty state ─────────────────────────── */
-
-.ext-git-board-empty {
- display: flex;
- align-items: center;
- justify-content: center;
- flex: 1;
- color: var(--text-3);
- font-size: 14px;
-}
-
-/* ── Issue Detail Modal ─────────────────── */
-
-.ext-git-board-modal-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,0.5);
- z-index: 1000;
- display: flex;
- align-items: flex-start;
- justify-content: center;
- padding: var(--sp-10) var(--sp-4);
- overflow-y: auto;
-}
-
-.ext-git-board-modal {
- background: var(--bg);
- border: 1px solid var(--border);
- border-radius: var(--radius-lg);
- width: 100%;
- max-width: 680px;
- max-height: calc(100vh - 80px);
- max-height: calc(100dvh - 80px);
- display: flex;
- flex-direction: column;
- box-shadow: 0 8px 32px rgba(0,0,0,0.3);
-}
-
-.ext-git-board-modal__header {
- display: flex;
- align-items: flex-start;
- justify-content: space-between;
- padding: var(--sp-4) var(--sp-5);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-
-.ext-git-board-modal__title-row {
- display: flex;
- align-items: baseline;
- gap: var(--sp-2);
- flex: 1;
- min-width: 0;
-}
-
-.ext-git-board-modal__title {
- font-size: 18px;
- font-weight: 600;
- color: var(--text);
- margin: 0;
- word-break: break-word;
-}
-
-.ext-git-board-modal__close {
- background: none;
- border: none;
- color: var(--text-3);
- font-size: 18px;
- cursor: pointer;
- padding: 2px var(--sp-2);
- border-radius: var(--radius);
- flex-shrink: 0;
-}
-.ext-git-board-modal__close:hover {
- color: var(--text);
- background: var(--bg-hover);
-}
-
-.ext-git-board-modal__body {
- overflow-y: auto;
- padding: var(--sp-4) var(--sp-5);
- flex: 1;
- min-height: 0;
-}
-
-.ext-git-board-modal__meta {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- flex-wrap: wrap;
- margin-bottom: var(--sp-3);
- font-size: 12px;
- color: var(--text-2);
-}
-
-.ext-git-board-modal__date {
- color: var(--text-3);
-}
-
-.ext-git-board-modal__extlink {
- margin-left: auto;
- color: var(--accent);
- text-decoration: none;
- font-size: 12px;
-}
-.ext-git-board-modal__extlink:hover {
- text-decoration: underline;
-}
-
-.ext-git-board-modal__description {
- margin-bottom: var(--sp-5);
- padding-bottom: var(--sp-4);
- border-bottom: 1px solid var(--border);
-}
-
-.ext-git-board-modal__body-text {
- font-family: var(--font);
- font-size: 13px;
- line-height: 1.6;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
- margin: 0;
- background: none;
- border: none;
- padding: 0;
-}
-
-.ext-git-board-modal__empty {
- color: var(--text-3);
- font-size: 13px;
- font-style: italic;
- margin: 0;
-}
-
-.ext-git-board-modal__section-title {
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2);
- text-transform: uppercase;
- letter-spacing: 0.03em;
- margin: 0 0 var(--sp-3);
-}
-
-/* ── Comments ────────────────────────────── */
-
-.ext-git-board-comment {
- padding: var(--sp-3) 0;
- border-bottom: 1px solid var(--border);
-}
-.ext-git-board-comment:last-child {
- border-bottom: none;
-}
-
-.ext-git-board-comment__header {
- display: flex;
- align-items: baseline;
- gap: var(--sp-2);
- margin-bottom: var(--sp-1);
- font-size: 12px;
-}
-.ext-git-board-comment__header strong {
- color: var(--accent);
-}
-
-.ext-git-board-comment__date {
- color: var(--text-3);
- font-size: 11px;
-}
-
-.ext-git-board-comment__body {
- font-size: 13px;
- line-height: 1.5;
- color: var(--text);
- white-space: pre-wrap;
- word-break: break-word;
-}
-
-/* ── Add Comment ─────────────────────────── */
-
-.ext-git-board-modal__add-comment {
- margin-top: var(--sp-4);
- padding-top: var(--sp-4);
- border-top: 1px solid var(--border);
-}
-
-.ext-git-board-modal__textarea {
- width: 100%;
- background: var(--bg-raised);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- color: var(--text);
- font-family: var(--font);
- font-size: 13px;
- padding: var(--sp-2) var(--sp-3);
- resize: vertical;
- outline: none;
- box-sizing: border-box;
-}
-.ext-git-board-modal__textarea:focus {
- border-color: var(--accent);
-}
-
-.ext-git-board-modal__actions {
- display: flex;
- gap: var(--sp-2);
- margin-top: var(--sp-2);
- justify-content: flex-end;
-}
-
-/* ── Badge variants ──────────────────────── */
-
-.ext-git-board-badge--green {
- background: var(--green);
- color: #fff;
-}
-.ext-git-board-badge--muted {
- background: var(--bg-raised);
- color: var(--text-3);
-}
-
-/* git-board danger button override — uses kernel .sw-btn--danger */
diff --git a/packages/git-board/js/main.js b/packages/git-board/js/main.js
deleted file mode 100644
index 15f5131..0000000
--- a/packages/git-board/js/main.js
+++ /dev/null
@@ -1,491 +0,0 @@
-/**
- * Git Board — Surface Entry Point
- *
- * Kanban board for Gitea issues and PRs.
- * Calls Starlark backend via /s/git-board/api/*.
- * Authentication via gitea-client library connections.
- *
- * Features: DnD between columns, issue detail modal with comments.
- */
-(async function () {
- 'use strict';
-
- var mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- var base = window.__BASE__ || '';
- var ver = window.__VERSION__ || '0';
- var slug = 'git-board';
-
- // ── Boot SDK ───────────────────────────────
- try {
- if (!window.preact) {
- var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
- var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
- var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
- window.preact = { h, render };
- window.hooks = hooksModule;
- window.html = htmModule.default.bind(h);
- }
- var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
- await sdk.boot();
- } catch (e) {
- mount.innerHTML = 'SDK boot failed: ' + e.message + '
';
- return;
- }
-
- var { html } = window;
- var { useState, useEffect, useCallback, useRef, useMemo } = hooks;
- var { render } = preact;
-
- var API = base + '/s/' + slug + '/api';
-
- async function api(path, opts) {
- var token = sw.auth._getToken();
- var fetchOpts = { headers: { 'Authorization': 'Bearer ' + token } };
- if (opts && opts.method) fetchOpts.method = opts.method;
- if (opts && opts.body) {
- fetchOpts.body = JSON.stringify(opts.body);
- fetchOpts.headers['Content-Type'] = 'application/json';
- }
- var resp = await fetch(API + path, fetchOpts);
- var text = await resp.text();
- try { return JSON.parse(text); } catch (_) { return { error: text }; }
- }
-
- // ── App ────────────────────────────────────
-
- function App() {
- var [owner, setOwner] = useState('');
- var [repo, setRepo] = useState('');
- var [repos, setRepos] = useState([]);
- var [board, setBoard] = useState(null);
- var [loading, setLoading] = useState(false);
- var [needsConn, setNeedsConn] = useState(false);
- var [modal, setModal] = useState(null); // {owner, repo, number}
- var menuRef = useRef(null);
-
- useEffect(function () {
- if (menuRef.current && sw.userMenu) {
- sw.userMenu(menuRef.current, { placement: 'down-left' });
- }
- }, []);
-
- useEffect(function () {
- api('/repos').then(function (d) {
- if (d.error) {
- if (d.error.indexOf('no gitea connection configured') !== -1) {
- setNeedsConn(true);
- } else { sw.toast(d.error, 'error'); }
- return;
- }
- setRepos(d.data || []);
- if (d.data && d.data.length > 0 && !owner) {
- setOwner(d.data[0].owner);
- setRepo(d.data[0].name);
- }
- });
- }, []);
-
- var loadBoard = useCallback(function () {
- if (!owner || !repo) return;
- setLoading(true);
- api('/board?owner=' + encodeURIComponent(owner) + '&repo=' + encodeURIComponent(repo))
- .then(function (d) {
- if (d.error) {
- sw.toast(d.error, 'error');
- if (d.error.indexOf('no gitea connection configured') !== -1) setNeedsConn(true);
- } else {
- setBoard(d);
- setNeedsConn(false);
- }
- setLoading(false);
- });
- }, [owner, repo]);
-
- useEffect(function () { loadBoard(); }, [loadBoard]);
-
- var onDrop = useCallback(function (issueNumber, targetCol) {
- if (!board) return;
- var issue = (board.issues || []).find(function (i) { return i.number === issueNumber; });
- if (!issue) return;
-
- var patch = {};
- if (targetCol === 'open') {
- // Move to Open: unassign + reopen
- if (issue.state === 'closed') patch.state = 'open';
- // Note: Gitea PATCH /issues doesn't support clearing assignee via empty string,
- // but we do our best — the board will re-split on refresh.
- } else if (targetCol === 'in_progress') {
- // Move to In Progress: assign to current user + ensure open
- if (issue.state === 'closed') patch.state = 'open';
- // Gitea needs assignees array — not supported by our simple update_issue.
- // For now, just reopen. User assigns via modal.
- if (issue.state === 'closed') patch.state = 'open';
- } else if (targetCol === 'done') {
- patch.state = 'closed';
- }
-
- if (!patch.state) return; // no meaningful change
-
- // Optimistic update
- var newIssues = (board.issues || []).map(function (i) {
- if (i.number !== issueNumber) return i;
- var copy = {};
- for (var k in i) copy[k] = i[k];
- if (patch.state) copy.state = patch.state;
- return copy;
- });
- setBoard({ issues: newIssues, pull_requests: board.pull_requests || [] });
-
- api('/issue/' + owner + '/' + repo + '/' + issueNumber, {
- method: 'POST', body: patch
- }).then(function (d) {
- if (d.error) {
- sw.toast('Update failed: ' + d.error, 'error');
- loadBoard(); // revert
- }
- });
- }, [board, owner, repo, loadBoard]);
-
- var openModal = useCallback(function (number) {
- setModal({ owner: owner, repo: repo, number: number });
- }, [owner, repo]);
-
- var closeModal = useCallback(function (changed) {
- setModal(null);
- if (changed) loadBoard();
- }, [loadBoard]);
-
- return html`
-
- ${needsConn ? html`<${ConnectionSetup} />` : html`
-
-
- ${board ? html`<${Board} data=${board} onDrop=${onDrop} onCardClick=${openModal} />` : html`
-
${loading ? 'Loading…' : 'Select a repository'}
- `}
-
- `}
- ${modal && html`<${IssueModal} ...${modal} onClose=${closeModal} />`}
- `;
- }
-
- // ── Connection Setup ─────────────────────────
-
- function ConnectionSetup() {
- return html`
-
-
-
-
Connect to Gitea
-
Git Board requires a Gitea connection to fetch repositories, issues, and pull requests.
-
Ask your admin to add a Gitea connection in
- Admin → Connections, or add a personal one in
- Settings → Connections.
-
-
Connections are managed centrally — no API tokens in extension settings.
-
-
- `;
- }
-
- // ── Repo Picker ────────────────────────────
-
- function RepoPicker({ repos, owner, repo, onSelect }) {
- var current = owner + '/' + repo;
- return html`
-
- `;
- }
-
- // ── Kanban Board with DnD ─────────────────
-
- function Board({ data, onDrop, onCardClick }) {
- var issues = data.issues || [];
- var prs = data.pull_requests || [];
-
- var openUnassigned = issues.filter(function (i) { return i.state === 'open' && !i.assignee; });
- var inProgress = issues.filter(function (i) { return i.state === 'open' && !!i.assignee; });
-
- return html`
-
- <${Column} id="open" title="Open" count=${openUnassigned.length} onDrop=${onDrop}>
- ${openUnassigned.map(function (i) {
- return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
- })}
- />
- <${Column} id="in_progress" title="In Progress" count=${inProgress.length} onDrop=${onDrop}>
- ${inProgress.map(function (i) {
- return html`<${IssueCard} key=${i.number} issue=${i} onClick=${onCardClick} />`;
- })}
- />
- <${Column} id="done" title="Done" count=${0} onDrop=${onDrop}>
- />
- <${Column} id="prs" title="Pull Requests" count=${prs.length}>
- ${prs.map(function (p) { return html`<${PRCard} key=${p.number} pr=${p} />`; })}
- />
-
- `;
- }
-
- function Column({ id, title, count, children, onDrop }) {
- var [over, setOver] = useState(false);
-
- var handlers = onDrop ? {
- onDragOver: function (e) { e.preventDefault(); setOver(true); },
- onDragLeave: function () { setOver(false); },
- onDrop: function (e) {
- e.preventDefault();
- setOver(false);
- var num = parseInt(e.dataTransfer.getData('text/plain'), 10);
- if (num && onDrop) onDrop(num, id);
- }
- } : {};
-
- return html`
-
-
- ${title}
- ${count || 0}
-
-
${children}
-
- `;
- }
-
- function IssueCard({ issue, onClick }) {
- return html`
-
-
-
${esc(issue.title)}
-
- ${(issue.labels || []).map(function (l) {
- return html`${esc(l)}`;
- })}
-
-
- `;
- }
-
- function PRCard({ pr }) {
- return html`
-
-
- ${esc(pr.title)}
-
- @${esc(pr.user)}
- ${timeAgo(pr.created_at)}
-
-
- `;
- }
-
- // ── Issue Detail Modal ────────────────────
-
- function IssueModal({ owner, repo, number, onClose }) {
- var [issue, setIssue] = useState(null);
- var [loading, setLoading] = useState(true);
- var [comment, setComment] = useState('');
- var [posting, setPosting] = useState(false);
- var [changed, setChanged] = useState(false);
- var bodyRef = useRef(null);
-
- useEffect(function () {
- setLoading(true);
- api('/issue/' + owner + '/' + repo + '/' + number).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); onClose(false); return; }
- setIssue(d);
- setLoading(false);
- });
- }, [owner, repo, number]);
-
- // Close on Escape
- useEffect(function () {
- var handler = function (e) { if (e.key === 'Escape') onClose(changed); };
- document.addEventListener('keydown', handler);
- return function () { document.removeEventListener('keydown', handler); };
- }, [changed]);
-
- var postComment = useCallback(function () {
- if (!comment.trim() || posting) return;
- setPosting(true);
- api('/issue/' + owner + '/' + repo + '/' + number + '/comment', {
- method: 'POST', body: { body: comment.trim() }
- }).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); }
- else {
- // Append to local comments
- setIssue(function (prev) {
- if (!prev) return prev;
- var copy = {};
- for (var k in prev) copy[k] = prev[k];
- copy.comments = (prev.comments || []).concat([d]);
- return copy;
- });
- setComment('');
- setChanged(true);
- }
- setPosting(false);
- });
- }, [comment, posting, owner, repo, number]);
-
- var toggleState = useCallback(function () {
- if (!issue) return;
- var newState = issue.state === 'open' ? 'closed' : 'open';
- api('/issue/' + owner + '/' + repo + '/' + number, {
- method: 'POST', body: { state: newState }
- }).then(function (d) {
- if (d.error) { sw.toast(d.error, 'error'); return; }
- setIssue(function (prev) {
- if (!prev) return prev;
- var copy = {};
- for (var k in prev) copy[k] = prev[k];
- copy.state = newState;
- return copy;
- });
- setChanged(true);
- });
- }, [issue, owner, repo, number]);
-
- return html`
-
-
-
-
- ${!loading && issue && html`
-
-
-
- ${issue.labels && issue.labels.length > 0 && html`
-
- ${issue.labels.map(function (l) { return html`${esc(l)}`; })}
-
- `}
-
-
- ${issue.body ? html`
${esc(issue.body)}`
- : html`
No description.
`}
-
-
-
-
-
-
- `}
-
-
- `;
- }
-
- // ── Utilities ──────────────────────────────
-
- function esc(s) {
- var el = document.createElement('span');
- el.textContent = String(s || '');
- return el.innerHTML;
- }
-
- function timeAgo(iso) {
- if (!iso) return '';
- var sec = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
- if (sec < 60) return 'now';
- var min = Math.floor(sec / 60);
- if (min < 60) return min + 'm';
- var hr = Math.floor(min / 60);
- if (hr < 24) return hr + 'h';
- return Math.floor(hr / 24) + 'd';
- }
-
- // ── Mount ──────────────────────────────────
- render(html`<${App} />`, mount);
- console.log('[git-board] Surface mounted');
-})();
diff --git a/packages/git-board/manifest.json b/packages/git-board/manifest.json
deleted file mode 100644
index d855f2a..0000000
--- a/packages/git-board/manifest.json
+++ /dev/null
@@ -1,114 +0,0 @@
-{
- "id": "git-board",
- "title": "Git Board",
- "type": "full",
- "tier": "starlark",
- "route": "/s/git-board",
- "auth": "authenticated",
- "layout": "single",
- "version": "0.2.0",
- "icon": "🔀",
- "description": "Gitea issue and PR board powered by the gitea-client library. Uses extension connections for authentication.",
- "author": "armature",
-
- "permissions": ["connections.read"],
-
- "dependencies": {
- "gitea-client": ">=1.0.0"
- },
-
- "api_routes": [
- {"method": "GET", "path": "/repos"},
- {"method": "GET", "path": "/board"},
- {"method": "GET", "path": "/issue/*"},
- {"method": "POST", "path": "/issue/*"},
- {"method": "GET", "path": "/ci/*"}
- ],
-
- "tools": [
- {
- "name": "list_issues",
- "description": "List issues for a repository. Returns titles, numbers, labels, assignees, and state.",
- "parameters": {
- "owner": {"type": "string", "required": true, "description": "Repository owner or org"},
- "repo": {"type": "string", "required": true, "description": "Repository name"},
- "state": {"type": "string", "required": false, "description": "Filter: open (default), closed, all"}
- }
- },
- {
- "name": "get_issue",
- "description": "Get full details for a single issue including body and comments.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true, "description": "Issue number"}
- }
- },
- {
- "name": "create_issue",
- "description": "Create a new issue in a repository.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "title": {"type": "string", "required": true},
- "body": {"type": "string", "required": false},
- "labels": {"type": "string", "required": false, "description": "Comma-separated label names"}
- }
- },
- {
- "name": "update_issue",
- "description": "Update an existing issue (title, body, state, labels).",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true},
- "title": {"type": "string", "required": false},
- "body": {"type": "string", "required": false},
- "state": {"type": "string", "required": false, "description": "open or closed"}
- }
- },
- {
- "name": "add_comment",
- "description": "Add a comment to an issue or pull request.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "number": {"type": "number", "required": true},
- "body": {"type": "string", "required": true}
- }
- },
- {
- "name": "list_pull_requests",
- "description": "List pull requests for a repository with status, branch, and review state.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "state": {"type": "string", "required": false, "description": "open (default), closed, all"}
- }
- },
- {
- "name": "get_ci_status",
- "description": "Get CI/CD job status for a commit or branch reference.",
- "parameters": {
- "owner": {"type": "string", "required": true},
- "repo": {"type": "string", "required": true},
- "ref": {"type": "string", "required": true, "description": "Branch name, tag, or commit SHA"}
- }
- }
- ],
-
- "settings": {
- "default_owner": {
- "type": "string",
- "label": "Default Owner/Org",
- "description": "Default repository owner for the board view.",
- "default": ""
- },
- "default_repo": {
- "type": "string",
- "label": "Default Repository",
- "description": "Default repository name for the board view.",
- "default": ""
- }
- }
-}
diff --git a/packages/git-board/script.star b/packages/git-board/script.star
deleted file mode 100644
index ba66282..0000000
--- a/packages/git-board/script.star
+++ /dev/null
@@ -1,205 +0,0 @@
-# Git Board — Starlark Backend (v0.2.0)
-#
-# Library consumer — delegates all Gitea API work to gitea-client.
-#
-# Entry points:
-# on_request(req) → surface API routes
-# on_tool_call(tool_name, params) → LLM tool execution
-#
-# Modules:
-# connections — resolve gitea connection (connections.read)
-# lib — load gitea-client library
-# json — encode/decode (universal)
-# settings — user: default_owner, default_repo
-
-gitea = lib.require("gitea-client")
-
-
-def _conn():
- """Resolve the first available gitea connection."""
- return connections.get("gitea")
-
-
-# ═══════════════════════════════════════════════
-# Surface API routes (/s/git-board/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- conn = _conn()
- if conn == None:
- return _resp(400, {"error": "no gitea connection configured — add one in Settings > Connections"})
-
- if method == "GET" and path == "/repos":
- return _handle_repos(conn)
- elif method == "GET" and path == "/board":
- return _handle_board(conn, req)
- elif method == "GET" and path.startswith("/issue/"):
- return _handle_get_issue(conn, path[len("/issue/"):])
- elif method == "POST" and path.startswith("/issue/"):
- return _handle_post_issue(conn, path[len("/issue/"):], req)
- elif method == "GET" and path.startswith("/ci/"):
- return _handle_ci(conn, path[len("/ci/"):])
-
- return _resp(404, {"error": "not found"})
-
-
-def _handle_repos(conn):
- """List repositories accessible via the connection."""
- repos = gitea.get_repos(conn)
- if repos == None:
- return _resp(502, {"error": "gitea request failed"})
- return _resp(200, {"data": repos})
-
-
-def _handle_board(conn, req):
- """Combined issues + PRs for kanban board."""
- q = req.get("query", {})
- owner = _str(q.get("owner", "")) or settings.get("default_owner") or ""
- repo = _str(q.get("repo", "")) or settings.get("default_repo") or ""
- if not owner or not repo:
- return _resp(400, {"error": "owner and repo required (query params or user settings)"})
-
- issues = gitea.get_issues(conn, owner, repo, "open") or []
- prs = gitea.get_prs(conn, owner, repo, "open") or []
-
- board = {"issues": issues, "pull_requests": prs}
- return _resp(200, board)
-
-
-def _handle_get_issue(conn, issue_path):
- """Get issue detail: /issue/:owner/:repo/:number"""
- parts = issue_path.split("/", 2)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- data = gitea.get_issue(conn, owner, repo, num)
- if data == None:
- return _resp(404, {"error": "issue not found"})
- return _resp(200, data)
-
-
-def _handle_post_issue(conn, issue_path, req):
- """Update issue or add comment: /issue/:owner/:repo/:number[/comment]"""
- parts = issue_path.split("/", 3)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /issue/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- action = parts[3] if len(parts) > 3 else ""
-
- body = json.decode(req.get("body", "{}"))
-
- if action == "comment":
- text = body.get("body", "")
- if not text:
- return _resp(400, {"error": "comment body required"})
- result = gitea.add_comment(conn, owner, repo, num, text)
- if result == None:
- return _resp(502, {"error": "failed to add comment"})
- return _resp(201, result)
-
- # Default: update issue fields (state, title, body, assignee)
- title = body.get("title", "")
- issue_body = body.get("body", "")
- state = body.get("state", "")
- result = gitea.update_issue(conn, owner, repo, num, title, issue_body, state)
- if result == None:
- return _resp(502, {"error": "failed to update issue"})
- return _resp(200, result)
-
-
-def _handle_ci(conn, ref_path):
- """CI status for owner/repo/ref."""
- parts = ref_path.split("/", 2)
- if len(parts) < 3:
- return _resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
- owner, repo, ref = parts[0], parts[1], parts[2]
- result = gitea.get_ci_status(conn, owner, repo, ref)
- if result == None:
- return _resp(502, {"error": "CI status request failed"})
- return _resp(200, result)
-
-
-# ═══════════════════════════════════════════════
-# LLM Tools (called by AI during chat)
-# ═══════════════════════════════════════════════
-
-def on_tool_call(tool_name, params):
- conn = _conn()
- if conn == None:
- return {"error": "no gitea connection configured — add one in Settings > Connections"}
-
- owner = params.get("owner", "")
- repo = params.get("repo", "")
-
- if tool_name == "list_issues":
- state = params.get("state", "open")
- data = gitea.get_issues(conn, owner, repo, state)
- if data == None:
- return {"error": "failed to list issues"}
- return {"issues": data, "count": len(data)}
-
- elif tool_name == "get_issue":
- num = int(params.get("number", 0))
- data = gitea.get_issue(conn, owner, repo, num)
- if data == None:
- return {"error": "issue not found"}
- return data
-
- elif tool_name == "create_issue":
- data = gitea.create_issue(conn, owner, repo,
- params.get("title", ""),
- params.get("body", ""),
- params.get("labels", ""))
- if data == None:
- return {"error": "failed to create issue"}
- return data
-
- elif tool_name == "update_issue":
- num = int(params.get("number", 0))
- data = gitea.update_issue(conn, owner, repo, num,
- params.get("title", ""),
- params.get("body", ""),
- params.get("state", ""))
- if data == None:
- return {"error": "failed to update issue"}
- return data
-
- elif tool_name == "add_comment":
- num = int(params.get("number", 0))
- data = gitea.add_comment(conn, owner, repo, num, params.get("body", ""))
- if data == None:
- return {"error": "failed to add comment"}
- return data
-
- elif tool_name == "list_pull_requests":
- state = params.get("state", "open")
- data = gitea.get_prs(conn, owner, repo, state)
- if data == None:
- return {"error": "failed to list PRs"}
- return {"pull_requests": data, "count": len(data)}
-
- elif tool_name == "get_ci_status":
- ref = params.get("ref", "")
- data = gitea.get_ci_status(conn, owner, repo, ref)
- if data == None:
- return {"error": "failed to get CI status"}
- return data
-
- return {"error": "unknown tool: " + tool_name}
-
-
-# ═══════════════════════════════════════════════
-# Response helpers
-# ═══════════════════════════════════════════════
-
-def _resp(status, data):
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
-
-def _str(v):
- """Coerce Starlark query param to string."""
- if v == None:
- return ""
- return str(v)
diff --git a/packages/gitea-client/manifest.json b/packages/gitea-client/manifest.json
deleted file mode 100644
index 2710bb1..0000000
--- a/packages/gitea-client/manifest.json
+++ /dev/null
@@ -1,72 +0,0 @@
-{
- "id": "gitea-client",
- "title": "Gitea API Client",
- "type": "library",
- "tier": "starlark",
- "version": "1.0.1",
- "description": "Shared Gitea client — connections, API helpers, optional data caching.",
- "author": "armature",
-
- "permissions": ["api.http", "connections.read", "db.read", "db.write"],
-
- "exports": [
- "get_repos", "get_issues", "get_issue", "create_issue",
- "update_issue", "add_comment", "get_prs", "get_ci_status"
- ],
-
- "api_routes": [
- {"method": "GET", "path": "/repos"},
- {"method": "GET", "path": "/issues"},
- {"method": "GET", "path": "/issues/*"},
- {"method": "POST", "path": "/issues"},
- {"method": "GET", "path": "/prs"},
- {"method": "GET", "path": "/ci/*"},
- {"method": "POST", "path": "/sync"}
- ],
-
- "connections": [
- {
- "type": "gitea",
- "label": "Gitea Instance",
- "fields": {
- "base_url": {"type": "url", "required": "true", "label": "Server URL"},
- "api_token": {"type": "secret", "required": "true", "label": "API Token"},
- "org": {"type": "string", "required": "false", "label": "Default Org"}
- },
- "scopes": ["global", "team", "personal"]
- }
- ],
-
- "db_tables": {
- "repos": {
- "columns": {
- "connection_id": "text",
- "full_name": "text",
- "owner": "text",
- "name": "text",
- "description": "text",
- "html_url": "text",
- "open_issues": "integer",
- "synced_at": "timestamp"
- },
- "indexes": [["connection_id"]]
- },
- "issues": {
- "columns": {
- "connection_id": "text",
- "repo_full_name": "text",
- "number": "integer",
- "title": "text",
- "state": "text",
- "labels": "text",
- "assignee": "text",
- "body": "text",
- "created_at": "text",
- "synced_at": "timestamp"
- },
- "indexes": [["connection_id", "repo_full_name"]]
- }
- },
-
- "schema_version": 1
-}
diff --git a/packages/gitea-client/script.star b/packages/gitea-client/script.star
deleted file mode 100644
index cca7b3e..0000000
--- a/packages/gitea-client/script.star
+++ /dev/null
@@ -1,186 +0,0 @@
-# Gitea API Client — Library Package
-#
-# Shared Gitea client providing connection-backed API helpers.
-# Consumers call lib.require("gitea-client") and pass a conn dict
-# obtained from connections.get("gitea").
-#
-# Entry points:
-# on_request(req) — REST API routes for browser-side consumers
-# Exported functions — Starlark consumers via lib.require()
-#
-# Modules available (via library permissions):
-# http — outbound HTTP (api.http)
-# connections — credential resolution (connections.read)
-# db — ext_gitea_client_* tables (db.read, db.write)
-# json — encode/decode (universal)
-
-load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch", "auth_headers", "resp")
-load("star/repos.star", _repos_get = "get_repos")
-load("star/issues.star", _issues_get = "get_issues", _issue_get = "get_issue", _issue_create = "create_issue", _issue_update = "update_issue", _comment_add = "add_comment")
-load("star/ci.star", _ci_get = "get_ci_status")
-
-# Re-export loaded functions so lib.require() can find them in globals.
-# Starlark load() names are file-local; explicit assignment makes them global.
-get_repos = _repos_get
-get_issues = _issues_get
-get_issue = _issue_get
-create_issue = _issue_create
-update_issue = _issue_update
-add_comment = _comment_add
-get_ci_status = _ci_get
-
-
-# ═══════════════════════════════════════════════
-# REST API routes (/s/gitea-client/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- conn = _resolve_conn()
- if conn == None:
- return resp(400, {"error": "no gitea connection configured"})
-
- if method == "GET" and path == "/repos":
- repos = get_repos(conn)
- if repos == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": repos})
-
- elif method == "GET" and path == "/issues":
- q = req.get("query", {})
- owner = _str(q.get("owner", ""))
- repo = _str(q.get("repo", ""))
- state = _str(q.get("state", "")) or "open"
- if not owner or not repo:
- return resp(400, {"error": "owner and repo query params required"})
- issues = get_issues(conn, owner, repo, state)
- if issues == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": issues, "count": len(issues)})
-
- elif method == "GET" and path.startswith("/issues/"):
- parts = path[len("/issues/"):].split("/", 2)
- if len(parts) < 3:
- return resp(400, {"error": "path must be /issues/:owner/:repo/:number"})
- owner, repo, num = parts[0], parts[1], int(parts[2])
- issue = get_issue(conn, owner, repo, num)
- if issue == None:
- return resp(404, {"error": "issue not found"})
- return resp(200, issue)
-
- elif method == "POST" and path == "/issues":
- body = json.decode(req.get("body", "{}"))
- result = create_issue(conn, body.get("owner", ""), body.get("repo", ""),
- body.get("title", ""), body.get("body", ""),
- body.get("labels", ""))
- if result == None:
- return resp(502, {"error": "failed to create issue"})
- return resp(201, result)
-
- elif method == "GET" and path == "/prs":
- q = req.get("query", {})
- owner = _str(q.get("owner", ""))
- repo = _str(q.get("repo", ""))
- state = _str(q.get("state", "")) or "open"
- if not owner or not repo:
- return resp(400, {"error": "owner and repo query params required"})
- prs = get_prs(conn, owner, repo, state)
- if prs == None:
- return resp(502, {"error": "gitea request failed"})
- return resp(200, {"data": prs, "count": len(prs)})
-
- elif method == "GET" and path.startswith("/ci/"):
- parts = path[len("/ci/"):].split("/", 2)
- if len(parts) < 3:
- return resp(400, {"error": "path must be /ci/:owner/:repo/:ref"})
- owner, repo, ref = parts[0], parts[1], parts[2]
- result = get_ci_status(conn, owner, repo, ref)
- if result == None:
- return resp(502, {"error": "CI status request failed"})
- return resp(200, result)
-
- elif method == "POST" and path == "/sync":
- return _handle_sync(conn, req)
-
- return resp(404, {"error": "not found"})
-
-
-# ═══════════════════════════════════════════════
-# PR helper (not large enough for its own file)
-# ═══════════════════════════════════════════════
-
-def get_prs(conn, owner, repo, state):
- """List pull requests for a repository."""
- state = state or "open"
- path = "/repos/" + owner + "/" + repo + "/pulls?state=" + state + "&limit=50"
- data = gitea_get(conn, path)
- if data == None:
- return None
- items = []
- for p in data:
- items.append({
- "number": p.get("number", 0),
- "title": p.get("title", ""),
- "state": p.get("state", ""),
- "head": p.get("head", {}).get("ref", ""),
- "base": p.get("base", {}).get("ref", ""),
- "user": (p.get("user") or {}).get("login", ""),
- "created_at": p.get("created_at", ""),
- "html_url": p.get("html_url", ""),
- "mergeable": p.get("mergeable", None),
- })
- return items
-
-
-# ═══════════════════════════════════════════════
-# Sync handler — cache repos/issues to db tables
-# ═══════════════════════════════════════════════
-
-def _handle_sync(conn, req):
- """Sync repos and issues into local cache tables."""
- repos = get_repos(conn)
- if repos == None:
- return resp(502, {"error": "failed to fetch repos"})
-
- conn_id = conn.get("id", "default")
-
- # Clear and re-insert repos
- db.exec("DELETE FROM repos WHERE connection_id = ?", [conn_id])
- for r in repos:
- db.exec(
- "INSERT INTO repos (connection_id, full_name, owner, name, description, html_url, open_issues, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))",
- [conn_id, r["full_name"], r["owner"], r["name"], r.get("description", ""), r.get("html_url", ""), r.get("open_issues", 0)]
- )
-
- # Sync open issues for each repo
- issue_count = 0
- db.exec("DELETE FROM issues WHERE connection_id = ?", [conn_id])
- for r in repos:
- issues = get_issues(conn, r["owner"], r["name"], "open")
- if issues == None:
- continue
- for i in issues:
- labels_str = ",".join(i.get("labels", []))
- db.exec(
- "INSERT INTO issues (connection_id, repo_full_name, number, title, state, labels, assignee, body, created_at, synced_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))",
- [conn_id, r["full_name"], i["number"], i["title"], i["state"], labels_str, i.get("assignee", ""), "", i.get("created_at", "")]
- )
- issue_count += 1
-
- return resp(200, {"repos": len(repos), "issues": issue_count})
-
-
-# ═══════════════════════════════════════════════
-# Helpers
-# ═══════════════════════════════════════════════
-
-def _resolve_conn():
- """Resolve the first available gitea connection."""
- return connections.get("gitea")
-
-def _str(v):
- if v == None:
- return ""
- return str(v)
diff --git a/packages/gitea-client/star/ci.star b/packages/gitea-client/star/ci.star
deleted file mode 100644
index 6a3f110..0000000
--- a/packages/gitea-client/star/ci.star
+++ /dev/null
@@ -1,25 +0,0 @@
-# gitea-client — CI status operations
-
-load("star/http_helpers.star", "gitea_get")
-
-def get_ci_status(conn, owner, repo, ref):
- """Get CI/CD job status for a commit or branch reference."""
- path = "/repos/" + owner + "/" + repo + "/commits/" + ref + "/status"
- data = gitea_get(conn, path)
- if data == None:
- return None
- statuses = data.get("statuses", [])
- items = []
- for s in statuses:
- items.append({
- "context": s.get("context", s.get("name", "")),
- "state": s.get("state", s.get("status", "")),
- "description": s.get("description", ""),
- "target_url": s.get("target_url", ""),
- })
- return {
- "ref": ref,
- "state": data.get("state", ""),
- "statuses": items,
- "count": len(items),
- }
diff --git a/packages/gitea-client/star/http_helpers.star b/packages/gitea-client/star/http_helpers.star
deleted file mode 100644
index 09f3cf5..0000000
--- a/packages/gitea-client/star/http_helpers.star
+++ /dev/null
@@ -1,60 +0,0 @@
-# gitea-client — HTTP helpers
-#
-# Shared HTTP utilities for Gitea API calls.
-# All functions take a `conn` dict from connections.get("gitea").
-
-def auth_headers(conn):
- """Build authorization headers from a connection dict."""
- token = conn.get("api_token", "")
- if not token:
- return {}
- return {"Authorization": "token " + token}
-
-def _base(conn):
- """Return base URL with trailing slash stripped."""
- url = conn.get("base_url", "")
- if url.endswith("/"):
- url = url[:-1]
- return url
-
-def gitea_get(conn, path):
- """GET request to Gitea API. Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- resp = http.get(url=url, headers=auth_headers(conn))
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def gitea_post(conn, path, data):
- """POST request to Gitea API. Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- hdrs = dict(auth_headers(conn), **{"Content-Type": "application/json"})
- resp = http.post(url=url, body=json.encode(data), headers=hdrs)
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def gitea_patch(conn, path, data):
- """PATCH request to Gitea API (via X-HTTP-Method-Override). Returns decoded body or None."""
- url = _base(conn) + "/api/v1" + path
- hdrs = dict(auth_headers(conn), **{
- "Content-Type": "application/json",
- "X-HTTP-Method-Override": "PATCH",
- })
- resp = http.post(url=url, body=json.encode(data), headers=hdrs)
- if int(resp["status"]) >= 400:
- return None
- body = resp.get("body", "")
- if not body:
- return None
- return json.decode(body)
-
-def resp(status, data):
- """Build a standard JSON response dict."""
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
diff --git a/packages/gitea-client/star/issues.star b/packages/gitea-client/star/issues.star
deleted file mode 100644
index 1d8a509..0000000
--- a/packages/gitea-client/star/issues.star
+++ /dev/null
@@ -1,94 +0,0 @@
-# gitea-client — Issue operations
-
-load("star/http_helpers.star", "gitea_get", "gitea_post", "gitea_patch")
-
-def get_issues(conn, owner, repo, state):
- """List issues for a repository."""
- state = state or "open"
- path = "/repos/" + owner + "/" + repo + "/issues?state=" + state + "&limit=50&type=issues"
- data = gitea_get(conn, path)
- if data == None:
- return None
- items = []
- for i in data:
- labels = [l.get("name", "") for l in (i.get("labels") or [])]
- items.append({
- "number": i.get("number", 0),
- "title": i.get("title", ""),
- "state": i.get("state", ""),
- "labels": labels,
- "assignee": (i.get("assignee") or {}).get("login", ""),
- "created_at": i.get("created_at", ""),
- "updated_at": i.get("updated_at", ""),
- "html_url": i.get("html_url", ""),
- })
- return items
-
-def get_issue(conn, owner, repo, number):
- """Get full issue details including comments."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
- data = gitea_get(conn, path)
- if data == None:
- return None
- comments_data = gitea_get(conn, path + "/comments") or []
- comments = []
- for c in comments_data:
- comments.append({
- "user": (c.get("user") or {}).get("login", ""),
- "body": c.get("body", ""),
- "created_at": c.get("created_at", ""),
- })
- return {
- "number": data.get("number", 0),
- "title": data.get("title", ""),
- "body": data.get("body", ""),
- "state": data.get("state", ""),
- "labels": [l.get("name", "") for l in (data.get("labels") or [])],
- "assignee": (data.get("assignee") or {}).get("login", ""),
- "created_at": data.get("created_at", ""),
- "comments": comments,
- }
-
-def create_issue(conn, owner, repo, title, body, labels):
- """Create a new issue."""
- path = "/repos/" + owner + "/" + repo + "/issues"
- payload = {"title": title}
- if body:
- payload["body"] = body
- if labels:
- payload["labels"] = [l.strip() for l in labels.split(",") if l.strip()]
- data = gitea_post(conn, path, payload)
- if data == None:
- return None
- return {
- "number": data.get("number", 0),
- "title": data.get("title", ""),
- "html_url": data.get("html_url", ""),
- }
-
-def update_issue(conn, owner, repo, number, title, body, state):
- """Update an existing issue."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number)
- patch = {}
- if title:
- patch["title"] = title
- if body:
- patch["body"] = body
- if state:
- patch["state"] = state
- data = gitea_patch(conn, path, patch)
- if data == None:
- return None
- return {
- "number": data.get("number", 0),
- "state": data.get("state", ""),
- "title": data.get("title", ""),
- }
-
-def add_comment(conn, owner, repo, number, body):
- """Add a comment to an issue."""
- path = "/repos/" + owner + "/" + repo + "/issues/" + str(number) + "/comments"
- data = gitea_post(conn, path, {"body": body})
- if data == None:
- return None
- return {"id": data.get("id", 0), "body": data.get("body", "")}
diff --git a/packages/gitea-client/star/repos.star b/packages/gitea-client/star/repos.star
deleted file mode 100644
index 0ed4ede..0000000
--- a/packages/gitea-client/star/repos.star
+++ /dev/null
@@ -1,21 +0,0 @@
-# gitea-client — Repository operations
-
-load("star/http_helpers.star", "gitea_get")
-
-def get_repos(conn):
- """List repositories accessible via the connection."""
- data = gitea_get(conn, "/repos/search?limit=50&sort=updated")
- if data == None:
- return None
- repos = data if type(data) == "list" else data.get("data", [])
- out = []
- for r in repos:
- out.append({
- "full_name": r.get("full_name", ""),
- "name": r.get("name", ""),
- "owner": r.get("owner", {}).get("login", ""),
- "description": r.get("description", ""),
- "html_url": r.get("html_url", ""),
- "open_issues": r.get("open_issues_count", 0),
- })
- return out
diff --git a/packages/tasks/README.md b/packages/tasks/README.md
deleted file mode 100644
index 0524cb9..0000000
--- a/packages/tasks/README.md
+++ /dev/null
@@ -1,22 +0,0 @@
-# Tasks
-
-**Status: Proof of Concept**
-
-Task management surface rebuilt as a Starlark extension. Ships with core
-for SDK validation purposes, not as a permanent commitment.
-
-## Purpose
-
-Validates all three trigger primitives (event bus, webhook, cron) and
-proves the full extension surface stack: Starlark API routes, ext_data
-tables, SDK UI components, and notifications.
-
-## Graduation Criteria
-
-To become a permanent core package, tasks must meet:
-
-1. **Feature completeness** — assignees, comments, attachments, subtasks
-2. **Test coverage** — API contract tests in the ICD test runner
-3. **Performance** — tested at 1000+ tasks with acceptable load times
-4. **Design review** — UI/UX matches platform design language
-5. **No kernel special-casing** — runs purely through extension APIs
diff --git a/packages/tasks/css/main.css b/packages/tasks/css/main.css
deleted file mode 100644
index b3c4a87..0000000
--- a/packages/tasks/css/main.css
+++ /dev/null
@@ -1,280 +0,0 @@
-/* ── Tasks Surface Styles ────────────────── */
-
-.ext-tasks-app {
- max-width: 1100px;
- margin: 0 auto;
- padding: var(--sp-6) var(--sp-5);
- height: 100%;
- display: flex;
- flex-direction: column;
- overflow: hidden;
-}
-
-/* ── Header ──────────────────────────────── */
-.ext-tasks-header {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-bottom: var(--sp-5);
- flex-shrink: 0;
-}
-.ext-tasks-title {
- font-size: 20px;
- font-weight: 600;
- color: var(--text);
-}
-.ext-tasks-stats {
- font-size: 13px;
- color: var(--text-3);
-}
-.ext-tasks-header__right {
- margin-left: auto;
- display: flex;
- align-items: center;
- gap: var(--sp-3);
-}
-.ext-tasks-view-tabs {
- display: flex;
- gap: 2px;
- background: var(--bg-raised);
- border-radius: var(--radius);
- padding: 2px;
-}
-.ext-tasks-view-tab {
- padding: 5px var(--sp-4);
- font-size: 13px;
- border: none;
- background: transparent;
- color: var(--text-2);
- border-radius: calc(var(--radius) - 2px);
- cursor: pointer;
- transition: var(--transition);
-}
-.ext-tasks-view-tab:hover { color: var(--text); background: var(--bg-hover); }
-.ext-tasks-view-tab--active { color: var(--text); background: var(--bg-surface); }
-
-/* ── List View ───────────────────────────── */
-.ext-tasks-list {
- flex: 1;
- overflow-y: auto;
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-.ext-tasks-list__filters {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-bottom: var(--sp-2);
- flex-shrink: 0;
-}
-.ext-tasks-filter {
- padding: 5px var(--sp-3);
- font-size: 13px;
- background: var(--bg-raised);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: var(--radius);
-}
-.ext-tasks-list__count {
- font-size: 12px;
- color: var(--text-3);
-}
-.ext-tasks-list__loading, .ext-tasks-list__empty {
- display: flex;
- justify-content: center;
- padding: var(--sp-10) 0;
- color: var(--text-3);
- font-size: 14px;
-}
-
-/* ── Task Card ───────────────────────────── */
-.ext-tasks-card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3) var(--sp-4);
- transition: var(--transition);
-}
-.ext-tasks-card:hover {
- border-color: var(--border-light);
- background: var(--bg-raised);
-}
-.ext-tasks-card__header {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.ext-tasks-card__priority { font-size: 10px; }
-.ext-tasks-card__title {
- flex: 1;
- font-size: 14px;
- font-weight: 500;
- color: var(--text);
- cursor: pointer;
-}
-.ext-tasks-card__title:hover { color: var(--accent); }
-.ext-tasks-card__status {
- font-size: 11px;
- padding: 2px var(--sp-2);
- border-radius: var(--radius-lg);
- font-weight: 500;
-}
-.ext-tasks-card__status--todo { background: var(--accent-dim); color: var(--accent); }
-.ext-tasks-card__status--in_progress { background: var(--warning-dim); color: var(--warning); }
-.ext-tasks-card__status--done { background: var(--success-dim); color: var(--success); }
-.ext-tasks-card__status--cancelled { background: var(--bg-raised); color: var(--text-3); }
-
-.ext-tasks-card__desc {
- font-size: 13px;
- color: var(--text-2);
- margin-top: var(--sp-2);
- line-height: 1.4;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-.ext-tasks-card__footer {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- margin-top: var(--sp-2);
- font-size: 12px;
- color: var(--text-3);
-}
-.ext-tasks-card__actions {
- margin-left: auto;
- display: flex;
- gap: var(--sp-1);
- opacity: 0;
- transition: var(--transition);
-}
-.ext-tasks-card:hover .ext-tasks-card__actions { opacity: 1; }
-
-/* ── Inline buttons ──────────────────────── */
-.ext-tasks-btn {
- border: none;
- background: var(--bg-raised);
- color: var(--text-2);
- cursor: pointer;
- border-radius: var(--radius);
- transition: var(--transition);
-}
-.ext-tasks-btn--sm { padding: 2px var(--sp-2); font-size: 13px; }
-.ext-tasks-btn:hover { background: var(--bg-hover); color: var(--text); }
-.ext-tasks-btn--danger:hover { background: var(--danger-dim); color: var(--danger); }
-
-/* ── Board View ──────────────────────────── */
-.ext-tasks-board {
- flex: 1;
- display: grid;
- grid-template-columns: repeat(4, 1fr);
- gap: var(--sp-3);
- overflow-x: auto;
- overflow-y: hidden;
-}
-.ext-tasks-board__col {
- background: var(--bg-raised);
- border-radius: var(--radius);
- display: flex;
- flex-direction: column;
- min-height: 0;
-}
-.ext-tasks-board__col-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-3) var(--sp-3);
- font-size: 13px;
- font-weight: 600;
- color: var(--text-2);
- border-bottom: 1px solid var(--border);
- flex-shrink: 0;
-}
-.ext-tasks-board__col-count {
- font-size: 11px;
- color: var(--text-3);
- background: var(--bg-hover);
- padding: 1px 7px;
- border-radius: var(--radius-lg);
-}
-.ext-tasks-board__col-body {
- flex: 1;
- overflow-y: auto;
- padding: var(--sp-2);
- display: flex;
- flex-direction: column;
- gap: var(--sp-2);
-}
-.ext-tasks-board__card {
- background: var(--bg-surface);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- padding: var(--sp-3) var(--sp-3);
- cursor: pointer;
- transition: var(--transition);
-}
-.ext-tasks-board__card:hover {
- border-color: var(--accent);
-}
-.ext-tasks-board__card-title {
- font-size: 13px;
- font-weight: 500;
- color: var(--text);
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.ext-tasks-board__card-due {
- font-size: 11px;
- color: var(--text-3);
- margin-top: var(--sp-1);
-}
-
-/* ── Form ────────────────────────────────── */
-.ext-tasks-form { display: flex; flex-direction: column; gap: var(--sp-3); }
-.ext-tasks-form__row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
-.ext-tasks-form__actions { display: flex; justify-content: flex-end; gap: var(--sp-2); margin-top: var(--sp-1); }
-.ext-tasks-form textarea,
-.ext-tasks-form input,
-.ext-tasks-form select {
- width: 100%;
- padding: 7px var(--sp-3);
- font-size: 13px;
- background: var(--input-bg);
- color: var(--text);
- border: 1px solid var(--border);
- border-radius: var(--radius);
- font-family: var(--font);
-}
-.ext-tasks-form textarea:focus,
-.ext-tasks-form input:focus,
-.ext-tasks-form select:focus {
- outline: none;
- border-color: var(--accent);
-}
-
-/* ── Statusbar widget ────────────────────── */
-.ext-tasks-status-widget {
- font-size: 12px;
- color: var(--text-3);
- padding: var(--sp-1) var(--sp-3);
-}
-
-/* ── Slot containers ─────────────────────── */
-.sw-slot {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
-}
-.sw-slot--statusbar {
- padding: var(--sp-1) var(--sp-3);
- border-top: 1px solid var(--border);
- font-size: 12px;
- color: var(--text-3);
- flex-shrink: 0;
-}
-.sw-slot--toolbar {
- padding: var(--sp-1) var(--sp-3);
- flex-shrink: 0;
-}
diff --git a/packages/tasks/js/main.js b/packages/tasks/js/main.js
deleted file mode 100644
index 194f25c..0000000
--- a/packages/tasks/js/main.js
+++ /dev/null
@@ -1,397 +0,0 @@
-/**
- * Tasks — Surface Entry Point (v0.1.0)
- *
- * Task management surface using the v0.2.3 SDK:
- * sw.api.ext('tasks') — scoped API client
- * sw.ui.* — primitive components
- * sw.slots — statusbar widget
- * sw.actions — create-task action
- */
-(async function () {
- 'use strict';
-
- var mount = document.getElementById('extension-mount');
- if (!mount) return;
-
- var base = window.__BASE__ || '';
- var ver = window.__VERSION__ || '0';
-
- // ── Boot SDK ───────────────────────────────
- try {
- if (!window.preact) {
- var { h, render } = await import(base + '/js/sw/vendor/preact.module.js');
- var hooksModule = await import(base + '/js/sw/vendor/hooks.module.js');
- var htmModule = await import(base + '/js/sw/vendor/htm.module.js');
- window.preact = { h, render };
- window.hooks = hooksModule;
- window.html = htmModule.default.bind(h);
- }
- var sdk = await import(base + '/js/sw/sdk/index.js?v=' + ver);
- await sdk.boot();
- } catch (e) {
- mount.innerHTML = 'SDK boot failed: ' + e.message + '
';
- return;
- }
-
- var { html } = window;
- var { useState, useEffect, useCallback, useRef } = hooks;
- var { render } = preact;
-
- // ── SDK modules ────────────────────────────
- var api = sw.api.ext('tasks');
- var { Button, FormField, Dialog, Spinner, Dropdown, Tabs, Banner } = sw.ui;
-
- // ── Constants ──────────────────────────────
- var STATUSES = ['todo', 'in_progress', 'done', 'cancelled'];
- var PRIORITIES = ['low', 'medium', 'high', 'urgent'];
-
- var STATUS_LABELS = {
- todo: 'To Do', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled'
- };
- var PRIORITY_COLORS = {
- low: 'var(--text-3)', medium: 'var(--accent)', high: 'var(--warning)', urgent: 'var(--danger)'
- };
-
- // ═══════════════════════════════════════════
- // TaskForm — create / edit dialog
- // ═══════════════════════════════════════════
-
- function TaskForm({ task, onSave, onClose }) {
- var [title, setTitle] = useState(task ? task.title : '');
- var [desc, setDesc] = useState(task ? task.description : '');
- var [status, setStatus] = useState(task ? task.status : STATUSES[0]);
- var [priority, setPriority] = useState(task ? task.priority : 'medium');
- var [dueDate, setDueDate] = useState(task ? task.due_date : '');
- var [tags, setTags] = useState(task ? task.tags : '');
- var [saving, setSaving] = useState(false);
- var [error, setError] = useState('');
-
- async function handleSubmit(e) {
- e.preventDefault();
- if (!title.trim()) { setError('Title is required'); return; }
- setSaving(true);
- setError('');
- try {
- var data = { title: title.trim(), description: desc, status, priority, due_date: dueDate, tags };
- if (task) {
- await api.put('/items/' + task.id, data);
- } else {
- await api.post('/items', data);
- }
- onSave();
- } catch (err) {
- setError(err.message || 'Save failed');
- } finally {
- setSaving(false);
- }
- }
-
- return html`
- <${Dialog} open onClose=${onClose} title=${task ? 'Edit Task' : 'New Task'}>
-
- />
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskList — filterable task table
- // ═══════════════════════════════════════════
-
- function TaskList({ items, loading, onEdit, onRefresh }) {
- var [filter, setFilter] = useState('all');
-
- var filtered = items;
- if (filter !== 'all') {
- filtered = items.filter(function(t) { return t.status === filter; });
- }
-
- async function handleDelete(id) {
- if (!await sw.confirm('Delete this task?', { destructive: true })) return;
- await api.del('/items/' + id);
- onRefresh();
- }
-
- async function handleQuickStatus(id, newStatus) {
- await api.put('/items/' + id, { status: newStatus });
- onRefresh();
- }
-
- return html`
-
-
-
- ${filtered.length} task${filtered.length !== 1 ? 's' : ''}
-
-
- ${loading && html`
<${Spinner} size="md" />
`}
-
- ${!loading && filtered.length === 0 && html`
-
No tasks${filter !== 'all' ? ' matching filter' : ''}. Create one to get started.
- `}
-
- ${!loading && filtered.map(function(t) {
- return html`
-
-
- ${t.description && html`
-
${t.description}
- `}
-
-
- `;
- })}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskBoard — kanban columns
- // ═══════════════════════════════════════════
-
- function TaskBoard({ items, loading, onEdit, onRefresh }) {
- async function handleQuickStatus(id, newStatus) {
- await api.put('/items/' + id, { status: newStatus });
- onRefresh();
- }
-
- return html`
-
- ${loading && html`
<${Spinner} size="md" />
`}
- ${!loading && STATUSES.map(function(status) {
- var col = items.filter(function(t) { return t.status === status; });
- return html`
-
-
-
- ${col.map(function(t) {
- return html`
-
onEdit(t)}>
-
- ●
- ${t.title}
-
- ${t.due_date && html`
Due: ${t.due_date}
`}
-
- `;
- })}
-
-
- `;
- })}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // TaskApp — root component
- // ═══════════════════════════════════════════
-
- function TaskApp() {
- var [items, setItems] = useState([]);
- var [loading, setLoading] = useState(true);
- var [error, setError] = useState('');
- var [view, setView] = useState('list');
- var [editTask, setEditTask] = useState(null); // null = closed, {} = new, {id} = edit
- var [stats, setStats] = useState(null);
-
- var loadItems = useCallback(async function () {
- setLoading(true);
- try {
- var res = await api.get('/items');
- setItems(res.data || res || []);
- setError('');
- } catch (err) {
- setError(err.message || 'Failed to load tasks');
- } finally {
- setLoading(false);
- }
- }, []);
-
- var loadStats = useCallback(async function () {
- try {
- var res = await api.get('/stats');
- setStats(res);
- } catch (_) {}
- }, []);
-
- useEffect(function () {
- loadItems();
- loadStats();
- }, []);
-
- // Live updates via event bus
- useEffect(function () {
- var off = sw.on('task.*', function () {
- loadItems();
- loadStats();
- });
- return off;
- }, []);
-
- function handleSave() {
- setEditTask(null);
- loadItems();
- loadStats();
- }
-
- var tabs = [
- { id: 'list', label: 'List' },
- { id: 'board', label: 'Board' },
- ];
-
- var Topbar = sw.shell?.Topbar;
-
- return html`
-
- ${Topbar ? html`
- <${Topbar} title="Tasks">
- ${stats && html`
${stats.total || 0} total`}
-
- ${tabs.map(function(tab) {
- return html``;
- })}
-
- <${Button} variant="primary" size="sm" onClick=${() => setEditTask({})}>+ New Task/>
- />
- ` : html`
-
- `}
-
- ${error && html`<${Banner} variant="danger" text=${error} />`}
-
- ${view === 'list' && html`
- <${TaskList} items=${items} loading=${loading}
- onEdit=${setEditTask} onRefresh=${loadItems} />
- `}
-
- ${view === 'board' && html`
- <${TaskBoard} items=${items} loading=${loading}
- onEdit=${setEditTask} onRefresh=${loadItems} />
- `}
-
- ${editTask !== null && html`
- <${TaskForm} task=${editTask.id ? editTask : null}
- onSave=${handleSave}
- onClose=${() => setEditTask(null)} />
- `}
-
- `;
- }
-
-
- // ═══════════════════════════════════════════
- // Actions & Slots registration
- // ═══════════════════════════════════════════
-
- // Register create-task action
- sw.actions.register('tasks.create', {
- label: 'Create Task',
- handler: function () {
- // Emit event that the app listens to
- sw.emit('tasks.open-create', {}, { localOnly: true });
- },
- });
-
- // Statusbar widget
- function TaskStatusWidget() {
- var [count, setCount] = useState(null);
-
- useEffect(function () {
- async function load() {
- try {
- var res = await api.get('/stats');
- var open = (res.counts || {}).todo || 0;
- var inProg = (res.counts || {}).in_progress || 0;
- setCount(open + inProg);
- } catch (_) {}
- }
- load();
- var off = sw.on('task.*', load);
- return off;
- }, []);
-
- if (count === null) return null;
- return html`${count} open task${count !== 1 ? 's' : ''}`;
- }
-
- sw.slots.register('statusbar', {
- id: 'tasks-open-count',
- component: TaskStatusWidget,
- priority: 50,
- });
-
-
- // ── Mount ──────────────────────────────────
- render(html`<${TaskApp} />`, mount);
-
-})();
diff --git a/packages/tasks/manifest.json b/packages/tasks/manifest.json
deleted file mode 100644
index af1396f..0000000
--- a/packages/tasks/manifest.json
+++ /dev/null
@@ -1,74 +0,0 @@
-{
- "id": "tasks",
- "title": "Tasks",
- "type": "full",
- "tier": "starlark",
- "route": "/s/tasks",
- "auth": "authenticated",
- "layout": "single",
- "version": "0.1.0",
- "icon": "✅",
- "description": "Task management surface with lifecycle events, webhook integration, and scheduled reminders.",
- "author": "armature",
-
- "permissions": ["db.write", "notifications.send"],
-
- "api_routes": [
- {"method": "GET", "path": "/items"},
- {"method": "POST", "path": "/items"},
- {"method": "GET", "path": "/items/*"},
- {"method": "PUT", "path": "/items/*"},
- {"method": "DELETE", "path": "/items/*"},
- {"method": "GET", "path": "/stats"}
- ],
-
- "db_tables": {
- "items": {
- "columns": {
- "title": "text",
- "description": "text",
- "status": "text",
- "priority": "text",
- "assignee_id": "text",
- "creator_id": "text",
- "due_date": "text",
- "tags": "text",
- "updated_at": "text",
- "completed_at": "text"
- },
- "indexes": [
- ["status"],
- ["assignee_id"],
- ["creator_id"]
- ]
- }
- },
-
- "triggers": [
- {
- "type": "event",
- "pattern": "task.*",
- "entry_point": "on_task_event"
- },
- {
- "type": "webhook",
- "slug": "create",
- "entry_point": "on_webhook"
- }
- ],
-
- "settings": {
- "statuses": {
- "type": "string",
- "label": "Statuses",
- "description": "Comma-separated task statuses",
- "default": "todo,in_progress,done,cancelled"
- },
- "priorities": {
- "type": "string",
- "label": "Priorities",
- "description": "Comma-separated task priorities",
- "default": "low,medium,high,urgent"
- }
- }
-}
diff --git a/packages/tasks/script.star b/packages/tasks/script.star
deleted file mode 100644
index 3ea6fe7..0000000
--- a/packages/tasks/script.star
+++ /dev/null
@@ -1,263 +0,0 @@
-# Tasks — Starlark Backend (v0.1.0)
-#
-# Task management extension using ext_data + notifications + triggers.
-#
-# Entry points:
-# on_request(req) → surface API routes
-# on_task_event(event) → event trigger (task.*)
-# on_webhook(req) → webhook trigger for external task creation
-#
-# Modules: db, json, notifications, settings
-
-
-# ═══════════════════════════════════════════════
-# Helpers
-# ═══════════════════════════════════════════════
-
-def _resp(status, data):
- return {"status": status, "body": json.encode(data), "headers": {"Content-Type": "application/json"}}
-
-def _str(v):
- if v == None:
- return ""
- return str(v)
-
-def _now():
- """ISO-ish timestamp from Starlark (no time module — use db auto-created_at or pass from caller)."""
- return ""
-
-def _statuses():
- raw = settings.get("statuses")
- if not raw:
- return ["todo", "in_progress", "done", "cancelled"]
- return [s.strip() for s in raw.split(",") if s.strip()]
-
-def _priorities():
- raw = settings.get("priorities")
- if not raw:
- return ["low", "medium", "high", "urgent"]
- return [s.strip() for s in raw.split(",") if s.strip()]
-
-
-# ═══════════════════════════════════════════════
-# Surface API routes (/s/tasks/api/*)
-# ═══════════════════════════════════════════════
-
-def on_request(req):
- path = req["path"]
- method = req["method"]
-
- # GET /items — list tasks
- if method == "GET" and path == "/items":
- return _list_items(req)
-
- # POST /items — create task
- if method == "POST" and path == "/items":
- return _create_item(req)
-
- # GET /stats — aggregate counts
- if method == "GET" and path == "/stats":
- return _get_stats()
-
- # GET /items/:id
- if method == "GET" and path.startswith("/items/"):
- return _get_item(path[len("/items/"):])
-
- # PUT /items/:id
- if method == "PUT" and path.startswith("/items/"):
- return _update_item(path[len("/items/"):], req)
-
- # DELETE /items/:id
- if method == "DELETE" and path.startswith("/items/"):
- return _delete_item(path[len("/items/"):])
-
- return _resp(404, {"error": "not found"})
-
-
-def _list_items(req):
- q = req.get("query", {})
- filters = {}
-
- status = _str(q.get("status", ""))
- if status:
- filters["status"] = status
-
- assignee = _str(q.get("assignee_id", ""))
- if assignee:
- filters["assignee_id"] = assignee
-
- priority = _str(q.get("priority", ""))
- if priority:
- filters["priority"] = priority
-
- creator = _str(q.get("creator_id", ""))
- if creator:
- filters["creator_id"] = creator
-
- order = _str(q.get("order", "")) or "created_at"
- limit_str = _str(q.get("limit", ""))
- limit = int(limit_str) if limit_str else 100
-
- rows = db.query("items", filters=filters, order=order, limit=limit)
- return _resp(200, {"data": rows or []})
-
-
-def _create_item(req):
- body = json.decode(req.get("body", "{}"))
- user_id = req.get("user_id", "")
-
- title = _str(body.get("title", ""))
- if not title:
- return _resp(400, {"error": "title is required"})
-
- statuses = _statuses()
- status = _str(body.get("status", ""))
- if not status:
- status = statuses[0] if statuses else "todo"
-
- priorities = _priorities()
- priority = _str(body.get("priority", ""))
- if not priority:
- priority = priorities[0] if priorities else "medium"
-
- row = db.insert("items", {
- "title": title,
- "description": _str(body.get("description", "")),
- "status": status,
- "priority": priority,
- "assignee_id": _str(body.get("assignee_id", "")) or user_id,
- "creator_id": user_id,
- "due_date": _str(body.get("due_date", "")),
- "tags": _str(body.get("tags", "")),
- "updated_at": "",
- "completed_at":"",
- })
- return _resp(201, row)
-
-
-def _get_item(item_id):
- rows = db.query("items", filters={"id": item_id}, limit=1)
- if not rows:
- return _resp(404, {"error": "task not found"})
- return _resp(200, rows[0])
-
-
-def _update_item(item_id, req):
- body = json.decode(req.get("body", "{}"))
- user_id = req.get("user_id", "")
-
- # Fetch existing to detect transitions
- existing = db.query("items", filters={"id": item_id}, limit=1)
- if not existing:
- return _resp(404, {"error": "task not found"})
- old = existing[0]
-
- updates = {}
- for key in ["title", "description", "status", "priority", "assignee_id", "due_date", "tags"]:
- if key in body:
- updates[key] = _str(body[key])
-
- # Detect completion transition
- new_status = updates.get("status", "")
- old_status = _str(old.get("status", ""))
- if new_status == "done" and old_status != "done":
- updates["completed_at"] = "now" # sentinel — db module handles timestamp
-
- ok = db.update("items", item_id, updates)
- if not ok:
- return _resp(500, {"error": "update failed"})
-
- # Notify creator on completion if assignee is different
- if new_status == "done" and old_status != "done":
- creator = _str(old.get("creator_id", ""))
- assignee = _str(old.get("assignee_id", ""))
- if creator and assignee and creator != assignee:
- notifications.send(
- creator,
- "Task completed: " + _str(old.get("title", "")),
- body=_str(old.get("title", "")) + " was marked done by assignee.",
- )
-
- # Re-fetch updated row
- rows = db.query("items", filters={"id": item_id}, limit=1)
- return _resp(200, rows[0] if rows else {})
-
-
-def _delete_item(item_id):
- ok = db.delete("items", item_id)
- if not ok:
- return _resp(404, {"error": "task not found"})
- return _resp(200, {"deleted": True})
-
-
-def _get_stats():
- all_items = db.query("items", limit=10000)
- items = all_items or []
- counts = {}
- for s in _statuses():
- counts[s] = 0
- for item in items:
- st = _str(item.get("status", ""))
- if st in counts:
- counts[st] = counts[st] + 1
- else:
- counts[st] = 1
- return _resp(200, {"counts": counts, "total": len(items)})
-
-
-# ═══════════════════════════════════════════════
-# Event trigger handler
-# ═══════════════════════════════════════════════
-
-def on_task_event(event):
- """Handle task lifecycle events emitted by the platform."""
- label = event.get("event_label", "")
- payload = event.get("event_payload", {})
-
- if type(payload) == "string":
- payload = json.decode(payload) if payload else {}
-
- # Could be used for audit logging, metrics, etc.
- # For now just a placeholder — actual notifications happen inline in _update_item.
- pass
-
-
-# ═══════════════════════════════════════════════
-# Webhook trigger handler
-# ═══════════════════════════════════════════════
-
-def on_webhook(req):
- """
- External task creation via webhook.
- POST /api/v1/hooks/tasks/create with JSON body:
- { "title": "...", "description": "...", "priority": "...", "assignee_id": "..." }
- """
- raw_body = req.get("body", "{}")
- body = json.decode(raw_body) if raw_body else {}
-
- title = _str(body.get("title", ""))
- if not title:
- return {"status": 400, "body": json.encode({"error": "title is required"})}
-
- priorities = _priorities()
- priority = _str(body.get("priority", ""))
- if not priority:
- priority = priorities[0] if priorities else "medium"
-
- statuses = _statuses()
- status = statuses[0] if statuses else "todo"
-
- row = db.insert("items", {
- "title": title,
- "description": _str(body.get("description", "")),
- "status": status,
- "priority": priority,
- "assignee_id": _str(body.get("assignee_id", "")),
- "creator_id": "",
- "due_date": _str(body.get("due_date", "")),
- "tags": _str(body.get("tags", "")),
- "updated_at": "",
- "completed_at":"",
- })
-
- return {"status": 201, "body": json.encode(row), "headers": {"Content-Type": "application/json"}}
Comments (${(issue.comments || []).length})
- ${(issue.comments || []).length === 0 && html` -No comments yet.
- `} - ${(issue.comments || []).map(function (c, i) { - return html` -