Skip to content

mdq: standalone markdown query and edit package, with a jq-like CLI - #219

Open
DavertMik wants to merge 22 commits into
mainfrom
feat/mdq-package
Open

DavertMik wants to merge 22 commits into
mainfrom
feat/mdq-package

Conversation

@DavertMik

@DavertMik DavertMik commented Sep 15, 2026

Copy link
Copy Markdown
Contributor

Extracts src/utils/markdown-query.ts into src/utils/mdq/ as a standalone npm package that can query and edit markdown, plus a jq-like CLI runnable with npx mdq.

Design: docs/superpowers/specs/2026-09-14-mdq-package-design.md · Plan: docs/superpowers/plans/2026-09-15-mdq-package.md

Shape

One class, two files. query.ts holds MarkdownQuery — the document plus the set of blocks currently selected — with the query engine as private methods below the public API. edit.ts holds pure functions over (source, ranges); it imports types from query.ts type-only and never touches a class value, so the split is acyclic. Reads narrow the selection, writes return a fresh query over the edited document.

mdq(source)
  .query('section("API")').append('## Notes\n')
  .query('blockquote[0]').remove()
  .toString();

toString() is always the whole document; text() is the current selection.

New beyond the move

  • Write verbsremove insertBefore insertAfter prepend append addRow addItem setEntry, each returning a MarkdownQuery so edits chain.
  • comment selector — matches block comments on their inner body, so comment(/^test/) reaches the multi-line <!-- test … --> blocks that test-plan-markdown.ts:122+ hand-parses today. Plus html.
  • Frontmatter — parsed with yaml's Document API, kept out of the token index, exposed via frontmatter()/setFrontmatter(); comments survive a write. Without this, marked reads a knowledge file's --- block as a setext heading titled url: /login.
  • Value matchersstring (exact), RegExp (own flags), or predicate, removing the hand-escaping at researcher/focus.ts:77.
  • Sugarsection heading table comment … each exactly query(sel, matcher), plus at/slice/exists.
  • Loud failures — an unknown selector throws MdqSelectorError with a position instead of silently matching nothing.
  • CLI + packagenpx mdq 'section("API") table' --json README.md. Exit codes compose like grep: 0 match, 1 no match, 2 error.

Fixes to existing behaviour

  • Regex matchers honored their flags nowhere: /x/ was always case-insensitive while "x" and ~"x" were case-sensitive. Now /x/i is insensitive and /x/ is not. One production call site depended on the old behaviour (researcher.ts:316) and is now explicit.
  • Table text-matching only ever saw headers, so table(~"GET") could never match a cell.
  • Reading a file no longer waits on stdin. readFileSync(0, 'utf8') throws EAGAIN on Node when stdin is a pipe with no data ready — Bun tolerates it, Node dies with a stack trace. Stdin is now read asynchronously, and only when no file argument is given.

Migration

All 54 existing call sites keep working through a re-export shim at src/utils/markdown-query.ts; deprecated aliases (get toJson keyValue setKeyValue meta before after) cover every read rename.

Writes could not be shielded — they return MarkdownQuery now — so 17 call sites were migrated. The design doc predicted eleven; type-checking by type name repo-wide rather than by file path found six more. One deserves attention: research-result.ts did if (updated === this.text) return;, which a class instance makes always-false — a guard that silently stops firing rather than crashing. Another fed the result to marked.lexer, which does not coerce and threw at runtime.

This repo runs tsc with --noCheck, so none of that surfaces in a green CI. The gate used here was bunx tsc -p tsconfig.json --noEmit | grep -E "MarkdownQuery|Markdown", which returns nothing.

Whitespace

marked separators are uneven — a heading raw is "# A\n\n", a mid-document paragraph raw is "para" with a sibling space token, and a document-final paragraph raw is "last\n" with none. The invariant is never zero blank lines between blocks, never more than one, and two rules follow, both derived from real token output:

  • remove() collapses only at end-of-document. The obvious alternative ("no trailing space → trim backwards") turns '# A\n\n## B\n\ntext\n' into '# A\ntext\n'.
  • insertAt normalizes only the seam. A global \n{3,} collapse would rewrite blank lines inside fenced code blocks, whose raw genuinely contains them.

Packaging

Mirrors how prima-cli ships. src/utils/mdq/package.json is the standalone manifest; scripts/build-mdq-npm.ts bundles both entries with bun build --target=node (marked, yaml, commander stay external) and emits declarations; .github/workflows/publish-mdq.yml publishes on a release tagged mdq-*, gated on that prefix so an explorbot release cannot publish it by accident.

Result is 11.5 kB, 6 files, 3 dependencies. The workflow's smoke test installs the tarball into a clean project and exercises the CLI, stdin, the EAGAIN case, exit codes and a library import on Node — the same sequence was run locally. The mdq bin is removed from explorbot's package.json so the two packages cannot collide on the binary name.

To publish: cut a release tagged mdq-v0.1.0.

On the grammar

The selector grammar is bespoke — it is not a standard and has no upstream parser. The README now says so, names the real alternative (unist-util-select, CSS selectors over mdast via css-selector-parser), and states the three things that do not fall out of that stack: mdast is flat so there is no section; remark-sectionize adds the nesting but its nodes carry no position, which splice-based editing needs; and mdast headings have no flat text field while CSS dropped :contains() and unist-util-select ignores the attribute i flag.

A spike compiled the terse grammar to that CSS and got 23/23 identical results against this engine, so a migration is viable — at the cost of 5 packages / 2.0 MB becoming 79 / 7.9 MB, and rebuilding edit.ts on derived section ranges. Not done here; recorded so the option is understood.

Tests

1547 unit + 138 prima, zero failures. The original 110-test suite moved to tests/unit/mdq/query.test.ts intact as the regression net; six new suites cover frontmatter, selectors, sugar, edits, structural inserts and the CLI. Every README example was executed before committing.

Regression

This touches 17 call sites across researcher, planner and experience-tracker. A regression run looks worthwhile, but the label is the maintainer's call — none was applied.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD

DavertMik and others added 17 commits September 14, 2026 14:46
Extracts src/utils/markdown-query.ts into src/utils/mdq/ as a
publish-ready package: MarkdownDoc + Selection, insert/remove verbs
alongside query, a comment selector, frontmatter handling, JS-value
matchers, and a planned jq-like CLI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Frontmatter uses yaml's Document API (comment-preserving) rather than a
hand-rolled parser; MarkdownDoc gains append/prepend; leading '.' is
accepted in the CLI grammar; documents a fourth migration breakage class
where MarkdownDoc === string silently stops a guard from firing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
11 tasks, 68 steps. Ordering keeps the repo green at every boundary:
port behind a shim first, migrate the 11 write-return-type breaks second,
then add features additively.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
removeRanges collapsed backwards whenever a node had no trailing space
token, which ate a blank line on '# A\n\n## B\n\ntext\n'. marked bakes
separators into heading and document-final paragraph raws, so the rule is
"collapse only at end-of-document".

insertAt used a global \n{3,} collapse that would rewrite blank lines
inside fenced code blocks; scoped it to the seam.

Also: selector error index is 8 not 7, YAML needs new Document({}) when
frontmatter is absent, and the --set CLI test now proves the old value is
gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Types moved to end of file and ternaries replaced with early returns,
per repo style. All 1416 unit tests pass through the shim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Splits MarkdownQuery into MarkdownDoc (whole document) and Selection
(matched ranges, extends MarkdownDoc). Every write returns a MarkdownDoc,
so several edits compose in one expression.

Migrates 17 call sites, not the 11 the plan predicted. The extras were
found by type-checking on the type name repo-wide rather than by file
path: a test helper fed a MarkdownDoc to marked.lexer, which does not
coerce and threw at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
…tor errors

comment matches block comments on their inner body, so anchored patterns
like /^test/ work; html matches every html block on raw text. Regex flags
are now honored instead of parsed and discarded, unknown selectors throw
MdqSelectorError with a position instead of silently matching nothing,
and table text-matching reaches cells rather than headers only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Matchers can be a string (exact), RegExp (own flags) or predicate, so a
dynamic value no longer has to be escaped into a selector string.

The sugar layer lives on MarkdownDoc and Selection inherits it, rather
than a separate abstract base: Selection already extends MarkdownDoc and
overrides query(), so the eleven one-liners dispatch correctly with no
extra class.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
remove() takes a node plus its adjacent space token and collapses only at
end-of-document, because marked bakes separators into heading and
document-final paragraph raws but emits them as siblings elsewhere.
insertAt normalizes only the seam, so blank lines inside fenced code
blocks survive.

mdq() now returns MarkdownDoc rather than Selection, so document-level
append/prepend are reachable; blocks() selects every top-level block for
the one caller that read the whole document as a selection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
addRow re-renders the table so column pipes stay aligned and preserves
alignment markers; addItem copies the list's existing marker and indent
and continues an ordered list's numbering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Uses YAML.parseDocument rather than parse/stringify, so nested maps,
lists and block scalars parse correctly and comments survive a write.
Verified against the knowledge and experience formats CLAUDE.md
documents: raw marked lexes their --- block as a setext h2 titled
'url: /login...', mdq reports zero headings and typed frontmatter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
runMdq returns { output, code } rather than writing to stdout or exiting,
so the whole surface is testable; bin/mdq.ts is the only place that
touches the process. Exit codes compose like grep: 0 match, 1 no match,
2 usage or selector error.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Every example executed against the implementation before committing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Selection extends MarkdownDoc to share the sugar layer, so it type-checks
anywhere a MarkdownDoc does, but stringifies to its matched markdown
rather than the whole document. That is the useful behaviour - a
selection can be passed straight back as a fragment - but it was
previously unpinned by any test and undocumented.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
The duplication review found two places where query.ts kept logic the
spec assigns to edit.ts:

- replaceEach reimplemented spliceRanges verbatim, so the one write path
  used by replace() and setEntry() was the only one not delegating.
- setEntry inlined the whole entry-rewrite transformation; it now calls
  rewriteEntries, matching the shape of every other verb.

Also: entryKey moves to edit.ts beside rewriteEntries, dedupeRanges
becomes internal now that query.ts no longer needs it, two new ternaries
are gone, a dead insertAt import is dropped, three hand-rolled
/\s+$/ strips become trimEnd(), and isSectionSelector's /^section\d?$/
now matches the [1-6] every other depth check uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
@DenysKuchma

Copy link
Copy Markdown
Collaborator

The npm mdq binary keeps a Bun shebang, unlike the other CLIs rewritten to Node. Be better add it to the build-npm.sh rewrite

DavertMik and others added 5 commits September 17, 2026 00:58
bin/mdq.ts is now Node-compatible, not Bun-only. Two real breaks fixed:

- readFileSync(0, 'utf8') throws EAGAIN on Node when stdin is a pipe with
  no data ready, crashing with a stack trace. Bun tolerates it. Stdin is
  now read asynchronously.
- Stdin was read whenever it was not a TTY, before knowing whether a file
  argument was given, so 'mdq h2 file.md' in a pipeline read input it
  never needed. runMdq now takes a lazy reader and calls it only when no
  file is given.

Packaging mirrors prima-cli: src/utils/mdq/package.json declares the
standalone manifest, scripts/build-mdq-npm.ts bundles both entries with
bun build --target=node (marked, yaml and commander stay external) and
emits declarations, and .github/workflows/publish-mdq.yml publishes on a
release tagged mdq-*. The workflow's smoke test installs the tarball into
a clean project and exercises the CLI, stdin, the EAGAIN case, exit codes
and a library import on Node - the same sequence was run locally.

The mdq bin is removed from explorbot's package.json so the two packages
cannot collide on the binary name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
query.ts was 790 lines: 20 module-level helper functions and three error
classes sitting above a two-class split, with a 120-line hand-rolled
character-scanning parser for the selector grammar.

Now 577 lines with the class first and three module functions after it.

- MarkdownDoc and Selection collapse back into one MarkdownQuery holding
  source plus matches, as it was before. The query engine (section
  scoping, item extraction, text matching, narrowing) moves inside as
  private methods, below the public API.
- The selector parser drops from 120 lines of peek/advance/readUntilAny
  closures to one sticky regex and a 40-line loop. The grammar is closed,
  which CLAUDE.md says is a regex, not a parser.
- blocks() is gone - it only existed to paper over the two-class split -
  and with it the toString/text asymmetry: toString() is always the
  document, text() the selection.
- Document-level append/prepend are gone; they had no caller.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
The grammar is bespoke, not a standard, and the README presented it as a
given. Names the real alternative (remark/mdast + unist-util-select) and
the three things that do not fall out of it, each verified against the
stack rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
# Conflicts:
#	CHANGELOG.md
#	docs/superpowers/plans/2026-09-15-mdq-package.md
edit.ts becomes MarkdownEditor: an object holding the source with
remove/replace/insert/frontmatter/setFrontmatter on it, and the pure
helpers (blockEnd, splitFrontmatter, table, item, entries) as statics.
query.ts delegates every write to it instead of importing ten loose
functions.

Constants were largely restating each other. TOKEN_TYPES was eight
identity pairs plus one real mapping, so only the mapping survives as
TOKEN_ALIASES. TEXT_TOKENS is gone - tokenText falls through to the
token's own text field, which is what the whitelist was approximating.
The three that remain sit at the end of the file beside the types.

Seven repeated @deprecated lines become one comment over the alias group.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhJNfxLquFknSLJ6V8J9iD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants