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..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 @@ -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,74 @@ 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(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.") + 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..1d64833b --- /dev/null +++ b/gx-cli/src/main/scala/org/jpablo/graphexplorer/gx/SkillLocation.scala @@ -0,0 +1,93 @@ +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" + + /** 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. */ + 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..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. * @@ -776,3 +778,128 @@ 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")) + } + + /** 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/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 diff --git a/skills/gx/SKILL.md b/skills/gx/SKILL.md new file mode 100644 index 00000000..405a8e86 --- /dev/null +++ b/skills/gx/SKILL.md @@ -0,0 +1,218 @@ +--- +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` + +`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**; 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? | +|---|---|---|---| +| 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 — 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. + +## 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. + +## 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. + +## 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..2812e93d --- /dev/null +++ b/skills/gx/library.md @@ -0,0 +1,99 @@ +# 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. + +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 +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.