From 88db13b5e14a82c827ee675e229eb8f758bc8c3e Mon Sep 17 00:00:00 2001 From: Juan Pablo Romero Date: Thu, 20 Aug 2026 20:04:25 -0700 Subject: [PATCH 1/4] fix(gx-core): gx could not read back the DOT it wrote MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DiagramText.render` called the DOT printer directly instead of going through `ViewerGraph.viewerGraphToText`. The printer is the last of three steps, and calling it directly skipped the other two: - `combineStyleAttributes` folds the synthetic style sub-attributes back into a real `style="filled"`. Without it `gx` wrote `fillstyle="true"` into the user's file, and since that is not DOT, the reader rejected gx's own output. Two commands were enough to break a diagram: gx run g.dot set-attribute --params \ '{"targets":["node:a"],"name":"fillcolor","value":"red"}' gx run g.dot list-nodes gx: could not parse the diagram: assertion failed - `graph.id` and `graph.tpe` carry the graph's name and whether it is directed. Defaulting them rewrote `graph MyNet { a -- b }` into `digraph "G" { "a" -> "b" }`, which is a different diagram, and said nothing about it. The reader's `assert` that no sub-attribute ever reaches it was detecting a real defect, but it named neither the attribute nor the element, and files written by a released gx are already on disk. It now drops stray sub-attributes, which is what `dot` does with an attribute it does not know; honouring `fillstyle` would make the viewer paint a fill graphviz would not. Such a file loses its fill on load, which is the truthful outcome: it renders the way `dot` renders it. DiagramTextRoundTripSpec asserts the property — parse -> render -> parse — rather than the printed text. Five of its six tests fail without this change. --- .../gxcore/command/DiagramText.scala | 17 +++- .../command/DiagramTextRoundTripSpec.scala | 91 +++++++++++++++++++ .../viewer/graph/VizViewerGraphElements.scala | 23 ++++- 3 files changed, 125 insertions(+), 6 deletions(-) create mode 100644 gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/command/DiagramTextRoundTripSpec.scala diff --git a/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/command/DiagramText.scala b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/command/DiagramText.scala index 980aa53d..6f5ab820 100644 --- a/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/command/DiagramText.scala +++ b/gx-core/jvm/src/main/scala/org/jpablo/graphexplorer/gxcore/command/DiagramText.scala @@ -3,7 +3,7 @@ package org.jpablo.graphexplorer.gxcore.command import org.jpablo.graphexplorer.graphviz.Graphviz as ScalaGraphviz import org.jpablo.graphexplorer.viewer.backends.DiagramFormat import org.jpablo.graphexplorer.viewer.backends.graphviz.vizjs.simplegraph.{SimpleGraph, toViewerGraph} -import org.jpablo.graphexplorer.viewer.graph.{ViewerGraph, viewerGraphElementsToText} +import org.jpablo.graphexplorer.viewer.graph.ViewerGraph import upickle.default.read import scala.util.control.NonFatal @@ -69,6 +69,19 @@ object DiagramText: * `omitInternal` because a round trip must not leak the layout's own * bookkeeping (`_gvid` and friends) into the user's file — those are * artifacts of how the graph was read, not of what it says. + * + * Goes through `viewerGraphToText` rather than calling the printer directly, + * which is not a stylistic preference — the printer is the LAST step of + * three, and this used to skip the other two: + * + * - `combineStyleAttributes` folds the synthetic sub-attributes + * (`fillstyle` and friends) back into a real `style="filled"`. Without + * it `gx` wrote `fillstyle="true"` into the user's file, and then could + * not read that file back: sub-attributes are not DOT, so the reader + * rejected its own output. Two commands were enough to break a diagram. + * - `graph.id` and `graph.tpe` carry the graph's NAME and whether it is + * directed. Defaulting them silently rewrote `graph MyNet { a -- b }` + * into `digraph "G" { "a" -> "b" }`, which is a different diagram. */ def render(graph: ViewerGraph): String = - viewerGraphElementsToText(graph.elements, omitInternal = true) + ViewerGraph.viewerGraphToText(graph, omitInternal = true) diff --git a/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/command/DiagramTextRoundTripSpec.scala b/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/command/DiagramTextRoundTripSpec.scala new file mode 100644 index 00000000..2d663c6a --- /dev/null +++ b/gx-core/jvm/src/test/scala/org/jpablo/graphexplorer/gxcore/command/DiagramTextRoundTripSpec.scala @@ -0,0 +1,91 @@ +package org.jpablo.graphexplorer.gxcore.command + +import munit.FunSuite +import org.jpablo.graphexplorer.viewer.models.NodeId + +/** `gx` must be able to read back what `gx` wrote. + * + * Nothing asserted this, and it was false. `DiagramText.render` called the DOT + * printer directly instead of going through `ViewerGraph.viewerGraphToText`, + * which skipped the two steps that stand in front of it — so two commands were + * enough to break a diagram: + * + * gx run g.dot set-attribute '{"targets":["node:a"],"name":"fillcolor",...}' + * gx run g.dot list-nodes # gx: could not parse the diagram: assertion failed + * + * A round trip is the property, not any one attribute, so these go through + * `parse -> render -> parse` rather than matching the printed text. + */ +class DiagramTextRoundTripSpec extends FunSuite: + + private def reparse(text: String) = + DiagramText.parse(text).flatMap(g => DiagramText.parse(DiagramText.render(g))) + + private def roundTrip(text: String) = + DiagramText.parse(text).map(DiagramText.render).flatMap(DiagramText.parse) match + case Right(g) => g + case Left(e) => fail(s"could not re-read what render produced: $e") + + /** The exact break: setting a fill emits the synthetic `fillstyle`, which is + * not DOT, and the reader rejected it. + */ + test("a filled node survives a round trip") { + val filled = DiagramText + .parse("""digraph "G" { "a" -> "b" }""") + .map(g => DocumentCommands.run(g, DocumentCommand.SetAttribute(Set(NodeId("a")), "fillcolor", "red"))) + .fold(fail(_), identity) + .fold(e => fail(e.message), identity) + + val text = filled match + case CommandResult.Updated(g) => DiagramText.render(g) + case other => fail(s"expected an update, got $other") + + // The synthetic sub-attribute is an internal spelling of `style="filled"` + // and must never reach a file. + assert(!text.contains("fillstyle"), s"synthetic attribute leaked into the DOT:\n$text") + assert(text.contains("filled"), s"the fill was lost entirely:\n$text") + + assert(DiagramText.parse(text).isRight, s"gx cannot read back what it wrote:\n$text") + } + + /** The same bypass defaulted the graph's name and type, which is a quieter + * failure than the crash: it round-trips fine, as a different diagram. + */ + test("an undirected graph keeps its name and its edges") { + val text = DiagramText.parse("graph MyNet { a -- b }").map(DiagramText.render).fold(fail(_), identity) + assert(!text.contains("digraph"), s"an undirected graph became directed:\n$text") + assert(!text.contains("->"), s"undirected edges became directed:\n$text") + assert(text.contains("MyNet"), s"the graph lost its name:\n$text") + } + + test("a named digraph keeps its name") { + val text = DiagramText.parse("""digraph "Services" { a -> b }""").map(DiagramText.render).fold(fail(_), identity) + assert(text.contains("Services"), s"the graph lost its name:\n$text") + } + + /** Files written by a released `gx` already carry `fillstyle`, so reading one + * has to work — and has to agree with `dot`, which ignores an attribute it + * does not know rather than painting a fill for it. + */ + test("a file already poisoned with a synthetic attribute still loads") { + val poisoned = """digraph "G" { "a" [fillcolor="red", fillstyle="true"]; "b"; "a" -> "b"; }""" + val graph = DiagramText.parse(poisoned).fold(e => fail(s"could not load a poisoned file: $e"), identity) + assertEquals(graph.nodeIds, Set(NodeId("a"), NodeId("b"))) + assert(!DiagramText.render(graph).contains("fillstyle"), "the synthetic attribute survived a read") + } + + test("round-tripping twice is stable") { + val once = DiagramText.parse("""digraph "G" { "a" [style="filled", fillcolor="red"]; "a" -> "b"; }""") + .map(DiagramText.render).fold(fail(_), identity) + val twice = DiagramText.parse(once).map(DiagramText.render).fold(fail(_), identity) + assertEquals(twice, once, "the second render disagreed with the first") + } + + test("clusters and their edges survive a round trip") { + val g = roundTrip("""digraph G { subgraph cluster_svc { label="services"; api; db } api -> db; web -> api }""") + assertEquals(g.nodeIds, Set(NodeId("api"), NodeId("db"), NodeId("web"))) + assertEquals(g.groups.size, 1) + assertEquals(g.arrows.size, 2) + // Named so a failure says which direction was lost. + assert(reparse("""digraph G { subgraph cluster_svc { api; db } api -> db }""").isRight) + } diff --git a/shared/src/main/scala/org/jpablo/graphexplorer/viewer/graph/VizViewerGraphElements.scala b/shared/src/main/scala/org/jpablo/graphexplorer/viewer/graph/VizViewerGraphElements.scala index c5f5b154..108f3161 100644 --- a/shared/src/main/scala/org/jpablo/graphexplorer/viewer/graph/VizViewerGraphElements.scala +++ b/shared/src/main/scala/org/jpablo/graphexplorer/viewer/graph/VizViewerGraphElements.scala @@ -94,13 +94,28 @@ object VizViewerGraphElements: * a new set of attributes where the "style" attribute, if present, is replaced with its sub-attributes */ private def expandElementStyleAttributes(attrs: Attributes): Attributes = - // At this point there should be no sub-attributes in the attributes map. - StyleSubAttributes.subAttributeIds.foreach(attrId => assert(attrs.get(attrId).isEmpty)) + // Sub-attributes are synthetic: they exist inside a ViewerGraph and nowhere + // in the DOT language, so text arriving here should carry none. + // + // This was an `assert`, and the input it was meant to be impossible turned + // out to be reachable: `DiagramText.render` printed a graph WITHOUT folding + // sub-attributes back into `style`, so `gx` wrote `fillstyle="true"` into + // the user's file and then could not read that file back. The printer is + // fixed, but files written by a released `gx` are already on disk, and + // `assertion failed` — naming no attribute and no element — is not a + // diagnosis anyone can act on. + // + // Dropped rather than honoured, because that is what `dot` does with an + // attribute it does not know. Honouring `fillstyle` would make the viewer + // paint a fill graphviz would not, and the whole point of this reader is to + // agree with graphviz. A file that lost its fill this way says so in the + // only way that stays truthful: it renders the way `dot` renders it. + val cleaned = attrs -- StyleSubAttributes.subAttributeIds // Check for either NodeStyle or Style (EdgeStyle uses Style.attrId) - val style = attrs.get(NodeStyle) orElse attrs.get(Style) orElse attrs.get(EdgeStyle) + val style = cleaned.get(NodeStyle) orElse cleaned.get(Style) orElse cleaned.get(EdgeStyle) val styleSubAttrs = StyleSubAttributes.fromStyleString(style.map(_.toString)) // 1. Normalize fill color if present // 2. Remove the "style" attribute if it exists // 3. Add the expanded style sub-attributes back to the attributes map (which the UI will use) - removeIncorrectCombos(attrs, styleSubAttrs) - NodeStyle - EdgeStyle - Style ++ styleSubAttrs.toAttributes + removeIncorrectCombos(cleaned, styleSubAttrs) - NodeStyle - EdgeStyle - Style ++ styleSubAttrs.toAttributes From 227e0c4a8665345412d84f6544c8ae80b1ab96ea Mon Sep 17 00:00:00 2001 From: Juan Pablo Romero Date: Thu, 20 Aug 2026 20:04:37 -0700 Subject: [PATCH 2/4] feat(gx): an agent skill, and a command that says where it lives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/skills/gx/SKILL.md` teaches a coding agent to drive gx: the three tiers and which of them need a desktop, how a ref resolves, that element refs must be read from `list-*` rather than constructed (arrow ids carry an index, and a group id is not the cluster name), the exit codes, and the fact that a document mutation reprints the whole file rather than patching it. Written against the running binary — every command in it was run and its real output pasted, which is how the exit code for a malformed ref turned out to be 1 and not 4. `gx skill [] [--latest] [--json]` prints where that skill lives and the sentence to hand an agent. It deliberately does not install it: a skill is a prompt someone's agent will load and act on, so writing it into their agent directory should be a decision rather than a side effect of asking where it is — and every harness keeps skills somewhere different anyway. A location plus the instruction works for all of them and leaves the human in the loop. The location is pinned to the running binary, because the skill names commands, param keys and exit codes, all of which are API that moves between releases: an agent reading the branch tip while driving an older gx would be reading about commands it does not have. A dev build has no tag to point at, so it falls back to the branch and says out loud that it is not pinned, suggesting the release it was cut from. --- .claude/skills/gx/SKILL.md | 283 ++++++++++++++++++ .../org/jpablo/graphexplorer/gx/Cli.scala | 69 +++++ .../graphexplorer/gx/SkillLocation.scala | 74 +++++ .../org/jpablo/graphexplorer/gx/CliSpec.scala | 75 +++++ 4 files changed, 501 insertions(+) create mode 100644 .claude/skills/gx/SKILL.md create mode 100644 gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala diff --git a/.claude/skills/gx/SKILL.md b/.claude/skills/gx/SKILL.md new file mode 100644 index 00000000..317bc0ce --- /dev/null +++ b/.claude/skills/gx/SKILL.md @@ -0,0 +1,283 @@ +--- +name: gx +description: Read, query and edit graph diagrams from the command line with `gx`, the Graph Explorer CLI — list a DOT or Mermaid file's nodes/edges/groups, set attributes, group and hide elements, keep a file synced with the diagram library, and show it in the Graph Explorer desktop app. Use whenever a task involves .dot/.gv/.mmd diagram files, asks what a diagram contains, asks to restyle or restructure one, or asks to open/visualize a graph. +--- + +# Driving Graph Explorer with `gx` + +`gx` is a single native binary. It edits diagram files **structurally** — it parses +DOT/Mermaid into a graph, applies a named command, and writes the graph back — so you can +say "set `fillcolor` on these three nodes" without writing a regex over someone's DOT. + +**Everything except `gx open` and `gx session` works with no GUI running.** Do not start +the desktop app to inspect or edit a diagram. + +## Before anything else + +```bash +gx --version +``` + +If that fails, `gx` is not installed — say so and point at + (the `gx-vX.Y.Z-` asset). +Do not try to build it from source unless asked; it is a GraalVM native image and the +build is not quick. + +Then, once per task: + +```bash +gx status +``` + +It prints the library root, how many diagrams are in it, and whether a desktop is up. It +never fails for want of a desktop. + +## The one distinction that matters: a path vs. a library ref + +Every command takes a ``, and a ref is resolved in this order: + +1. a **library diagram id** (`gx ls`), +2. an exact **library diagram name**, +3. a **path** to a file on disk. + +Ambiguity is reported, never guessed at. + +A loose file on disk needs no setup — `gx run ./arch.dot list-nodes` works immediately. +But **the record tier only exists for library diagrams** (see the tiers below); on a loose +file those commands refuse with exit 4 and tell you to `gx import` it first. + +## Three tiers + +| Tier | Verb | Operates on | Needs a desktop? | +|---|---|---|---| +| Document | `gx run ` | the diagram **text** — nodes, edges, attributes | no | +| Record | `gx run ` | stored **metadata** — hidden elements, tags, folder | no (library only) | +| Session | `gx session ` | the **live view** — selection, viewport | **yes** | + +`gx run` accepts document and record commands interchangeably; the tier decides what gets +written, not how you spell the call. + +```bash +gx run --list # every headless command name +gx session --list # the five live-view commands +``` + +## Reading a diagram + +```bash +gx run ./arch.dot list-nodes +gx run ./arch.dot list-arrows +gx run ./arch.dot list-groups +gx get ./arch.dot # the raw text +gx get ./arch.dot --json # text + path + content hash +``` + +Output is columns for a human, JSON with `--json`. Real output: + +``` +$ gx run demo.dot list-arrows +arrow:api->db/0 source=node:api target=node:db +arrow:web->api/1 source=node:web target=node:api +``` + +### Never construct an element ref — read it + +Refs are `node:`, `arrow:`, `group:`, and only node refs are guessable. +Arrow ids carry a disambiguating index (`arrow:api->db/0`) and group ids are **not** the +DOT cluster name (`subgraph cluster_svc` → `group:svc`). Always get refs from +`list-nodes` / `list-arrows` / `list-groups` first, then pass them verbatim. + +A malformed ref is a **usage** error (exit 1), and every bad one in a batch is reported at +once rather than one per round trip: + +``` +gx: get-attributes: unknown element kind 'nde' in 'nde:a' (expected node, arrow, group) +``` + +A ref that is well-formed but names nothing is exit **4**: `gx: get-attributes: no such +element: node:nope`. + +## Editing a diagram + +Mutations take their arguments as a JSON object: + +```bash +gx run ./arch.dot set-attribute \ + --params '{"targets":["node:api","node:db"],"name":"fillcolor","value":"lightblue"}' +``` + +For anything with quoting in it — an HTML label, a long node list — pipe it instead: + +```bash +jq -n '{targets:$t,name:"label",value:$v}' --argjson t '["node:api"]' --arg v '<API>' \ + | gx run ./arch.dot set-attribute --stdin +``` + +You can also replace the whole text: + +```bash +gx set ./arch.dot --stdin < new.dot +gx set ./arch.dot --text 'digraph G { a -> b }' +``` + +### ⚠️ A document mutation reformats the whole file + +`gx run ` parses the file and **prints the graph back out in Graph +Explorer's canonical form**. It is not a surgical patch. Every identifier gets quoted, +indentation is normalized, and edges move inside the cluster that owns them: + +```dot +digraph G { digraph "G" { + subgraph cluster_svc { graph [label=""]; + label="services"; api; db ──► subgraph "cluster_svc" { + } graph [label="services", cluster="true"]; + api -> db [label="reads"] "api"; "db"; +} "api" -> "db" [label="reads"]; + } + } +``` + +The graph is preserved; the formatting, comments and layout of the source are not. So: + +- **On a file under version control, show the diff before committing** — the first + mutation on a hand-written file is mostly reformatting noise. +- If the user wants minimal edits to a hand-maintained file, edit the text directly and + use `gx run` only for *queries*. +- Work on a copy when the source file is precious. + +### The graph's name and kind are preserved + +`graph MyNet { a -- b }` stays undirected and stays `MyNet`. (Both were silently rewritten +to `digraph "G"` before v0.9.5 — if you are on an older `gx`, check the diff after any +mutation on an undirected graph.) + +## Conflict safety + +Writes are compare-and-swap on the file's content hash. If the file changed underneath +you, the write is refused with **exit 5** rather than clobbering it: + +``` +gx: conflict — /path/arch.dot changed underneath this write +gx: expected 000000000000, found b163c1d6bdfb +``` + +`gx get --json` gives you the current `hash`; pass it as `--base ` to make a write +conditional on it. Without `--base`, `gx` reads the hash itself immediately before +writing, which is enough for ordinary sequential use. + +## The library, and syncing + +The library lives in `~/.graph-explorer/library`. Importing a file gives it a record — +which is what makes tags, notes, folders and hidden elements possible — and a **binding** +to the file it came from: + +```bash +gx import ./arch.dot --mode sync --name "Architecture" +gx ls +gx sync --all # reconcile every bound diagram; exit 5 if any diverged +``` + +Modes: `pull` (default — the file wins, edits stay local), `push` (the library wins), +`sync` (both directions), `detached` (no file). + +Record-tier commands then work on it: + +```bash +gx run architecture hide --params '{"targets":["node:legacy"]}' +gx run architecture tag --params '{"tags":["infra"]}' +gx run architecture get-record --json +``` + +Record edits **never write the origin file**, whatever the mode says — hiding a node must +not make a regenerated file conflict. + +## Watching + +```bash +gx watch ./arch.dot --json +``` + +Streams one JSON object per line (`changed` / `restored` / `deleted`, with the new hash) +until killed. This is the headless way to react to a diagram changing. Run it in the +background and read its output; never in the foreground of a turn. + +## Showing it to the user + +```bash +gx open ./arch.dot +``` + +This is the only reason to need the desktop app. If none is running it exits **2** and +says so — that is not a failure of the task, it is a missing window. Report it and carry +on with the headless work. + +`gx session select --params '{"targets":["node:api"]}'` and friends act on whatever is +currently on screen — there is no ``, because the live view already knows what it is +displaying. + +## Exit codes + +| Code | Meaning | What to do | +|---|---|---| +| 0 | ok | | +| 1 | usage — including a **malformed** element ref | fix the command line | +| 2 | needs a desktop | only `open` / `session`; everything else still works | +| 4 | element doesn't exist, bad path, policy refusal, or unparseable diagram | check `gx ls` / `list-nodes`, or import the file first | +| 5 | conflict, or `sync` found divergence | re-read the file and retry | +| 6 | unexpected | report it | + +## Command reference + +Document tier — operates on the text: + +| Command | Params | +|---|---| +| `list-nodes`, `list-arrows`, `list-groups` | none | +| `get-attributes` | `targets` | +| `set-attribute` | `targets`, `name`, `value` | +| `remove-attribute` | `targets`, `name` | +| `reset-attributes` | `targets` | +| `group` | `targets`, `label` (optional) | +| `ungroup` | `targets` | +| `combine-into-record` | `nodes` | +| `split-record`, `transpose-record` | `node` (a single ref string) | + +Record tier — operates on stored metadata, library diagrams only: + +| Command | Params | +|---|---| +| `hide`, `unhide` | `targets` | +| `unhide-all`, `expand-all`, `get-record` | none | +| `collapse`, `expand` | `groups` (group refs only) | +| `tag`, `untag` | `tags` (array of strings) | +| `set-notes` | `notes` | +| `move-to-folder` | `folder` | +| `rename-diagram` | `name` | + +Session tier — `gx session `, needs a desktop: +`select` / `add-to-selection` (`targets`), `clear-selection`, `reset-view`, +`what-is-selected`. + +## Sandboxing + +`GX_ALLOWED_ROOTS` and `GX_DENY_ROOTS` (path-separated) restrict which files `gx` will +touch; a refusal is exit 4 with the reason, and it is recorded in +`~/.graph-explorer/runtime/audit.log.jsonl`. If a path is denied, do not work around it — +report it. + +## Gotchas + +- `--json` on every command. Parse that, not the column output, which is formatted for + people and may add or drop columns. +- `get-attributes` includes `_gvid`, an internal layout id. Ignore it; do not set it. +- `gx status` reports `watching N files`, which is what the desktop is *following on + disk* — not what is on screen, and not what you just imported. +- A `gx` before v0.9.5 wrote a synthetic `fillstyle="true"` into files it edited, and then + could not re-read them (`could not parse the diagram: assertion failed`). Current `gx` + reads those files fine, but drops the attribute — as `dot` does — so a node that looked + filled loses its fill. Re-apply it with `style` + `fillcolor` if you see one. +- A `pull`-mode diagram accepts `gx set` and keeps the edit in the library **without + writing the file**. It says so (`saved locally`). If you meant to change the file, bind + it `push` or `sync`, or write the path directly. +- `gx skill` prints where this skill lives, pinned to the running binary. Re-read it after + a `gx` upgrade — command names and params are versioned API. diff --git a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala index badb0dfb..4af9c334 100644 --- a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala @@ -56,6 +56,8 @@ object Cli: | gx session [--params J] act on the LIVE view (needs a desktop) | gx open show it in the desktop | + | gx skill [] [--latest] where the agent skill lives + | | M = detached | pull | push | sync (default: pull) | gx run --list the commands `run` accepts | @@ -98,6 +100,7 @@ object Cli: case "run" => runCommand(args, env) case "session" => sessionCommand(args, env) case "open" => open(args, env) + case "skill" => skill(args, env) case other => env.err(s"gx: unknown command '$other'\n") env.err(Usage) @@ -812,6 +815,72 @@ object Cli: case None => checkPolicy(env.cwd.resolve(ref), env).left.map(identity) + // -------------------------------------------------------------- skill + + /** Print where the agent skill lives, and the sentence to hand an agent. + * + * Deliberately NOT an installer. The skill is a prompt that a coding agent + * will load and act on, so writing it into someone's agent directory is a + * decision rather than a side effect of asking where it is — and every + * harness keeps skills somewhere different anyway. Printing a location plus + * the instruction works for all of them and leaves the human in the loop. + * + * Pinned to this binary by default. The skill names commands, param keys and + * exit codes, all of which are API that moves between releases, so an agent + * reading the branch tip while driving an older `gx` would be reading about + * commands it does not have. + */ + private def skill(args: Args, env: CliEnv): Int = + SkillLocation.resolve( + requested = args.positionalAt(0), + latest = args.has("latest"), + running = buildinfo.BuildInfo.version + ) match + case Left(why) => + env.err(s"gx: $why") + ExitCode.Usage + + case Right(found) => + if args.json then + env.out( + ujson.Obj( + "skill" -> SkillLocation.Name, + "version" -> found.version, + "ref" -> found.ref, + "pinned" -> found.pinned, + "page" -> found.page, + "raw" -> found.raw + ).render(indent = 2) + ) + else + env.out(s"gx ${found.version} — agent skill '${SkillLocation.Name}'") + env.out("") + env.out(s" browse: ${found.page}") + env.out(s" fetch: ${found.raw}") + env.out("") + env.out("Tell your coding agent:") + env.out("") + // Second person, addressed to the agent rather than about it, so the + // block can be pasted straight into a prompt. The URL gets a line of + // its own: it is the one part that must survive being copied out of a + // wrapped terminal intact. + env.out(" Read and analyze the skill at") + env.out(s" ${found.raw}") + env.out(" check it against the `gx` on this machine, then install it: save it as") + env.out(s" ${SkillLocation.File} in this project, or under your home directory to") + env.out(" have it in every project. Keep the YAML frontmatter intact.") + env.out("") + if found.pinned then + env.out(s"Pinned to ${found.ref}, the gx you are running.") + env.out("`gx skill --latest` prints the branch tip instead.") + else + env.out(s"This gx (${found.version}) is not a released version, so this is the tip of") + env.out(s"`${SkillLocation.DefaultBranch}` and may describe commands it does not have.") + SkillLocation.baseRelease(found.version) match + case Some(base) => env.out(s"Pin it to a release instead: gx skill $base") + case None => env.out("Pin it to a release instead: gx skill ") + ExitCode.Ok + // ---------------------------------------------------------- resolution private def withTarget(args: Args, env: CliEnv)(f: Target => Int): Int = diff --git a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala new file mode 100644 index 00000000..eb70f3a6 --- /dev/null +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala @@ -0,0 +1,74 @@ +package org.jpablo.graphexplorer.gx + +/** Where the agent skill that teaches a coding agent to drive `gx` lives. + * + * `gx` deliberately does not *install* it. A skill is a prompt that will be + * loaded into someone's agent and acted on, and a CLI writing one into + * `~/.claude/skills` on their behalf is the kind of thing that should be a + * decision rather than a side effect of running a command. Printing a location + * plus the sentence to hand the agent keeps the human in the loop and works + * for every harness, including the ones that keep skills somewhere else + * entirely. + * + * The location is PINNED to the running binary. The skill documents command + * names, param shapes and exit codes, all of which are API that moves between + * releases — an agent reading the tip of the branch while driving a + * six-months-old `gx` would be reading about commands it does not have. + */ +object SkillLocation: + + val Repo = "https://github.com/jpablo/graph-explorer" + val RawHost = "https://raw.githubusercontent.com/jpablo/graph-explorer" + val Directory = ".claude/skills/gx" + val File = s"$Directory/SKILL.md" + val Name = "gx" + val DefaultBranch = "viewer" + + /** A released version, with or without the `v` the tags carry. */ + private val Release = raw"v?(\d+\.\d+\.\d+)".r + + /** The release a dev build was cut from, if its version says. + * + * dynver stamps `0.9.3+3-468f2c52`, whose leading `0.9.3` is a real tag and + * therefore the most useful thing to suggest pinning to — better than naming + * a version in the help text, which would rot at the next release. + */ + def baseRelease(version: String): Option[String] = + Release.findPrefixMatchOf(version).map(_.group(1)) + + /** What a resolution came out as. + * + * `pinned` is not decoration: an unpinned answer is the branch tip, which + * may describe commands the running binary does not have, and the caller has + * to be able to say so. + */ + final case class Resolved(version: String, ref: String, pinned: Boolean): + def page: String = s"$Repo/tree/$ref/$Directory" + def raw: String = s"$RawHost/$ref/$File" + + /** @param requested + * a version named on the command line, if any + * @param latest + * `--latest`: the branch tip, whatever this binary is + * @param running + * this binary's own version (`BuildInfo.version`) + */ + def resolve(requested: Option[String], latest: Boolean, running: String): Either[String, Resolved] = + (requested, latest) match + case (Some(v), true) => + Left(s"--latest and an explicit version ('$v') ask for different things; pick one") + + case (Some(Release(v)), false) => Right(Resolved(v, s"v$v", pinned = true)) + + case (Some(other), false) => + Left(s"'$other' is not a version; expected something like 0.9.4") + + case (None, true) => Right(Resolved(running, DefaultBranch, pinned = false)) + + case (None, false) => + running match + // dynver stamps a dev build `0.9.3+13-2b8d0a46+20260730-2334`, and + // there is no tag by that name to point at. The tip is the honest + // answer, and the caller says out loud that it is not pinned. + case Release(v) if v == running => Right(Resolved(v, s"v$v", pinned = true)) + case _ => Right(Resolved(running, DefaultBranch, pinned = false)) diff --git a/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala b/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala index 5b57347d..1146f4f3 100644 --- a/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala +++ b/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala @@ -776,3 +776,78 @@ class CliSpec extends FunSuite: l("ls", "--json") assertEquals(ujson.read(l.stdout).arr.size, 1) } + + // --------------------------------------------------------------- skill + + /** `gx skill` points an agent at the skill; it must never install one. + * + * A skill is a prompt someone's agent will load and act on, so writing it + * into their harness is a decision rather than a side effect of asking where + * it is. These assert the printing, and — via the resolver — that the answer + * is pinned to the binary rather than to whatever the branch says today. + */ + + tmp.test("skill prints a location and an instruction, and writes nothing") { dir => + val r = Run(dir) + assertEquals(r("skill"), ExitCode.Ok, r.stderr) + assert(r.stdout.contains(SkillLocation.File), r.stdout) + assert(r.stdout.contains("Tell your coding agent"), r.stdout) + // The whole point of the command: it is a pointer, not an installer. + assert(!Files.exists(dir.resolve(SkillLocation.File)), "skill must not install anything") + } + + tmp.test("skill --json is machine-readable and says whether it is pinned") { dir => + val r = Run(dir) + assertEquals(r("skill", "--json"), ExitCode.Ok, r.stderr) + val json = ujson.read(r.stdout) + assertEquals(json("skill").str, SkillLocation.Name) + assert(json("raw").str.endsWith(SkillLocation.File), json("raw").str) + json("pinned").bool // present, and a boolean + } + + tmp.test("skill takes an explicit version and pins to that tag") { dir => + val r = Run(dir) + assertEquals(r("skill", "0.9.4", "--json"), ExitCode.Ok, r.stderr) + val json = ujson.read(r.stdout) + assertEquals(json("ref").str, "v0.9.4") + assertEquals(json("pinned").bool, true) + } + + tmp.test("skill refuses something that is not a version") { dir => + val r = Run(dir) + assertEquals(r("skill", "yesterday"), ExitCode.Usage) + assert(r.stderr.contains("not a version"), r.stderr) + } + + tmp.test("skill --latest is the branch tip, and not pinned") { dir => + val r = Run(dir) + assertEquals(r("skill", "--latest", "--json"), ExitCode.Ok, r.stderr) + val json = ujson.read(r.stdout) + assertEquals(json("ref").str, SkillLocation.DefaultBranch) + assertEquals(json("pinned").bool, false) + } + + tmp.test("skill will not guess between --latest and a named version") { dir => + val r = Run(dir) + assertEquals(r("skill", "0.9.4", "--latest"), ExitCode.Usage) + assert(r.stderr.contains("pick one"), r.stderr) + } + + // The resolver itself, away from the printing: a dev build has no tag to + // point at, and pretending otherwise would send an agent to a 404. + test("a released version resolves to its tag; a dev build falls back to the tip") { + val release = SkillLocation.resolve(None, latest = false, running = "0.9.4") + assertEquals(release.map(_.ref), Right("v0.9.4")) + assertEquals(release.map(_.pinned), Right(true)) + + val dev = SkillLocation.resolve(None, latest = false, running = "0.9.3+13-2b8d0a46+20260730-2334") + assertEquals(dev.map(_.ref), Right(SkillLocation.DefaultBranch)) + assertEquals(dev.map(_.pinned), Right(false)) + // The tip is not pinned, but the version still names a real tag to suggest. + assertEquals(SkillLocation.baseRelease("0.9.3+13-2b8d0a46+20260730-2334"), Some("0.9.3")) + } + + test("a version given with the tag's own 'v' is accepted") { + val found = SkillLocation.resolve(Some("v1.2.3"), latest = false, running = "0.9.4") + assertEquals(found.map(_.ref), Right("v1.2.3")) + } From a382aa38821b5ce5be13239ff21bd5e68d0a3142 Mon Sep 17 00:00:00 2001 From: Juan Pablo Romero Date: Thu, 20 Aug 2026 20:18:00 -0700 Subject: [PATCH 3/4] refactor(gx): the skill moves to the portable Agent Skills layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `.claude/skills/gx/` is where a skill is INSTALLED for one harness. It is the wrong place to publish one from: the Agent Skills format is read by Claude Code, Codex CLI, Cursor and others, and each keeps installed skills somewhere different. The skill now lives at `skills/gx/`, and where it goes on the receiving end is named in the instruction `gx skill` prints rather than baked into the path it is served from. Split for progressive disclosure, which is the point of the format: a runtime loads the frontmatter always, the body when the skill is relevant, and the supporting files only when a step needs them. SKILL.md keeps what an agent must know before it touches a diagram — the tiers, ref resolution, the reformat hazard, conflict safety — and 178 lines of lookup move out: commands.md every command, its params, and the exit codes library.md import/bind/sync modes, watching, the filesystem sandbox Frontmatter stays inside the spec's six permitted fields and gains `license` and `compatibility`; anything else (an `argument-hint`, say) is a hard error when the skill is packaged or uploaded, not an ignored key. Two tests guard what nothing else would notice, since a skill is an asset no Scala code imports: that the location `gx skill` advertises actually exists with the supporting files SKILL.md links, and that the frontmatter carries no non-portable key. Both fail when broken — the second was checked by adding `argument-hint` and watching it name that key. --- .../org/jpablo/graphexplorer/gx/Cli.scala | 8 +- .../graphexplorer/gx/SkillLocation.scala | 29 ++++- .../org/jpablo/graphexplorer/gx/CliSpec.scala | 54 ++++++++- {.claude/skills => skills}/gx/SKILL.md | 108 +++--------------- skills/gx/commands.md | 102 +++++++++++++++++ skills/gx/library.md | 93 +++++++++++++++ 6 files changed, 292 insertions(+), 102 deletions(-) rename {.claude/skills => skills}/gx/SKILL.md (69%) create mode 100644 skills/gx/commands.md create mode 100644 skills/gx/library.md diff --git a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala index 4af9c334..5d7d1ef5 100644 --- a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/Cli.scala @@ -866,9 +866,11 @@ object Cli: // wrapped terminal intact. env.out(" Read and analyze the skill at") env.out(s" ${found.raw}") - env.out(" check it against the `gx` on this machine, then install it: save it as") - env.out(s" ${SkillLocation.File} in this project, or under your home directory to") - env.out(" have it in every project. Keep the YAML frontmatter intact.") + env.out(s" together with ${SkillLocation.SupportingFiles.mkString(" and ")} beside it, which it links.") + env.out(" Check it against the `gx` on this machine, then install all three as a") + env.out(s" skill named `${SkillLocation.Name}` wherever this harness keeps skills — for Claude") + env.out(s" Code that is ${SkillLocation.Name}/ under .claude/skills/ in the project, or under") + env.out(" your home directory to have it everywhere. Keep the frontmatter intact.") env.out("") if found.pinned then env.out(s"Pinned to ${found.ref}, the gx you are running.") diff --git a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala index eb70f3a6..1d64833b 100644 --- a/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala @@ -17,11 +17,30 @@ package org.jpablo.graphexplorer.gx */ object SkillLocation: - val Repo = "https://github.com/jpablo/graph-explorer" - val RawHost = "https://raw.githubusercontent.com/jpablo/graph-explorer" - val Directory = ".claude/skills/gx" - val File = s"$Directory/SKILL.md" - val Name = "gx" + val Repo = "https://github.com/jpablo/graph-explorer" + val RawHost = "https://raw.githubusercontent.com/jpablo/graph-explorer" + + /** Where the skill lives in the REPO, which is not where it gets installed. + * + * A vendor-neutral `skills//` rather than `.claude/skills/`: the Agent + * Skills format is read by more than one harness, and each keeps installed + * skills somewhere different — so the directory a skill is *published* from + * should not be spelled after any one of them. `.claude/skills/gx` is one + * possible destination, named in the instruction rather than here. + */ + val Directory = "skills/gx" + val File = s"$Directory/SKILL.md" + + /** The spec requires `name` to match the directory it lives in. */ + val Name = "gx" + + /** The supporting files beside SKILL.md, which an installer has to take too. + * + * SKILL.md links them and stops short of repeating them, so fetching it + * alone yields a skill whose reference sections are dangling links. + */ + val SupportingFiles = Vector("commands.md", "library.md") + val DefaultBranch = "viewer" /** A released version, with or without the `v` the tags carry. */ diff --git a/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala b/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala index 1146f4f3..91871233 100644 --- a/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala +++ b/gx-cli/src/test/scala/org/jpablo/graphexplorer/gx/CliSpec.scala @@ -5,7 +5,9 @@ import org.jpablo.graphexplorer.gxcore.fs.{AccessPolicy, Audit, Documents} import org.jpablo.graphexplorer.gxcore.rpc.ChannelError import org.jpablo.graphexplorer.gxcore.store.LibraryStore -import java.nio.file.{Files, Path} +import java.nio.file.{Files, Path, Paths} + +import scala.jdk.CollectionConverters.* /** V-09: every command except `open` works with no desktop running. * @@ -851,3 +853,53 @@ class CliSpec extends FunSuite: val found = SkillLocation.resolve(Some("v1.2.3"), latest = false, running = "0.9.4") assertEquals(found.map(_.ref), Right("v1.2.3")) } + + /** The path `gx skill` advertises has to be the path the skill is actually + * at, or every URL the command prints is a 404 — and nothing else in the + * build would notice, because the skill is an asset no Scala code imports. + */ + test("the advertised location exists in this repository") { + val root = repoRoot() + val skill = root.resolve(SkillLocation.File) + assert(Files.exists(skill), s"gx skill points at ${SkillLocation.File}, which does not exist") + for name <- SkillLocation.SupportingFiles do + val f = root.resolve(SkillLocation.Directory).resolve(name) + assert(Files.exists(f), s"SKILL.md links $name, which does not exist") + } + + /** Only the six fields the Agent Skills spec allows. + * + * Not a style preference: packaging or uploading a skill with any other key + * fails with a hard error rather than ignoring it, so a Claude Code-only + * field here would make the skill unusable everywhere else. + */ + test("the skill's frontmatter is portable") { + val allowed = Set("allowed-tools", "compatibility", "description", "license", "metadata", "name") + val lines = Files.readAllLines(repoRoot().resolve(SkillLocation.File)).asScala.toVector + assertEquals(lines.headOption, Some("---"), "SKILL.md must open with YAML frontmatter") + + val body = lines.drop(1) + val end = body.indexOf("---") + assert(end > 0, "the frontmatter is not closed") + + val keys = body.take(end).filterNot(_.startsWith(" ")).filter(_.contains(":")).map(_.takeWhile(_ != ':')) + val bad = keys.filterNot(allowed.contains) + assertEquals(bad, Vector.empty[String], s"non-portable frontmatter key(s): ${bad.mkString(", ")}") + + // The spec ties the name to the directory; a mismatch simply fails to load. + assert(keys.contains("name"), "SKILL.md needs a name") + assert( + body.take(end).contains(s"name: ${SkillLocation.Name}"), + s"the skill's name must match its directory (${SkillLocation.Name})" + ) + } + + /** sbt runs tests from wherever it was launched, so walk up rather than + * assuming the module directory. + */ + private def repoRoot(): Path = + Iterator + .iterate(Paths.get(sys.props.getOrElse("user.dir", ".")).toAbsolutePath)(_.getParent) + .takeWhile(_ != null) + .find(p => Files.exists(p.resolve("build.sbt"))) + .getOrElse(fail("could not find the repository root")) diff --git a/.claude/skills/gx/SKILL.md b/skills/gx/SKILL.md similarity index 69% rename from .claude/skills/gx/SKILL.md rename to skills/gx/SKILL.md index 317bc0ce..30933832 100644 --- a/.claude/skills/gx/SKILL.md +++ b/skills/gx/SKILL.md @@ -1,6 +1,8 @@ --- name: gx description: Read, query and edit graph diagrams from the command line with `gx`, the Graph Explorer CLI — list a DOT or Mermaid file's nodes/edges/groups, set attributes, group and hide elements, keep a file synced with the diagram library, and show it in the Graph Explorer desktop app. Use whenever a task involves .dot/.gv/.mmd diagram files, asks what a diagram contains, asks to restyle or restructure one, or asks to open/visualize a graph. +license: Apache-2.0 +compatibility: Requires the `gx` binary on PATH (Graph Explorer; download from https://github.com/jpablo/graph-explorer/releases). Everything except `gx open` and `gx session` runs headless — no desktop app and no display needed. --- # Driving Graph Explorer with `gx` @@ -43,8 +45,8 @@ Every command takes a ``, and a ref is resolved in this order: Ambiguity is reported, never guessed at. A loose file on disk needs no setup — `gx run ./arch.dot list-nodes` works immediately. -But **the record tier only exists for library diagrams** (see the tiers below); on a loose -file those commands refuse with exit 4 and tell you to `gx import` it first. +But **the record tier only exists for library diagrams**; on a loose file those commands +refuse with exit 4 and tell you to `gx import` it first. ## Three tiers @@ -145,11 +147,9 @@ The graph is preserved; the formatting, comments and layout of the source are no use `gx run` only for *queries*. - Work on a copy when the source file is precious. -### The graph's name and kind are preserved - -`graph MyNet { a -- b }` stays undirected and stays `MyNet`. (Both were silently rewritten -to `digraph "G"` before v0.9.5 — if you are on an older `gx`, check the diff after any -mutation on an undirected graph.) +The graph's **name and kind are preserved**: `graph MyNet { a -- b }` stays undirected and +stays `MyNet`. (Both were silently rewritten to `digraph "G"` before v0.9.5 — on an older +`gx`, check the diff after any mutation on an undirected graph.) ## Conflict safety @@ -165,42 +165,6 @@ gx: expected 000000000000, found b163c1d6bdfb conditional on it. Without `--base`, `gx` reads the hash itself immediately before writing, which is enough for ordinary sequential use. -## The library, and syncing - -The library lives in `~/.graph-explorer/library`. Importing a file gives it a record — -which is what makes tags, notes, folders and hidden elements possible — and a **binding** -to the file it came from: - -```bash -gx import ./arch.dot --mode sync --name "Architecture" -gx ls -gx sync --all # reconcile every bound diagram; exit 5 if any diverged -``` - -Modes: `pull` (default — the file wins, edits stay local), `push` (the library wins), -`sync` (both directions), `detached` (no file). - -Record-tier commands then work on it: - -```bash -gx run architecture hide --params '{"targets":["node:legacy"]}' -gx run architecture tag --params '{"tags":["infra"]}' -gx run architecture get-record --json -``` - -Record edits **never write the origin file**, whatever the mode says — hiding a node must -not make a regenerated file conflict. - -## Watching - -```bash -gx watch ./arch.dot --json -``` - -Streams one JSON object per line (`changed` / `restored` / `deleted`, with the new hash) -until killed. This is the headless way to react to a diagram changing. Run it in the -background and read its output; never in the foreground of a turn. - ## Showing it to the user ```bash @@ -215,56 +179,6 @@ on with the headless work. currently on screen — there is no ``, because the live view already knows what it is displaying. -## Exit codes - -| Code | Meaning | What to do | -|---|---|---| -| 0 | ok | | -| 1 | usage — including a **malformed** element ref | fix the command line | -| 2 | needs a desktop | only `open` / `session`; everything else still works | -| 4 | element doesn't exist, bad path, policy refusal, or unparseable diagram | check `gx ls` / `list-nodes`, or import the file first | -| 5 | conflict, or `sync` found divergence | re-read the file and retry | -| 6 | unexpected | report it | - -## Command reference - -Document tier — operates on the text: - -| Command | Params | -|---|---| -| `list-nodes`, `list-arrows`, `list-groups` | none | -| `get-attributes` | `targets` | -| `set-attribute` | `targets`, `name`, `value` | -| `remove-attribute` | `targets`, `name` | -| `reset-attributes` | `targets` | -| `group` | `targets`, `label` (optional) | -| `ungroup` | `targets` | -| `combine-into-record` | `nodes` | -| `split-record`, `transpose-record` | `node` (a single ref string) | - -Record tier — operates on stored metadata, library diagrams only: - -| Command | Params | -|---|---| -| `hide`, `unhide` | `targets` | -| `unhide-all`, `expand-all`, `get-record` | none | -| `collapse`, `expand` | `groups` (group refs only) | -| `tag`, `untag` | `tags` (array of strings) | -| `set-notes` | `notes` | -| `move-to-folder` | `folder` | -| `rename-diagram` | `name` | - -Session tier — `gx session `, needs a desktop: -`select` / `add-to-selection` (`targets`), `clear-selection`, `reset-view`, -`what-is-selected`. - -## Sandboxing - -`GX_ALLOWED_ROOTS` and `GX_DENY_ROOTS` (path-separated) restrict which files `gx` will -touch; a refusal is exit 4 with the reason, and it is recorded in -`~/.graph-explorer/runtime/audit.log.jsonl`. If a path is denied, do not work around it — -report it. - ## Gotchas - `--json` on every command. Parse that, not the column output, which is formatted for @@ -281,3 +195,11 @@ report it. it `push` or `sync`, or write the path directly. - `gx skill` prints where this skill lives, pinned to the running binary. Re-read it after a `gx` upgrade — command names and params are versioned API. + +## Additional resources + +- Every command name, its parameters, and the exit codes: [commands.md](commands.md). + Load it before issuing any command whose params you are not certain of. +- Importing, binding, sync modes, watching, and the filesystem sandbox: + [library.md](library.md). Load it when the task involves the library rather than a + loose file on disk, or when a path is refused by policy. diff --git a/skills/gx/commands.md b/skills/gx/commands.md new file mode 100644 index 00000000..fda5796e --- /dev/null +++ b/skills/gx/commands.md @@ -0,0 +1,102 @@ +# `gx` command reference + +Every command name, what it takes, and what it returns. Names are API — they appear in +`gx run --list`, in the audit log, and in every recorded command a replay would re-run. + +Params are a JSON object, passed as `--params ''` or on stdin with `--stdin`. +Element refs (`targets`, `groups`, `nodes`, `node`) must come from a `list-*` query — +see the "Never construct an element ref" section of [SKILL.md](SKILL.md). + +## Document tier — operates on the diagram text + +`gx run `. Works on a loose file or a library diagram. A mutation rewrites +the whole file in canonical form; a query never writes. + +| Command | Params | Returns | +|---|---|---| +| `list-nodes` | none | `[{ref, label}]` | +| `list-arrows` | none | `[{ref, source, target}]` | +| `list-groups` | none | `[{ref, label}]` | +| `get-attributes` | `targets` | `{ref: {attr: value}}` | +| `set-attribute` | `targets`, `name`, `value` | the updated diagram | +| `remove-attribute` | `targets`, `name` | the updated diagram | +| `reset-attributes` | `targets` | the updated diagram | +| `group` | `targets`, `label` (optional) | the updated diagram | +| `ungroup` | `targets` | the updated diagram | +| `combine-into-record` | `nodes` (node refs only) | the updated diagram | +| `split-record` | `node` (one ref string) | the updated diagram | +| `transpose-record` | `node` (one ref string) | the updated diagram | + +`combine-into-record` and friends take **nodes**, not a mixed set: passing `group:g1` is a +category error and is refused by name rather than silently dropped. + +## Record tier — operates on stored metadata + +`gx run `, **library diagrams only**. On a loose file these exit 4 with +`'x.dot' is not in the library, so it has no record to change`. + +Record edits never write the origin file, whatever the sync mode says — hiding a node must +not make a regenerating origin conflict. + +| Command | Params | +|---|---| +| `hide` | `targets` | +| `unhide` | `targets` | +| `unhide-all` | none | +| `collapse` | `groups` (group refs only) | +| `expand` | `groups` (group refs only) | +| `expand-all` | none | +| `tag` | `tags` (non-empty array of strings) | +| `untag` | `tags` | +| `set-notes` | `notes` (string) | +| `move-to-folder` | `folder` (string) | +| `rename-diagram` | `name` (string) | +| `get-record` | none | + +## Session tier — operates on the live view + +`gx session `. Needs a running desktop; there is no ``, because the live +view already knows what it is displaying. + +| Command | Params | +|---|---| +| `select` | `targets` | +| `add-to-selection` | `targets` | +| `clear-selection` | none | +| `reset-view` | none | +| `what-is-selected` | none | + +Without a desktop these exit **2**. So does a desktop with nothing on screen +(`NO_SESSION`) — the caller's next move is the same either way: open something. + +## Top-level commands + +| Command | Notes | +|---|---| +| `gx status [--json]` | library root, diagram count, desktop state. Never fails for want of a desktop | +| `gx ls [--folder F] [--json]` | library contents | +| `gx get [--json]` | the text; `--json` adds `path` and the content `hash` | +| `gx set (--stdin \| --text T) [--base H]` | replace the text | +| `gx import [--mode M] [--folder F] [--name N]` | see [library.md](library.md) | +| `gx bind [--mode M]` / `gx unbind ` | see [library.md](library.md) | +| `gx sync [] [--all] [--json]` | see [library.md](library.md) | +| `gx watch [...] [--all] [--json]` | see [library.md](library.md) | +| `gx run ` / `gx run --list` | document and record tiers | +| `gx session ` / `gx session --list` | live view | +| `gx open ` | show it in the desktop | +| `gx skill [] [--latest] [--json]` | where this skill lives | +| `gx --version` | the binary's version | + +## Exit codes + +| Code | Meaning | What to do | +|---|---|---| +| 0 | ok | | +| 1 | usage — including a **malformed** element ref | fix the command line | +| 2 | needs a desktop | only `open` / `session`; everything else still works | +| 4 | element doesn't exist, bad path, policy refusal, or unparseable diagram | check `gx ls` / `list-nodes`, or import the file first | +| 5 | conflict, or `sync` found divergence | re-read the file and retry | +| 6 | unexpected | report it | + +Exit 3 does not exist and is not reused: it meant an auth failure in v1, and v2 has no +token to fail against. diff --git a/skills/gx/library.md b/skills/gx/library.md new file mode 100644 index 00000000..b6f80dff --- /dev/null +++ b/skills/gx/library.md @@ -0,0 +1,93 @@ +# The `gx` library, syncing, watching, and the sandbox + +Load this when the task involves the diagram library rather than a loose file on disk, or +when a path is refused by policy. + +## What the library is for + +The library lives in `~/.graph-explorer/library`. A loose file +needs no library at all — `gx run ./arch.dot list-nodes` works immediately. What importing +adds is a **record**: the stored metadata that makes tags, notes, folders, hidden elements +and collapsed groups possible. That is the whole difference between the document tier and +the record tier. + +```bash +gx import ./arch.dot --mode sync --name "Architecture" +gx ls +gx ls --json # id, name, folder, format, origin, mode +``` + +Importing the same file twice does not create a second record. It warns +(`already imported as `) and reuses the first — a second record fighting the first over +one file is worse than a duplicate. + +## Bindings and sync modes + +An imported diagram carries a **binding**: the origin it came from, a mode, and the content +hash both sides last agreed on. + +| Mode | The file wins | The library wins | `gx set` writes the file | +|---|---|---|---| +| `pull` (default) | yes | no | no — edits stay local | +| `push` | no | yes | yes | +| `sync` | both directions | both directions | yes | +| `detached` | no origin at all | — | — | + +```bash +gx bind ./arch.dot --mode sync +gx unbind +gx sync --all --json # reconcile everything bound +``` + +`gx sync` reports a state per diagram and **exits 5 if any diverged**. Divergence is a +state, not an error — but a script that just pushed and wants to know whether it landed +deserves a non-zero code. + +A binding against a file that does not exist yet is legal: it binds against the diagram's +own text, because the origin is where it is going, not only where it came from. + +### The `pull`-mode trap + +A `pull` diagram accepts `gx set` and keeps the edit **in the library without writing the +file**. It says so: + +``` +architecture saved locally +(Pull does not write back; the origin is unchanged) +``` + +If you meant to change the file, bind it `push` or `sync`, or address the path directly +rather than the library ref. + +## Watching + +```bash +gx watch ./arch.dot --json +gx watch --all --json +``` + +Streams one JSON object per line until killed: + +```json +{"event":"changed","origin":"file:///path/arch.dot","hash":"b163c1d6…"} +``` + +Events are `changed`, `restored`, `deleted`. This is the headless way to react to a +diagram changing — no window anywhere in the picture. **Run it in the background** and read +its output; it never returns on its own. `--interval ` sets the poll and debounce +(default 50). + +`--open` is accepted and warned about rather than silently ignored; it needs a desktop. + +## The filesystem sandbox + +`GX_ALLOWED_ROOTS` and `GX_DENY_ROOTS` (path-separated, like `PATH`) restrict which files +`gx` will touch. `GRAPH_EXPLORER_ALLOWED_ROOTS` / `GRAPH_EXPLORER_DENY_ROOTS` are accepted +as aliases. With no allow-list set, everything outside the built-in denied roots is +allowed. + +A refusal is exit **4** with the reason, and it is recorded in +`~/.graph-explorer/runtime/audit.log.jsonl` along with every write and every conflict. + +**If a path is denied, do not work around it** — do not copy the file somewhere allowed, +and do not edit the environment. Report the refusal and let the user decide. From ccc73b956e21d39c60913b5926a884dcf7d9be22 Mon Sep 17 00:00:00 2001 From: Juan Pablo Romero Date: Thu, 20 Aug 2026 22:59:14 -0700 Subject: [PATCH 4/4] docs(gx): a name match is not proof you have the right diagram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by running the skill against fresh agents. Two of them were given the same task — hide a node — with and without the skill. The one without it ran `gx ls`, saw a diagram named `infra`, hid the node on it, verified against it, and reported success. That record was bound to a different file in a different directory; its own file was never imported and never touched. Nothing in the tool misbehaved. Ref resolution reports *ambiguity*, and one exact name match is not ambiguous — it is just possibly the wrong file. The library is global while a task is local, so a bare name is not a safe address for a record you did not import yourself. Both files now say so: check the `origin` before acting on a library ref, or address the path and sidestep the question. --- skills/gx/SKILL.md | 13 +++++++++++++ skills/gx/library.md | 6 ++++++ 2 files changed, 19 insertions(+) diff --git a/skills/gx/SKILL.md b/skills/gx/SKILL.md index 30933832..405a8e86 100644 --- a/skills/gx/SKILL.md +++ b/skills/gx/SKILL.md @@ -48,6 +48,19 @@ A loose file on disk needs no setup — `gx run ./arch.dot list-nodes` works imm But **the record tier only exists for library diagrams**; on a loose file those commands refuse with exit 4 and tell you to `gx import` it first. +⚠️ **A name match is not proof you have the right diagram.** The library is global, so a +name like `infra` may already belong to a diagram bound to a completely different file. +Resolution reports *ambiguity*, but one exact match is not ambiguous — it is just possibly +the wrong file. Before acting on a library ref you did not import yourself, check what it +is bound to: + +```bash +gx ls --json # or: gx run get-record --json +``` + +and confirm the `origin` is the file you mean. Addressing the **path** instead of the name +sidesteps the question entirely, and is the safer default when you have a path in hand. + ## Three tiers | Tier | Verb | Operates on | Needs a desktop? | diff --git a/skills/gx/library.md b/skills/gx/library.md index b6f80dff..2812e93d 100644 --- a/skills/gx/library.md +++ b/skills/gx/library.md @@ -21,6 +21,12 @@ Importing the same file twice does not create a second record. It warns (`already imported as `) and reuses the first — a second record fighting the first over one file is worse than a duplicate. +Note what that uniqueness is keyed on: the **origin**, not the name. Two different files +can both be called `infra`, and the second import gets a fresh id (`infra-2`) while keeping +the name. So a bare name is not a safe address — `gx run infra hide ...` may act on a +record bound to someone else's file, succeed, and report success. Check the `origin` first, +or address the path. + ## Bindings and sync modes An imported diagram carries a **binding**: the origin it came from, a mode, and the content