fdeops 3.24.0 → 3.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/install.js CHANGED
@@ -3,6 +3,7 @@
3
3
  const fs = require('fs')
4
4
  const path = require('path')
5
5
  const os = require('os')
6
+ const { checkPath, checkTree, copyFile, writeFile, mkdir } = require('./lib/install-paths')
6
7
 
7
8
  const SKILLS_SRC = path.join(__dirname, '..', 'skills')
8
9
  const HOOKS_SRC = path.join(__dirname, '..', 'hooks')
@@ -17,28 +18,30 @@ const HOOK_SCRIPTS = ['session-start', 'session-stop', 'pre-compact']
17
18
  const ENGAGEMENTS_ROOT = path.join(os.homedir(), 'fde-engagements')
18
19
 
19
20
  function copyDir(src, dest) {
20
- fs.mkdirSync(dest, { recursive: true })
21
+ checkTree(src, dest)
22
+ mkdir(dest)
21
23
  for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
22
24
  const srcPath = path.join(src, entry.name)
23
25
  const destPath = path.join(dest, entry.name)
24
26
  if (entry.isDirectory()) {
25
27
  copyDir(srcPath, destPath)
26
28
  } else {
27
- fs.copyFileSync(srcPath, destPath)
29
+ copyFile(srcPath, destPath)
28
30
  }
29
31
  }
30
32
  }
31
33
 
32
34
  function copyTemplateTree(src, dest, onlyMissing) {
35
+ checkTree(src, dest)
33
36
  let merged = 0
34
- fs.mkdirSync(dest, { recursive: true })
37
+ mkdir(dest)
35
38
  for (const name of fs.readdirSync(src)) {
36
39
  const srcPath = path.join(src, name)
37
40
  const destPath = path.join(dest, name)
38
41
  if (fs.statSync(srcPath).isDirectory()) {
39
42
  merged += copyTemplateTree(srcPath, destPath, onlyMissing)
40
43
  } else if (!onlyMissing || !fs.existsSync(destPath)) {
41
- fs.copyFileSync(srcPath, destPath)
44
+ copyFile(srcPath, destPath)
42
45
  if (onlyMissing) merged++
43
46
  }
44
47
  }
@@ -62,17 +65,17 @@ const MANAGED_MARKER = '.fdeops-managed'
62
65
  function markManaged(dir) {
63
66
  let version = 'unknown'
64
67
  try { version = require(path.join(__dirname, '..', 'package.json')).version } catch (_) {}
65
- try {
66
- fs.writeFileSync(
67
- path.join(dir, MANAGED_MARKER),
68
- `managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
69
- 'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
70
- )
71
- } catch (_) {}
68
+ writeFile(
69
+ path.join(dir, MANAGED_MARKER),
70
+ `managed-by: fdeops\nversion: ${version}\ninstalled: ${new Date().toISOString()}\n` +
71
+ 'Delete this file to make fdeops treat the directory as yours and leave it alone.\n',
72
+ )
72
73
  }
73
74
 
74
75
  function isManaged(dir) {
75
- return fs.existsSync(path.join(dir, MANAGED_MARKER))
76
+ const marker = path.join(dir, MANAGED_MARKER)
77
+ checkPath(marker)
78
+ return fs.existsSync(marker) && /^managed-by: fdeops$/m.test(fs.readFileSync(marker, 'utf8'))
76
79
  }
77
80
 
78
81
  // A skill "directory" that is really a symlink points somewhere outside
@@ -89,6 +92,7 @@ function isLink(p) {
89
92
  // directory whose name matches one we ship, and only to overwrite - never to delete.
90
93
  function wasInstalledByUs(dir) {
91
94
  try {
95
+ checkPath(path.join(dir, 'SKILL.md'))
92
96
  const md = fs.readFileSync(path.join(dir, 'SKILL.md'), 'utf8')
93
97
  const fm = (md.match(/^---\n([\s\S]*?)\n---/) || [])[1]
94
98
  if (!fm) return false
@@ -106,6 +110,7 @@ const LEGACY_SKILL_DIRS = [
106
110
  ]
107
111
 
108
112
  function removeLegacySkills(opts = {}) {
113
+ checkPath(GLOBAL_SKILLS_DIR)
109
114
  let removed = 0
110
115
  const skipped = []
111
116
  const links = []
@@ -128,24 +133,26 @@ function installSkillDirs(opts = {}) {
128
133
  const skipped = []
129
134
  const links = []
130
135
  const failed = []
131
- fs.mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true })
136
+ mkdir(GLOBAL_SKILLS_DIR)
132
137
  for (const entry of fs.readdirSync(SKILLS_SRC, { withFileTypes: true })) {
133
138
  const src = path.join(SKILLS_SRC, entry.name)
134
139
  const dest = path.join(GLOBAL_SKILLS_DIR, entry.name)
135
- if (!entry.isDirectory()) { fs.copyFileSync(src, dest); continue }
140
+ if (!entry.isDirectory()) { copyFile(src, dest); continue }
136
141
  if (isLink(dest)) { links.push(entry.name); continue }
137
- if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
138
- // Installs predating the marker are still ours: adopt a same-named dir
139
- // whose SKILL.md is recognizably fdeops', so upgrades keep working.
140
- if (!wasInstalledByUs(dest)) {
141
- skipped.push(entry.name)
142
- continue
143
- }
144
- console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
145
- }
146
- // One unwritable skill dir must not abort the install with a stack trace:
147
- // say it in human terms, place the rest, and exit non-zero at the end.
148
142
  try {
143
+ checkTree(src, dest)
144
+ checkPath(path.join(dest, MANAGED_MARKER))
145
+ if (fs.existsSync(dest) && !isManaged(dest) && !opts.force) {
146
+ // Installs predating the marker are still ours: adopt a same-named dir
147
+ // whose SKILL.md is recognizably fdeops', so upgrades keep working.
148
+ if (!wasInstalledByUs(dest)) {
149
+ skipped.push(entry.name)
150
+ continue
151
+ }
152
+ console.log(` adopt ~/.claude/skills/${entry.name} (earlier fdeops install)`)
153
+ }
154
+ // One unwritable skill dir must not abort the install with a stack trace:
155
+ // say it in human terms, place the rest, and exit non-zero at the end.
149
156
  copyDir(src, dest)
150
157
  markManaged(dest)
151
158
  } catch (e) {
@@ -174,6 +181,7 @@ function reportCollisions(paths, verb) {
174
181
 
175
182
  function reportLinks(names) {
176
183
  if (!names.length) return
184
+ installIncomplete = true
177
185
  console.log(` skip ${names.length} skill path(s) that are symlinks - fdeops will not write through them:`)
178
186
  for (const name of names) console.log(` ~/.claude/skills/${name} -> ${readLinkQuiet(path.join(GLOBAL_SKILLS_DIR, name))}`)
179
187
  console.log(' remove the link if you want fdeops to install at that path itself')
@@ -190,7 +198,7 @@ function reportFailures(failures) {
190
198
  installIncomplete = true
191
199
  console.log(` error ${failures.length} skill dir(s) could not be written:`)
192
200
  for (const f of failures) {
193
- const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code
201
+ const why = f.code === 'EACCES' || f.code === 'EPERM' ? 'permission denied' : f.code === 'UNSAFE_PATH' ? 'unsafe symlink or non-regular path' : f.code
194
202
  console.log(` ~/.claude/skills/${f.name} - ${why} at ${f.path}`)
195
203
  }
196
204
  console.log(' fix the permissions (or remove the directory) and re-run - the rest of the install continued')
@@ -204,26 +212,26 @@ function installSkills(opts = {}) {
204
212
  reportCollisions(placed.skipped, 'overwriting them')
205
213
  reportLinks([...new Set([...legacy.links, ...placed.links])])
206
214
  reportFailures(placed.failed)
207
- fs.mkdirSync(GLOBAL_HOOKS_DIR, { recursive: true })
215
+ mkdir(GLOBAL_HOOKS_DIR)
208
216
  for (const name of HOOK_SCRIPTS) {
209
217
  const src = path.join(HOOKS_SRC, name)
210
218
  if (!fs.existsSync(src)) continue
211
219
  const dest = path.join(GLOBAL_HOOKS_DIR, `fdeops-${name}`)
212
- fs.copyFileSync(src, dest)
220
+ copyFile(src, dest)
213
221
  try {
214
222
  fs.chmodSync(dest, '755')
215
223
  } catch (_) {}
216
224
  }
217
225
  const globalPointer = path.join(os.homedir(), '.claude', 'FDEOPS-CLAUDE.md')
218
226
  if (!fs.existsSync(globalPointer)) {
219
- fs.copyFileSync(CLAUDE_MD_SRC, globalPointer)
227
+ copyFile(CLAUDE_MD_SRC, globalPointer)
220
228
  }
221
- fs.copyFileSync(CLAUDE_MD_SRC, path.join(os.homedir(), '.claude', 'FDEOPS-CLAUDE.md.template'))
229
+ copyFile(CLAUDE_MD_SRC, path.join(os.homedir(), '.claude', 'FDEOPS-CLAUDE.md.template'))
222
230
 
223
231
  // the fde CLI + templates, so the skill can call it from any workspace
224
232
  const cliHome = path.join(os.homedir(), '.claude', 'fdeops')
225
- fs.mkdirSync(cliHome, { recursive: true })
226
- fs.copyFileSync(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
233
+ mkdir(cliHome)
234
+ copyFile(path.join(__dirname, 'fde.js'), path.join(cliHome, 'fde.js'))
227
235
  copyDir(LIB_SRC, path.join(cliHome, 'lib'))
228
236
  try { fs.chmodSync(path.join(cliHome, 'fde.js'), '755') } catch (_) {}
229
237
  copyDir(FDE_TEMPLATES_SRC, path.join(cliHome, 'templates', '.fde'))
@@ -245,7 +253,8 @@ const ADAPTER_TARGETS = [
245
253
  const FDE_MARKER = '<!-- fdeops adapter - points your AI tool at @fde; safe to keep -->'
246
254
 
247
255
  function placePointer(destPath, content, label, appendable) {
248
- fs.mkdirSync(path.dirname(destPath), { recursive: true })
256
+ checkPath(destPath)
257
+ mkdir(path.dirname(destPath))
249
258
  if (fs.existsSync(destPath)) {
250
259
  const existing = fs.readFileSync(destPath, 'utf8')
251
260
  if (/FDEOS|fdeops/i.test(existing)) {
@@ -256,16 +265,17 @@ function placePointer(destPath, content, label, appendable) {
256
265
  console.log(` skip ${label} (exists - left untouched)`)
257
266
  return
258
267
  }
259
- fs.writeFileSync(destPath, `${existing.trimEnd()}\n\n${FDE_MARKER}\n\n${content}`)
268
+ writeFile(destPath, `${existing.trimEnd()}\n\n${FDE_MARKER}\n\n${content}`)
260
269
  console.log(` append ${label}`)
261
270
  return
262
271
  }
263
- fs.writeFileSync(destPath, content)
272
+ writeFile(destPath, content)
264
273
  console.log(` write ${label}`)
265
274
  }
266
275
 
267
276
  function cmdAdapters(targetDir, opts = {}) {
268
277
  const dest = path.resolve(targetDir || process.cwd())
278
+ checkPath(dest)
269
279
  console.log('')
270
280
  console.log(` fdeops cross-platform adapters → ${dest}`)
271
281
  console.log(' One brain (skills/fde/SKILL.md). These are thin pointers per tool.')
@@ -275,6 +285,7 @@ function cmdAdapters(targetDir, opts = {}) {
275
285
  // that file - `adapters` alone wrote pointers to a brain that didn't exist
276
286
  // yet, a dangling reference for anyone following the documented Cursor/Codex
277
287
  // path. installSkills() is idempotent (safe to call every run).
288
+ checkPath(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))
278
289
  if (!fs.existsSync(path.join(GLOBAL_SKILLS_DIR, 'fde', 'SKILL.md'))) {
279
290
  installSkills(opts)
280
291
  console.log(' Skills → ~/.claude/skills/ (installed - the pointers below need this)')
@@ -282,11 +293,16 @@ function cmdAdapters(targetDir, opts = {}) {
282
293
  }
283
294
  for (const a of ADAPTER_TARGETS) {
284
295
  if (!fs.existsSync(a.src)) { console.log(` skip ${a.label} (template missing)`); continue }
285
- placePointer(path.join(dest, a.dest), fs.readFileSync(a.src, 'utf8'), a.label, a.appendable)
296
+ try {
297
+ placePointer(path.join(dest, a.dest), fs.readFileSync(a.src, 'utf8'), a.label, a.appendable)
298
+ } catch (e) {
299
+ installIncomplete = true
300
+ console.error(` error ${a.label}: ${e.message}`)
301
+ }
286
302
  }
287
303
  console.log('')
288
304
  console.log(' Open this workspace in Claude Code, Cursor, Codex, Gemini CLI, or Copilot')
289
- console.log(' and type @fde - each tool now routes to the same engagement brain.')
305
+ console.log(installIncomplete ? ' Some adapters could not be installed; fix the errors above and run again.' : ' and type @fde - each tool now routes to the same engagement brain.')
290
306
  console.log('')
291
307
  }
292
308
 
@@ -304,7 +320,7 @@ function cmdInit(engagementName) {
304
320
 
305
321
  const pointer = path.join(root, 'ENGAGEMENT.md')
306
322
  if (!fs.existsSync(pointer)) {
307
- fs.writeFileSync(
323
+ writeFile(
308
324
  pointer,
309
325
  `# ${engagementName}\n\nEngagement root: \`${fdeDir}\`\n\nPoint your **AI coding agent** at this folder (not a human colleague). Add to ~/.claude/FDEOPS-CLAUDE.md:\n\n\`\`\`\nFDEOPS_ENGAGEMENT=${fdeDir}\n\`\`\`\n\nOpen your workspace. In the AI chat, type \`@fde\`.\n`,
310
326
  )
@@ -435,16 +451,23 @@ function handOffToCli(verb) {
435
451
  require(path.join(__dirname, 'fde.js'))
436
452
  }
437
453
 
438
- if (!raw && askedVersion) {
439
- console.log(require(path.join(__dirname, '..', 'package.json')).version)
440
- } else if (!raw && askedHelp) {
441
- handOffToCli('help')
442
- } else if (arg === 'init') {
443
- cmdInit(positional[1])
444
- } else if (arg === 'adapters') {
445
- cmdAdapters(positional[1], { force })
446
- } else if (arg && arg !== 'install') {
447
- handOffToCli(arg)
448
- } else {
449
- cmdInstall({ force })
454
+ try {
455
+ if (!raw && askedVersion) {
456
+ console.log(require(path.join(__dirname, '..', 'package.json')).version)
457
+ } else if (!raw && askedHelp) {
458
+ handOffToCli('help')
459
+ } else if (arg === 'init') {
460
+ cmdInit(positional[1])
461
+ } else if (arg === 'adapters') {
462
+ cmdAdapters(positional[1], { force })
463
+ } else if (arg && arg !== 'install') {
464
+ handOffToCli(arg)
465
+ } else {
466
+ cmdInstall({ force })
467
+ }
468
+
469
+ } catch (e) {
470
+ console.error(` fdeops: ${e.message}`)
471
+ process.exitCode = 1
450
472
  }
473
+ if (installIncomplete) process.exitCode = 1
@@ -0,0 +1,85 @@
1
+ 'use strict'
2
+
3
+ const DEFAULT_BYTES = 16384
4
+ const MIN_BYTES = 4096
5
+ const MAX_BYTES = 65536
6
+ const OMITTED = '\n[Excerpt truncated; retrieve the source before relying on omitted details.]'
7
+
8
+ // A byte ceiling is deterministic across hosts. It is deliberately not called
9
+ // a token count: tokenization varies by model and language.
10
+ function clipUtf8(text, bytes) {
11
+ if (Buffer.byteLength(text) <= bytes) return text
12
+ const buf = Buffer.from(text)
13
+ let end = Math.max(0, bytes)
14
+ while (end > 0 && (buf[end] & 0xc0) === 0x80) end--
15
+ return buf.subarray(0, end).toString('utf8')
16
+ }
17
+ function budgetArgs(args) {
18
+ const rest = [...args]
19
+ let maxBytes = DEFAULT_BYTES
20
+ const at = rest.indexOf('--max-bytes')
21
+ if (at !== -1) {
22
+ const raw = rest[at + 1]
23
+ maxBytes = Number(raw)
24
+ if (!/^\d+$/.test(raw || '') || !Number.isSafeInteger(maxBytes) || maxBytes < MIN_BYTES || maxBytes > MAX_BYTES) {
25
+ throw new Error(`--max-bytes must be an integer from ${MIN_BYTES} to ${MAX_BYTES}`)
26
+ }
27
+ rest.splice(at, 2)
28
+ if (rest.includes('--max-bytes')) throw new Error('supply --max-bytes only once')
29
+ }
30
+ return { args: rest, maxBytes }
31
+ }
32
+ function boundedSections(sections, maxBytes = DEFAULT_BYTES) {
33
+ const footer = '\n\nCONTEXT: selected excerpts, not the complete record or proof of approval. Use fde recall <topic> for relevant evidence. If policy or constraints are truncated, retrieve them before acting. Verify conflicting decisions.\n'
34
+ let out = ''
35
+ const filled = sections.filter(s => s && s.trim())
36
+ for (let i = 0; i < filled.length; i++) {
37
+ const available = maxBytes - Buffer.byteLength(out + footer) - 2
38
+ const share = Math.max(0, Math.floor(available / (filled.length - i)))
39
+ const text = filled[i]
40
+ const next = Buffer.byteLength(text) <= share ? text : clipUtf8(text, Math.max(0, share - Buffer.byteLength(OMITTED))) + OMITTED
41
+ out += (out ? '\n\n' : '') + next
42
+ }
43
+ return out + footer
44
+ }
45
+
46
+ // Literal, client-scoped lexical retrieval. Retain independently matching
47
+ // records, including conflicting/older ones; recency never means truth.
48
+ function recallSections(documents, query, maxHits = 12) {
49
+ const words = [...new Set(query.toLocaleLowerCase().split(/\s+/).filter(Boolean))].slice(0, 16)
50
+ const hits = []
51
+ for (const { file, text } of documents) {
52
+ const lines = text.split('\n')
53
+ for (let i = 0; i < lines.length; i++) {
54
+ const line = lines[i]
55
+ const lower = line.toLocaleLowerCase()
56
+ const score = words.reduce((n, word) => n + Number(lower.includes(word)), 0)
57
+ if (!score) continue
58
+ const first = Math.max(0, i - 1)
59
+ const last = Math.min(lines.length, i + 3)
60
+ const excerpt = lines.slice(first, last).map(l => Buffer.byteLength(l) <= 1024 ? l : clipUtf8(l, 900) + OMITTED).join('\n')
61
+ hits.push({ file, line: first + 1, end: last, score, text: excerpt.trim() })
62
+ }
63
+ }
64
+ hits.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file) || b.line - a.line)
65
+ // Round-robin files, taking recent and older matches so a long topic does
66
+ // not hide its latest change or original constraint. Omission stays explicit.
67
+ const groups = new Map()
68
+ for (const hit of hits) {
69
+ if (!groups.has(hit.file)) groups.set(hit.file, [])
70
+ groups.get(hit.file).push(hit)
71
+ }
72
+ const selected = []
73
+ let oldest = false
74
+ while (selected.length < maxHits && [...groups.values()].some(g => g.length)) {
75
+ for (const group of groups.values()) {
76
+ if (group.length && selected.length < maxHits) selected.push(oldest ? group.pop() : group.shift())
77
+ }
78
+ oldest = !oldest
79
+ }
80
+ return {
81
+ total: hits.length,
82
+ sections: selected.map(h => `${h.file}:${h.line}-${h.end} (lines in redacted view)\n${h.text}`),
83
+ }
84
+ }
85
+ module.exports = { DEFAULT_BYTES, clipUtf8, budgetArgs, boundedSections, recallSections }
@@ -6,6 +6,10 @@ module.exports = function fieldbookClient() {
6
6
  const views = all('.fb-view')
7
7
  const nav = all('.fb-nav')
8
8
  const search = document.getElementById('fb-search')
9
+ const filter = document.getElementById('fb-filter')
10
+ const rail = document.getElementById('fb-client-rail')
11
+ const clientsButton = document.getElementById('fb-clients-btn')
12
+ const mobile = window.matchMedia('(max-width:640px)')
9
13
  const main = document.querySelector('.fb-main')
10
14
  const status = document.getElementById('fb-status')
11
15
  const palette = document.getElementById('fb-palette-dialog')
@@ -15,6 +19,13 @@ module.exports = function fieldbookClient() {
15
19
  let returnFocus = null
16
20
  let active = 0
17
21
  const currentId = () => document.querySelector('.fb-view:not([hidden])').id.replace('view-', '')
22
+ function showClients(show) {
23
+ rail.classList.toggle('is-collapsed', !show)
24
+ clientsButton.setAttribute('aria-expanded', String(show))
25
+ }
26
+ showClients(!mobile.matches)
27
+ mobile.addEventListener('change', event => showClients(!event.matches))
28
+ clientsButton.addEventListener('click', () => showClients(clientsButton.getAttribute('aria-expanded') !== 'true'))
18
29
  function selectView(id, updateHash = true, focus = false) {
19
30
  if (id === 'fb-main') { id = 'today'; updateHash = false }
20
31
  if (!document.getElementById('view-' + id)) id = 'today'
@@ -27,7 +38,10 @@ module.exports = function fieldbookClient() {
27
38
  })
28
39
  if (updateHash && location.hash !== '#' + id) location.hash = id
29
40
  main.scrollTop = 0
30
- if (focus) document.querySelector('.fb-view:not([hidden]) h1').focus()
41
+ if (focus) {
42
+ document.querySelector('.fb-view:not([hidden]) h1').focus()
43
+ if (mobile.matches) showClients(false)
44
+ }
31
45
  }
32
46
  const hashId = () => location.hash.slice(1)
33
47
  selectView(hashId() || 'today', false)
@@ -47,22 +61,34 @@ module.exports = function fieldbookClient() {
47
61
  const start = Math.max(0, i - 24), end = Math.min(raw.length, i + term.length + 40)
48
62
  return (start ? '…' : '') + esc(raw.slice(start, i)) + '<mark>' + esc(raw.slice(i, i + term.length)) + '</mark>' + esc(raw.slice(i + term.length, end)) + (end < raw.length ? '…' : '')
49
63
  }
50
- search.addEventListener('input', () => {
64
+ function filterClients() {
51
65
  const term = search.value.trim().toLowerCase()
52
- let count = 0
66
+ const matches = new Set()
53
67
  nav.filter(n => n.dataset.target !== 'today').forEach(n => {
54
- const raw = n.dataset.search || ''
55
- const hit = !term || raw.includes(term)
68
+ const raw = (n.dataset.search || '').toLowerCase()
69
+ const inGroup = filter.value === 'all' || (n.dataset.attention === 'true') === (filter.value === 'attention')
70
+ const hit = inGroup && (!term || raw.includes(term))
56
71
  n.classList.toggle('hide', !hit)
57
- if (hit) count++
72
+ if (hit) matches.add(n.dataset.target)
58
73
  const reason = n.querySelector('.fb-nav-reason')
59
74
  if (term && hit) reason.innerHTML = snippet(raw, term)
60
75
  else reason.textContent = n.dataset.defaultReason || ''
61
76
  reason.classList.toggle('hide', !reason.textContent)
62
77
  })
63
- document.getElementById('fb-search-empty').hidden = !term || count > 0
64
- document.getElementById('fb-search-count').textContent = term ? count + ' matching engagement' + (count === 1 ? '' : 's') : ''
65
- })
78
+ const count = matches.size
79
+ all('[data-client]').forEach(row => row.classList.toggle('hide', !matches.has(row.dataset.client)))
80
+ const filtering = Boolean(term) || filter.value !== 'all'
81
+ document.getElementById('fb-search-empty').hidden = !filtering || count > 0
82
+ const empty = document.getElementById('fb-results-empty')
83
+ if (empty) empty.hidden = !filtering || count > 0
84
+ all('[data-filter-section]').forEach(section => { section.hidden = !Array.from(section.querySelectorAll('[data-client]')).some(row => !row.classList.contains('hide')) })
85
+ const total = nav.length - 1
86
+ document.getElementById('fb-search-count').textContent = count + ' of ' + total + ' client' + (total === 1 ? '' : 's') + (filtering ? ' match' : '')
87
+ }
88
+ search.addEventListener('input', filterClients)
89
+ filter.addEventListener('change', filterClients)
90
+ all('[data-reset-filters]').forEach(button => button.addEventListener('click', () => { search.value = ''; filter.value = 'all'; filterClients() }))
91
+ filterClients()
66
92
 
67
93
  function toggleTheme() {
68
94
  const next = document.documentElement.dataset.fdeTheme === 'dark' ? 'light' : 'dark'
@@ -114,6 +140,7 @@ module.exports = function fieldbookClient() {
114
140
  if (!it) return
115
141
  palette.close()
116
142
  if (it.dataset.target === '__theme__') toggleTheme()
143
+ else if (it.dataset.target === '__print__') window.print()
117
144
  else selectView(it.dataset.target, true, true)
118
145
  }
119
146
  document.getElementById('fb-palette-btn').addEventListener('click', openPalette)
@@ -134,6 +161,10 @@ module.exports = function fieldbookClient() {
134
161
  try {
135
162
  await navigator.clipboard.writeText(text)
136
163
  status.textContent = 'Prompt copied. Paste it into your coding agent; review changes before saving.'
164
+ const label = button.textContent
165
+ button.textContent = 'Copied'
166
+ button.disabled = true
167
+ setTimeout(() => { button.textContent = label; button.disabled = false }, 1800)
137
168
  } catch (_) {
138
169
  const input = document.getElementById('fb-prompt-text')
139
170
  input.value = text
@@ -154,7 +185,7 @@ module.exports = function fieldbookClient() {
154
185
  return
155
186
  }
156
187
  if (event.target.closest('.fb-table-scroll')) return
157
- if (event.key === '/') { event.preventDefault(); search.focus(); return }
188
+ if (event.key === '/') { event.preventDefault(); showClients(true); search.focus(); return }
158
189
  if (event.key === 'j' || event.key === 'k' || (event.target.closest('.fb-rail') && ['ArrowDown', 'ArrowUp'].includes(event.key))) {
159
190
  event.preventDefault()
160
191
  const ids = nav.filter(n => !n.classList.contains('hide')).map(n => n.dataset.target)