experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(cmd): add noun registration API for declarative command generation

Extensions now declare what data they manage (nouns with capabilities)
and the cmd kernel auto-generates standard verb commands (list, open, new).
Converted 7 extensions: tags, groups, feeds, entities, scripts, pagestream, editor.

New files: noun-registry.js (pure generation), nouns.js (portable pubsub API)
Includes 31 unit tests and DEVELOPMENT.md documentation.

+1154 -243
+66
DEVELOPMENT.md
··· 166 166 export default extension; 167 167 ``` 168 168 169 + ### Noun Registration API 170 + 171 + Extensions that manage data (tags, groups, feeds, scripts, entities) should register **nouns** instead of individual verb commands. The noun system auto-generates standard verb commands (`list`, `open`, `new`) from capability declarations. 172 + 173 + **Architecture:** 174 + ``` 175 + Extension → registerNoun() (nouns.js) 176 + → publishes noun metadata via pubsub 177 + → cmd background.js receives, generates commands via noun-registry.js 178 + → commands appear in palette like any other commands 179 + → execution routes back via noun:{capability}:{name} pubsub 180 + ``` 181 + 182 + All layers use only `window.app` pubsub — zero platform-specific code. 183 + 184 + **Usage:** 185 + ```javascript 186 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 187 + 188 + registerNoun({ 189 + name: 'groups', // Plural (required) 190 + singular: 'group', // Singular (required) 191 + description: 'Saved tab groups', 192 + 193 + // Capabilities — each auto-generates verb commands 194 + query: async ({ search }) => items, // → "list groups", bare "groups" 195 + browse: async () => { /* open UI */ },// → "open groups", bare "groups" 196 + open: async ({ search }) => {}, // → "open group {search}" (needs query too) 197 + create: async ({ search }) => result, // → "new group {search}" 198 + 199 + produces: 'application/json', // MIME type for chaining 200 + params: [{type: 'tag', multiple: true}], 201 + skipBare: false, // If true, suppress bare "{name}" command 202 + }); 203 + ``` 204 + 205 + **Auto-generated commands:** 206 + 207 + | User types | Capability | Requires | 208 + |-----------|-----------|----------| 209 + | `groups` | browse (or query if no browse) | browse or query | 210 + | `list groups` | query | query | 211 + | `open groups` | browse | browse | 212 + | `open group X` | open | query + open | 213 + | `new group X` | create | create | 214 + 215 + **Key files:** 216 + - `extensions/cmd/noun-registry.js` — Pure command generation (no deps) 217 + - `extensions/cmd/nouns.js` — Registration API (uses `window.app` pubsub) 218 + - `extensions/cmd/background.js` — Noun registry + command broadcasting 219 + - `extensions/cmd/commands.js` — Noun-aware proxy execution 220 + 221 + **Converted extensions:** 222 + 223 + | Extension | Capabilities | `skipBare` | Regular commands kept | 224 + |-----------|-------------|------------|----------------------| 225 + | tags | query, browse | true | tag, tags, untag, tagset | 226 + | groups | query, browse, open, create | false | — | 227 + | feeds | query, browse, create | false | refresh feeds | 228 + | entities | query, browse | true | extract entities | 229 + | scripts | query, browse, open, create | false | — | 230 + | pagestream | browse | false | — | 231 + | editor | browse | true | — | 232 + 233 + **When NOT to use nouns:** Panel-local commands (note, edit, history, url) that run directly in the panel window and don't go through pubsub. Also, action verbs that aren't standard CRUD (e.g., `refresh feeds`, `extract entities`). 234 + 169 235 ### Editor Extension 170 236 171 237 The editor extension (`extensions/editor/`) is the canonical surface for creating, editing, and deleting items. It complements the tags extension, which focuses on browsing and tag management.
+522
backend/electron/noun-registry.test.ts
··· 1 + /** 2 + * Unit tests for noun-registry.js — pure command generation from noun definitions 3 + * 4 + * Tests generateCommandsFromNoun() and validateNounDef() logic. 5 + * These are logic-only tests — no DOM, no Electron, no extensions runtime. 6 + */ 7 + 8 + import { describe, it } from 'node:test'; 9 + import * as assert from 'node:assert'; 10 + 11 + // ============================================================================ 12 + // Inline copies of pure functions from extensions/cmd/noun-registry.js 13 + // (Same pattern as cmd-logic.test.ts — avoids ESM import issues in test runner) 14 + // ============================================================================ 15 + 16 + interface NounDef { 17 + name: string; 18 + singular: string; 19 + description?: string; 20 + source?: string; 21 + hasQuery?: boolean; 22 + hasBrowse?: boolean; 23 + hasOpen?: boolean; 24 + hasCreate?: boolean; 25 + produces?: string | null; 26 + params?: any[]; 27 + modes?: string[]; 28 + skipBare?: boolean; 29 + } 30 + 31 + interface CommandMeta { 32 + name: string; 33 + description: string; 34 + source: string; 35 + accepts: any[]; 36 + produces: any[]; 37 + params: any[]; 38 + modes: string[]; 39 + _nounName: string; 40 + _nounCapability: string; 41 + } 42 + 43 + function validateNounDef(def: Partial<NounDef>): { valid: boolean; error?: string } { 44 + if (!def.name) return { valid: false, error: 'name is required' }; 45 + if (!def.singular) return { valid: false, error: 'singular is required' }; 46 + if (!def.hasQuery && !def.hasBrowse && !def.hasOpen && !def.hasCreate) { 47 + return { valid: false, error: 'at least one capability is required' }; 48 + } 49 + return { valid: true }; 50 + } 51 + 52 + function generateCommandsFromNoun(noun: NounDef): CommandMeta[] { 53 + const commands: CommandMeta[] = []; 54 + const base = { 55 + source: noun.source || '', 56 + params: noun.params || [], 57 + modes: noun.modes || [], 58 + }; 59 + 60 + if (!noun.skipBare && (noun.hasBrowse || noun.hasQuery)) { 61 + const capability = noun.hasBrowse ? 'browse' : 'query'; 62 + commands.push({ 63 + ...base, 64 + name: noun.name, 65 + description: noun.description || `${noun.hasBrowse ? 'Open' : 'List'} ${noun.name}`, 66 + accepts: [], 67 + produces: capability === 'query' ? (noun.produces ? [noun.produces] : ['application/json']) : [], 68 + _nounName: noun.name, 69 + _nounCapability: capability, 70 + }); 71 + } 72 + 73 + if (noun.hasQuery) { 74 + commands.push({ 75 + ...base, 76 + name: `list ${noun.name}`, 77 + description: `List ${noun.name}`, 78 + accepts: [], 79 + produces: noun.produces ? [noun.produces] : ['application/json'], 80 + _nounName: noun.name, 81 + _nounCapability: 'query', 82 + }); 83 + } 84 + 85 + if (noun.hasBrowse) { 86 + commands.push({ 87 + ...base, 88 + name: `open ${noun.name}`, 89 + description: `Open ${noun.name}`, 90 + accepts: [], 91 + produces: [], 92 + _nounName: noun.name, 93 + _nounCapability: 'browse', 94 + }); 95 + } 96 + 97 + if (noun.hasQuery && noun.hasOpen) { 98 + commands.push({ 99 + ...base, 100 + name: `open ${noun.singular}`, 101 + description: `Open a ${noun.singular} by name`, 102 + accepts: [], 103 + produces: [], 104 + _nounName: noun.name, 105 + _nounCapability: 'open', 106 + }); 107 + } 108 + 109 + if (noun.hasCreate) { 110 + commands.push({ 111 + ...base, 112 + name: `new ${noun.singular}`, 113 + description: `Create a new ${noun.singular}`, 114 + accepts: [], 115 + produces: noun.produces ? [noun.produces] : [], 116 + _nounName: noun.name, 117 + _nounCapability: 'create', 118 + }); 119 + } 120 + 121 + return commands; 122 + } 123 + 124 + // ============================================================================ 125 + // validateNounDef tests 126 + // ============================================================================ 127 + 128 + describe('validateNounDef', () => { 129 + it('valid with name + singular + query', () => { 130 + const result = validateNounDef({ name: 'tags', singular: 'tag', hasQuery: true }); 131 + assert.deepStrictEqual(result, { valid: true }); 132 + }); 133 + 134 + it('valid with name + singular + browse', () => { 135 + const result = validateNounDef({ name: 'feeds', singular: 'feed', hasBrowse: true }); 136 + assert.deepStrictEqual(result, { valid: true }); 137 + }); 138 + 139 + it('valid with name + singular + create', () => { 140 + const result = validateNounDef({ name: 'notes', singular: 'note', hasCreate: true }); 141 + assert.deepStrictEqual(result, { valid: true }); 142 + }); 143 + 144 + it('invalid without name', () => { 145 + const result = validateNounDef({ singular: 'tag', hasQuery: true } as any); 146 + assert.strictEqual(result.valid, false); 147 + assert.ok(result.error?.includes('name')); 148 + }); 149 + 150 + it('invalid without singular', () => { 151 + const result = validateNounDef({ name: 'tags', hasQuery: true } as any); 152 + assert.strictEqual(result.valid, false); 153 + assert.ok(result.error?.includes('singular')); 154 + }); 155 + 156 + it('invalid without any capability', () => { 157 + const result = validateNounDef({ name: 'tags', singular: 'tag' }); 158 + assert.strictEqual(result.valid, false); 159 + assert.ok(result.error?.includes('capability')); 160 + }); 161 + }); 162 + 163 + // ============================================================================ 164 + // generateCommandsFromNoun tests 165 + // ============================================================================ 166 + 167 + describe('generateCommandsFromNoun', () => { 168 + it('generates all 5 commands for full capability noun', () => { 169 + const cmds = generateCommandsFromNoun({ 170 + name: 'groups', 171 + singular: 'group', 172 + description: 'Saved tab groups', 173 + hasQuery: true, 174 + hasBrowse: true, 175 + hasOpen: true, 176 + hasCreate: true, 177 + }); 178 + 179 + const names = cmds.map(c => c.name); 180 + assert.deepStrictEqual(names, [ 181 + 'groups', // bare (browse wins) 182 + 'list groups', 183 + 'open groups', 184 + 'open group', 185 + 'new group', 186 + ]); 187 + }); 188 + 189 + it('bare noun uses browse when both browse and query exist', () => { 190 + const cmds = generateCommandsFromNoun({ 191 + name: 'groups', 192 + singular: 'group', 193 + hasQuery: true, 194 + hasBrowse: true, 195 + }); 196 + 197 + const bare = cmds.find(c => c.name === 'groups'); 198 + assert.strictEqual(bare?._nounCapability, 'browse'); 199 + }); 200 + 201 + it('bare noun uses query when only query exists', () => { 202 + const cmds = generateCommandsFromNoun({ 203 + name: 'items', 204 + singular: 'item', 205 + hasQuery: true, 206 + }); 207 + 208 + const bare = cmds.find(c => c.name === 'items'); 209 + assert.strictEqual(bare?._nounCapability, 'query'); 210 + }); 211 + 212 + it('skipBare suppresses bare noun command but not list/open', () => { 213 + const cmds = generateCommandsFromNoun({ 214 + name: 'tags', 215 + singular: 'tag', 216 + hasQuery: true, 217 + hasBrowse: true, 218 + skipBare: true, 219 + }); 220 + 221 + const names = cmds.map(c => c.name); 222 + assert.ok(!names.includes('tags'), 'bare "tags" should be suppressed'); 223 + assert.ok(names.includes('list tags'), 'list tags should exist'); 224 + assert.ok(names.includes('open tags'), 'open tags should exist'); 225 + }); 226 + 227 + it('open singular requires both query and open', () => { 228 + // Only open, no query — should NOT generate "open group" 229 + const cmds = generateCommandsFromNoun({ 230 + name: 'groups', 231 + singular: 'group', 232 + hasOpen: true, 233 + }); 234 + const names = cmds.map(c => c.name); 235 + assert.ok(!names.includes('open group')); 236 + }); 237 + 238 + it('passes through produces, params, modes, source', () => { 239 + const cmds = generateCommandsFromNoun({ 240 + name: 'feeds', 241 + singular: 'feed', 242 + source: 'ext:feeds', 243 + hasQuery: true, 244 + produces: 'application/rss+xml', 245 + params: [{ type: 'tag', multiple: true }], 246 + modes: ['reader'], 247 + }); 248 + 249 + const listCmd = cmds.find(c => c.name === 'list feeds'); 250 + assert.ok(listCmd); 251 + assert.strictEqual(listCmd.source, 'ext:feeds'); 252 + assert.deepStrictEqual(listCmd.produces, ['application/rss+xml']); 253 + assert.deepStrictEqual(listCmd.params, [{ type: 'tag', multiple: true }]); 254 + assert.deepStrictEqual(listCmd.modes, ['reader']); 255 + }); 256 + 257 + it('no capabilities generates no commands', () => { 258 + const cmds = generateCommandsFromNoun({ 259 + name: 'empty', 260 + singular: 'empt', 261 + }); 262 + assert.strictEqual(cmds.length, 0); 263 + }); 264 + 265 + it('query-only noun generates bare + list (no open/new)', () => { 266 + const cmds = generateCommandsFromNoun({ 267 + name: 'logs', 268 + singular: 'log', 269 + hasQuery: true, 270 + }); 271 + const names = cmds.map(c => c.name); 272 + assert.deepStrictEqual(names, ['logs', 'list logs']); 273 + }); 274 + 275 + it('browse-only noun generates bare + open (no list/new)', () => { 276 + const cmds = generateCommandsFromNoun({ 277 + name: 'settings', 278 + singular: 'setting', 279 + hasBrowse: true, 280 + }); 281 + const names = cmds.map(c => c.name); 282 + assert.deepStrictEqual(names, ['settings', 'open settings']); 283 + }); 284 + 285 + it('create-only noun generates only new', () => { 286 + const cmds = generateCommandsFromNoun({ 287 + name: 'notes', 288 + singular: 'note', 289 + hasCreate: true, 290 + }); 291 + const names = cmds.map(c => c.name); 292 + assert.deepStrictEqual(names, ['new note']); 293 + }); 294 + 295 + it('all generated commands have _nounName set', () => { 296 + const cmds = generateCommandsFromNoun({ 297 + name: 'groups', 298 + singular: 'group', 299 + hasQuery: true, 300 + hasBrowse: true, 301 + hasOpen: true, 302 + hasCreate: true, 303 + }); 304 + 305 + for (const cmd of cmds) { 306 + assert.strictEqual(cmd._nounName, 'groups'); 307 + } 308 + }); 309 + 310 + it('default produces is application/json for query commands', () => { 311 + const cmds = generateCommandsFromNoun({ 312 + name: 'items', 313 + singular: 'item', 314 + hasQuery: true, 315 + }); 316 + 317 + const listCmd = cmds.find(c => c.name === 'list items'); 318 + assert.deepStrictEqual(listCmd?.produces, ['application/json']); 319 + }); 320 + }); 321 + 322 + // ============================================================================ 323 + // Real-world extension migration tests 324 + // Verify the exact command sets generated for each converted extension 325 + // ============================================================================ 326 + 327 + describe('generateCommandsFromNoun — extension migrations', () => { 328 + 329 + it('tags noun (skipBare + query + browse) → list tags, open tags', () => { 330 + const cmds = generateCommandsFromNoun({ 331 + name: 'tags', 332 + singular: 'tag', 333 + description: 'Tags for organizing items', 334 + skipBare: true, 335 + hasQuery: true, 336 + hasBrowse: true, 337 + produces: 'application/json', 338 + }); 339 + const names = cmds.map(c => c.name); 340 + assert.deepStrictEqual(names, ['list tags', 'open tags']); 341 + // list tags should produce JSON 342 + assert.deepStrictEqual(cmds[0].produces, ['application/json']); 343 + // open tags should have browse capability 344 + assert.strictEqual(cmds[1]._nounCapability, 'browse'); 345 + }); 346 + 347 + it('groups noun (all 4 capabilities) → 5 commands', () => { 348 + const cmds = generateCommandsFromNoun({ 349 + name: 'groups', 350 + singular: 'group', 351 + description: 'Saved tab groups', 352 + hasQuery: true, 353 + hasBrowse: true, 354 + hasOpen: true, 355 + hasCreate: true, 356 + produces: 'application/json', 357 + }); 358 + const names = cmds.map(c => c.name); 359 + assert.deepStrictEqual(names, [ 360 + 'groups', 'list groups', 'open groups', 'open group', 'new group' 361 + ]); 362 + assert.strictEqual(cmds[0]._nounCapability, 'browse'); // bare → browse 363 + assert.strictEqual(cmds[3]._nounCapability, 'open'); // open group → open 364 + assert.strictEqual(cmds[4]._nounCapability, 'create'); // new group → create 365 + }); 366 + 367 + it('feeds noun (query + browse + create) → 4 commands', () => { 368 + const cmds = generateCommandsFromNoun({ 369 + name: 'feeds', 370 + singular: 'feed', 371 + description: 'RSS/Atom feed subscriptions', 372 + hasQuery: true, 373 + hasBrowse: true, 374 + hasCreate: true, 375 + produces: 'application/json', 376 + }); 377 + const names = cmds.map(c => c.name); 378 + assert.deepStrictEqual(names, [ 379 + 'feeds', 'list feeds', 'open feeds', 'new feed' 380 + ]); 381 + assert.strictEqual(cmds[0]._nounCapability, 'browse'); 382 + assert.strictEqual(cmds[3]._nounCapability, 'create'); 383 + }); 384 + 385 + it('entities noun (skipBare + query + browse) → list entities, open entities', () => { 386 + const cmds = generateCommandsFromNoun({ 387 + name: 'entities', 388 + singular: 'entity', 389 + description: 'Extracted people, places, organizations', 390 + skipBare: true, 391 + hasQuery: true, 392 + hasBrowse: true, 393 + produces: 'application/json', 394 + }); 395 + const names = cmds.map(c => c.name); 396 + assert.deepStrictEqual(names, ['list entities', 'open entities']); 397 + }); 398 + 399 + it('scripts noun (all 4 capabilities) → 5 commands', () => { 400 + const cmds = generateCommandsFromNoun({ 401 + name: 'scripts', 402 + singular: 'script', 403 + description: 'Userscripts and content scripts', 404 + hasQuery: true, 405 + hasBrowse: true, 406 + hasOpen: true, 407 + hasCreate: true, 408 + produces: 'application/json', 409 + }); 410 + const names = cmds.map(c => c.name); 411 + assert.deepStrictEqual(names, [ 412 + 'scripts', 'list scripts', 'open scripts', 'open script', 'new script' 413 + ]); 414 + }); 415 + 416 + it('pagestream noun (browse only) → 2 commands', () => { 417 + const cmds = generateCommandsFromNoun({ 418 + name: 'pagestream', 419 + singular: 'pagestream', 420 + description: 'Browsing history stream', 421 + hasBrowse: true, 422 + }); 423 + const names = cmds.map(c => c.name); 424 + assert.deepStrictEqual(names, ['pagestream', 'open pagestream']); 425 + assert.strictEqual(cmds[0]._nounCapability, 'browse'); 426 + assert.strictEqual(cmds[1]._nounCapability, 'browse'); 427 + }); 428 + 429 + it('editor noun (skipBare + browse only) → open editor', () => { 430 + const cmds = generateCommandsFromNoun({ 431 + name: 'editor', 432 + singular: 'editor', 433 + description: 'Markdown editor', 434 + skipBare: true, 435 + hasBrowse: true, 436 + }); 437 + const names = cmds.map(c => c.name); 438 + assert.deepStrictEqual(names, ['open editor']); 439 + assert.strictEqual(cmds[0]._nounCapability, 'browse'); 440 + }); 441 + }); 442 + 443 + // ============================================================================ 444 + // Edge cases and consistency tests 445 + // ============================================================================ 446 + 447 + describe('generateCommandsFromNoun — edge cases', () => { 448 + 449 + it('same singular and plural still generates correct commands', () => { 450 + // pagestream uses same word for both 451 + const cmds = generateCommandsFromNoun({ 452 + name: 'pagestream', 453 + singular: 'pagestream', 454 + hasBrowse: true, 455 + hasQuery: true, 456 + }); 457 + const names = cmds.map(c => c.name); 458 + // "open pagestream" appears only once (not duplicated by open {name} and open {singular}) 459 + assert.deepStrictEqual(names, ['pagestream', 'list pagestream', 'open pagestream']); 460 + }); 461 + 462 + it('browse-only with skipBare generates only open command', () => { 463 + const cmds = generateCommandsFromNoun({ 464 + name: 'editor', 465 + singular: 'editor', 466 + skipBare: true, 467 + hasBrowse: true, 468 + }); 469 + assert.strictEqual(cmds.length, 1); 470 + assert.strictEqual(cmds[0].name, 'open editor'); 471 + }); 472 + 473 + it('create with custom produces propagates to new command', () => { 474 + const cmds = generateCommandsFromNoun({ 475 + name: 'feeds', 476 + singular: 'feed', 477 + hasCreate: true, 478 + produces: 'application/rss+xml', 479 + }); 480 + const newCmd = cmds.find(c => c.name === 'new feed'); 481 + assert.ok(newCmd); 482 + assert.deepStrictEqual(newCmd.produces, ['application/rss+xml']); 483 + }); 484 + 485 + it('create without produces generates empty produces', () => { 486 + const cmds = generateCommandsFromNoun({ 487 + name: 'tasks', 488 + singular: 'task', 489 + hasCreate: true, 490 + }); 491 + const newCmd = cmds.find(c => c.name === 'new task'); 492 + assert.ok(newCmd); 493 + assert.deepStrictEqual(newCmd.produces, []); 494 + }); 495 + 496 + it('open singular not generated when only open (no query)', () => { 497 + // open {singular} requires query+open because you need query to resolve the name 498 + const cmds = generateCommandsFromNoun({ 499 + name: 'feeds', 500 + singular: 'feed', 501 + hasOpen: true, 502 + hasBrowse: true, 503 + }); 504 + const names = cmds.map(c => c.name); 505 + assert.ok(!names.includes('open feed'), 'open feed should not exist without query'); 506 + assert.ok(names.includes('open feeds'), 'open feeds should exist from browse'); 507 + }); 508 + 509 + it('all commands have consistent accepts: empty array', () => { 510 + const cmds = generateCommandsFromNoun({ 511 + name: 'groups', 512 + singular: 'group', 513 + hasQuery: true, 514 + hasBrowse: true, 515 + hasOpen: true, 516 + hasCreate: true, 517 + }); 518 + for (const cmd of cmds) { 519 + assert.deepStrictEqual(cmd.accepts, []); 520 + } 521 + }); 522 + });
+75
extensions/cmd/background.js
··· 12 12 13 13 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 14 14 import { log } from 'peek://app/log.js'; 15 + import { generateCommandsFromNoun, validateNounDef } from './noun-registry.js'; 15 16 16 17 const api = window.app; 17 18 ··· 21 22 // This extension owns the command registry. Other extensions register 22 23 // commands by publishing to cmd:register, and we store them here. 23 24 const commandRegistry = new Map(); 25 + 26 + // Noun registry — stores noun metadata for regenerating commands 27 + const nounRegistry = new Map(); 24 28 25 29 // Track commands that were freshly registered by live extensions (not from cache) 26 30 const liveRegisteredCommands = new Set(); ··· 103 107 appVersion, 104 108 extensionVersions, 105 109 commands, 110 + nouns: Array.from(nounRegistry.values()), 106 111 cachedAt: Date.now() 107 112 }; 108 113 ··· 240 245 commandRegistry.delete(msg.name); 241 246 }, api.scopes.GLOBAL); 242 247 248 + // ===== Noun Registration Handlers ===== 249 + 250 + // Handle batch noun registrations from extensions 251 + api.subscribe('noun:register-batch', (msg) => { 252 + if (!msg.nouns || !Array.isArray(msg.nouns)) return; 253 + 254 + log('ext:cmd', 'noun:register-batch received:', msg.nouns.length, 'nouns'); 255 + 256 + const generatedCommands = []; 257 + 258 + for (const nounDef of msg.nouns) { 259 + const validation = validateNounDef(nounDef); 260 + if (!validation.valid) { 261 + log.error('ext:cmd', 'Invalid noun definition:', nounDef.name, validation.error); 262 + continue; 263 + } 264 + 265 + // Store noun metadata 266 + nounRegistry.set(nounDef.name, nounDef); 267 + 268 + // Generate commands from noun definition 269 + const commands = generateCommandsFromNoun(nounDef); 270 + for (const cmd of commands) { 271 + commandRegistry.set(cmd.name, cmd); 272 + liveRegisteredCommands.add(cmd.name); 273 + generatedCommands.push(cmd); 274 + } 275 + 276 + log('ext:cmd', 'Noun registered:', nounDef.name, '→', commands.map(c => c.name).join(', ')); 277 + } 278 + 279 + // Broadcast generated commands to panel via existing cmd:register-batch flow 280 + if (generatedCommands.length > 0) { 281 + api.publish('cmd:register-batch', { commands: generatedCommands }, api.scopes.GLOBAL); 282 + } 283 + }, api.scopes.GLOBAL); 284 + 285 + // Handle noun unregistrations 286 + api.subscribe('noun:unregister', (msg) => { 287 + if (!msg.name) return; 288 + 289 + const nounDef = nounRegistry.get(msg.name); 290 + if (!nounDef) return; 291 + 292 + log('ext:cmd', 'Noun unregistering:', msg.name); 293 + 294 + // Regenerate command names from noun metadata to know what to remove 295 + const commands = generateCommandsFromNoun(nounDef); 296 + for (const cmd of commands) { 297 + commandRegistry.delete(cmd.name); 298 + liveRegisteredCommands.delete(cmd.name); 299 + api.publish('cmd:unregister', { name: cmd.name }, api.scopes.GLOBAL); 300 + } 301 + 302 + nounRegistry.delete(msg.name); 303 + }, api.scopes.GLOBAL); 304 + 243 305 // Handle command list queries from the panel 244 306 api.subscribe('cmd:query-commands', () => { 245 307 const commands = Array.from(commandRegistry.values()); ··· 395 457 params: cmd.params || [] 396 458 }); 397 459 } 460 + 461 + // Restore cached nouns and regenerate their commands 462 + if (cache.nouns) { 463 + for (const nounDef of cache.nouns) { 464 + nounRegistry.set(nounDef.name, nounDef); 465 + const nounCmds = generateCommandsFromNoun(nounDef); 466 + for (const cmd of nounCmds) { 467 + commandRegistry.set(cmd.name, cmd); 468 + } 469 + } 470 + log('ext:cmd', 'Restored', cache.nouns.length, 'nouns from cache'); 471 + } 472 + 398 473 log('ext:cmd', 'Pre-populated registry from cache:', commandRegistry.size, 'commands'); 399 474 } 400 475
+33 -10
extensions/cmd/commands.js
··· 92 92 * result, which we capture here and return for the chaining flow. 93 93 */ 94 94 function createProxyCommand(cmdData) { 95 - return { 95 + const base = { 96 96 name: cmdData.name, 97 97 description: cmdData.description || '', 98 98 source: cmdData.source, ··· 101 101 produces: cmdData.produces || [], 102 102 // Parameter definitions for completions 103 103 params: cmdData.params || [], 104 - execute: async (ctx) => { 104 + }; 105 + 106 + // Noun proxy — dispatch via noun:{capability}:{nounName} instead of cmd:execute:{name} 107 + if (cmdData._nounCapability && cmdData._nounName) { 108 + const topic = `noun:${cmdData._nounCapability}:${cmdData._nounName}`; 109 + base.execute = async (ctx) => { 105 110 return new Promise((resolve) => { 106 - const resultTopic = `cmd:execute:${cmdData.name}:result`; 111 + const resultTopic = `noun:result:${cmdData._nounName}:${Date.now()}`; 107 112 108 - // Set up listener for result (one-time) 109 113 const unsubscribe = api.subscribe(resultTopic, (result) => { 110 - // Got result from extension - pass it back for chaining 111 114 resolve(result); 112 115 }, api.scopes.GLOBAL); 113 116 114 - // Publish execution request to the extension 115 - // Include a flag so extension knows to publish result 116 - api.publish(`cmd:execute:${cmdData.name}`, { 117 + api.publish(topic, { 117 118 ...ctx, 118 119 expectResult: true, 119 120 resultTopic 120 121 }, api.scopes.GLOBAL); 121 122 122 - // Timeout after 30 seconds - return undefined (no chaining) 123 123 setTimeout(() => { 124 124 resolve(undefined); 125 125 }, 30000); 126 126 }); 127 - } 127 + }; 128 + return base; 129 + } 130 + 131 + // Regular command proxy — dispatch via cmd:execute:{name} 132 + base.execute = async (ctx) => { 133 + return new Promise((resolve) => { 134 + const resultTopic = `cmd:execute:${cmdData.name}:result`; 135 + 136 + const unsubscribe = api.subscribe(resultTopic, (result) => { 137 + resolve(result); 138 + }, api.scopes.GLOBAL); 139 + 140 + api.publish(`cmd:execute:${cmdData.name}`, { 141 + ...ctx, 142 + expectResult: true, 143 + resultTopic 144 + }, api.scopes.GLOBAL); 145 + 146 + setTimeout(() => { 147 + resolve(undefined); 148 + }, 30000); 149 + }); 128 150 }; 151 + return base; 129 152 } 130 153 131 154 /**
+113
extensions/cmd/noun-registry.js
··· 1 + /** 2 + * Noun Registry — pure command generation from noun definitions 3 + * 4 + * No side effects, no imports. Used by background.js to convert noun metadata 5 + * (with boolean capability flags) into standard command metadata objects. 6 + */ 7 + 8 + /** 9 + * Validate a noun definition has required fields and at least one capability 10 + * @param {Object} def - Noun definition with boolean capability flags 11 + * @returns {{ valid: boolean, error?: string }} 12 + */ 13 + export function validateNounDef(def) { 14 + if (!def.name) return { valid: false, error: 'name is required' }; 15 + if (!def.singular) return { valid: false, error: 'singular is required' }; 16 + if (!def.hasQuery && !def.hasBrowse && !def.hasOpen && !def.hasCreate) { 17 + return { valid: false, error: 'at least one capability is required' }; 18 + } 19 + return { valid: true }; 20 + } 21 + 22 + /** 23 + * Generate command metadata objects from a noun definition 24 + * 25 + * @param {Object} noun - Noun metadata (boolean flags, not functions): 26 + * - name: string (plural, e.g. 'groups') 27 + * - singular: string (e.g. 'group') 28 + * - description: string 29 + * - source: string 30 + * - hasQuery, hasBrowse, hasOpen, hasCreate: boolean 31 + * - produces: string|null 32 + * - params: Array 33 + * - modes: Array 34 + * - skipBare: boolean 35 + * @returns {Array<Object>} Command metadata objects 36 + */ 37 + export function generateCommandsFromNoun(noun) { 38 + const commands = []; 39 + const base = { 40 + source: noun.source || '', 41 + params: noun.params || [], 42 + modes: noun.modes || [], 43 + }; 44 + 45 + // Bare noun command: "{name}" (e.g. "groups") 46 + // Uses browse if available, else query 47 + if (!noun.skipBare && (noun.hasBrowse || noun.hasQuery)) { 48 + const capability = noun.hasBrowse ? 'browse' : 'query'; 49 + commands.push({ 50 + ...base, 51 + name: noun.name, 52 + description: noun.description || `${noun.hasBrowse ? 'Open' : 'List'} ${noun.name}`, 53 + accepts: [], 54 + produces: capability === 'query' ? (noun.produces ? [noun.produces] : ['application/json']) : [], 55 + _nounName: noun.name, 56 + _nounCapability: capability, 57 + }); 58 + } 59 + 60 + // "list {name}" — requires query 61 + if (noun.hasQuery) { 62 + commands.push({ 63 + ...base, 64 + name: `list ${noun.name}`, 65 + description: `List ${noun.name}`, 66 + accepts: [], 67 + produces: noun.produces ? [noun.produces] : ['application/json'], 68 + _nounName: noun.name, 69 + _nounCapability: 'query', 70 + }); 71 + } 72 + 73 + // "open {name}" — requires browse 74 + if (noun.hasBrowse) { 75 + commands.push({ 76 + ...base, 77 + name: `open ${noun.name}`, 78 + description: `Open ${noun.name}`, 79 + accepts: [], 80 + produces: [], 81 + _nounName: noun.name, 82 + _nounCapability: 'browse', 83 + }); 84 + } 85 + 86 + // "open {singular}" — requires query AND open 87 + if (noun.hasQuery && noun.hasOpen) { 88 + commands.push({ 89 + ...base, 90 + name: `open ${noun.singular}`, 91 + description: `Open a ${noun.singular} by name`, 92 + accepts: [], 93 + produces: [], 94 + _nounName: noun.name, 95 + _nounCapability: 'open', 96 + }); 97 + } 98 + 99 + // "new {singular}" — requires create 100 + if (noun.hasCreate) { 101 + commands.push({ 102 + ...base, 103 + name: `new ${noun.singular}`, 104 + description: `Create a new ${noun.singular}`, 105 + accepts: [], 106 + produces: noun.produces ? [noun.produces] : [], 107 + _nounName: noun.name, 108 + _nounCapability: 'create', 109 + }); 110 + } 111 + 112 + return commands; 113 + }
+109
extensions/cmd/nouns.js
··· 1 + /** 2 + * Noun Registration API — portable, zero platform dependencies 3 + * 4 + * Extensions import this to declare what data they manage. 5 + * Uses only window.app pubsub (works on Electron, Tauri, browser extension). 6 + * 7 + * Usage: 8 + * import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 9 + */ 10 + 11 + const api = window.app; 12 + 13 + // Local handler storage (per-window, same as window._cmdHandlers pattern) 14 + const nounHandlers = {}; 15 + 16 + // Batching (same debounce pattern as preload command batching) 17 + let pending = []; 18 + let timer = null; 19 + const BATCH_DELAY = 16; 20 + 21 + function flush() { 22 + if (!pending.length) return; 23 + const batch = pending; 24 + pending = []; 25 + timer = null; 26 + api.publish('noun:register-batch', { nouns: batch }, api.scopes.GLOBAL); 27 + } 28 + 29 + /** 30 + * Register a noun with the command system 31 + * 32 + * @param {Object} nounDef 33 + * @param {string} nounDef.name - Plural name (e.g. 'groups') 34 + * @param {string} nounDef.singular - Singular name (e.g. 'group') 35 + * @param {string} [nounDef.description] - Human description 36 + * @param {string} [nounDef.source] - Source identifier 37 + * @param {Function} [nounDef.query] - async ({search, params}) => items 38 + * @param {Function} [nounDef.browse] - async () => open UI 39 + * @param {Function} [nounDef.open] - async ({search}) => open instance 40 + * @param {Function} [nounDef.create] - async ({search, params}) => result 41 + * @param {string} [nounDef.produces] - MIME type for chaining 42 + * @param {Array} [nounDef.params] - Parameter definitions 43 + * @param {Array} [nounDef.modes] - Required major modes 44 + * @param {boolean} [nounDef.skipBare] - If true, don't generate bare "{name}" command 45 + */ 46 + export function registerNoun(nounDef) { 47 + if (!nounDef.name || !nounDef.singular) { 48 + console.error('[nouns] name and singular are required'); 49 + return; 50 + } 51 + 52 + nounHandlers[nounDef.name] = {}; 53 + const capabilities = ['query', 'browse', 'open', 'create']; 54 + 55 + for (const cap of capabilities) { 56 + if (typeof nounDef[cap] !== 'function') continue; 57 + nounHandlers[nounDef.name][cap] = nounDef[cap]; 58 + 59 + // Subscribe to capability execution via platform-agnostic pubsub 60 + api.subscribe(`noun:${cap}:${nounDef.name}`, async (msg) => { 61 + const handler = nounHandlers[nounDef.name]?.[cap]; 62 + if (!handler) return; 63 + try { 64 + const result = await handler(msg); 65 + if (msg.expectResult && msg.resultTopic) { 66 + api.publish(msg.resultTopic, result, api.scopes.GLOBAL); 67 + } 68 + } catch (err) { 69 + console.error('[nouns] Error in', cap, nounDef.name, err); 70 + if (msg.expectResult && msg.resultTopic) { 71 + api.publish(msg.resultTopic, { error: err.message }, api.scopes.GLOBAL); 72 + } 73 + } 74 + }, api.scopes.GLOBAL); 75 + } 76 + 77 + // Queue metadata (booleans, not functions) for batched publish 78 + pending.push({ 79 + name: nounDef.name, 80 + singular: nounDef.singular, 81 + description: nounDef.description || '', 82 + source: nounDef.source || '', 83 + hasQuery: typeof nounDef.query === 'function', 84 + hasBrowse: typeof nounDef.browse === 'function', 85 + hasOpen: typeof nounDef.open === 'function', 86 + hasCreate: typeof nounDef.create === 'function', 87 + produces: nounDef.produces || null, 88 + params: nounDef.params || [], 89 + modes: nounDef.modes || [], 90 + skipBare: nounDef.skipBare || false, 91 + }); 92 + 93 + clearTimeout(timer); 94 + timer = setTimeout(flush, BATCH_DELAY); 95 + } 96 + 97 + /** 98 + * Unregister a noun and its generated commands 99 + * @param {string} name - The plural noun name 100 + */ 101 + export function unregisterNoun(name) { 102 + delete nounHandlers[name]; 103 + api.publish('noun:unregister', { name }, api.scopes.GLOBAL); 104 + } 105 + 106 + /** 107 + * Flush pending noun registrations immediately 108 + */ 109 + export function flushNouns() { flush(); }
+9 -5
extensions/editor/background.js
··· 9 9 * - Pubsub integration 10 10 */ 11 11 12 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 13 + 12 14 // Feature detection 13 15 const hasPeekAPI = typeof window.app !== 'undefined'; 14 16 const api = hasPeekAPI ? window.app : null; ··· 45 47 * Register commands - called when cmd extension is ready 46 48 */ 47 49 registerCommands() { 48 - api.commands.register({ 49 - name: 'open editor', 50 - description: 'Open the markdown editor', 51 - execute: () => openEditor() 50 + registerNoun({ 51 + name: 'editor', 52 + singular: 'editor', 53 + description: 'Markdown editor', 54 + skipBare: true, // No bare "editor" command 55 + browse: async () => { openEditor(); }, 52 56 }); 53 57 54 58 console.log('[editor] Commands registered'); ··· 99 103 console.log('[editor] Cleaning up...'); 100 104 101 105 if (hasPeekAPI) { 102 - api.commands.unregister('open editor'); 106 + unregisterNoun('editor'); 103 107 api.shortcuts.unregister('Option+e', { global: true }); 104 108 api.shortcuts.unregister('CommandOrControl+E'); 105 109 }
+40 -6
extensions/entities/background.js
··· 13 13 import { extractStructuredDataEntities, extractPageMetadata } from './extractors/structured-data.js'; 14 14 import { processEntities } from './entity-matcher.js'; 15 15 import { getEntities, getObservations } from './entity-store.js'; 16 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 16 17 17 18 const api = window.app; 18 19 ··· 303 304 }, 304 305 305 306 registerCommands() { 306 - api.commands.register({ 307 + // Register noun for auto-generated browse/list commands 308 + registerNoun({ 307 309 name: 'entities', 308 - description: 'Open entity browser', 309 - execute: openEntityBrowser 310 + singular: 'entity', 311 + description: 'Extracted people, places, organizations, and more', 312 + skipBare: true, // "entities" bare command should browse, but we want the noun to generate "open entities" and "list entities" 313 + 314 + query: async ({ search }) => { 315 + const entities = await getEntities({ 316 + search: search || undefined, 317 + limit: 50 318 + }); 319 + if (entities.length === 0) { 320 + return { output: 'No entities found.', mimeType: 'text/plain' }; 321 + } 322 + return { 323 + success: true, 324 + output: { 325 + data: entities.map(e => { 326 + let meta = {}; 327 + try { meta = JSON.parse(e.metadata || '{}'); } catch {} 328 + return { 329 + id: e.id, 330 + name: e.content, 331 + type: meta.entityType, 332 + confidence: meta.confidence 333 + }; 334 + }), 335 + mimeType: 'application/json', 336 + title: `Entities (${entities.length})` 337 + } 338 + }; 339 + }, 340 + 341 + browse: async () => { openEntityBrowser(); }, 342 + produces: 'application/json' 310 343 }); 311 344 345 + // Extract is a specialized verb action — keep as regular command 312 346 api.commands.register({ 313 - name: 'entity:extract', 347 + name: 'extract entities', 314 348 description: 'Extract entities from current page', 315 349 execute: async () => { 316 350 const entities = await extractCurrentPage(); ··· 413 447 pendingExtractions.clear(); 414 448 415 449 // Unregister commands 416 - api.commands.unregister('entities'); 417 - api.commands.unregister('entity:extract'); 450 + unregisterNoun('entities'); 451 + api.commands.unregister('extract entities'); 418 452 } 419 453 }; 420 454
+42 -20
extensions/feeds/background.js
··· 8 8 * - Commands for subscribing and viewing feeds 9 9 */ 10 10 11 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 12 + 11 13 const api = window.app; 12 14 const POLL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes 13 15 ··· 318 320 }, 319 321 320 322 registerCommands() { 321 - api.commands.register({ 323 + // Register noun for auto-generated browse/list/create commands 324 + registerNoun({ 322 325 name: 'feeds', 323 - description: 'Open feed reader', 324 - execute: openFeedsUI 325 - }); 326 + singular: 'feed', 327 + description: 'RSS/Atom feed subscriptions', 326 328 327 - api.commands.register({ 328 - name: 'feeds:subscribe', 329 - description: 'Subscribe to an RSS/Atom feed', 330 - execute: async (ctx) => { 331 - const url = ctx?.input?.text || ctx?.input; 332 - if (url && typeof url === 'string' && url.startsWith('http')) { 333 - const result = await subscribeFeed(url); 334 - if (result.success) { 335 - openFeedsUI(); 329 + query: async ({ search }) => { 330 + const feeds = await getFeeds(); 331 + let filtered = feeds; 332 + if (search) { 333 + const s = search.toLowerCase(); 334 + filtered = feeds.filter(f => f.name.toLowerCase().includes(s) || f.url.toLowerCase().includes(s)); 335 + } 336 + if (filtered.length === 0) { 337 + return { output: 'No feeds found.', mimeType: 'text/plain' }; 338 + } 339 + return { 340 + success: true, 341 + output: { 342 + data: filtered, 343 + mimeType: 'application/json', 344 + title: `Feeds (${filtered.length})` 336 345 } 337 - } else { 338 - // Open UI with subscribe prompt 346 + }; 347 + }, 348 + 349 + browse: async () => { openFeedsUI(); }, 350 + 351 + create: async ({ search }) => { 352 + if (!search || !search.startsWith('http')) { 353 + openFeedsUI(); 354 + return { success: true, message: 'Opening feed reader' }; 355 + } 356 + const result = await subscribeFeed(search.trim()); 357 + if (result.success) { 339 358 openFeedsUI(); 340 359 } 341 - } 360 + return result; 361 + }, 362 + 363 + produces: 'application/json' 342 364 }); 343 365 366 + // Refresh is a verb action, not a noun capability — keep as regular command 344 367 api.commands.register({ 345 - name: 'feeds:refresh', 368 + name: 'refresh feeds', 346 369 description: 'Refresh all feeds', 347 370 execute: pollAllFeeds 348 371 }); ··· 394 417 } 395 418 396 419 api.shortcuts.unregister('Option+Shift+F', { global: true }); 397 - api.commands.unregister('feeds'); 398 - api.commands.unregister('feeds:subscribe'); 399 - api.commands.unregister('feeds:refresh'); 420 + unregisterNoun('feeds'); 421 + api.commands.unregister('refresh feeds'); 400 422 } 401 423 }; 402 424
+43 -141
extensions/groups/background.js
··· 8 8 */ 9 9 10 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 11 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 11 12 12 13 const api = window.app; 13 14 const debug = api.debug; ··· 298 299 return { success: true, count: urlItems.length, windowIds: openedWindows }; 299 300 }; 300 301 301 - // ===== Command definitions ===== 302 - 303 - const commandDefinitions = [ 304 - { 305 - name: 'open groups', 306 - description: 'Open the groups manager', 307 - execute: async (ctx) => { 308 - console.log('[ext:groups] Opening groups manager'); 309 - openGroupsWindow(); 310 - } 311 - }, 312 - { 313 - name: 'list groups', 314 - description: 'List all groups (chainable)', 315 - accepts: [], 316 - produces: ['application/json'], 317 - execute: async (ctx) => { 318 - const groups = await getAllGroups(); 319 - if (groups.length === 0) { 320 - return { output: 'No groups yet. Tag some pages to create groups.', mimeType: 'text/plain' }; 321 - } 322 - 323 - // Get URL item counts for each group 324 - const groupsWithCounts = await Promise.all(groups.map(async (g) => { 325 - const result = await api.datastore.getItemsByTag(g.id); 326 - const urlCount = result.success ? result.data.filter(item => item.type === 'url').length : 0; 327 - return { id: g.id, name: g.name, color: g.color, count: urlCount }; 328 - })); 329 - 330 - // Filter to non-empty groups 331 - const nonEmpty = groupsWithCounts.filter(g => g.count > 0); 332 - if (nonEmpty.length === 0) { 333 - return { output: 'No groups with pages yet.', mimeType: 'text/plain' }; 334 - } 335 - 336 - // Return chainable JSON output 337 - return { 338 - success: true, 339 - output: { 340 - data: nonEmpty, 341 - mimeType: 'application/json', 342 - title: `Groups (${nonEmpty.length} items)` 343 - } 344 - }; 345 - } 346 - }, 347 - { 348 - name: 'save group', 349 - description: 'Save open windows to a group', 350 - execute: async (ctx) => { 351 - if (ctx.search) { 352 - const groupName = ctx.search.trim(); 353 - const result = await saveToGroup(groupName); 354 - if (result.success) { 355 - console.log(`[ext:groups] Saved ${result.count} of ${result.total} windows to "${groupName}"`); 356 - } 357 - } else { 358 - console.log('[ext:groups] Usage: save group <name>'); 359 - } 360 - } 361 - }, 362 - { 363 - name: 'open group', 364 - description: 'Open all URLs in a group', 365 - execute: async (ctx) => { 366 - if (ctx.search) { 367 - const groupName = ctx.search.trim(); 368 - await openGroup(groupName); 369 - } else { 370 - const groups = await getAllGroups(); 371 - if (groups.length === 0) { 372 - console.log('[ext:groups] No groups saved yet. Use "save group <name>" to create one.'); 373 - } else { 374 - console.log('[ext:groups] Available groups:'); 375 - groups.forEach(g => console.log(' -', g.name)); 376 - } 377 - } 378 - } 379 - } 380 - ]; 381 - 382 - // Track dynamic group commands separately 383 - let dynamicGroupCommands = []; 384 - 385 302 // ===== Registration ===== 386 303 387 304 let registeredShortcut = null; 388 - let registeredCommands = []; 389 305 const LOCAL_SHORTCUT = 'CommandOrControl+G'; 390 306 391 307 const initShortcut = (shortcut) => { ··· 400 316 }); 401 317 }; 402 318 403 - /** 404 - * Register dynamic commands for each existing group 405 - * Creates "group GroupName" commands that are searchable 406 - */ 407 - const registerDynamicGroupCommands = async () => { 408 - // Unregister old dynamic commands first 409 - for (const name of dynamicGroupCommands) { 410 - api.commands.unregister(name); 411 - } 412 - dynamicGroupCommands = []; 319 + const initCommands = () => { 320 + registerNoun({ 321 + name: 'groups', 322 + singular: 'group', 323 + description: 'Saved tab groups', 324 + 325 + query: async ({ search }) => { 326 + const groups = await getAllGroups(); 327 + const withCounts = await Promise.all(groups.map(async g => { 328 + const result = await api.datastore.getItemsByTag(g.id); 329 + const count = result.success ? result.data.filter(i => i.type === 'url').length : 0; 330 + return { id: g.id, name: g.name, color: g.color, count }; 331 + })); 332 + let filtered = withCounts.filter(g => g.count > 0); 333 + if (search) { 334 + const s = search.toLowerCase(); 335 + filtered = filtered.filter(g => g.name.toLowerCase().includes(s)); 336 + } 337 + if (filtered.length === 0) { 338 + return { output: 'No groups found.', mimeType: 'text/plain' }; 339 + } 340 + return { 341 + success: true, 342 + output: { 343 + data: filtered, 344 + mimeType: 'application/json', 345 + title: `Groups (${filtered.length})` 346 + } 347 + }; 348 + }, 413 349 414 - // Get all groups with URL items 415 - const groups = await getAllGroups(); 416 - const groupsWithCounts = await Promise.all(groups.map(async (g) => { 417 - const result = await api.datastore.getItemsByTag(g.id); 418 - const urlCount = result.success ? result.data.filter(item => item.type === 'url').length : 0; 419 - return { ...g, count: urlCount }; 420 - })); 350 + browse: async () => { openGroupsWindow(); }, 421 351 422 - // Register a command for each non-empty group 423 - const nonEmpty = groupsWithCounts.filter(g => g.count > 0); 424 - for (const group of nonEmpty) { 425 - const cmdName = `group ${group.name}`; 426 - const cmd = { 427 - name: cmdName, 428 - description: `Open ${group.count} pages in "${group.name}"`, 429 - execute: async () => { 430 - await openGroup(group.name); 431 - } 432 - }; 433 - api.commands.register(cmd); 434 - dynamicGroupCommands.push(cmdName); 435 - } 352 + open: async (ctx) => { 353 + if (ctx.search) await openGroup(ctx.search.trim()); 354 + }, 436 355 437 - if (nonEmpty.length > 0) { 438 - debug && console.log('[ext:groups] Registered dynamic group commands:', dynamicGroupCommands); 439 - } 440 - }; 356 + create: async ({ search }) => { 357 + if (!search) return { success: false, error: 'Usage: new group <name>' }; 358 + return await saveToGroup(search.trim()); 359 + }, 441 360 442 - const initCommands = async () => { 443 - // Register static commands 444 - commandDefinitions.forEach(cmd => { 445 - api.commands.register(cmd); 446 - registeredCommands.push(cmd.name); 361 + produces: 'application/json' 447 362 }); 448 - console.log('[ext:groups] Registered commands:', registeredCommands); 449 363 450 - // Register dynamic group commands 451 - await registerDynamicGroupCommands(); 364 + console.log('[ext:groups] Noun registered: groups'); 452 365 }; 453 366 454 367 const uninitCommands = () => { 455 - // Unregister static commands 456 - registeredCommands.forEach(name => { 457 - api.commands.unregister(name); 458 - }); 459 - registeredCommands = []; 460 - 461 - // Unregister dynamic commands 462 - for (const name of dynamicGroupCommands) { 463 - api.commands.unregister(name); 464 - } 465 - dynamicGroupCommands = []; 466 - 467 - console.log('[ext:groups] Unregistered commands'); 368 + unregisterNoun('groups'); 369 + console.log('[ext:groups] Noun unregistered: groups'); 468 370 }; 469 371 470 372 const init = async () => {
+11 -32
extensions/pagestream/background.js
··· 8 8 */ 9 9 10 10 import { id, labels, schemas, storageKeys, defaults } from './config.js'; 11 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 11 12 12 13 const api = window.app; 13 14 const debug = api.debug; ··· 79 80 } 80 81 }; 81 82 82 - // ===== Command definitions ===== 83 - 84 - const commandDefinitions = [ 85 - { 86 - name: 'open pagestream', 87 - description: 'Open the browsing history stream', 88 - execute: async (ctx) => { 89 - console.log('[ext:pagestream] Opening pagestream'); 90 - openPagestreamWindow(); 91 - } 92 - }, 93 - { 94 - name: 'pagestream', 95 - description: 'Open the browsing history stream', 96 - execute: async (ctx) => { 97 - console.log('[ext:pagestream] Opening pagestream'); 98 - openPagestreamWindow(); 99 - } 100 - } 101 - ]; 102 - 103 83 // ===== Registration ===== 104 84 105 85 let registeredShortcut = null; 106 - let registeredCommands = []; 107 86 108 87 const initShortcut = (shortcut) => { 109 88 api.shortcuts.register(shortcut, () => { ··· 112 91 registeredShortcut = shortcut; 113 92 }; 114 93 115 - const initCommands = async () => { 116 - commandDefinitions.forEach(cmd => { 117 - api.commands.register(cmd); 118 - registeredCommands.push(cmd.name); 94 + const initCommands = () => { 95 + registerNoun({ 96 + name: 'pagestream', 97 + singular: 'pagestream', 98 + description: 'Browsing history stream', 99 + skipBare: false, 100 + browse: async () => { openPagestreamWindow(); }, 119 101 }); 120 - console.log('[ext:pagestream] Registered commands:', registeredCommands); 102 + console.log('[ext:pagestream] Noun registered: pagestream'); 121 103 }; 122 104 123 105 const uninitCommands = () => { 124 - registeredCommands.forEach(name => { 125 - api.commands.unregister(name); 126 - }); 127 - registeredCommands = []; 128 - console.log('[ext:pagestream] Unregistered commands'); 106 + unregisterNoun('pagestream'); 107 + console.log('[ext:pagestream] Noun unregistered: pagestream'); 129 108 }; 130 109 131 110 const init = async () => {
+56 -14
extensions/scripts/background.js
··· 13 13 */ 14 14 15 15 import { scriptExecutor } from './script-executor.js'; 16 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 16 17 17 18 const api = window.app; 18 19 ··· 200 201 }; 201 202 202 203 /** 203 - * Register commands - called when cmd extension is ready 204 + * Register commands via noun API 204 205 */ 205 206 const registerCommands = () => { 206 - api.commands.register({ 207 + registerNoun({ 207 208 name: 'scripts', 208 - description: 'Open scripts manager', 209 - execute: () => openScriptsManager() 210 - }); 209 + singular: 'script', 210 + description: 'Userscripts and content scripts', 211 + 212 + query: async ({ search }) => { 213 + const result = await getScripts(); 214 + if (!result.success) return { success: false }; 215 + let scripts = result.data; 216 + if (search) { 217 + const s = search.toLowerCase(); 218 + scripts = scripts.filter(sc => sc.name.toLowerCase().includes(s)); 219 + } 220 + if (scripts.length === 0) { 221 + return { output: 'No scripts found.', mimeType: 'text/plain' }; 222 + } 223 + return { 224 + success: true, 225 + output: { 226 + data: scripts.map(sc => ({ 227 + id: sc.id, 228 + name: sc.name, 229 + description: sc.description, 230 + enabled: sc.enabled, 231 + matchPatterns: sc.matchPatterns 232 + })), 233 + mimeType: 'application/json', 234 + title: `Scripts (${scripts.length})` 235 + } 236 + }; 237 + }, 238 + 239 + browse: async () => { openScriptsManager(); }, 240 + 241 + open: async (ctx) => { 242 + if (ctx.search) { 243 + const result = await getScripts(); 244 + if (result.success) { 245 + const match = result.data.find(s => s.name.toLowerCase().includes(ctx.search.toLowerCase())); 246 + if (match) { 247 + openScriptsManager({ scriptId: match.id }); 248 + return; 249 + } 250 + } 251 + } 252 + openScriptsManager(); 253 + }, 211 254 212 - api.commands.register({ 213 - name: 'scripts: new', 214 - description: 'Create a new script', 215 - execute: async () => { 216 - const result = await createScript({}); 255 + create: async ({ search }) => { 256 + const result = await createScript({ name: search || undefined }); 217 257 if (result.success) { 218 258 openScriptsManager({ scriptId: result.data.id }); 219 259 } 220 - } 260 + return result; 261 + }, 262 + 263 + produces: 'application/json' 221 264 }); 222 265 223 - console.log('[ext:scripts] Commands registered'); 266 + console.log('[ext:scripts] Noun registered: scripts'); 224 267 }; 225 268 226 269 /** ··· 278 321 */ 279 322 const uninit = () => { 280 323 console.log('[ext:scripts] Cleaning up...'); 281 - api.commands.unregister('scripts'); 282 - api.commands.unregister('scripts: new'); 324 + unregisterNoun('scripts'); 283 325 api.shortcuts.unregister('Command+Shift+S'); 284 326 }; 285 327
+33 -13
extensions/tags/background.js
··· 9 9 * - Commands: tag, tags, untag, tagset 10 10 */ 11 11 12 + import { registerNoun, unregisterNoun } from 'peek://ext/cmd/nouns.js'; 13 + 12 14 // Feature detection - check if Peek API is available 13 15 const hasPeekAPI = typeof window.app !== 'undefined'; 14 16 const api = hasPeekAPI ? window.app : null; ··· 425 427 * Register commands - called when cmd extension is ready 426 428 */ 427 429 registerCommands() { 428 - // Command to open tags view 429 - api.commands.register({ 430 - name: 'open tags', 431 - description: 'Open the tags browser', 432 - execute: openTags 433 - }); 430 + // Register noun for auto-generated open/list commands 431 + registerNoun({ 432 + name: 'tags', 433 + singular: 'tag', 434 + description: 'Tags for organizing items', 435 + skipBare: true, // Keep specialized 'tags' command below 436 + 437 + query: async ({ search }) => { 438 + const result = await api.datastore.getTagsByFrecency(); 439 + if (!result.success) return { success: false }; 440 + let tags = result.data; 441 + if (search) { 442 + const q = search.toLowerCase(); 443 + tags = tags.filter(t => t.name.toLowerCase().includes(q)); 444 + } 445 + return { 446 + success: true, 447 + output: { 448 + data: tags.slice(0, 20).map(t => ({ 449 + name: t.name, 450 + frequency: t.frequency, 451 + frecency: t.frecencyScore 452 + })), 453 + mimeType: 'application/json', 454 + title: `Tags (${tags.length})` 455 + } 456 + }; 457 + }, 434 458 435 - // Command to list tags 436 - api.commands.register({ 437 - name: 'list tags', 438 - description: 'List all tags by frecency', 439 - execute: listTags 459 + browse: async () => { openTags(); }, 460 + produces: 'application/json' 440 461 }); 441 462 442 463 // Tag command - add tags to the active window URL ··· 497 518 console.log('[tags] Cleaning up...'); 498 519 499 520 if (hasPeekAPI) { 500 - api.commands.unregister('open tags'); 501 - api.commands.unregister('list tags'); 521 + unregisterNoun('tags'); 502 522 api.commands.unregister('tag'); 503 523 api.commands.unregister('tags'); 504 524 api.commands.unregister('untag');
+2 -2
tests/desktop/smoke.spec.ts
··· 2268 2268 expect(groupsWindow).toBeTruthy(); 2269 2269 await groupsWindow.waitForLoadState('domcontentloaded'); 2270 2270 2271 - // Wait for cards to render 2272 - await groupsWindow.waitForSelector('.cards', { timeout: 5000 }); 2271 + // Wait for group cards to render (async data loading + rendering) 2272 + await groupsWindow.waitForSelector(`peek-card.group-card[data-tag-id="${nonEmptyTag.data.tag.id}"]`, { timeout: 10000 }); 2273 2273 2274 2274 // Get all group card tag IDs 2275 2275 const groupCards = await groupsWindow.$$eval('peek-card.group-card', (cards: any[]) =>