SLOPSHOPPER

datatug

DataTug CLI plugin: skills that teach AI agents how to use the datatug CLI for data exploration, dataset management, schema scanning, and query execution.

newpanecommandtoastprocess
v0.0.4MITupdated 2026-10-09datatug/ai-plugin
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · datatug
│ ┃ DataTug ✕ › fix the failing auth test and add an audit log call │ ┃ 1: Overview 2: Environments 3: Queries 4: │ ┃ ⏺ Read(src/auth.ts) │ ┃ (project details unavailable) ⎿ Read 6 lines │ ┃ ? environments · ? queries · ? boards ⏺ Update(src/auth.ts) │ ┃ ⚠ datatug show: its output is not JSON ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /datatug │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · DataTug
1: Overview 2: Environments 3: Queries 4: Boards (project details unavailable) ? environments · ? queries · ? boards ⚠ datatug show: its output is not JSON
README

DataTug AI Plugin

AI plugin for DataTug - skills that teach AI agents how to use the datatug CLI for data exploration, dataset management, schema scanning, and query execution.

This repository contains the plugin source. It is installed on top of the datatug CLI; the CLI is a prerequisite.

Contents

DirectoryDescription
skills/Agent skills - one per major datatug CLI surface area, progressively loaded per-verb
.claude-plugin/Claude Code plugin manifest
hooks/, lib/Claude Code mod: the /datatug project pane (Claude Code only)
tests/Mod tests, run with claude plugin test
.codex-plugin/Codex plugin manifest
plugin.jsonPortable Agent Plugins manifest for Cursor and other compatible hosts
gemini-extension.jsonGemini CLI extension manifest
.github/plugin.jsonGitHub Copilot CLI / VS Code agent plugin manifest

Claude Code mod

In Claude Code, the plugin also ships a mod: run /datatug in a DataTug project's root folder to open a read-only pane with four tabs (Overview, Environments, Queries, Boards). Everything it shows comes from the datatug CLI (datatug show, datatug queries and datatug board list, each with --format json); the mod reads no project file itself. Where nothing can draw (VS Code chat, claude -p), it prints a text summary instead.

On the Queries tab, Tab moves between queries and Enter opens one (title, ID, type and parameters). Press a there to draft a request in the prompt box without sending it, so you can add details first; this closes the pane, and /datatug opens it again. b goes back to the list.

The mod runs the datatug found first on your PATH, in the session's working directory, as the plugin's skills do.

It requires Claude Code 2.1.287 or later and datatug 0.67.0 or later; other hosts do not run the mod (GitHub Copilot CLI logs one error line about the root hooks/hooks.json when it loads the plugin, and still loads its skills). If the CLI is missing or older, /datatug says so and names the fix.

Develop and test it from this directory with claude --plugin-dir ., claude plugin validate . and claude plugin test.

Install

The same skills/<name>/SKILL.md payload is shared across package formats; each host reads its own manifest. This repository has native inventory receipts for Claude Code and Codex. The Gemini CLI, GitHub Copilot, and Cursor metadata is declarative here and needs host-runtime validation in those environments.

Claude Code

Via the DataTug AI marketplace:

/plugin marketplace add datatug/ai-marketplace
/plugin install datatug@datatug

Codex

codex plugin marketplace add datatug/ai-marketplace

Then enable the datatug plugin from Codex's plugin directory. Codex reads .codex-plugin/plugin.json.

Gemini CLI

gemini extensions install https://github.com/datatug/ai-plugin

gemini-extension.json declares the bundled skills/ tree for Gemini CLI; Gemini runtime discovery is not exercised in this repository.

GitHub Copilot CLI

copilot plugin install datatug/ai-plugin

.github/plugin.json declares the skills for GitHub Copilot; Copilot runtime discovery is not exercised in this repository.

Cursor

The root plugin.json declares the portable Agent Plugins schema supported by Cursor. For local development, follow Cursor's local plugin folder guidance: place this repository under ~/.cursor/plugins/local/datatug and reload Cursor. Cursor runtime discovery is not exercised in this repository.

First use

The datatug CLI must be on your PATH before any wrapper skill can run. Options:

Verify with datatug --help.

Relationship to the CLI

The plugin wraps the datatug CLI - it does not replace it. Skills encode when to call a command, which flags to pass, and how to interpret exit codes. The CLI source of truth is datatug/datatug-cli.

A change in the CLI surface typically produces a matching skill update in this repository; the two evolve together but release independently.

Relationship to other plugins

datatug is a base-layer CLI wrapper plugin, in the same shape as:

Releases

Releases are tagged as datatug--v<version> on this repository to support Claude Code's dependency resolution.

License

MIT - see LICENSE.

Source 7 files
hooks/register.js 143 lines
1import {
2  EXIT_NOT_A_PROJECT,
3  LISTINGS,
4  MIN_CLI_VERSION,
5  VERSION_ARGS,
6  isOlder,
7  missingCliText,
8  notAProjectText,
9  parseVersion,
10  tooOldText,
11} from '../lib/cli.js'
12import { appendPrefix, queryDraft } from '../lib/draft.js'
13import { findQuery, summaryText } from '../lib/lines.js'
14import { buildProject } from '../lib/project.js'
15import { buildPane, canDrawPane } from '../lib/view.js'
16
17const PANE = 'datatug-project'
18const TIMEOUT_MS = 15000
19
20// What the pane shows: the loaded project and the open tab.
21let project = null
22let tab = 'overview'
23let selected = null // path of the chosen query, or null
24let focusPath = null // the query row that takes the focus after Back
25let isAsking = false // an Ask Claude press is running
26
27// Runs one read-only datatug command, with no shell. A command that cannot be
28// started, or runs past the timeout, resolves with isStarted false.
29async function runDatatug($, args, cwd) {
30  try {
31    const result = await $.process.run(['datatug', ...args], { cwd, timeoutMs: TIMEOUT_MS })
32    return {
33      isStarted: true,
34      exitCode: result.exitCode,
35      stdout: result.stdout,
36      stderr: result.stderr,
37      isStdoutTruncated: result.isStdoutTruncated === true,
38    }
39  } catch (err) {
40    return { isStarted: false, exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }
41  }
42}
43
44export function register(on) {
45  on('session.start', async ($, e, next) => {
46    await $.command.register({
47      name: 'datatug',
48      description: 'Show the DataTug project in this directory',
49    })
50    return next(e)
51  })
52
53  on('command.run', { command: 'datatug' }, async ($) => {
54    const cwd = await $.session.cwd()
55
56    const version = await runDatatug($, VERSION_ARGS, cwd)
57    if (!version.isStarted) return { text: missingCliText() }
58    // A version that cannot be read is a development build: let it through.
59    const found = parseVersion(version.stdout)
60    if (found !== null && isOlder(found, parseVersion(MIN_CLI_VERSION))) {
61      return { text: tooOldText(found.join('.')) }
62    }
63
64    const [show, queries, boards] = await Promise.all([
65      runDatatug($, LISTINGS.show, cwd),
66      runDatatug($, LISTINGS.queries, cwd),
67      runDatatug($, LISTINGS.boards, cwd),
68    ])
69    if (show.exitCode === EXIT_NOT_A_PROJECT) return { text: notAProjectText(cwd) }
70
71    project = buildProject({ show, queries, boards })
72    tab = 'overview'
73    selected = null
74    focusPath = null
75    // An open pane is redrawn only when asked: re-opening its id just retitles it.
76    $.ui.invalidate('ui.render')
77
78    // Where no surface shows panes (a `-p` run names none), reply with a
79    // plain-text summary: `$.ui.open` would report a pane placed that nothing draws.
80    const surfaces = await $.session.surfaces()
81    if (!canDrawPane(surfaces)) return { text: summaryText(project) }
82
83    try {
84      const opened = await $.ui.open({ id: PANE, title: 'DataTug', focus: true, closeOnEscape: true })
85      if (opened.isPlaced) return {}
86    } catch {
87      // fall through to the text summary
88    }
89    return { text: summaryText(project) }
90  })
91
92  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
93    if (e.requestId !== PANE) return next(e)
94    const elements = $.ui.resolve(e)
95    if (project === null) {
96      return elements.Text({ dimColor: true, children: ['Run /datatug to load the project.'] })
97    }
98    return buildPane(elements, project, tab, selected, {
99      onTab: (id) => {
100        tab = id
101        selected = null
102        focusPath = null
103        $.ui.invalidate('ui.render')
104      },
105      onSelect: (path) => {
106        selected = path
107        focusPath = path
108        $.ui.invalidate('ui.render')
109      },
110      onBack: () => {
111        selected = null
112        $.ui.invalidate('ui.render')
113      },
114      onAsk: async () => {
115        if (isAsking) return
116        const query = selected === null || project === null ? null : findQuery(project, selected)
117        if (query === null) return
118        const draft = queryDraft(query)
119        if (draft === null) return
120        isAsking = true
121        try {
122          let isFilled = false
123          try {
124            const box = await $.prompt.read()
125            const filled = await $.prompt.fill({ text: appendPrefix(box.text) + draft, mode: 'append' })
126            isFilled = filled.isFilled === true
127            // No call hands the keys back and leaves the pane open, so close
128            // it. The draft is in the box by now: a close that fails is not
129            // a failed draft.
130            if (isFilled) await $.ui.close({ id: PANE })
131          } catch {
132            if (!isFilled) $.ui.toast(draft, { timeoutMs: 15000 })
133            return
134          }
135          if (!isFilled) $.ui.toast(draft, { timeoutMs: 15000 })
136        } finally {
137          isAsking = false
138        }
139      },
140    }, focusPath)
141  })
142}
143
lib/cli.js 84 lines
1// What the mod asks of the datatug CLI, and how it reads the answers. Pure:
2// starting a process is the hooks module's job.
3import { cleanText } from './text.js'
4
5// The first datatug release with `board list`, `queries --format json` and
6// `show --depth` (datatug-cli spec/features/cli/board, queries, show).
7export const MIN_CLI_VERSION = '0.67.0'
8
9// Argument lists, without the leading `datatug`. All four are read-only.
10export const VERSION_ARGS = ['--version']
11export const LISTINGS = {
12  show: ['show', '--format', 'json', '--depth', 'tables'],
13  queries: ['queries', '--format', 'json'],
14  boards: ['board', 'list', '--format', 'json'],
15}
16
17// The CLI's exit code for a folder that is not a project.
18export const EXIT_NOT_A_PROJECT = 3
19
20const COMMAND_NAMES = {
21  show: 'datatug show',
22  queries: 'datatug queries',
23  boards: 'datatug board list',
24}
25
26const ANSI_STYLE = /\u001b\[[0-9;]*m/g
27
28export function parseVersion(text) {
29  const m = /(\d+)\.(\d+)\.(\d+)/.exec(text ?? '')
30  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
31}
32
33export function isOlder(version, minimum) {
34  for (let i = 0; i < 3; i += 1) {
35    if (version[i] !== minimum[i]) return version[i] < minimum[i]
36  }
37  return false
38}
39
40const MAX_ERROR_LENGTH = 300
41
42// The CLI prints a failure as a banner line "ERROR" between blank lines, then
43// the message, wrapped over several lines when it is long. A warning row
44// carries the message as one line.
45export function errorMessage(stderr, exitCode) {
46  const text = cleanText(
47    String(stderr ?? '')
48      .replace(ANSI_STYLE, '')
49      .split('\n')
50      .map((line) => line.trim())
51      .filter((line) => line !== '' && line !== 'ERROR')
52      .join(' '),
53    MAX_ERROR_LENGTH,
54  )
55  return text === '' ? 'exit code ' + exitCode : text
56}
57
58// Turns one finished listing command into its parsed JSON, or a warning.
59export function readListing(name, result) {
60  if (result.exitCode !== 0) {
61    return { data: null, warning: COMMAND_NAMES[name] + ': ' + errorMessage(result.stderr, result.exitCode) }
62  }
63  if (result.isStdoutTruncated === true) {
64    return { data: null, warning: COMMAND_NAMES[name] + ': its output is too large to read' }
65  }
66  try {
67    return { data: JSON.parse(result.stdout), warning: null }
68  } catch {
69    return { data: null, warning: COMMAND_NAMES[name] + ': its output is not JSON' }
70  }
71}
72
73export function missingCliText() {
74  return 'The datatug CLI could not be started, or did not answer in 15 seconds. If it is not installed, use the datatug:datatug-install skill, then run /datatug again.'
75}
76
77export function tooOldText(found) {
78  return 'datatug ' + found + ' is too old for this pane: it needs ' + MIN_CLI_VERSION + ' or later. Run `datatug self-update`, then /datatug again.'
79}
80
81export function notAProjectText(cwd) {
82  return cwd + ' is not a DataTug project: run /datatug from a project\'s root folder.'
83}
84
lib/draft.js 60 lines
1// The request Ask Claude drafts for a query. Pure. It carries the query's ID
2// and parameter names only, and only when they are plain names: free text from
3// the project (a title, an odd parameter id) never reaches the prompt box.
4
5export const MAX_DRAFT = 500
6const MAX_NAMES = 10
7const MAX_SEGMENTS = 16
8const MAX_PATH = 200
9
10const PLAIN_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/
11
12export const isPlainName = (name) => typeof name === 'string' && PLAIN_NAME.test(name)
13
14// A query path is plain when every "/" segment is plain, there are at most 16
15// segments, and the whole path is at most 200 characters (what keeps the draft
16// within MAX_DRAFT).
17export function isPlainPath(path) {
18  if (typeof path !== 'string' || path.length > MAX_PATH) return false
19  const segments = path.split('/')
20  return segments.length <= MAX_SEGMENTS && segments.every(isPlainName)
21}
22
23function build(path, required, optional, nReq, nOpt) {
24  const shownReq = required.slice(0, nReq)
25  const moreReq = required.length - shownReq.length
26  const shownOpt = optional.slice(0, nOpt)
27  const moreOpt = optional.length - shownOpt.length
28  const optItems = [...shownOpt, ...(moreOpt ? ['+' + moreOpt + ' more'] : [])]
29  return (
30    'Run the saved DataTug query ' +
31    path +
32    (shownReq.length ? ' with ' + shownReq.map((id) => id + '=').join(', ') : '') +
33    (moreReq ? ' (+' + moreReq + ' more required)' : '') +
34    (optItems.length ? ' (optional: ' + optItems.join(', ') + ')' : '')
35  )
36}
37
38// The draft for a query, or null when its ID is not made of plain names.
39export function queryDraft(query) {
40  if (!isPlainPath(query.path)) return null
41  const plain = query.parameters.filter((param) => isPlainName(param.id))
42  const required = plain.filter((param) => param.required).map((param) => param.id)
43  const optional = plain.filter((param) => !param.required).map((param) => param.id)
44  let nReq = Math.min(MAX_NAMES, required.length)
45  let nOpt = Math.min(MAX_NAMES, optional.length)
46  let draft = build(query.path, required, optional, nReq, nOpt)
47  // Long names: drop shown names, optional first, until it fits.
48  while (draft.length > MAX_DRAFT && (nReq > 0 || nOpt > 0)) {
49    if (nOpt > 0) nOpt -= 1
50    else nReq -= 1
51    draft = build(query.path, required, optional, nReq, nOpt)
52  }
53  return draft
54}
55
56// What goes before the draft so it does not run into the text already typed.
57export function appendPrefix(existingText) {
58  return existingText !== '' && !/\s$/.test(existingText) ? ' ' : ''
59}
60
lib/lines.js 149 lines
1import { truncate } from './text.js'
2
3// Turns the project model into the lines the pane and the text summary show.
4
5export const SECTIONS = [
6  { id: 'overview', label: 'Overview', hotkey: '1' },
7  { id: 'environments', label: 'Environments', hotkey: '2' },
8  { id: 'queries', label: 'Queries', hotkey: '3' },
9  { id: 'boards', label: 'Boards', hotkey: '4' },
10]
11
12const line = (text, tone = 'normal', path) => (path === undefined ? { text, tone } : { text, tone, path })
13const warnLines = (warnings) => warnings.map((warning) => line('⚠ ' + warning, 'warn'))
14const plural = (n, one, many = one + 's') => n + ' ' + (n === 1 ? one : many)
15
16// The most rows one section shows; the rest are counted, not listed.
17export const MAX_ROWS = 200
18
19// The longest a query row's button label is.
20export const MAX_LABEL = 120
21
22// The most parameter rows the detail view shows.
23export const MAX_PARAM_ROWS = 20
24
25// Cuts the rows of a section to MAX_ROWS, says how many were left out, and
26// puts the warnings after them.
27function capped(rows, warnings) {
28  const shown = rows.slice(0, MAX_ROWS)
29  if (rows.length > MAX_ROWS) shown.push(line('… ' + (rows.length - MAX_ROWS) + ' more', 'dim'))
30  return [...shown, ...warnLines(warnings)]
31}
32
33// A count of a section whose command failed is unknown, not zero.
34function countText(section, count, one, many) {
35  return section.warnings.length ? '? ' + (many ?? one + 's') : plural(count, one, many)
36}
37
38function countsLine(project) {
39  return [
40    countText(project.environments, project.environments.items.length, 'environment'),
41    countText(project.queries, project.queries.count, 'query', 'queries'),
42    countText(project.boards, project.boards.items.length, 'board'),
43  ].join(' · ')
44}
45
46function overviewLines(project) {
47  const o = project.overview
48  const known = o.id !== null || o.title !== null || o.access !== null
49  const head = known
50    ? [line(o.title ?? '(untitled project)'), line('id: ' + (o.id ?? '—'), 'dim'), line('access: ' + (o.access ?? '—'), 'dim')]
51    : [line('(project details unavailable)', 'dim')]
52  return [...head, line(countsLine(project)), ...warnLines(o.warnings)]
53}
54
55function sourceLine(source) {
56  let scanned = plural(source.tables, 'table') + (source.views ? ', ' + plural(source.views, 'view') : '')
57  if (source.notScanned) scanned = 'not scanned'
58  else if (source.empty) scanned = 'no tables or views'
59  const detail = [source.driver, scanned].filter(Boolean).join(' · ')
60  return line('  ' + source.id + '  ' + detail)
61}
62
63function environmentLines(project) {
64  const { items, warnings } = project.environments
65  const rows = items.flatMap((env) => [
66    line(env.id),
67    ...(env.sources.length ? env.sources.map(sourceLine) : [line('  no sources', 'dim')]),
68  ])
69  return capped(rows.length ? rows : [line('No environments.', 'dim')], warnings)
70}
71
72function queryRows(node, indent, out) {
73  for (const query of node.queries) {
74    const label = query.title && query.title !== query.id ? query.title + ' (' + query.id + ')' : query.id
75    out.push(line(truncate(indent + label + (query.type ? ' [' + query.type + ']' : ''), MAX_LABEL), 'normal', query.path))
76  }
77  for (const child of node.children) {
78    out.push(line(indent + child.name + '/', 'dim'))
79    queryRows(child, indent + '  ', out)
80  }
81  return out
82}
83
84function queryLines(project) {
85  const { tree, count, warnings } = project.queries
86  return capped(count ? queryRows(tree, '', []) : [line('No queries.', 'dim')], warnings)
87}
88
89function boardLines(project) {
90  const { items, warnings } = project.boards
91  const rows = items.map((board) => line(board.title ? board.id + ' — ' + board.title : board.id))
92  return capped(rows.length ? rows : [line('No boards.', 'dim')], warnings)
93}
94
95// The query at a path in the tree, or null.
96export function findQuery(project, path) {
97  const walk = (node) => {
98    const own = node.queries.find((query) => query.path === path)
99    if (own) return own
100    for (const child of node.children) {
101      const found = walk(child)
102      if (found) return found
103    }
104    return null
105  }
106  return walk(project.queries.tree)
107}
108
109// The top of a query's detail view: its title (or id), id and type. The
110// actions are drawn right under these, above the parameters.
111export function queryHeadLines(query) {
112  return [
113    line(query.title ?? query.id),
114    line('id: ' + query.path, 'dim'),
115    ...(query.type ? [line('type: ' + query.type, 'dim')] : []),
116  ]
117}
118
119// The parameters of a query: at most MAX_PARAM_ROWS rows, then a count of the rest.
120export function queryParamLines(query) {
121  if (!query.parameters.length) return [line('Parameters'), line('No parameters.', 'dim')]
122  const rows = query.parameters
123    .slice(0, MAX_PARAM_ROWS)
124    .map((param) => line(param.id + (param.type ? ': ' + param.type : '') + (param.required ? ' (required)' : '')))
125  const more = query.parameters.length - rows.length + (query.moreParameters ?? 0)
126  return [line('Parameters'), ...rows, ...(more > 0 ? [line('… ' + more + ' more', 'dim')] : [])]
127}
128
129export function sectionLines(project, id) {
130  switch (id) {
131    case 'overview':
132      return overviewLines(project)
133    case 'environments':
134      return environmentLines(project)
135    case 'queries':
136      return queryLines(project)
137    case 'boards':
138      return boardLines(project)
139    default:
140      return []
141  }
142}
143
144export function summaryText(project) {
145  return SECTIONS.map((section) =>
146    [section.label, ...sectionLines(project, section.id).map((row) => '  ' + row.text)].join('\n'),
147  ).join('\n\n')
148}
149
lib/project.js 107 lines
1import { readListing } from './cli.js'
2import { cleanText } from './text.js'
3
4// Builds the project the pane shows from what the three datatug listing
5// commands printed. Pure, and tolerant: a command that failed, or printed
6// something of an unexpected shape, leaves its own sections empty.
7
8// Text from the project or the CLI, made safe to show; null when nothing is left.
9const asString = (value) => cleanText(value) || null
10
11// The entries of a list that carry an id that is left after cleaning, with
12// that cleaned id; anything else is dropped. A query's id (`isPath`) keeps its
13// slashes: each segment is cleaned on its own, and empty ones are dropped.
14function withIds(value, isPath = false) {
15  if (!Array.isArray(value)) return []
16  const out = []
17  for (const item of value) {
18    if (!item || typeof item.id !== 'string') continue
19    const id = isPath ? item.id.split('/').map((part) => cleanText(part)).filter(Boolean).join('/') : cleanText(item.id)
20    if (id !== '') out.push({ ...item, id })
21  }
22  return out
23}
24
25const warningsOf = (read) => (read.warning ? [read.warning] : [])
26
27function toSource(source) {
28  const schemas = Array.isArray(source.schemas) ? source.schemas : []
29  const count = (key) => schemas.reduce((n, schema) => n + (Array.isArray(schema?.[key]) ? schema[key].length : 0), 0)
30  return {
31    id: source.id,
32    driver: asString(source.driver),
33    tables: count('tables'),
34    views: count('views'),
35    notScanned: source.notScanned === true,
36    empty: source.empty === true,
37  }
38}
39
40// The most parameters kept per query; the rest are counted.
41export const MAX_PARAMETERS = 50
42
43// A query's parameters as the CLI lists them (it may list none, or not at all
44// with an older CLI): cleaned, and those without a usable id dropped.
45function toParameters(value) {
46  const all = withIds(value).map((param) => ({
47    id: param.id,
48    type: asString(param.type),
49    required: param.required === true,
50  }))
51  return { parameters: all.slice(0, MAX_PARAMETERS), moreParameters: Math.max(0, all.length - MAX_PARAMETERS) }
52}
53
54// A query's ID is its folders and its name joined by "/".
55function buildQueryTree(items) {
56  const root = { name: 'queries', queries: [], children: [] }
57  for (const item of items) {
58    const parts = item.id.split('/')
59    let node = root
60    for (const folder of parts.slice(0, -1)) {
61      let child = node.children.find((candidate) => candidate.name === folder)
62      if (!child) {
63        child = { name: folder, queries: [], children: [] }
64        node.children.push(child)
65      }
66      node = child
67    }
68    node.queries.push({
69      id: parts.at(-1),
70      path: item.id,
71      title: asString(item.title),
72      type: asString(item.type),
73      ...toParameters(item.parameters),
74    })
75  }
76  return root
77}
78
79export function buildProject(results) {
80  const show = readListing('show', results.show)
81  const queries = readListing('queries', results.queries)
82  const boards = readListing('boards', results.boards)
83
84  const doc = show.data && typeof show.data === 'object' ? show.data : {}
85  // Ids that clean to the same path would be one key: keep the first.
86  const seen = new Set()
87  const queryItems = withIds(queries.data, true).filter((item) => !seen.has(item.id) && seen.add(item.id))
88
89  return {
90    overview: {
91      id: asString(doc.project),
92      title: asString(doc.title),
93      access: asString(doc.access),
94      warnings: warningsOf(show),
95    },
96    environments: {
97      items: withIds(doc.environments).map((env) => ({ id: env.id, sources: withIds(env.sources).map(toSource) })),
98      warnings: warningsOf(show),
99    },
100    queries: { tree: buildQueryTree(queryItems), count: queryItems.length, warnings: warningsOf(queries) },
101    boards: {
102      items: withIds(boards.data).map((board) => ({ id: board.id, title: asString(board.title) })),
103      warnings: warningsOf(boards),
104    },
105  }
106}
107
lib/view.js 83 lines
1import { isPlainPath } from './draft.js'
2import { SECTIONS, findQuery, queryHeadLines, queryParamLines, sectionLines } from './lines.js'
3
4// Only the terminal and the Desktop app show a mod's panes. `surfaces` is the
5// list `$.session.surfaces()` returns.
6export function canDrawPane(surfaces) {
7  return Array.isArray(surfaces) && (surfaces.includes('terminal') || surfaces.includes('desktop'))
8}
9
10const textOf = (Text, row) =>
11  Text({
12    wrap: 'truncate-end',
13    ...(row.tone === 'warn' ? { color: 'yellow' } : {}),
14    ...(row.tone === 'dim' ? { dimColor: true } : {}),
15    children: [row.text],
16  })
17
18// `elements` is `{ Box, Text, Button }` from `$.ui.resolve(e)`. `selected` is
19// the path of the chosen query, or null; `on` has the callbacks `onTab(id)`,
20// `onSelect(path)`, `onBack()` and `onAsk()`. `focusPath` is the query row
21// that takes the focus on the Queries tab (the first row when null or absent).
22export function buildPane({ Box, Text, Button }, project, tab, selected, on, focusPath = null) {
23  const tabs = SECTIONS.map((section) =>
24    Button({
25      key: 'tab-' + section.id,
26      label: section.label,
27      hotkey: section.hotkey,
28      plain: true,
29      dimColor: tab !== section.id,
30      onPress: () => on.onTab(section.id),
31    }),
32  )
33
34  const query = selected === null ? null : findQuery(project, selected)
35  let body
36  if (query !== null) {
37    const canAsk = isPlainPath(query.path)
38    body = [
39      ...queryHeadLines(query).map((row) => textOf(Text, row)),
40      Text({ children: [' '] }),
41      Box({
42        flexDirection: 'row',
43        columnGap: 2,
44        children: [
45          ...(canAsk
46            ? [Button({ key: 'ask-claude', label: 'Ask Claude', hotkey: 'a', plain: true, autoFocus: true, onPress: () => on.onAsk() })]
47            : [textOf(Text, { text: "This query's ID cannot be named in a request.", tone: 'dim' })]),
48          Button({
49            key: 'query-back',
50            label: 'Back',
51            hotkey: 'b',
52            plain: true,
53            ...(canAsk ? {} : { autoFocus: true }),
54            onPress: () => on.onBack(),
55          }),
56        ],
57      }),
58      Text({ children: [' '] }),
59      ...queryParamLines(query).map((row) => textOf(Text, row)),
60    ]
61  } else {
62    const rows = sectionLines(project, tab)
63    const paths = rows.filter((row) => row.path !== undefined).map((row) => row.path)
64    const focus = focusPath !== null && paths.includes(focusPath) ? focusPath : paths[0]
65    body = rows.map((row) =>
66      row.path === undefined
67        ? textOf(Text, row)
68        : Button({
69            key: 'query-' + row.path,
70            label: row.text,
71            plain: true,
72            ...(row.path === focus ? { autoFocus: true } : {}),
73            onPress: () => on.onSelect(row.path),
74          }),
75    )
76  }
77
78  return Box({
79    flexDirection: 'column',
80    children: [Box({ flexDirection: 'row', columnGap: 2, children: tabs }), Text({ children: [' '] }), ...body],
81  })
82}
83
lib/text.js 25 lines
1// Makes text that comes from a project file or from the CLI safe to show:
2// pure, and the one place that decides what "safe" means.
3
4// C0 controls (with tab, newline and carriage return), DEL, C1 controls, the
5// soft hyphen, bidirectional controls, the line and paragraph separators,
6// zero-width and invisible formatting characters, the byte order mark and the
7// tag block. Built with `new RegExp` and the `u` flag so the astral range
8// works.
9const UNSAFE = new RegExp(
10  '[\u0000-\u001f\u007f-\u009f­؜᠎​-‏‪-‮⁠-⁤⁦-⁩

\u{e0000}-\u{e007f}]',
11  'gu',
12)
13
14// Cuts text to `maxLength` code points, with an ellipsis as the last one when
15// it was cut. Never splits a surrogate pair.
16export function truncate(text, maxLength) {
17  const points = Array.from(text)
18  return points.length > maxLength ? points.slice(0, maxLength - 1).join('') + '…' : text
19}
20
21export function cleanText(value, maxLength = 200) {
22  if (typeof value !== 'string') return ''
23  return truncate(value.replace(UNSAFE, ' ').replace(/\s+/g, ' ').trim(), maxLength)
24}
25