@cleocode/cleo 2026.5.86 → 2026.5.87

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,9 +10,11 @@
10
10
  <header class="topbar">
11
11
  <h1>CLEO Docs</h1>
12
12
  <input id="filter" type="search" placeholder="Filter by slug, title, type..." autocomplete="off" />
13
+ <input id="search" type="search" placeholder="Search content (ranked)..." autocomplete="off" aria-label="Search docs by content" />
13
14
  </header>
14
15
  <main class="layout">
15
16
  <aside id="sidebar">
17
+ <div id="search-results" aria-live="polite" hidden></div>
16
18
  <div id="doc-list" aria-live="polite">Loading docs...</div>
17
19
  </aside>
18
20
  <section id="content">
@@ -130,3 +130,67 @@ html, body {
130
130
 
131
131
  .hint { color: var(--text-muted); }
132
132
  .error { color: #ff7f7f; }
133
+
134
+ /* T9647 — search results UI */
135
+ #search {
136
+ flex: 1;
137
+ max-width: 360px;
138
+ background: var(--surface-2);
139
+ border: 1px solid var(--border);
140
+ color: var(--text);
141
+ padding: 0.45rem 0.75rem;
142
+ border-radius: 6px;
143
+ font: inherit;
144
+ }
145
+ #search:focus { outline: 2px solid var(--accent); outline-offset: 0; }
146
+
147
+ #search-results {
148
+ padding: 0.25rem 0;
149
+ border-bottom: 1px solid var(--border);
150
+ }
151
+ #search-results .result {
152
+ display: block;
153
+ padding: 0.55rem 1rem;
154
+ color: var(--text);
155
+ text-decoration: none;
156
+ border-left: 3px solid transparent;
157
+ }
158
+ #search-results .result:hover {
159
+ background: var(--surface-2);
160
+ border-left-color: var(--accent);
161
+ }
162
+ #search-results .result-head {
163
+ display: flex;
164
+ align-items: baseline;
165
+ gap: 0.5rem;
166
+ font-size: 0.9rem;
167
+ }
168
+ #search-results .result-name {
169
+ font-weight: 600;
170
+ color: var(--accent);
171
+ flex: 1;
172
+ word-break: break-word;
173
+ }
174
+ #search-results .result-type {
175
+ font-size: 0.7rem;
176
+ color: var(--text-muted);
177
+ text-transform: uppercase;
178
+ letter-spacing: 0.03em;
179
+ }
180
+ #search-results .result-score {
181
+ font-size: 0.75rem;
182
+ color: var(--text-muted);
183
+ font-variant-numeric: tabular-nums;
184
+ }
185
+ #search-results .result-snippet {
186
+ color: var(--text-muted);
187
+ font-size: 0.8rem;
188
+ line-height: 1.4;
189
+ margin-top: 0.2rem;
190
+ }
191
+ #search-results mark {
192
+ background: rgba(106, 167, 255, 0.25);
193
+ color: inherit;
194
+ padding: 0 2px;
195
+ border-radius: 2px;
196
+ }
@@ -6,10 +6,13 @@
6
6
  const $list = document.getElementById('doc-list');
7
7
  const $render = document.getElementById('doc-render');
8
8
  const $filter = document.getElementById('filter');
9
+ const $search = document.getElementById('search');
10
+ const $searchResults = document.getElementById('search-results');
9
11
 
10
12
  const state = {
11
13
  docs: [],
12
14
  activeSlug: null,
15
+ searchSeq: 0,
13
16
  };
14
17
 
15
18
  function escapeHtml(str) {
@@ -287,6 +290,100 @@ function slugFromPath(pathname) {
287
290
 
288
291
  $filter.addEventListener('input', (e) => applyFilter(e.target.value));
289
292
 
293
+ // ── Search (T9647) — debounced /api/search with ranked results ──────────────
294
+
295
+ function debounce(fn, ms) {
296
+ let timer = null;
297
+ return function debounced(...args) {
298
+ if (timer !== null) clearTimeout(timer);
299
+ timer = setTimeout(() => {
300
+ timer = null;
301
+ fn.apply(this, args);
302
+ }, ms);
303
+ };
304
+ }
305
+
306
+ function highlightMatches(snippet, query) {
307
+ if (!query) return escapeHtml(snippet);
308
+ const terms = query
309
+ .toLowerCase()
310
+ .split(/\s+/)
311
+ .filter((t) => t.length >= 2);
312
+ if (terms.length === 0) return escapeHtml(snippet);
313
+ // Build a regex that matches any term, case-insensitive. Escape regex metachars.
314
+ const pattern = terms
315
+ .map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
316
+ .join('|');
317
+ const re = new RegExp(`(${pattern})`, 'gi');
318
+ const escaped = escapeHtml(snippet);
319
+ return escaped.replace(re, '<mark>$1</mark>');
320
+ }
321
+
322
+ function renderSearchResults(query, payload) {
323
+ if (!payload || !payload.hits || payload.hits.length === 0) {
324
+ $searchResults.innerHTML =
325
+ `<p class="hint">No results for <code>${escapeHtml(query)}</code>.</p>`;
326
+ $searchResults.hidden = false;
327
+ $list.hidden = true;
328
+ return;
329
+ }
330
+ const html = payload.hits
331
+ .map((h) => {
332
+ const slug = escapeHtml(h.slug || h.id);
333
+ const name = escapeHtml(h.name || h.slug || h.id);
334
+ const type = h.type ? `<span class="result-type">${escapeHtml(h.type)}</span>` : '';
335
+ const score = typeof h.score === 'number' ? h.score.toFixed(3) : '—';
336
+ const snippet = highlightMatches(h.snippet || '', query);
337
+ return [
338
+ `<a href="/docs/${slug}" data-slug="${slug}" class="result">`,
339
+ `<div class="result-head"><span class="result-name">${name}</span>${type}<span class="result-score">${score}</span></div>`,
340
+ `<div class="result-snippet">${snippet}</div>`,
341
+ `</a>`,
342
+ ].join('');
343
+ })
344
+ .join('');
345
+ $searchResults.innerHTML = html;
346
+ $searchResults.hidden = false;
347
+ $list.hidden = true;
348
+ $searchResults.querySelectorAll('a.result').forEach((a) => {
349
+ a.addEventListener('click', (e) => {
350
+ e.preventDefault();
351
+ const slug = a.getAttribute('data-slug');
352
+ navigate(slug);
353
+ });
354
+ });
355
+ }
356
+
357
+ function clearSearchResults() {
358
+ $searchResults.hidden = true;
359
+ $searchResults.innerHTML = '';
360
+ $list.hidden = false;
361
+ }
362
+
363
+ async function runSearch(query) {
364
+ const q = query.trim();
365
+ if (q.length === 0) {
366
+ clearSearchResults();
367
+ return;
368
+ }
369
+ const seq = ++state.searchSeq;
370
+ try {
371
+ const url = `/api/search?q=${encodeURIComponent(q)}&limit=20`;
372
+ const data = await fetchJson(url);
373
+ // Drop stale responses if a newer query has fired.
374
+ if (seq !== state.searchSeq) return;
375
+ renderSearchResults(q, data);
376
+ } catch (err) {
377
+ if (seq !== state.searchSeq) return;
378
+ $searchResults.innerHTML = `<p class="error">${escapeHtml(String(err.message || err))}</p>`;
379
+ $searchResults.hidden = false;
380
+ $list.hidden = true;
381
+ }
382
+ }
383
+
384
+ const debouncedSearch = debounce(runSearch, 300);
385
+ $search.addEventListener('input', (e) => debouncedSearch(e.target.value));
386
+
290
387
  (async function init() {
291
388
  try {
292
389
  const data = await fetchJson('/api/docs');
package/dist/cli/index.js CHANGED
@@ -41701,7 +41701,8 @@ import { dirname as dirname8, join as join16, normalize, resolve as resolve5 } f
41701
41701
  import { fileURLToPath as fileURLToPath5 } from "node:url";
41702
41702
  import {
41703
41703
  createAttachmentStore as createAttachmentStore2,
41704
- getProjectRoot as getProjectRoot27
41704
+ getProjectRoot as getProjectRoot27,
41705
+ searchAllProjectDocs
41705
41706
  } from "@cleocode/core/internal";
41706
41707
  function getViewerAssetsDir() {
41707
41708
  const thisFile = fileURLToPath5(import.meta.url);
@@ -41810,6 +41811,39 @@ function buildViewerHandler(opts = {}) {
41810
41811
  send(res, 200, "application/json", lafsSuccessJson({ docs }));
41811
41812
  return;
41812
41813
  }
41814
+ if (pathname === "/api/search") {
41815
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
41816
+ const q = (url.searchParams.get("q") ?? "").trim();
41817
+ const limitRaw = url.searchParams.get("limit");
41818
+ const type2 = url.searchParams.get("type") ?? void 0;
41819
+ if (q.length === 0) {
41820
+ send(
41821
+ res,
41822
+ 400,
41823
+ "application/json",
41824
+ lafsErrorJson(
41825
+ "E_VALIDATION",
41826
+ "query parameter `q` is required",
41827
+ "append ?q=<query> to /api/search, e.g. '/api/search?q=release'"
41828
+ )
41829
+ );
41830
+ return;
41831
+ }
41832
+ const limit = limitRaw ? Math.max(1, Math.min(50, Number.parseInt(limitRaw, 10))) : 10;
41833
+ try {
41834
+ const result = await searchAllProjectDocs(q, {
41835
+ projectRoot,
41836
+ limit: Number.isFinite(limit) ? limit : 10,
41837
+ type: type2 && type2.length > 0 ? type2 : void 0
41838
+ });
41839
+ send(res, 200, "application/json", lafsSuccessJson(result));
41840
+ } catch (err) {
41841
+ const msg = err instanceof Error ? err.message : String(err);
41842
+ const code = err instanceof Error && err.code ? err.code : "E_SEARCH_FAILED";
41843
+ send(res, 500, "application/json", lafsErrorJson(code, msg));
41844
+ }
41845
+ return;
41846
+ }
41813
41847
  const apiDocMatch = pathname.match(/^\/api\/docs\/([^/]+)$/);
41814
41848
  if (apiDocMatch) {
41815
41849
  const slug = decodeURIComponent(apiDocMatch[1]);
@@ -41905,7 +41939,7 @@ import { spawn } from "node:child_process";
41905
41939
  import { open as fsOpen } from "node:fs/promises";
41906
41940
  import { join as join17 } from "node:path";
41907
41941
  import { fileURLToPath as fileURLToPath6 } from "node:url";
41908
- import { getCleoHome as getCleoHome3, getProjectRoot as getProjectRoot28 } from "@cleocode/core/internal";
41942
+ import { getCleoHome as getCleoHome3, getProjectRoot as getProjectRoot28 } from "@cleocode/core";
41909
41943
  function getCleoBinPath() {
41910
41944
  const thisFile = fileURLToPath6(import.meta.url);
41911
41945
  return join17(thisFile, "..", "..", "index.js");
@@ -42402,6 +42436,7 @@ import {
42402
42436
  readJson,
42403
42437
  recordPublication,
42404
42438
  runDocsImport,
42439
+ searchAllProjectDocs as searchAllProjectDocs2,
42405
42440
  searchDocs as searchDocs2,
42406
42441
  statusDocs,
42407
42442
  syncFromGit
@@ -42765,7 +42800,7 @@ var init_docs3 = __esm({
42765
42800
  searchCommand = defineCommand({
42766
42801
  meta: {
42767
42802
  name: "search",
42768
- description: "Search attachments by semantic similarity using llmtxt/similarity.rankBySimilarity. Pass --owner to scope the search to a specific entity (T###, ses_*, O-*)."
42803
+ description: "Search attachments by semantic similarity using llmtxt/similarity.rankBySimilarity. Without --owner, ranks every published doc in the project by content (T9647). Pass --owner to scope to a specific entity (T###, ses_*, O-*) and rank by blob name."
42769
42804
  },
42770
42805
  args: {
42771
42806
  query: {
@@ -42775,7 +42810,11 @@ var init_docs3 = __esm({
42775
42810
  },
42776
42811
  owner: {
42777
42812
  type: "string",
42778
- description: "Scope search to a specific owner entity ID"
42813
+ description: "Scope search to a specific owner entity ID (legacy name-only ranking)"
42814
+ },
42815
+ type: {
42816
+ type: "string",
42817
+ description: "Filter project-wide search by taxonomy type: spec|adr|research|handoff|note|llm-readme"
42779
42818
  },
42780
42819
  limit: {
42781
42820
  type: "string",
@@ -42788,10 +42827,15 @@ var init_docs3 = __esm({
42788
42827
  },
42789
42828
  async run({ args }) {
42790
42829
  const projectRoot = getProjectRoot29();
42830
+ const limit = args.limit ? Number.parseInt(String(args.limit), 10) : 10;
42791
42831
  try {
42792
- const result = await searchDocs2(String(args.query), {
42793
- ownerId: args.owner ?? void 0,
42794
- limit: args.limit ? Number.parseInt(String(args.limit), 10) : 10,
42832
+ const result = args.owner ? await searchDocs2(String(args.query), {
42833
+ ownerId: String(args.owner),
42834
+ limit,
42835
+ projectRoot
42836
+ }) : await searchAllProjectDocs2(String(args.query), {
42837
+ limit,
42838
+ type: args.type ? String(args.type) : void 0,
42795
42839
  projectRoot
42796
42840
  });
42797
42841
  cliOutput(result, { command: "docs search", operation: "docs.search" });