A command-line interface for navigating and managing a Foliage graph over NATS.
| Environment variable | Default | Description |
|---|---|---|
NATS_URL |
nats://nats:foliage@nats:4222 |
NATS server URL |
NATS_HUB_DOMAIN |
hub |
Hub domain name |
NATS_REQUEST_TIMEOUT_SEC |
60 |
Request timeout in seconds |
FOLIAGE_CLI_DIR |
~/.foliage-cli |
Directory for persisting cursor state |
foliage-cli tui
Opens a full-screen three-panel TUI. Navigation starts from the last persisted
position (or root on first run). Below 90 columns it falls back to a single
column, which renders the same things — including every modal.
Here the outgoing column has focus with a link selected, so the centre panel is showing that link. Standing on the centre column instead shows the vertex, and neither side panel is highlighted:
┌──────────────────────────────────────────────────────────────────┐
│ ◈ hub/router-1 │
├──────────────────┬───────────────────────────┬────────────────────┤
│ ← INCOMING (2) │ ⎯ port-b [objects-link] │ → OUTGOING (5) │
│ │ │ │
│ managed_by 1 │ router-1 ──▶ port-b │ contains 3 │
│ admin ← │ │ port-a → │
│ │ name port-b │ ► port-b → │
│ │ type contains │ port-c → │
│ │ via objects-link │ │
│ │ tags uplink, prod │ depends_on 2 │
│ │ │ upstream → │
│ │ body │ gateway → │
│ │ speed: 10G │ │
└──────────────────┴───────────────────────────┴────────────────────┘
jk:nav Enter:go b:back n:new i:edit d:del L:link ?:help
Press ? inside the TUI for the full keymap — the status bar only has room for
the most-used bindings. Both are rendered from one table in the source, so they
cannot disagree with each other, and a test checks that table against the key
handlers, so neither can disagree with the code.
The subject is either a vertex or a link, and which one is decided by the column you are standing in: the centre column is the vertex, a side column is the link under its cursor. The centre panel always shows the subject in full — for a link that means its endpoints, name, type, API tier, tags and body, none of which used to be visible anywhere.
Every action applies to the subject:
| Key | Action |
|---|---|
i |
edit the subject |
d |
delete the subject |
y |
yank the subject's body, to paste with ctrl+t |
One rule, instead of the four this replaced. There is deliberately no key for "the vertex regardless of where I am standing": the column is the selector, and a second way to say it would be a second answer to the question the columns exist to answer. In a side column with no link under the cursor there is simply no subject, and the keys say so rather than quietly acting on the vertex.
It is also why editing a link is not a binding you have to be told about: you can see the link, so pressing the edit key over it is the obvious move.
Navigate and view
| Key | Action |
|---|---|
h / l |
Move between the columns: incoming · the vertex · outgoing |
j / ↓, k / ↑ |
Move cursor |
Enter |
Navigate to selected vertex; on a group header — toggle collapse |
Tab |
Toggle collapse of the current link-type group |
b / Backspace |
Go back (history stack) |
v |
Toggle raw JSON body vs. key-value view |
c |
Copy current vertex ID to clipboard |
: |
Go to a vertex by id · Tab cycles the built-in ones |
/ |
JPGQL query from current vertex |
f |
Live filter links by name · Esc restores the previous filter |
e |
Export graph to file (graphml / dot / json2xml), choose depth |
r |
Reload where you are standing — for changes somebody else made |
R |
Jump to root and reset all state, including a pending link |
g / G |
Scroll the body up / down |
x |
Switch the CRUD API between high-level and low-level |
? |
Show the full keymap · j/k scroll it |
q / Ctrl+C |
Quit |
Nothing is cached. Every navigation is a real read, so what is on screen is
what the graph says now — the CLI is not the only thing writing to it. r is
for the one case navigation cannot cover: somebody else changed something while
you stood still.
Esc always pops one level and never destroys data. Ctrl+C quits from
anywhere. q quits while browsing and is an ordinary letter wherever there is
a text field. Wherever Tab cycles something, the status bar says so.
The centre column is part of the focus cycle, and that is what decides the
subject. Standing on it means you are working with the vertex, and neither side
panel is highlighted — an unfocused panel never highlights anything, so nothing
on screen claims a link is selected when none is. h/l step one column at a
time and clamp at the ends; step into a link panel and its first row is
highlighted straight away, because there the claim is true.
The CRUD API is always stated at the left of the status bar: CRUD: high-level or CRUD: low-level, switched with x. It governs create, edit
and delete — not navigation, which always walks raw edges.
It governs them strictly. The high-level API knows types, objects, types-links and objects-links; the low-level one knows raw vertices and raw links. An operation with no meaning under the armed API is refused, and the refusal names the key that makes it possible:
x is a plain vertex — the high-level API only knows types and objects
— press x to switch the CRUD API
The create menu obeys the same rule, so type is unavailable in low-level mode
and raw vertex in high-level mode, each saying why. And confirmations name
what they are about to remove — DELETE TYPE srv, Delete OBJECT srv-1,
Delete VERTEX srv with the LOW-LEVEL API — because the same vertex is very
different amounts of graph through the two APIs.
The header badge names what you are standing on — [type], [object of srv],
[built-in], [vertex], or [object · instance-of link missing] for a
half-written one. It is worth reading, because it is what the create menu is
gated on.
| Key | Action |
|---|---|
n |
New… — what can be created from where you are standing |
L |
The link key — see below |
Esc |
Cancel a pending link |
L follows the subject like everything else, and the status bar always says
which of the three it will do:
| where you are | L |
|---|---|
| a link is selected in a side column | edit that link |
| a link is pending | commit it here |
| otherwise | start a link from this vertex |
Commit wins while something is pending, because the banner has been promising that commit across every keystroke since it appeared — where the cursor happens to rest must not change what the screen said it would do.
You create a thing where that thing lives. Types are created from
hub/types, objects from their own type, and a raw vertex is always attached
to the vertex you create it from. A relation between two types — schema or
inheritance — is made with L, by walking from one to the other. This is not
ceremony: the TUI finds things by walking the graph, so anything created
outside its home would be unreachable by the very tool that made it. After a
successful create you are moved onto the new entity.
Where an action does not belong, the menu still lists it, says where it does live, and pressing it takes you there — so the rule is learned by using it rather than by being refused.
Linking is a two-step commit, because typing a target id defeats the point of having a browser:
- Stand on the source, press
L— a banner appears:◆ LINK PENDING srv-1 ──▶ … - Navigate anywhere. Every key keeps working; the banner stays.
- At the target press
Lagain to commit, orEscto cancel.
Editing a link obeys the armed CRUD API like everything else: in high-level
mode you can edit types-links, objects-links, claimed-type object links and
sub-type edges — the ones the CMDB owns — and a raw link between plain vertices
is refused until you press x.
Which API the link goes through is derived from the endpoints: object→object
creates an objects-link, and anything else a raw link. Inside the form
←/→ swaps the direction.
Two types can be related in two entirely different ways, so the form asks
which — ←/→ on the relation row:
- types-link — a schema declaration: objects of these types may then be linked, under the object-link type you name
- sub-type — inheritance: the target inherits from the source
Whichever you pick, the form shows only the fields that apply to it.
An objects-link form also offers from as / to as, prefilled with the endpoints' real types. Naming a super-type of either instead links the two objects under a schema declared further up the hierarchy. Leave them alone for the ordinary case.
i (or t, which lands on the tags row) opens the editor for the selected
link: its tags and its body in one form, because the server applies replace
to both with a single flag. The form is built from a read of the link, never
from the list view — the list deliberately does not fetch tags or bodies, and a
form seeded from it would show blanks over real data.
Every form — creating, editing, the body editor — appears in the centre
column, which is where the subject lives. Inside the body editor: Ctrl+S
apply · Ctrl+R switch MERGE ⇄ REPLACE · Ctrl+E open $EDITOR for more
room · Ctrl+T paste the yanked body · Esc cancel. JSON is
validated as you type and submission is blocked while it is invalid.
Machine-owned paths (triggers, cache, a types-link's type, …) are held
aside and listed under the editor, so nothing is lost.
Deletion routes through whichever API owns the edge, which changes what it does:
- a types-link deletes the schema declaration and the corresponding link on every object of the source type — so it asks you to type the type's name, the same as deleting a type
- a sub-type edge removes the inheritance relation and re-runs the computation on every descendant — likewise confirmed by name
- everything else is a single-key
y/n
A form that has been submitted says so — working… / deleting… — until the
server answers. Without that, pressing Enter on a call that takes a moment
looks exactly like a key that did nothing.
A cascading delete asks you to type a word, and the confirmation shows which
word next to the box you type it in — for a types-link it is the owner type's
name, because the deletion reaches every object of that type, which is not the
name you would guess. Deleting a type likewise requires typing its name,
because it removes every object of that type. Structural vertices (root, types, objects, trash_can, …)
are refused outright.
After a delete you land on the deleted entity's home — an object goes up to
its type, a type to hub/types — which is the mirror of the rule that governs
creation. That is also where the deletion is visible: the entity is no longer
in the list. Staying put would show its body as though nothing had happened,
and on a runtime with a trash can that is literally true, because a deleted
object is parked rather than erased.
Such a runtime re-links the object under hub/trash_can with its original type
and the deletion moment on the edge — so select that link in the trash can and
the centre panel shows both.
Applied and no-op are reported distinctly (✓ vs ∅): the client returns
success for both, and a delete that deleted nothing leaves you where you are.
Everything the TUI can do is also available non-interactively, for scripting
and for building reproducible fixtures. The two groups mirror the SDK's own
split: graph is the low-level API (raw vertices and links, no CMDB
semantics), cmdb is the typed one.
graph vertex create|update|delete|read
graph link create|update|delete|read
cmdb type create|update|delete|read
cmdb type subtype add|rm
cmdb typeslink create|update|delete|read
cmdb object create|update|delete|read
cmdb objectslink create|update|delete|read
cmdb objectslink super create|update|delete
A positional id may be omitted, in which case the gwalk cursor is used —
the same idiom gwalk inspect and gwalk query already follow:
foliage-cli gwalk to srv-1
foliage-cli graph link create --to rack-A --type rel # from = srv-1
foliage-cli cmdb object read # the cursor vertexA worked example — schema first, then instances:
foliage-cli cmdb type create srv --body '{"desc":"server"}'
foliage-cli cmdb type create rack
foliage-cli cmdb typeslink create srv rack --object-link-type mounted_in
foliage-cli cmdb object create srv-1 --type srv --body @srv-1.json
foliage-cli cmdb object create rack-A --type rack
foliage-cli cmdb objectslink create srv-1 rack-AShared flags
| Flag | Meaning |
|---|---|
--body |
inline JSON, @file, or - for stdin |
--tags a,b |
comma separated. Omitting it leaves existing tags alone; clearing them needs --replace, because empty tags are never sent |
--replace |
replace the body instead of deep-merging it (a merge can never remove a key or an array element) |
--json |
machine-readable output |
--yes |
required for a cascading delete |
Flags may come before or after the positional arguments — both orders work.
Exit codes: 0 applied or no-op, 1 the operation failed, 2 bad
arguments or a refused operation. Applied and no-op are reported distinctly
(✓ vs ∅), because the underlying client returns success for both and
"nothing changed" is worth knowing.
Cascading deletes (cmdb type delete, cmdb typeslink delete,
cmdb type subtype rm) refuse to run without --yes and state the blast
radius first. There is no interactive prompt: a script that hangs on a
question is worse than one that fails loudly.
Walk to a vertex and persist the position:
foliage-cli gwalk to hub/network/router-1
Show the current vertex body and its link IDs:
foliage-cli gwalk inspect
foliage-cli gwalk inspect --pretty_print # -p JSON pretty print
foliage-cli gwalk inspect --all # -a include link bodies
Print a tree of all routes reachable from the current position:
foliage-cli gwalk routes
foliage-cli gwalk routes --forward_depth 3 --backward_depth 1
foliage-cli gwalk routes --verbose 2 # 0=links, 1=+types, 2=+tags
Run a JPGQL query from the current vertex:
foliage-cli gwalk query "out('contains').out('depends_on')"
Export the (sub)graph rooted at the current vertex:
foliage-cli gwalk export # graphml, full depth
foliage-cli gwalk export --format dot --depth 2
foliage-cli gwalk export --format graphml_json2xml --raw
foliage-cli gwalk export --exclude-vertex __meta --exclude-edge __meta
Flags:
| Flag | Short | Default | Description |
|---|---|---|---|
--format |
-f |
graphml |
Output format: graphml, dot, graphml_json2xml |
--depth |
-d |
-1 (all) |
BFS depth limit |
--raw |
-r |
false | Raw data only (no formatting) |
--exclude-vertex |
-X |
— | Exclude vertex body fields (repeatable) |
--exclude-edge |
-x |
— | Exclude edge body fields (repeatable) |
Upload a graph from a file or stdin:
foliage-cli gwalk import graph.graphml
foliage-cli gwalk import --stdin < graph.graphml
go build -o foliage-cli .
docker run -it --rm \
-v ./:/go/src/github.com/foliagecp/cli \
-w /go/src/github.com/foliagecp/cli \
-e CGO_ENABLED=1 \
docker.elastic.co/beats-dev/golang-crossbuild:1.21.1-main \
--build-cmd "go build -o foliage-cli *.go" \
-p "linux/amd64"
See elastic/golang-crossbuild for available platforms.