Skip to content

Commit 68ebe0b

Browse files
anshulsaoclaude
andauthored
feat(skills): ship use-ig (Praxis-MCP read variant) at login (#60)
* feat(skillinstall): ship `use-ig` Praxis-MCP tree skill at login Add a second binary-embedded tree skill, `use-ig`, that praxis installs during `praxis login`. It is the Praxis-MCP READ variant of ig's native use-ig skill: the graph query mental-model (catalog / members / interface-nodes / calls|deploys_as|provisions edges) and the worked examples (FE->BE trace, blast-radius/impact, where-does-X-live) are preserved, but the whole invocation surface is swapped to the server-side `praxis mcp ig` tools (ig_list_catalogs / ig_explain / ig_impact / ig_query), so the host needs no local `ig` binary, no synced tree, and no cloud secrets. Same bare name as ig's native skill — only one is ever installed at a time (ig ships its native copy only when praxis is absent). Wiring: - Generalize the tree-skill embed: rename onboardingTreeFiles -> treeSkillFiles, embed both embedded/praxis-onboarding and embedded/use-ig, and drive treeSkills() off a treeSkillNames slice. MetaSkillNames()/IsMetaSkill() therefore pick up use-ig with no further change, so login installs it and profile switches preserve it. Tests (TDD, RED->GREEN): - MetaSkillNames() includes "use-ig" (and stays sorted). - IsMetaSkill("use-ig") is true (survives profile switch). - treeSkillFS("use-ig") SKILL.md has `name: use-ig`, invokes reads via `praxis mcp ig`, and carries no local-`ig` read command surface (guards this is the MCP variant, not the native one). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(use-ig): thread the required `member` arg through the MCP read skill The ig read verbs address a node per member (`ig <verb> <member> <target>`), and the server-side MCP now requires `--arg member=<m>`. The skill was written for a single `--arg target=<node>`, so an agent following it would hit "member is required" on every read call. Teach `member` as a lens into the graph of graphs: * member=catalog -> interface/connection layer (service:/route:/graph: nodes + cross-repo calls/deploys_as/provisions edges) — where "how repos connect" lives * member=<repo> -> that repo's code graph * member=infra -> the Facets infra graph Updates the command surface, list-catalogs (now returns members), recipes, the worked example, and adds a "wrong member = no node" gotcha. Mirrors the already-correct 2-positional model in infragraphify's native use-ig skill. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(use-ig): full read-parity surface — free-text query, filters, ig_path, ig_catalog The server-side ig MCP grew to full raw-ig read parity (agent-factory), so teach the six tools instead of four: * ig_query now takes FREE TEXT via `--arg query="..."` (was a node `target`), has NO depth (ig hardcodes query depth), and takes `--arg relation=` / `--arg context=` edge filters + `--arg budget=`. * ig_impact gains the same relation/context filters (+ real depth). * new ig_catalog (`--arg catalog=<c>`) — the topology map; START HERE for "how do these connect". * new ig_path (`--arg from_node=<a> --arg to_node=<b>`) — shortest path. Updates the command surface, the boundary line (four → six), and the recipes (connect-map via ig_catalog, A→B via ig_path, free-text query=). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d729ec1 commit 68ebe0b

3 files changed

Lines changed: 302 additions & 16 deletions

File tree

internal/skillinstall/embedded.go

Lines changed: 26 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,30 +5,40 @@ import (
55
"io/fs"
66
)
77

8-
// onboardingTreeFiles holds the binary-embedded source of the
9-
// `praxis-onboarding` skill — a multi-file (tree) skill: a SKILL.md engine
10-
// plus per-flow files under flows/. Unlike the single-file meta-skills in
11-
// dummy.go (string bodies) and unlike org catalog skills (fetched from the
12-
// server), this content ships inside the binary as a real file tree so it
13-
// can carry multiple files.
8+
// treeSkillFiles holds the binary-embedded source of every multi-file (tree)
9+
// skill — each a SKILL.md engine plus optional subdirectories (e.g.
10+
// praxis-onboarding's flows/). Unlike the single-file meta-skills in dummy.go
11+
// (string bodies) and unlike org catalog skills (fetched from the server),
12+
// this content ships inside the binary as a real file tree so it can carry
13+
// multiple files.
1414
//
15-
//go:embed embedded/praxis-onboarding
16-
var onboardingTreeFiles embed.FS
15+
// - praxis-onboarding: the guided getting-started journey.
16+
// - use-ig: the Praxis-MCP read variant of ig's use-ig skill; reads run
17+
// server-side via `praxis mcp ig`, so the host needs no local `ig`.
18+
//
19+
//go:embed embedded/praxis-onboarding embedded/use-ig
20+
var treeSkillFiles embed.FS
21+
22+
// treeSkillNames is the set of binary-embedded tree skills, in the order they
23+
// are declared in the embed directive above.
24+
var treeSkillNames = []string{"praxis-onboarding", "use-ig"}
1725

1826
// treeSkills maps a binary-embedded multi-file skill name to its rooted file
1927
// tree (SKILL.md at the root, plus subdirectories like flows/). Tree skills
2028
// install via InstallTree instead of the single-file ContentFor path, and
2129
// they are meta-skills (preserved on profile switch — see IsMetaSkill).
2230
func treeSkills() map[string]fs.FS {
23-
// The embed path is a compile-time constant, so fs.Sub cannot fail in a
24-
// correctly-built binary.
25-
sub, err := fs.Sub(onboardingTreeFiles, "embedded/praxis-onboarding")
26-
if err != nil {
27-
panic("skillinstall: embedded praxis-onboarding tree missing: " + err.Error())
28-
}
29-
return map[string]fs.FS{
30-
"praxis-onboarding": sub,
31+
out := make(map[string]fs.FS, len(treeSkillNames))
32+
for _, name := range treeSkillNames {
33+
// The embed paths are compile-time constants, so fs.Sub cannot fail
34+
// in a correctly-built binary.
35+
sub, err := fs.Sub(treeSkillFiles, "embedded/"+name)
36+
if err != nil {
37+
panic("skillinstall: embedded tree skill missing: " + name + ": " + err.Error())
38+
}
39+
out[name] = sub
3140
}
41+
return out
3242
}
3343

3444
// isTreeSkill reports whether `name` is a binary-embedded multi-file skill.
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
---
2+
name: use-ig
3+
description: Use when working in a project that is onboarded to `ig` (infragraphify) and you need to answer how repos/services connect, where the code for a concept or endpoint lives, how a frontend call reaches its backend handler, what infrastructure backs a service, or the blast radius of a change — instead of grepping a monorepo. Triggers on cross-service/cross-repo tracing, FE↔BE API coupling, code↔infra mapping, impact/what-breaks questions, "how does X flow through the system".
4+
---
5+
6+
# Using ig to answer questions about a project
7+
8+
**Praxis-MCP read route — queries run server-side via `praxis mcp ig`; no local
9+
`ig` needed.** (If praxis is unavailable, ig ships a native local-`ig` copy of
10+
this skill instead. Only one is ever installed at a time.)
11+
12+
`ig` holds a prebuilt **catalog** for a project — a set of code and infra graphs
13+
joined at their shared boundaries. When a question spans repos or services, or
14+
asks "what connects to X / what breaks if I change X", query the catalog before
15+
grepping source. Every read runs on the Praxis server under org-managed
16+
credentials; your laptop needs no `ig` binary, no synced tree, no cloud secrets.
17+
18+
**The division of labor:** ig states facts (graphs, provenance, source
19+
file+line). YOU decide, act, and ask the user. The MCP tools are read-only —
20+
they never build, never prompt, never touch your working copy.
21+
22+
**Boundary — the six `praxis mcp ig` tools are the whole read interface.** Do
23+
not reach for a local `ig`/`graphify` binary and do not try to `praxis ig sync`;
24+
in this route reads execute server-side. (Building/refreshing a catalog is a
25+
separate CI/setup concern that still uses `ig` on a builder host — not your job
26+
here.)
27+
28+
## First: list the catalogs
29+
30+
There is **NO default catalog** and every read tool needs BOTH a `catalog` and a
31+
`member`. Start here:
32+
33+
```
34+
praxis mcp ig ig_list_catalogs
35+
```
36+
37+
It returns the org's catalogs as `name + version`, each with its **members**.
38+
Pick the `<catalog>` your question is about and pass it as `--arg catalog=<name>`
39+
to every other tool. A repo can be a member of MORE THAN ONE catalog (e.g.
40+
`control-plane` in both `capillary-cloud` and `saas-cp`); because `calls` edges
41+
only appear between members present in the SAME catalog, "who calls
42+
control-plane" is catalog-relative — ask each catalog.
43+
44+
## The `member` arg — pick your lens (REQUIRED)
45+
46+
A catalog is a **graph of graphs**, so a node is addressed *per member*. Every
47+
read tool takes `--arg member=<m>` and it selects which graph you read:
48+
49+
| `member=` | You reach | Use for |
50+
|---|---|---|
51+
| **`catalog`** | the connective tissue: `service:*` / `route:*` / `graph:*` interface nodes + the cross-repo `calls` / `deploys_as` / `provisions` edges | "how do repos/services connect", "what calls X", frontend↔backend, code↔infra |
52+
| **`<repo>`** (e.g. `control-plane`) | that repo's code graph (functions, files, symbols) | "where does the code for X live", per-repo blast radius |
53+
| **`infra`** | the Facets infra graph (module types, datastores) | "what infra module backs X" |
54+
55+
The literal member name `catalog` is the graph-of-graphs itself — that is where
56+
the interface/connection layer lives. `service:*`/`route:*` nodes are NOT inside
57+
a repo member (`ig_explain --arg member=control-plane --arg target="service:x"`
58+
returns "no node"); read them with `--arg member=catalog`.
59+
60+
## The catalog model — read this before running commands
61+
62+
```
63+
CATALOG (one project)
64+
graph:control-plane-ui-react ──calls(http, 469)──▶ graph:control-plane ← indicative connection
65+
│ deploys_as │ deploys_as
66+
▼ ▼
67+
service:control-plane-react service:control-plane-service
68+
▲ provisions ▲ provisions
69+
└──────────────── graph:infra ─────────────────────┘
70+
71+
MEMBERS (each a full graph)
72+
┌ control-plane-ui-react (code) ┐ ┌ control-plane (code) ┐ ┌ infra ┐
73+
│ .approveRelease() ─calls─▶ │ │ route:POST …/approve │ │ mongo │
74+
│ route:POST …/approveRelease ──┼───┼── handled_by ─▶ .approveRelease() │ redis │
75+
└───────────────────────────────┘ └──────────────────────┘ └───────┘
76+
▲ the SAME route node is in both members: the off-page
77+
connector that joins frontend call site → backend handler
78+
```
79+
80+
- **member** — one repo's graph (`kind=code`) or the Facets infra graph (`kind=infra`).
81+
- **graph node** (`graph:<member>`) — a whole member inside the catalog.
82+
- **interface node** — a shared boundary: `service:*` (a Facets service), `route:*`
83+
(an HTTP endpoint), `queue:*` (a queue/topic), `pkg:*` (a package).
84+
- **connection** — a typed cross-member edge: `deploys_as` (code → its service),
85+
`provisions` (infra → a service), `provides`/`consumes` (a member ↔ an interface).
86+
- **calls edge**`graph:A ──calls(http, N)──▶ graph:B`: indicative "A calls B
87+
over HTTP N times", rolled up from shared `route:` nodes. The concrete link is
88+
the route node itself, present in both members (frontend `calls` it, backend is
89+
`handled_by` it).
90+
91+
Two caveats seen in practice: a `service:*` id for the same physical service can
92+
be spelled differently across catalogs (`service:control-plane-service` in one vs
93+
`service:control-plane` in another — they mirror each project's infra naming), so
94+
rediscover it per catalog rather than reusing an id; and a wrong `target` yields
95+
a plain "no node" with no fuzzy suggestion.
96+
97+
## Command surface (the six `praxis mcp ig` tools)
98+
99+
Every read tool takes `--arg catalog=<c>` (from `ig_list_catalogs`) and, except
100+
`ig_list_catalogs`/`ig_catalog`, `--arg member=<m>` (the lens — see above).
101+
Output is **token-budgeted TEXT** written for an LLM to read.
102+
103+
```
104+
praxis mcp ig ig_list_catalogs → the org's catalogs (name + version + members); START HERE
105+
praxis mcp ig ig_catalog --arg catalog=<c> → topology MAP: members + service:* interfaces + deploys_as/provisions/calls edges
106+
praxis mcp ig ig_explain --arg catalog=<c> --arg member=<m> --arg target=<node> → node card (source file+line, edges) — the workhorse
107+
praxis mcp ig ig_impact --arg catalog=<c> --arg member=<m> --arg target=<node> [--arg depth=N] [--arg relation=R] [--arg context=C] → what a change to <node> reaches downstream
108+
praxis mcp ig ig_query --arg catalog=<c> --arg member=<m> --arg query="<free text>" [--arg relation=R] [--arg context=C] → BFS from name-matched start nodes (NOT semantic search)
109+
praxis mcp ig ig_path --arg catalog=<c> --arg member=<m> --arg from_node=<a> --arg to_node=<b> → shortest path between two nodes, hop by hop
110+
```
111+
112+
- **`ig_query` takes FREE TEXT.** `--arg query="where does approveRelease reach
113+
the DB"` or a symbol name — ig seeds the BFS from the name-matched start nodes.
114+
It has NO `depth` (ig hardcodes query depth); scope it instead with `--arg
115+
relation=calls` / `--arg context=call` (repeat by passing the arg again) or cap
116+
output with `--arg budget=N` (tokens). `ig_impact` takes the same filters plus
117+
a real `--arg depth=N`.
118+
- `ig_catalog` is the map — START HERE for "how do these repos/services connect".
119+
It needs no `member` (it IS the cross-member view).
120+
- `ig_path` answers "how does A reach B" with the concrete hop-by-hop edge chain.
121+
- `ig_explain` is the workhorse: it lands on a node and prints its source
122+
file+line, degree, and edges. Reach for it to locate a symbol (in a `<repo>`
123+
member) or an interface node (in the `catalog` member).
124+
- `ig_impact` traverses code edges downstream from `target` — "what does a change
125+
to this node reach". `depth` bounds the walk.
126+
- `ig_query` seeds from name-matched start nodes and walks — see the gotcha below.
127+
128+
## Recipes
129+
130+
**How do the repos/services connect?** `ig_catalog --arg catalog=<c>` — the
131+
one-shot topology map (members + `service:*` interfaces + `deploys_as` /
132+
`provisions` / `calls` edges). Start here, then drill into a specific interface
133+
with `ig_explain --arg member=catalog --arg target="service:<name>"`.
134+
135+
**Trace a frontend call to its backend handler.** From the `ig_catalog` map (or
136+
`ig_explain --arg member=catalog --arg target="service:<name>"` / the `route:`
137+
node) find the cross-repo `calls` edge, then drop into the backend `<repo>`
138+
member — `ig_explain --arg member=<be-repo> --arg target="<handler-symbol>"` — to
139+
land on the handler. The interface node in the `catalog` member is the join.
140+
141+
**How does A reach B?** `ig_path --arg member=<repo> --arg from_node=<a> --arg
142+
to_node=<b>` — the concrete hop-by-hop edge chain between two symbols (or "no
143+
path"). Faster than eyeballing `ig_impact` output when you have both endpoints.
144+
145+
**What breaks if I change a symbol?** `ig_impact --arg member=<repo> --arg
146+
target="<symbol>"` for internal downstream (scope with `--arg relation=calls` or
147+
`--arg depth=N`). For an HTTP endpoint, also `ig_explain --arg member=catalog
148+
--arg target="route:<METHOD /path>"`: its cross-member `calls` edge is the real
149+
coupling (a route/contract change forces the OpenAPI client + its callers to
150+
change), which `ig_impact` alone won't show.
151+
152+
**What infra backs a service?** Read the `provisions` connections on the
153+
`service:*` node in the `catalog` member (`ig_explain --arg member=catalog --arg
154+
target="service:<name>"`), then enumerate the `infra` member with `ig_query --arg
155+
member=infra --arg query="<resource>"`. Infra nodes are Facets module types
156+
(`service/*`, `s3/*`, `mongo`, `artifact/*`) and are often coarse/degree-0 —
157+
expect module-type + datastore names, not a fully wired topology.
158+
159+
**Where does the code for a concept live?** `ig_query --arg member=<repo> --arg
160+
query="<free-text question>"` to BFS from name-matched nodes, or `ig_explain
161+
--arg member=<repo> --arg target="<name-or-substring>"` to land on one node and
162+
follow its edges (more precise when you know the symbol). The node card's source
163+
file+line is relative to that member's repo root.
164+
165+
## Gotchas (these are where agents lose time)
166+
167+
- **`ig_query` is BFS traversal, not semantic search.** It picks a small set of
168+
best-name-match start nodes (not an OR over every word in the term string), then
169+
walks edges. A vague term (`"release"`) starts inside an unrelated node and
170+
wanders. Seed with ONE specific symbol, or skip it and `ig_explain` the node
171+
directly.
172+
- **Wrong `member` = "no node", even for the right target.** Interface nodes
173+
(`service:*`, `route:*`, `graph:*`) live ONLY in the `catalog` member; code
174+
symbols live only in their `<repo>` member. Asking for a `service:*` in a repo
175+
member, or a code symbol in `catalog`, returns "no node". If a target you're
176+
sure exists isn't found, you're probably in the wrong lens — switch `member`
177+
(`catalog` for connections, `<repo>` for code, `infra` for modules).
178+
- **A wrong `target` returns "no node" with NO fuzzy suggestion.** Don't guess ids
179+
blindly — start from `ig_list_catalogs`, then `ig_explain` a label/substring you
180+
are confident about, and copy the exact `ID:` it prints for follow-up calls.
181+
- **Reference a node by label/substring first; use the full ID to disambiguate.**
182+
A unique label works (`"approveRelease"`, or a route label `"POST
183+
…/approveRelease"`). When several nodes share a label, `ig_explain` may
184+
auto-pick one; copy the full `ID:` line from its output and pass THAT to
185+
`ig_impact` to be unambiguous.
186+
- **`ig_impact` on an HTTP entry point returns "No affected nodes" — that IS the
187+
answer:** the endpoint has no internal callers because it's invoked over HTTP.
188+
`ig_impact` traverses code edges; it is not an HTTP blast-radius oracle. Use the
189+
`route:` node + `calls` edge for cross-member coupling.
190+
- **Node ids differ by member kind** — code = a `path_symbol` slug
191+
(`v2_src_..._approverelease`), infra = module-type paths (`s3/default/0.2`,
192+
`service/deployment`, `mongo`, `artifact/*`). Both are accepted as `target`.
193+
194+
## Worked example (real: capillary-cloud)
195+
196+
```
197+
praxis mcp ig ig_list_catalogs
198+
→ capillary-cloud 2026.07.06-071024 members: control-plane, control-plane-ui-react, infra
199+
saas-cp 2026.07.05-… (pick capillary-cloud)
200+
praxis mcp ig ig_explain --arg catalog=capillary-cloud --arg member=catalog \
201+
--arg target="service:control-plane-service"
202+
→ Type: service <-- infra [provisions] --> control-plane [deploys_as] (the join)
203+
praxis mcp ig ig_explain --arg catalog=capillary-cloud --arg member=control-plane \
204+
--arg target="approveRelease"
205+
→ .approveRelease() UiDeploymentController.java:L449 Degree 6 (backend handler)
206+
praxis mcp ig ig_impact --arg catalog=capillary-cloud --arg member=control-plane \
207+
--arg target="approveRelease"
208+
→ the controller is an HTTP entry point, so internal downstream is minimal — the
209+
real blast radius is the shared route: node + the calls edge (a contract change
210+
forces the OpenAPI client + its callers to change).
211+
```
212+
213+
The shared `route:` node answers "which frontend calls which backend"; the
214+
`service:*`/`provisions` connections answer "what infra backs it".

internal/skillinstall/embedded_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package skillinstall
22

33
import (
4+
"io/fs"
45
"os"
56
"path/filepath"
67
"strings"
@@ -10,6 +11,67 @@ import (
1011
// onboarding is the one embedded multi-file (tree) meta-skill today.
1112
const onboardingSkill = "praxis-onboarding"
1213

14+
// use-ig is the Praxis-MCP read variant tree skill: it carries ig's graph
15+
// query mental-model but routes every read through `praxis mcp ig`, so the
16+
// host needs no local `ig`. Same bare name as ig's native skill (only one is
17+
// ever present — ig ships its native copy only when praxis is absent).
18+
const useIGSkill = "use-ig"
19+
20+
func TestMetaSkillNames_IncludesUseIG(t *testing.T) {
21+
names := MetaSkillNames()
22+
var found bool
23+
for _, n := range names {
24+
if n == useIGSkill {
25+
found = true
26+
}
27+
}
28+
if !found {
29+
t.Errorf("MetaSkillNames() = %v, want it to include %q", names, useIGSkill)
30+
}
31+
// Still sorted (login relies on deterministic order).
32+
for i := 1; i < len(names); i++ {
33+
if names[i-1] > names[i] {
34+
t.Errorf("MetaSkillNames() not sorted: %v", names)
35+
break
36+
}
37+
}
38+
}
39+
40+
func TestIsMetaSkill_UseIGPreserved(t *testing.T) {
41+
if !IsMetaSkill(useIGSkill) {
42+
t.Errorf("IsMetaSkill(%q) = false, want true (tree meta-skills must survive profile switch)", useIGSkill)
43+
}
44+
}
45+
46+
// TestUseIGTreeSkill_IsMCPVariant guards that the embedded use-ig skill is the
47+
// Praxis-MCP read variant: it queries the graph server-side via `praxis mcp
48+
// ig` and does NOT carry the native local-`ig` read command surface.
49+
func TestUseIGTreeSkill_IsMCPVariant(t *testing.T) {
50+
fsys, ok := treeSkillFS(useIGSkill)
51+
if !ok {
52+
t.Fatalf("treeSkillFS(%q) not found; use-ig must be an embedded tree skill", useIGSkill)
53+
}
54+
raw, err := fs.ReadFile(fsys, "SKILL.md")
55+
if err != nil {
56+
t.Fatalf("read use-ig SKILL.md: %v", err)
57+
}
58+
body := string(raw)
59+
60+
if !strings.Contains(body, "name: use-ig") {
61+
t.Errorf("use-ig SKILL.md missing frontmatter `name: use-ig`")
62+
}
63+
// Reads must route through the Praxis MCP.
64+
if !strings.Contains(body, "praxis mcp ig") {
65+
t.Errorf("use-ig SKILL.md must invoke reads via `praxis mcp ig` (MCP variant)")
66+
}
67+
// It must NOT carry the native local-`ig` read command surface. The
68+
// backticked `ig query` is the tell of the local-ig variant; this MCP
69+
// copy uses `praxis mcp ig ig_query` instead.
70+
if strings.Contains(body, "`ig query`") {
71+
t.Errorf("use-ig SKILL.md contains local-ig read command `ig query`; this must be the `praxis mcp ig` variant")
72+
}
73+
}
74+
1375
func TestMetaSkillNames_IncludesTreeSkill(t *testing.T) {
1476
names := MetaSkillNames()
1577
var found bool

0 commit comments

Comments
 (0)