diff --git a/.changeset/datasources-identify-contracts.md b/.changeset/datasources-identify-contracts.md
new file mode 100644
index 0000000000..386f744815
--- /dev/null
+++ b/.changeset/datasources-identify-contracts.md
@@ -0,0 +1,5 @@
+---
+"@ensnode/datasources": patch
+---
+
+Add contract identification by address. `@ensnode/datasources` exports `identifyDatasourceContracts(namespaceId, query)`, which finds every well-known contract in a namespace's datasources whose address matches a given address, optionally scoped to a chain.
diff --git a/.changeset/enscli-datasources-identify.md b/.changeset/enscli-datasources-identify.md
new file mode 100644
index 0000000000..57d2e58324
--- /dev/null
+++ b/.changeset/enscli-datasources-identify.md
@@ -0,0 +1,5 @@
+---
+"enscli": patch
+---
+
+`enscli` gains `datasources identify
`: an offline command that reports which well-known ENS contract an address corresponds to. It accepts a bare address, a chain-scoped `chainId:address`, or full CAIP-10 `eip155:chainId:address`, and `--namespace` (default `mainnet`) selects which namespace to search. A miss returns `{ matches: [] }` with exit code `0`.
diff --git a/.changeset/enscli-initial.md b/.changeset/enscli-initial.md
new file mode 100644
index 0000000000..e41026a279
--- /dev/null
+++ b/.changeset/enscli-initial.md
@@ -0,0 +1,5 @@
+---
+"enscli": patch
+---
+
+Introduce `enscli`, a new agent- and human-friendly CLI for ENS that wraps `enssdk` and the ENS Omnigraph. It supports raw Omnigraph queries (`enscli ensnode omnigraph "" --variables β¦`), offline schema exploration (`enscli ensnode omnigraph schema [Type[.field]]`), indexing status, ENSRainbow healing, and `namehash`/`labelhash`. It defaults to NameHash-hosted instances per `--namespace` (mainnet, sepolia, sepolia-v2), resolves config from flags/env/`.env`, outputs JSON when piped and a pretty form in a TTY, and hardens inputs against agent hallucinations.
diff --git a/.changeset/ensnode-sdk-sepolia-v2-default.md b/.changeset/ensnode-sdk-sepolia-v2-default.md
new file mode 100644
index 0000000000..81540ccba8
--- /dev/null
+++ b/.changeset/ensnode-sdk-sepolia-v2-default.md
@@ -0,0 +1,5 @@
+---
+"@ensnode/ensnode-sdk": patch
+---
+
+`getDefaultEnsNodeUrl` now returns the hosted default for the `sepolia-v2` namespace (`https://api.v2-sepolia.ensnode.io`).
diff --git a/.changeset/ensskills-datasources-identify.md b/.changeset/ensskills-datasources-identify.md
new file mode 100644
index 0000000000..5685e04672
--- /dev/null
+++ b/.changeset/ensskills-datasources-identify.md
@@ -0,0 +1,5 @@
+---
+"ensskills": patch
+---
+
+The `enscli` agent skill documents the new `datasources identify` command.
diff --git a/.changeset/ensskills-initial.md b/.changeset/ensskills-initial.md
new file mode 100644
index 0000000000..8491f0934d
--- /dev/null
+++ b/.changeset/ensskills-initial.md
@@ -0,0 +1,5 @@
+---
+"ensskills": patch
+---
+
+Introduce `ensskills`, a versioned, `skills-npm`-installable package of ENS agent skills. It ships the `ens-protocol` skill (a concise, stable, vendor-neutral conceptual model of the ENS protocol β nametree, normalization, hashing, registry/resolver/registrar, resolution, records, multichain β with pull-as-needed reference pages), the `omnigraph` skill (autogenerated schema reference + vetted example queries, plus prose on the unified ENSv1+ENSv2 datamodel and resolution), and the `enscli` skill (running Omnigraph queries and the other CLI commands, with the output contract, namespace/URL resolution, and input hardening), with stub skills reserved for `enssdk`, `enskit`, `migrate-to-omnigraph`, and `unigraph-sql`.
diff --git a/.changeset/ensskills-llms-txt-link.md b/.changeset/ensskills-llms-txt-link.md
new file mode 100644
index 0000000000..418dafba9f
--- /dev/null
+++ b/.changeset/ensskills-llms-txt-link.md
@@ -0,0 +1,5 @@
+---
+"ensskills": patch
+---
+
+Point the base skill at the published `llms.txt` / `llms-full.txt` docs endpoints so agents can load the full ENSNode documentation when a question reaches beyond the skills.
diff --git a/LICENSE b/LICENSE
index 24d66814d7..08d139577c 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/apps/ensadmin/LICENSE b/apps/ensadmin/LICENSE
index 24d66814d7..08d139577c 100644
--- a/apps/ensadmin/LICENSE
+++ b/apps/ensadmin/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/apps/ensapi/LICENSE b/apps/ensapi/LICENSE
index 24d66814d7..08d139577c 100644
--- a/apps/ensapi/LICENSE
+++ b/apps/ensapi/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/apps/ensapi/src/omnigraph-api/schema/domain.ts b/apps/ensapi/src/omnigraph-api/schema/domain.ts
index 25cd91262c..8c56dec249 100644
--- a/apps/ensapi/src/omnigraph-api/schema/domain.ts
+++ b/apps/ensapi/src/omnigraph-api/schema/domain.ts
@@ -145,7 +145,7 @@ DomainInterfaceRef.implement({
owner: t.field({
type: AccountRef,
description:
- "If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).",
+ "If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).",
nullable: true,
resolve: (parent) => parent.ownerId,
}),
@@ -279,7 +279,7 @@ DomainInterfaceRef.implement({
// Domain.subdomains
/////////////////////
subdomains: t.connection({
- description: "All Domains that are direct descendents of this Domain in the namegraph.",
+ description: "All Domains that are direct descendants of this Domain in the namegraph.",
type: DomainInterfaceRef,
args: {
where: t.arg({ type: SubdomainsWhereInput }),
diff --git a/apps/ensindexer/LICENSE b/apps/ensindexer/LICENSE
index 24d66814d7..08d139577c 100644
--- a/apps/ensindexer/LICENSE
+++ b/apps/ensindexer/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/apps/ensrainbow/LICENSE b/apps/ensrainbow/LICENSE
index 24d66814d7..08d139577c 100644
--- a/apps/ensrainbow/LICENSE
+++ b/apps/ensrainbow/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/apps/fallback-ensapi/LICENSE b/apps/fallback-ensapi/LICENSE
index 24d66814d7..08d139577c 100644
--- a/apps/fallback-ensapi/LICENSE
+++ b/apps/fallback-ensapi/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/docs/ensnode.io/LICENSE b/docs/ensnode.io/LICENSE
index 24d66814d7..08d139577c 100644
--- a/docs/ensnode.io/LICENSE
+++ b/docs/ensnode.io/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts
index 7e20f42bbe..3ddf735176 100644
--- a/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts
+++ b/docs/ensnode.io/config/integrations/starlight/sidebar-topics/integrate.ts
@@ -26,6 +26,10 @@ export const integrateSidebarTopic = {
},
],
},
+ {
+ label: "AI/LLM Tooling π€",
+ link: "/docs/integrate/ai-llm",
+ },
{
label: "ENS Subgraph",
collapsed: false,
@@ -236,9 +240,5 @@ export const integrateSidebarTopic = {
},
],
},
- {
- label: "AI / LLM Tooling",
- link: "/docs/integrate/ai-llm",
- },
],
};
diff --git a/docs/ensnode.io/src/components/molecules/HostedInstanceSdkVersionWarning.astro b/docs/ensnode.io/src/components/molecules/HostedInstanceSdkVersionWarning.astro
deleted file mode 100644
index e76f944d03..0000000000
--- a/docs/ensnode.io/src/components/molecules/HostedInstanceSdkVersionWarning.astro
+++ /dev/null
@@ -1,23 +0,0 @@
----
-import { Aside } from "@astrojs/starlight/components";
-
-import snapshot from "@data/omnigraph-examples/snapshot.json";
-
-// The SDK version is locked to the production-deployed Omnigraph version (see the vendored
-// `@data/omnigraph-examples/snapshot.json`). The SDK bundles the Omnigraph schema, so pinning the
-// matching version keeps gql.tada's generated types aligned with the deployed API.
-const VERSION = snapshot.sdkVersion;
----
-
-
- Our hosted ENSNode instances currently run ENSNode {VERSION}. The Omnigraph GraphQL schema is bundled inside the SDK and consumed by the gql.tada TypeScript plugin to type your queries, so pin an exact version (no ^ or ~) of enssdk@{VERSION} (and enskit@{VERSION} when using
- React) to keep your generated types matched to the deployed schema. Use these exact install commands:
- {`npm install enssdk@${VERSION}
-# or, for React apps:
-npm install enskit@${VERSION} enssdk@${VERSION}`}
-
diff --git a/docs/ensnode.io/src/components/molecules/HostedInstanceVersionWarning.astro b/docs/ensnode.io/src/components/molecules/HostedInstanceVersionWarning.astro
new file mode 100644
index 0000000000..ee46dfbabf
--- /dev/null
+++ b/docs/ensnode.io/src/components/molecules/HostedInstanceVersionWarning.astro
@@ -0,0 +1,53 @@
+---
+import { Aside } from "@astrojs/starlight/components";
+
+import snapshot from "@data/omnigraph-examples/snapshot.json";
+// TEMP prerelease: enscli/ensskills aren't published at `snapshot.sdkVersion` yet β remove at the
+// first official release (see @data/enscli-ensskills-prerelease).
+import { ENSCLI_ENSSKILLS_NPM_SPEC } from "@data/enscli-ensskills-prerelease";
+
+// The ENSNode suite is version-locked to the production-deployed Omnigraph (see the vendored
+// `@data/omnigraph-examples/snapshot.json`). The SDK bundles the Omnigraph schema (consumed by the
+// `gql.tada` TypeScript plugin) and `ensskills` bundles the schema plus example queries, so pinning
+// the matching version keeps generated types / agent knowledge aligned with the deployed API.
+interface Props {
+ // "sdk" warns about pinning enssdk/enskit for gql.tada types; "skills" warns about pinning
+ // ensskills/enscli for agent knowledge.
+ variant: "sdk" | "skills";
+}
+
+const { variant } = Astro.props;
+const VERSION = snapshot.sdkVersion;
+---
+
+
diff --git a/docs/ensnode.io/src/components/molecules/IntegrateHostedEnsNodeTip.astro b/docs/ensnode.io/src/components/molecules/IntegrateHostedEnsNodeTip.astro
index 5e98e4c61b..e7b7b1a619 100644
--- a/docs/ensnode.io/src/components/molecules/IntegrateHostedEnsNodeTip.astro
+++ b/docs/ensnode.io/src/components/molecules/IntegrateHostedEnsNodeTip.astro
@@ -1,11 +1,11 @@
---
import { Aside, LinkCard } from "@astrojs/starlight/components";
-import HostedInstanceSdkVersionWarning from "./HostedInstanceSdkVersionWarning.astro";
+import HostedInstanceVersionWarning from "./HostedInstanceVersionWarning.astro";
---
You don't need to run your own ENSNode to follow this guide β the steps below default to a
NameHash-hosted instance. Browse the available deployments below.
-
+
diff --git a/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx b/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx
index 1efd4cdef2..5f1a5ee0c8 100644
--- a/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/hosted-instances.mdx
@@ -7,7 +7,7 @@ sidebar:
import { LinkCard } from "@astrojs/starlight/components";
import HostedEnsNodeInstance from "@components/molecules/HostedEnsNodeInstance.astro";
-import HostedInstanceSdkVersionWarning from "@components/molecules/HostedInstanceSdkVersionWarning.astro";
+import HostedInstanceVersionWarning from "@components/molecules/HostedInstanceVersionWarning.astro";
import EnsSubgraphCorrectnessPostEnsV2Launch from "@components/molecules/EnsSubgraphCorrectnessPostEnsV2Launch.astro";
export const subgraphCompatibilityApiLevel = `API-level Subgraph Compatibility. This ENSNode instance has a fully backwards compatible ENS Subgraph GraphQL API. However, additional plugins have been activated which index a superset of data into the subgraph data model in ENSDb. This superset of indexed data means that the data returned for some ENS Subgraph API queries may be different.`;
@@ -24,7 +24,7 @@ NameHash Labs provides hosted instances of ENSNode for developers building on EN
These instances are currently provided free of charge with no API key required, have no rate limiting, and are maintained and monitored by the NameHash Labs team.
-
+
### ENS Namespaces
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx
index 499b95d541..45d9e343d9 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/ai-llm.mdx
@@ -3,11 +3,57 @@ title: AI / LLM Tooling
description: AI and LLM tooling for building on ENSv2.
---
-import { LinkCard } from "@astrojs/starlight/components";
+import { Code, LinkCard } from "@astrojs/starlight/components";
+// TEMP prerelease: enscli/ensskills install pins β remove at first official release (see enscli-ensskills-prerelease.ts)
+import { ENSCLI_ENSSKILLS_NPM_SPEC, ENSCLI_ENSSKILLS_GIT_REF } from "@data/enscli-ensskills-prerelease";
+import HostedInstanceVersionWarning from "@components/molecules/HostedInstanceVersionWarning.astro";
-AI and LLM tooling is a key priority for ENSNode, and we're building the infrastructure to make ENS a first-class citizen in the world of AI coding assistants and chat-based interfaces.
+We're building the infrastructure to make ENS a first-class citizen for AI agents.
-Next on the roadmap for this space are [`enscli`](/docs/integrate/integration-options/enscli) and [`ensskills`](/docs/integrate/integration-options/ensskills) β the foundation for how developers and their AI agents will reach for ENS.
+The foundation for how developers and their AI agents reach for ENS is [`ensskills`](/docs/integrate/integration-options/ensskills), that teach your AI assistant about ENS, ENSNode, the ENS Omnigraph, and how to drive [`enscli`](/docs/integrate/integration-options/enscli) β an agent- and human-friendly CLI β on your behalf.
+
+
+
+## Quickstart (`npm`/`pnpm`/`yarn`/`bun`)
+
+Add `ensskills` and [`skills-npm`](https://github.com/antfu/skills-npm) to your project and wire a `prepare` script so the pinned skills re-sync into your agent directories (`.claude/skills`, `.cursor/skills`, β¦) on every install:
+
+
+
+```bash
+npm install # symlinks the skills for your detected agents
+```
+
+## Quickstart (`npx skills`)
+
+Not in a Node project? [`skills`](https://github.com/vercel-labs/skills) installs every ENS skill straight from the repo. It normally pins to the matching `vβ¦` release tag; during the current prerelease window it tracks `main` until `enscli`/`ensskills` ship in their first official release:
+
+
+
+## Next Steps
+
+That's it β your AI agent now has all of [`ensskills`](/docs/integrate/integration-options/ensskills) at its disposal.
+
+```md title=prompt.md
+Which address currently owns vitalik.eth
+and how many other domains do they own?
+```
-Check back soon for more detail on our AI / LLM tooling roadmap.
+## Documentation as `llms.txt`
+
+If you aren't using `ensskills`, the entire documentation site is also published in the [`llms.txt`](https://llmstxt.org/) format so any agent or LLM can load it directly as context:
+
+- [`/llms.txt`](https://ensnode.io/llms.txt) β a structured index of the documentation with links to every page.
+- [`/llms-full.txt`](https://ensnode.io/llms-full.txt) β the entire documentation concatenated into a single file, ready to drop into a model's context window.
+
+Paste this at the top of a prompt to point your agent at the full documentation before asking your question:
+
+```md title="prompt.md"
+Load the ENSNode documentation from https://ensnode.io/llms-full.txt to answer the following question:
+```
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx
index 3fd9c3307f..2984a195a6 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/ens-subgraph/key-limitations.mdx
@@ -128,7 +128,7 @@ The Subgraph schema spreads ownership across multiple fields β `owner`, `regis
:::
:::tip[One effective owner field]
-In the [Omnigraph API](/docs/integrate/omnigraph) `Domain.owner` is _always_ the effective owner's addressβno weird edge-cases! For ENSv2 Domains, `Domain.owner` is Smart-Account-aware and represents the true owner of the Domain at a given time.
+In the [Omnigraph API](/docs/integrate/omnigraph) `Domain.owner` is _always_ the effective owner's address β no weird edge-cases! For ENSv2 Domains, `Domain.owner` is Smart-Account-aware and represents the true owner of the Domain at a given time.
:::
## Missing all offchain ENS names
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enscli.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enscli.mdx
index 1a0781f833..11265d0711 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enscli.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enscli.mdx
@@ -1,33 +1,131 @@
---
title: enscli (CLI)
-description: Coming soon β an agent- and human-friendly CLI for the ENS Omnigraph API, wrapping enssdk.
+description: An agent- and human-friendly CLI for ENS β query the ENS Omnigraph, explore the schema, resolve hashes, heal labels, and check indexing status from the terminal.
---
import { Aside, LinkCard } from "@astrojs/starlight/components";
+import HostedInstanceVersionWarning from "@components/molecules/HostedInstanceVersionWarning.astro";
-
- **`enscli`** is a planned ENS CLI that wraps
- [`enssdk`](/docs/integrate/integration-options/enssdk) to bring the ENS Omnigraph to the terminal.
- The npm name is reserved; we're still shaping the design. `enscli` will ship soon.
+`enscli` is the CLI entry point to ENS. It wraps [`enssdk`](/docs/integrate/integration-options/enssdk) and the [ENS Omnigraph](/docs/integrate/omnigraph) so you can resolve names, look up records, search domains, and run ad-hoc GraphQL queries against any ENSNode instance β without writing a script first.
+
+It is designed to feel natural whether you drive it yourself or let an AI agent drive it: predictable arguments, machine-readable output, runtime schema introspection, and loud, structured errors.
+
+
+
+## Quick start
+
+No install step beyond `npx`:
+
+```bash
+# Namehash a Name
+npx enscli namehash vitalik.eth
+
+# Run a GraphQL query against the Omnigraph (default: mainnet)
+npx enscli ensnode omnigraph '{ domain(by: { name: "vitalik.eth" }) { owner { address } } }'
+```
+
+## Output contract
+
+`enscli` is built for predictable parsing:
+
+- **JSON when piped, pretty in a TTY.** When stdout is not a terminal (i.e. for agents), every command prints JSON; interactively you get a friendlier rendering. Force either with `--output json` or `--output pretty`.
+- **Structured errors.** Failures print `{ "error": { "message": "β¦" } }` to stderr and exit non-zero.
+- **Input hardening.** Names, labels, and hashes containing control characters or `?`/`#`/`%` are rejected before any network call.
+
+## Selecting an ENSNode instance
+
+Most `ensnode` commands talk to an ENSNode instance. The target URL is resolved with this precedence:
+
+**`--ensnode-url` flag β `ENSNODE_URL` env β `.env` β namespace default.**
+
+`--namespace` (alias `-n`, or the `NAMESPACE` env var) selects a NameHash-hosted instance:
+
+| Namespace | Hosted default |
+| ------------ | -------------------------------------- |
+| `mainnet` | `https://api.alpha.ensnode.io` |
+| `sepolia` | `https://api.alpha-sepolia.ensnode.io` |
+| `sepolia-v2` | `https://api.v2-sepolia.ensnode.io` |
+
+```bash
+npx enscli ensnode indexing-status --namespace sepolia
+npx enscli ensnode indexing-status --ensnode-url http://localhost:4334
+```
+
+ENSRainbow commands resolve their URL similarly via `--ensrainbow-url` / `ENSRAINBOW_URL`.
+
+## Commands
+
+### `ensnode omnigraph `
+
+Send a raw GraphQL query β the string is the exact payload, so the [schema](#ensnode-omnigraph-schema-typefield) doubles as your documentation.
+
+```bash
+npx enscli ensnode omnigraph 'query D($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ canonical { name { interpreted } }
+ resolve { records { addresses(coinTypes: [60]) { address } } }
+ }
+}' --variables '{"name":"vitalik.eth"}'
+```
+
+
+ GraphQL is naturally field-masked β select only the fields you need to keep responses small and avoid context bloat. Resolution lives in the graph: select `Domain.resolve` (records) and `Account.resolve` (primary names) inline rather than as separate calls.
+
+
-`enscli` will be the terminal-shaped entry point to the [ENS Omnigraph](/docs/integrate/omnigraph) β a single CLI for resolving names, looking up records, searching domains, and running ad-hoc queries against any ENSNode instance.
+### `ensnode omnigraph schema [Type[.field]]`
-Designed to feel natural whether you're driving it yourself or letting an AI agent drive. From a terminal it's one `npx` away with sensible defaults against the public Omnigraph; for agents it's predictable arguments, structured output, and machine-readable help.
+Explore the Omnigraph schema offline (it ships with the CLI β no network):
-## Built for
+```bash
+npx enscli ensnode omnigraph schema # root query fields + major types
+npx enscli ensnode omnigraph schema Domain # a type's fields, with descriptions
+npx enscli ensnode omnigraph schema Domain.canonical # a single field
+npx enscli ensnode omnigraph schema --search primary # find types/fields by keyword
+```
-- Developers exploring or validating an ENS integration from a terminal, without writing a script first.
-- Operators wiring ENS lookups into shell pipelines, cron, or CI.
-- AI coding agents driving [`ensskills`](/docs/integrate/integration-options/ensskills), which reach into the protocol through `enscli`.
+### `ensnode indexing-status`
-## Related
+```bash
+npx enscli ensnode indexing-status
+```
-
+### `ensrainbow heal ` / `ensrainbow count`
+
+```bash
+npx enscli ensrainbow heal 0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc
+npx enscli ensrainbow count
+```
+
+### `datasources identify `
+
+Identify a well-known ENS contract by address β given `0xabcβ¦`, report which datasource contract it is across the namespace's chains. Fully offline (the datasource catalog ships with the CLI). Accepts a bare address, a chain-scoped `chainId:address`, or full CAIP-10 `eip155:chainId:address`; `--namespace` (default `mainnet`) selects which namespace to search.
+
+```bash
+npx enscli datasources identify 0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e # bare address (mainnet)
+npx enscli datasources identify 1:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85 # scope to a chain
+npx enscli datasources identify 0x94f523b8261b815b87effcf4d18e6abef18d6e4b -n sepolia # another namespace
+```
+
+The result is `{ query, matches }`. A miss is not an error: `matches` is `[]` with exit code `0`, so branch on `matches.length` rather than the exit code. Contracts indexed only by event (no fixed address) can't be identified and are never returned.
+
+### `namehash ` / `labelhash `
+
+```bash
+npx enscli namehash vitalik.eth
+npx enscli labelhash vitalik
+```
+
+## For AI agents
+
+`enscli` is the runtime that [`ensskills`](/docs/integrate/integration-options/ensskills) drive on your agent's behalf. The `omnigraph` skill teaches an agent to author queries and run them through `enscli`, with the schema available via `omnigraph schema`. See the [ensskills docs](/docs/integrate/integration-options/ensskills) to install them.
+
+## Related
+
+
+
:::caution[StackBlitz embed outage]
StackBlitz is having problems with embedded WebContainer projects as of **May 19**. If the editor does not load, use the **Fork on StackBlitz** button in the bottom left to open and run the example on stackblitz.com.
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk/example.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk/example.mdx
index c9bea05d60..9b8b1ab4cd 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk/example.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/enssdk/example.mdx
@@ -8,11 +8,11 @@ topic: integrate-with-ensv2
import { Icon } from "@astrojs/starlight/components";
import EnssdkExampleInteractivePlayground from "@components/organisms/EnssdkExampleInteractivePlayground";
-import HostedInstanceSdkVersionWarning from "@components/molecules/HostedInstanceSdkVersionWarning.astro";
+import HostedInstanceVersionWarning from "@components/molecules/HostedInstanceVersionWarning.astro";
This playground loads the same source as [`enssdk-example`](https://github.com/namehash/ensnode/tree/main/examples/enssdk-example): a TypeScript script that queries the `eth` domain and lists its first 20 subdomains via [`enssdk`](/docs/integrate/integration-options/enssdk) and the [ENS Omnigraph API](/docs/integrate/omnigraph).
-
+
:::note[First load may take a few minutes]
The embedded StackBlitz editor runs entirely in your browser. Downloading and installing all npm packages may take a few minutes. Watch the install progress in the terminal of the StackBlitz editor.
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensskills.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensskills.mdx
index 792edbba31..d25ebbfd0b 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensskills.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/ensskills.mdx
@@ -1,18 +1,112 @@
---
title: ensskills (AI agents)
-description: Coming soon β skill bundles that give AI agents an opinionated contract for ENS.
+description: Versioned agent skills for ENS β install with skills-npm to give AI coding agents an opinionated, version-locked contract for the ENS Omnigraph.
---
-import { Aside, LinkCard } from "@astrojs/starlight/components";
+import { Aside, Code, LinkCard } from "@astrojs/starlight/components";
+import snapshot from "@data/omnigraph-examples/snapshot.json";
+// TEMP prerelease: enscli/ensskills install pins β remove at first official release (see enscli-ensskills-prerelease.ts)
+import { ENSCLI_ENSSKILLS_NPM_SPEC, ENSCLI_ENSSKILLS_GIT_REF } from "@data/enscli-ensskills-prerelease";
+import HostedInstanceVersionWarning from "@components/molecules/HostedInstanceVersionWarning.astro";
-
- **`ensskills`** is a planned collection of agent skills for ENS development and ENS lookups. The
- npm name is reserved; we're still shaping the design. `ensskills` will ship soon.
+`ensskills` is a curated set of agent skills that give AI coding agents β Claude Code, Cursor, Codex, and the rest β a well-defined contract for working with ENS. Left to their own devices, agents reinvent ENS from scratch every prompt, burning context rediscovering the protocol. `ensskills` hands them the right context β no more, no less β so they produce ENS code that works correctly, the first time.
+
+The skills drive [`enscli`](/docs/integrate/integration-options/enscli) for live ENS lookups and steer agents toward [`enssdk`](/docs/integrate/integration-options/enssdk) / [`enskit`](/docs/integrate/integration-options/enskit) when writing integration code.
+
+Pick the path that matches your project. Either way, **pin to an exact `ensskills` version** so the skills match the APIs you're targeting β never track a moving `main`.
+
+
+
+## Quickstart (`npm`/`pnpm`/`yarn`/`bun`)
+
+`ensskills` is a normal, **version-locked** npm package: it ships the skills, and [`skills-npm`](https://github.com/antfu/skills-npm) symlinks them into whichever agent directories you have (`.claude/skills`, `.cursor/skills`, β¦). Because the skills are pinned like any dependency, the version you install is exactly the version that lands β and it stays in lockstep with the rest of your ENSNode suite.
+
+Add both to your project and wire a `prepare` script:
+
+{/* TEMP prerelease: remove this Aside at the first official release that publishes enscli + ensskills */}
+
+
+ `enscli` and `ensskills` are newer than the current `v{snapshot.sdkVersion}` release, so they
+ aren't published at that version yet. Until the first official release that includes them, the
+ snippets below track the `@next` npm tag and the `main` branch β they'll pin to an exact `vβ¦` tag
+ like the rest of the suite at that release.
-`ensskills` will be a small, curated set of skills that give AI coding agents β Claude Code, Cursor, Codex, and the rest β a well-defined contract for working with ENS. We want to support users who want a conversational interaction with ENS through their AI assistant, handled by skills that drive [`enscli`](/docs/integrate/integration-options/enscli) behind the scenes, and to streamline the developer experience writing integration code with [`enssdk`](/docs/integrate/integration-options/enssdk), [`enskit`](/docs/integrate/integration-options/enskit), or the raw [Omnigraph API](/docs/integrate/omnigraph).
+
+
+```bash
+npm install # symlinks the skills for your detected agents
+```
+
+### Tuning the install with `skills-npm.config.ts`
+
+By default `skills-npm` symlinks every skill it discovers into every agent directory it detects. Drop a `skills-npm.config.ts` in your project root to scope that β useful when your project pulls skills from more than one package, or when you only want to target specific agents:
+
+
+
+
+ The agent is keyed `claude-code`, not `claude` β an unrecognized key silently symlinks nothing. See the [`skills-npm` agent list](https://github.com/antfu/skills-npm) for every supported key.
+
+
+
+ In a monorepo, `skills-npm` walks up to the workspace root (it looks for `pnpm-workspace.yaml`, `lerna.json`, or a `package.json` with a `workspaces` field) and installs there. To scope it to one package instead, run it with `--cwd .` β i.e. `"prepare": "skills-npm --cwd ."`. See [`examples/ensskills-example`](https://github.com/namehash/ensnode/tree/main/examples/ensskills-example) for a worked setup.
+
+
+## Quickstart (`npx skills`)
+
+Not in a Node project? Vercel's [`skills`](https://github.com/vercel-labs/skills) tool installs the skills straight from this repo β no `package.json`, no `skills-npm`. Point it at the `packages/ensskills/skills` directory on the matching **release tag** (the `v` prefix matters):
+
+
+
+It detects your agents and writes the skills into each (`.claude/skills`, `.cursor/skills`, β¦). Bump the tag in the URL to upgrade.
+
+
+ Pinning to an exact version is intentional: ENS skills are version-locked to the ENSNode suite, so you always get skills that match the APIs you're targeting. With `skills-npm` that's the pinned npm version; with `npx skills` it's the `vβ¦` release tag in the URL. Avoid pulling skills from a moving git branch β except during the prerelease window noted above, which tracks `main`/`@next` until the first official release.
+
+
+## Skills included
+
+- **`ens-protocol`** β how the ENS protocol works at a conceptual level: the nametree, normalization, namehash/labelhash, registry/resolver/registrar, forward & reverse resolution, primary names, records, and ENS across chains. Vendor-neutral, intentionally stable, with pull-as-needed reference pages. The mental model the other skills build on.
+- **`omnigraph`** β the monolithic ENS skill. Teaches the unified ENSv1 + ENSv2 datamodel, resolution via `Domain.resolve` / `Account.resolve`, Relay pagination, a condensed schema reference, vetted example queries, and how to execute and explore queries with `enscli`.
+- **`enscli`** β running ENS lookups from the shell: the output contract, namespace/URL resolution, input hardening, and every command (Omnigraph queries, offline schema exploration, `namehash`/`labelhash`, ENSRainbow healing, indexing status).
+
+The following are reserved and ship as stubs today, fleshed out in upcoming releases:
-Left to their own devices, agents love to reinvent the wheel from scratch every prompt β burning through your token budget rediscovering what ENS even is before they get around to the actual task. `ensskills` will be blueprints: focused, versioned bundles that hand the agent the right context for ENS work β no more, no less. The result is agents that produce ENS code that actually works, without the developer having to brief them from scratch every time.
+- **`enssdk`** β TypeScript integration with the typed Omnigraph client.
+- **`enskit`** β React integration (`useOmnigraphQuery`, cache directives, infinite pagination).
+- **`migrate-to-omnigraph`** β migrating from the legacy ENS Subgraph.
+- **`unigraph-sql`** β SQL over ENSDb for query shapes the Omnigraph doesn't express.
## Built for
diff --git a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/index.mdx b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/index.mdx
index 3b3aaeb695..8d7e67d94b 100644
--- a/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/index.mdx
+++ b/docs/ensnode.io/src/content/docs/docs/integrate/integration-options/index.mdx
@@ -11,7 +11,7 @@ There are a few different ways to integrate with ENSNode, depending on your app,
## 1. enssdk
-With `enssdk`, leverage ENSNode and the Omnigraph from any JavaScript runtime to power your frontend or backend apps. `enssdk` comes with built-in type-safety and editor autocomplete for Omnigraph queries.
+With `enssdk`, leverage ENSNode and the ENS Omnigraph from any JavaScript runtime to power your frontend or backend apps. `enssdk` comes with built-in type-safety and editor autocomplete for Omnigraph queries.
{
+ it("identifies a well-known contract by address", () => {
+ const matches = identifyDatasourceContracts("mainnet", { address: BASE_REGISTRAR });
+ expect(matches).toContainEqual({
+ namespace: "mainnet",
+ datasource: "ensroot",
+ contract: "BaseRegistrar",
+ chainId: 1,
+ address: BASE_REGISTRAR,
+ });
+ });
+
+ it("matches regardless of input address checksum", () => {
+ const checksummed = "0x57f1887A8Bf19b14fC0dF6Fd9B2acc9Af147eA85";
+ const matches = identifyDatasourceContracts("mainnet", {
+ address: toNormalizedAddress(checksummed),
+ });
+ expect(matches.length).toBeGreaterThan(0);
+ });
+
+ it("restricts to the given chainId", () => {
+ const onChainOne = identifyDatasourceContracts("mainnet", {
+ chainId: 1,
+ address: BASE_REGISTRAR,
+ });
+ expect(onChainOne.length).toBeGreaterThan(0);
+
+ const onWrongChain = identifyDatasourceContracts("mainnet", {
+ chainId: 8453,
+ address: BASE_REGISTRAR,
+ });
+ expect(onWrongChain).toEqual([]);
+ });
+
+ it("returns an empty array when nothing matches", () => {
+ const matches = identifyDatasourceContracts("mainnet", {
+ address: toNormalizedAddress("0x0000000000000000000000000000000000000000"),
+ });
+ expect(matches).toEqual([]);
+ });
+});
diff --git a/packages/datasources/src/identify-contracts.ts b/packages/datasources/src/identify-contracts.ts
new file mode 100644
index 0000000000..a15a833698
--- /dev/null
+++ b/packages/datasources/src/identify-contracts.ts
@@ -0,0 +1,65 @@
+import { type ChainId, type NormalizedAddress, toNormalizedAddress } from "enssdk";
+
+import type { ContractConfig, Datasource, DatasourceName, ENSNamespaceId } from "./lib/types";
+import { getENSNamespace } from "./namespaces";
+
+/** A query for {@link identifyDatasourceContracts}: an address, optionally scoped to one chain. */
+export interface DatasourceIdentifyQuery {
+ /** When set, only consider Datasources deployed on this chain. */
+ chainId?: ChainId;
+ address: NormalizedAddress;
+}
+
+/** A well-known Datasource contract matched by {@link identifyDatasourceContracts}. */
+export interface DatasourceContractMatch {
+ namespace: ENSNamespaceId;
+ datasource: DatasourceName;
+ contract: string;
+ chainId: ChainId;
+ address: NormalizedAddress;
+}
+
+/**
+ * Finds every well-known contract in `namespaceId`'s Datasources whose address equals
+ * `query.address`, optionally restricted to `query.chainId`.
+ *
+ * Contracts without a fixed address (matched onchain by event only) are skipped β they have no
+ * address to identify. A single address may match multiple contracts, so all matches are returned.
+ *
+ * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env')
+ * @param query - The address to identify, optionally scoped to a chain
+ */
+export const identifyDatasourceContracts = (
+ namespaceId: ENSNamespaceId,
+ query: DatasourceIdentifyQuery,
+): DatasourceContractMatch[] => {
+ const matches: DatasourceContractMatch[] = [];
+
+ for (const [datasourceName, datasource] of Object.entries(getENSNamespace(namespaceId)) as [
+ DatasourceName,
+ Datasource,
+ ][]) {
+ if (query.chainId !== undefined && datasource.chain.id !== query.chainId) continue;
+
+ for (const [contractName, contract] of Object.entries(datasource.contracts) as [
+ string,
+ ContractConfig,
+ ][]) {
+ // Skip event-filter-only contracts: with no fixed address there is nothing to identify.
+ if (contract.address === undefined) continue;
+
+ const addresses = Array.isArray(contract.address) ? contract.address : [contract.address];
+ if (addresses.some((address) => toNormalizedAddress(address) === query.address)) {
+ matches.push({
+ namespace: namespaceId,
+ datasource: datasourceName,
+ contract: contractName,
+ chainId: datasource.chain.id,
+ address: query.address,
+ });
+ }
+ }
+ }
+
+ return matches;
+};
diff --git a/packages/datasources/src/index.ts b/packages/datasources/src/index.ts
index 37319822b5..f94714fb49 100644
--- a/packages/datasources/src/index.ts
+++ b/packages/datasources/src/index.ts
@@ -5,6 +5,7 @@ export { L2ReverseRegistrar as L2ReverseRegistrarABI } from "./abis/root/L2Rever
export { StandaloneReverseRegistrar as StandaloneReverseRegistrarABI } from "./abis/shared/StandaloneReverseRegistrar";
export { UniversalResolverABI } from "./abis/shared/UniversalResolver";
export { ThreeDNSToken as ThreeDNSTokenABI } from "./abis/threedns/ThreeDNSToken";
+export * from "./identify-contracts";
export { AnyRegistrarABI } from "./lib/AnyRegistrarABI";
export { AnyRegistrarControllerABI } from "./lib/AnyRegistrarControllerABI";
export * from "./lib/chains";
diff --git a/packages/ens-referrals/LICENSE b/packages/ens-referrals/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ens-referrals/LICENSE
+++ b/packages/ens-referrals/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/enscli/LICENSE b/packages/enscli/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/enscli/LICENSE
+++ b/packages/enscli/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/enscli/README.md b/packages/enscli/README.md
index 3ce7543ea3..4c5e55fb0e 100644
--- a/packages/enscli/README.md
+++ b/packages/enscli/README.md
@@ -1,5 +1,25 @@
# enscli
-This package name is reserved for the [ENSNode](https://ensnode.io) project by [NameHash Labs](https://namehashlabs.org).
+An agent- and human-friendly CLI for ENS, wrapping [`enssdk`](https://www.npmjs.com/package/enssdk) and the ENS Omnigraph.
-For more information, visit [ensnode.io](https://ensnode.io).
+```bash
+# Query the Omnigraph (defaults to the NameHash-hosted mainnet instance)
+npx enscli ensnode omnigraph '{ domain(by: { name: "vitalik.eth" }) { owner { address } } }'
+
+# Explore the schema offline
+npx enscli ensnode omnigraph schema Domain
+
+# Namehashing, Labelhashing
+npx enscli namehash vitalik.eth
+npx enscli labelhash vitalik
+```
+
+Outputs JSON when piped and a pretty form in a TTY.
+
+See the [enscli documentation](https://ensnode.io/docs/integrate/integration-options/enscli) for the full command reference, namespaces, and configuration.
+
+## License
+
+Licensed under the MIT License, Copyright Β© 2025-present [NameHash Labs](https://namehashlabs.org).
+
+See [LICENSE](./LICENSE) for more information.
diff --git a/packages/enscli/package.json b/packages/enscli/package.json
index e9c8218785..13c2fb7301 100644
--- a/packages/enscli/package.json
+++ b/packages/enscli/package.json
@@ -1,12 +1,48 @@
{
"name": "enscli",
"version": "1.15.1",
- "description": "Reserved for the ENSNode project by NameHash Labs. See https://ensnode.io",
+ "type": "module",
+ "description": "An agent- and human-friendly CLI for ENS, ENSNode, and the Omnigraph API.",
+ "license": "MIT",
"repository": {
"type": "git",
- "url": "https://github.com/namehash/ensnode.git",
+ "url": "git+https://github.com/namehash/ensnode.git",
"directory": "packages/enscli"
},
- "license": "MIT",
- "homepage": "https://ensnode.io"
+ "homepage": "https://ensnode.io",
+ "keywords": [
+ "ENS",
+ "ENSNode",
+ "Omnigraph",
+ "agent"
+ ],
+ "files": [
+ "dist"
+ ],
+ "bin": {
+ "enscli": "./dist/cli.js"
+ },
+ "scripts": {
+ "prepublish": "tsup",
+ "build": "tsup",
+ "lint": "biome check --write .",
+ "lint:ci": "biome ci",
+ "test": "vitest",
+ "typecheck": "tsgo --noEmit"
+ },
+ "dependencies": {
+ "@ensnode/datasources": "workspace:*",
+ "@ensnode/ensnode-sdk": "workspace:*",
+ "@ensnode/ensrainbow-sdk": "workspace:*",
+ "citty": "^0.1.6",
+ "enssdk": "workspace:*",
+ "graphql": "^16.11.0"
+ },
+ "devDependencies": {
+ "@ensnode/shared-configs": "workspace:*",
+ "@types/node": "catalog:",
+ "tsup": "catalog:",
+ "typescript": "catalog:",
+ "vitest": "catalog:"
+ }
}
diff --git a/packages/enscli/src/cli.integration.test.ts b/packages/enscli/src/cli.integration.test.ts
new file mode 100644
index 0000000000..5836ed3b5e
--- /dev/null
+++ b/packages/enscli/src/cli.integration.test.ts
@@ -0,0 +1,119 @@
+import { execFileSync, spawnSync } from "node:child_process";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { beforeAll, describe, expect, it } from "vitest";
+
+import { ENSCLI_EXAMPLE_COMMANDS, type EnscliExampleBackend } from "./example-commands";
+
+const PKG_DIR = join(dirname(fileURLToPath(import.meta.url)), "..");
+const CLI = join(PKG_DIR, "dist", "cli.js");
+
+const ENSNODE_URL = process.env.ENSNODE_URL ?? "http://localhost:4334";
+const ENSRAINBOW_URL = process.env.ENSRAINBOW_URL ?? "http://localhost:3223";
+
+function runCli(args: string[]) {
+ return spawnSync("node", [CLI, ...args], { cwd: PKG_DIR, env: process.env, encoding: "utf8" });
+}
+
+// Point a backend-dependent example at the devnet, overriding any namespace baked into the example.
+function backendArgs(backend: EnscliExampleBackend): string[] {
+ switch (backend) {
+ case "ensnode":
+ return ["--ensnode-url", ENSNODE_URL];
+ case "ensrainbow":
+ return ["--ensrainbow-url", ENSRAINBOW_URL];
+ case "none":
+ return [];
+ }
+}
+
+// Build the self-contained bin (inlines the Omnigraph SDL) and spawn the built artifact, mirroring
+// how a published `enscli` runs.
+beforeAll(() => {
+ // Quiet on success; surface the build logs on failure so CI errors are diagnosable.
+ try {
+ execFileSync("pnpm", ["build"], { cwd: PKG_DIR, stdio: "pipe" });
+ } catch (error) {
+ const e = error as { stdout?: Buffer; stderr?: Buffer };
+ if (e.stdout) process.stdout.write(e.stdout);
+ if (e.stderr) process.stderr.write(e.stderr);
+ throw error;
+ }
+}, 60_000);
+
+describe("enscli", () => {
+ it("namehash: computes the node (no network)", () => {
+ const result = runCli(["namehash", "vitalik.eth"]);
+ expect(result.status).toBe(0);
+ expect(JSON.parse(result.stdout)).toMatchObject({
+ name: "vitalik.eth",
+ node: "0xee6c4522aab0003e8d14cd40a6af439055fd2577951148c14b6cea9a53475835",
+ });
+ });
+
+ it("labelhash: computes the labelHash (no network)", () => {
+ const result = runCli(["labelhash", "vitalik"]);
+ expect(result.status).toBe(0);
+ expect(JSON.parse(result.stdout).labelHash).toMatch(/^0x[0-9a-f]{64}$/);
+ });
+
+ it("omnigraph schema: describes a type from the bundled SDL (no network)", () => {
+ const result = runCli(["ensnode", "omnigraph", "schema", "Domain"]);
+ expect(result.status).toBe(0);
+ expect(JSON.parse(result.stdout)).toMatchObject({
+ name: "Domain",
+ fields: expect.arrayContaining([expect.objectContaining({ name: "canonical" })]),
+ });
+ });
+
+ it("rejects hallucinated identifiers with a structured error and non-zero exit", () => {
+ const result = runCli(["namehash", "vitalik?.eth"]);
+ expect(result.status).toBe(1);
+ expect(JSON.parse(result.stderr)).toMatchObject({
+ error: { message: expect.stringContaining("forbidden") },
+ });
+ });
+
+ it("omnigraph: executes a query against the devnet", () => {
+ const result = runCli([
+ "ensnode",
+ "omnigraph",
+ '{ domain(by: { name: "eth" }) { id } }',
+ "--ensnode-url",
+ process.env.ENSNODE_URL ?? "http://localhost:4334",
+ ]);
+ expect(result.status).toBe(0);
+ const response = JSON.parse(result.stdout);
+ expect(response.errors).toBeUndefined();
+ expect(response).toHaveProperty("data");
+ });
+
+ it("indexing-status: fetches status from the devnet", () => {
+ const result = runCli([
+ "ensnode",
+ "indexing-status",
+ "--ensnode-url",
+ process.env.ENSNODE_URL ?? "http://localhost:4334",
+ ]);
+ expect(result.status).toBe(0);
+ expect(JSON.parse(result.stdout)).toBeTypeOf("object");
+ });
+});
+
+// Every command shipped in the `enscli` agent skill must actually run, so the skill never ships a
+// stale flag or a query that drifted from the schema. Driven by the same single source the ensskills
+// `generate` script renders into the SKILL.md.
+describe("enscli skill examples", () => {
+ it.each(ENSCLI_EXAMPLE_COMMANDS.map((example) => ({ ...example, name: example.id })))(
+ "$name runs successfully",
+ ({ args, group, backend }) => {
+ const result = runCli([...args, ...backendArgs(backend)]);
+ expect(result.status, result.stderr).toBe(0);
+ // GraphQL errors mean the query is invalid against the live schema, even with exit 0.
+ if (group === "omnigraph") {
+ expect(JSON.parse(result.stdout).errors).toBeUndefined();
+ }
+ },
+ );
+});
diff --git a/packages/enscli/src/cli.ts b/packages/enscli/src/cli.ts
new file mode 100644
index 0000000000..c07e0f1125
--- /dev/null
+++ b/packages/enscli/src/cli.ts
@@ -0,0 +1,13 @@
+#!/usr/bin/env node
+
+import { runMain } from "citty";
+
+import { main } from "./main";
+
+// Exit cleanly when output is piped into a command that closes the pipe early (e.g. `| head`).
+process.stdout.on("error", (error: NodeJS.ErrnoException) => {
+ if (error.code === "EPIPE") process.exit(0);
+ throw error;
+});
+
+runMain(main);
diff --git a/packages/enscli/src/commands/datasources/identify.ts b/packages/enscli/src/commands/datasources/identify.ts
new file mode 100644
index 0000000000..147abcb3fe
--- /dev/null
+++ b/packages/enscli/src/commands/datasources/identify.ts
@@ -0,0 +1,100 @@
+import { defineCommand } from "citty";
+import { stringifyAccountId, toNormalizedAddress } from "enssdk";
+
+import {
+ type DatasourceIdentifyQuery,
+ identifyDatasourceContracts,
+ maybeGetDatasource,
+} from "@ensnode/datasources";
+
+import { namespaceArgs, outputArgs } from "../../lib/args";
+import { resolveNamespace } from "../../lib/config";
+import { printResult, runSafely } from "../../lib/output";
+import { assertCleanIdentifier } from "../../lib/validate";
+
+/**
+ * Parses a `[chainId:]address` / `eip155:chainId:address` input into a {@link DatasourceIdentifyQuery}.
+ * The address is validated and normalized via {@link toNormalizedAddress}, which throws on non-addresses.
+ */
+function parseIdentifyQuery(input: string): DatasourceIdentifyQuery {
+ assertCleanIdentifier(input, "address");
+
+ const parts = input.split(":");
+ let chainIdPart: string | undefined;
+ let addressPart: string;
+
+ if (parts.length === 1) {
+ [addressPart] = parts;
+ } else if (parts.length === 2) {
+ [chainIdPart, addressPart] = parts;
+ } else if (parts.length === 3) {
+ const [caipNamespace, reference, address] = parts;
+ if (caipNamespace !== "eip155") {
+ throw new Error(
+ `Unsupported CAIP-10 namespace "${caipNamespace}". Only "eip155" is supported.`,
+ );
+ }
+ chainIdPart = reference;
+ addressPart = address;
+ } else {
+ throw new Error(
+ `Invalid address "${input}". Expected [chainId:]address or eip155:chainId:address.`,
+ );
+ }
+
+ const address = toNormalizedAddress(addressPart);
+ if (chainIdPart === undefined) return { address };
+
+ // Only accept base-10 digits; Number() would otherwise coerce "1e3", "0x10", etc. into chain IDs.
+ if (!/^\d+$/.test(chainIdPart)) {
+ throw new Error(`Invalid chainId "${chainIdPart}". Expected a positive integer.`);
+ }
+ const chainId = Number(chainIdPart);
+ if (!Number.isInteger(chainId) || chainId <= 0) {
+ throw new Error(`Invalid chainId "${chainIdPart}". Expected a positive integer.`);
+ }
+ return { chainId, address };
+}
+
+export const identify = defineCommand({
+ meta: {
+ name: "identify",
+ description: "Identify a well-known ENS contract by address (accepts [chainId:]address)",
+ },
+ args: {
+ address: {
+ type: "positional",
+ required: true,
+ description: "An address, optionally chain-scoped: 0xβ¦ , 1:0xβ¦ , or eip155:1:0xβ¦",
+ },
+ ...namespaceArgs,
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(() => {
+ const namespace = resolveNamespace(args);
+ const query = parseIdentifyQuery(args.address);
+
+ const matches = identifyDatasourceContracts(namespace, query).map((match) => ({
+ ...match,
+ chain: maybeGetDatasource(match.namespace, match.datasource)?.chain.name ?? null,
+ accountId: stringifyAccountId({ chainId: match.chainId, address: match.address }),
+ }));
+
+ const result = {
+ query: { namespace, chainId: query.chainId ?? null, address: query.address },
+ matches,
+ };
+
+ printResult(result, args, (data: typeof result) =>
+ data.matches.length === 0
+ ? "No known contract found."
+ : data.matches
+ .map(
+ (m) =>
+ `${m.namespace} Β· ${m.datasource} Β· ${m.contract} β ${m.accountId}${m.chain ? ` (${m.chain})` : ""}`,
+ )
+ .join("\n"),
+ );
+ }),
+});
diff --git a/packages/enscli/src/commands/datasources/index.ts b/packages/enscli/src/commands/datasources/index.ts
new file mode 100644
index 0000000000..24adf4227a
--- /dev/null
+++ b/packages/enscli/src/commands/datasources/index.ts
@@ -0,0 +1,13 @@
+import { defineCommand } from "citty";
+
+import { identify } from "./identify";
+
+export const datasources = defineCommand({
+ meta: {
+ name: "datasources",
+ description: "Inspect the ENS datasource catalog",
+ },
+ subCommands: {
+ identify,
+ },
+});
diff --git a/packages/enscli/src/commands/ensnode/index.ts b/packages/enscli/src/commands/ensnode/index.ts
new file mode 100644
index 0000000000..7df46266f7
--- /dev/null
+++ b/packages/enscli/src/commands/ensnode/index.ts
@@ -0,0 +1,15 @@
+import { defineCommand } from "citty";
+
+import { indexingStatus } from "./indexing-status";
+import { omnigraph } from "./omnigraph";
+
+export const ensnode = defineCommand({
+ meta: {
+ name: "ensnode",
+ description: "Interact with an ENSNode instance",
+ },
+ subCommands: {
+ omnigraph,
+ "indexing-status": indexingStatus,
+ },
+});
diff --git a/packages/enscli/src/commands/ensnode/indexing-status.ts b/packages/enscli/src/commands/ensnode/indexing-status.ts
new file mode 100644
index 0000000000..900d97952e
--- /dev/null
+++ b/packages/enscli/src/commands/ensnode/indexing-status.ts
@@ -0,0 +1,27 @@
+import { defineCommand } from "citty";
+
+import { EnsNodeClient, serializeEnsApiIndexingStatusResponse } from "@ensnode/ensnode-sdk";
+
+import { ensnodeArgs, outputArgs } from "../../lib/args";
+import { resolveEnsNodeUrl } from "../../lib/config";
+import { printResult, runSafely } from "../../lib/output";
+
+export const indexingStatus = defineCommand({
+ meta: {
+ name: "indexing-status",
+ description: "Fetch the indexing status of an ENSNode instance",
+ },
+ args: {
+ ...ensnodeArgs,
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(async () => {
+ const url = resolveEnsNodeUrl(args);
+ const client = new EnsNodeClient({ url });
+ // `indexingStatus()` returns the deserialized form (whose `omnichainSnapshot.chains` is a Map,
+ // which JSON.stringify drops); serialize back to the JSON-safe wire shape before printing.
+ const status = await client.indexingStatus();
+ printResult(serializeEnsApiIndexingStatusResponse(status), args);
+ }),
+});
diff --git a/packages/enscli/src/commands/ensnode/omnigraph-schema.ts b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts
new file mode 100644
index 0000000000..cb4dd4f7d4
--- /dev/null
+++ b/packages/enscli/src/commands/ensnode/omnigraph-schema.ts
@@ -0,0 +1,214 @@
+import schemaSDL from "enssdk/omnigraph/schema.graphql";
+import {
+ buildSchema,
+ type GraphQLArgument,
+ type GraphQLField,
+ type GraphQLInputField,
+ type GraphQLNamedType,
+ type GraphQLSchema,
+ isEnumType,
+ isInputObjectType,
+ isInterfaceType,
+ isObjectType,
+ isUnionType,
+} from "graphql";
+
+import { printResult } from "../../lib/output";
+
+/** Builds the Omnigraph schema from the SDL bundled with enssdk (no network, always matches the SDK). */
+function loadSchema(): GraphQLSchema {
+ return buildSchema(schemaSDL);
+}
+
+interface ArgInfo {
+ name: string;
+ type: string;
+ description: string | null;
+}
+
+interface FieldInfo {
+ name: string;
+ type: string;
+ description: string | null;
+ args?: ArgInfo[];
+}
+
+function argInfo(arg: GraphQLArgument): ArgInfo {
+ return { name: arg.name, type: arg.type.toString(), description: arg.description ?? null };
+}
+
+function fieldInfo(field: GraphQLField | GraphQLInputField): FieldInfo {
+ const args = "args" in field && field.args.length > 0 ? field.args.map(argInfo) : undefined;
+ return {
+ name: field.name,
+ type: field.type.toString(),
+ description: field.description ?? null,
+ ...(args ? { args } : {}),
+ };
+}
+
+/** Field/value listing for a single named type, abstracting over object/interface/input/enum/union. */
+function describeType(type: GraphQLNamedType) {
+ const base = { name: type.name, description: type.description ?? null };
+ if (isObjectType(type) || isInterfaceType(type)) {
+ return {
+ ...base,
+ kind: isObjectType(type) ? "object" : "interface",
+ fields: Object.values(type.getFields()).map(fieldInfo),
+ };
+ }
+ if (isInputObjectType(type)) {
+ return { ...base, kind: "input", fields: Object.values(type.getFields()).map(fieldInfo) };
+ }
+ if (isEnumType(type)) {
+ return {
+ ...base,
+ kind: "enum",
+ values: type.getValues().map((value) => ({
+ name: value.name,
+ description: value.description ?? null,
+ })),
+ };
+ }
+ if (isUnionType(type)) {
+ return { ...base, kind: "union", types: type.getTypes().map((member) => member.name) };
+ }
+ return { ...base, kind: "scalar" };
+}
+
+/** Root listing: query entrypoints plus the major (non-connection) types, abstracting Relay plumbing. */
+function describeRoot(schema: GraphQLSchema) {
+ const queryType = schema.getQueryType();
+ const queryFields = queryType ? Object.values(queryType.getFields()).map(fieldInfo) : [];
+ const types = Object.values(schema.getTypeMap())
+ .filter((type) => isObjectType(type) && !type.name.startsWith("__"))
+ .map((type) => type.name)
+ .filter(
+ (name) =>
+ name !== queryType?.name &&
+ !name.endsWith("Connection") &&
+ !name.endsWith("ConnectionEdge") &&
+ !name.endsWith("Edge") &&
+ !name.endsWith("Payload"),
+ )
+ .sort();
+ return { query: queryFields, types };
+}
+
+function describeFieldPath(schema: GraphQLSchema, typeName: string, fieldName: string) {
+ const type = schema.getType(typeName);
+ if (!type || !(isObjectType(type) || isInterfaceType(type) || isInputObjectType(type))) {
+ throw new Error(
+ `Type "${typeName}" has no fields. Run "enscli ensnode omnigraph schema" to list types.`,
+ );
+ }
+ const field = type.getFields()[fieldName];
+ if (!field) {
+ throw new Error(`Type "${typeName}" has no field "${fieldName}".`);
+ }
+ return { parent: typeName, ...fieldInfo(field) };
+}
+
+function searchSchema(schema: GraphQLSchema, keyword: string) {
+ const query = keyword.toLowerCase();
+ const types: string[] = [];
+ const fields: string[] = [];
+ for (const type of Object.values(schema.getTypeMap())) {
+ if (type.name.startsWith("__")) continue;
+ if (type.name.toLowerCase().includes(query)) types.push(type.name);
+ if (isObjectType(type) || isInterfaceType(type) || isInputObjectType(type)) {
+ for (const field of Object.values(type.getFields())) {
+ if (field.name.toLowerCase().includes(query)) fields.push(`${type.name}.${field.name}`);
+ }
+ }
+ }
+ return { types: types.sort(), fields: fields.sort() };
+}
+
+function renderField(field: FieldInfo, indent: string): string {
+ const args = field.args ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : "";
+ const description = field.description
+ ? `\n${indent} # ${field.description.replace(/\s+/g, " ").trim()}`
+ : "";
+ return `${indent}${field.name}${args}: ${field.type}${description}`;
+}
+
+function renderTypePretty(type: ReturnType): string {
+ const lines: string[] = [];
+ if (type.description) lines.push(`# ${type.description.replace(/\s+/g, " ").trim()}`);
+ lines.push(`${type.kind} ${type.name} {`);
+ if ("fields" in type) for (const field of type.fields) lines.push(renderField(field, " "));
+ else if ("values" in type) for (const value of type.values) lines.push(` ${value.name}`);
+ else if ("types" in type) lines.push(` ${type.types.join(" | ")}`);
+ lines.push("}");
+ return lines.join("\n");
+}
+
+function renderRootPretty(root: ReturnType): string {
+ return [
+ "# Root query fields",
+ ...root.query.map((field) => renderField(field, " ")),
+ "",
+ "# Types (use `schema ` for details)",
+ ...root.types.map((name) => ` ${name}`),
+ ].join("\n");
+}
+
+function renderFieldPathPretty(field: ReturnType): string {
+ return `${field.parent}.${renderField(field, "")}`;
+}
+
+function renderSearchPretty(result: ReturnType): string {
+ return [
+ "# Matching types",
+ ...result.types.map((name) => ` ${name}`),
+ "",
+ "# Matching fields",
+ ...result.fields.map((name) => ` ${name}`),
+ ].join("\n");
+}
+
+/**
+ * Renders the Omnigraph schema for `enscli ensnode omnigraph schema [Type[.field]]`. Dispatches to
+ * `--search`, a single field, a single type, or the root listing. `args` carries the output format.
+ */
+export function runOmnigraphSchema(
+ args: Record,
+ target: string | undefined,
+): void {
+ const omnigraphSchema = loadSchema();
+
+ if (typeof args.search === "string") {
+ printResult(searchSchema(omnigraphSchema, args.search), args, renderSearchPretty);
+ return;
+ }
+
+ if (!target) {
+ printResult(describeRoot(omnigraphSchema), args, renderRootPretty);
+ return;
+ }
+
+ if (target.includes(".")) {
+ const segments = target.split(".");
+ if (segments.length !== 2 || segments.some((segment) => segment.length === 0)) {
+ throw new Error(
+ `Invalid target "${target}". Expected "Type" or "Type.field" (e.g. Domain or Domain.canonical).`,
+ );
+ }
+ const [typeName, fieldName] = segments;
+ printResult(
+ describeFieldPath(omnigraphSchema, typeName, fieldName),
+ args,
+ renderFieldPathPretty,
+ );
+ return;
+ }
+
+ const type = omnigraphSchema.getType(target);
+ if (!type) {
+ throw new Error(
+ `Unknown type "${target}". Run "enscli ensnode omnigraph schema" to list types, or use --search.`,
+ );
+ }
+ printResult(describeType(type), args, renderTypePretty);
+}
diff --git a/packages/enscli/src/commands/ensnode/omnigraph.ts b/packages/enscli/src/commands/ensnode/omnigraph.ts
new file mode 100644
index 0000000000..43b8f2b201
--- /dev/null
+++ b/packages/enscli/src/commands/ensnode/omnigraph.ts
@@ -0,0 +1,72 @@
+import { defineCommand } from "citty";
+
+import { ensnodeArgs, outputArgs } from "../../lib/args";
+import { getEnsNodeClient } from "../../lib/get-ensnode-client";
+import { printResult, runSafely } from "../../lib/output";
+import { runOmnigraphSchema } from "./omnigraph-schema";
+
+export const omnigraph = defineCommand({
+ meta: {
+ name: "omnigraph",
+ description: 'Query the ENS Omnigraph GraphQL API, or explore its schema ("omnigraph schema")',
+ },
+ args: {
+ query: {
+ type: "positional",
+ required: false,
+ description: 'A GraphQL query string, or "schema" to explore the schema',
+ },
+ target: {
+ type: "positional",
+ required: false,
+ description:
+ 'With "schema": a type or "Type.field" to describe (e.g. Domain or Domain.canonical)',
+ },
+ variables: {
+ type: "string",
+ description: "GraphQL variables as a JSON object string",
+ },
+ search: {
+ type: "string",
+ description: 'With "schema": list type and field names matching a keyword',
+ },
+ ...ensnodeArgs,
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(async () => {
+ // "omnigraph schema [Type[.field]]" explores the bundled schema (no network); anything else is
+ // treated as a raw GraphQL query sent to the API.
+ if (args.query === "schema") {
+ runOmnigraphSchema(args, typeof args.target === "string" ? args.target : undefined);
+ return;
+ }
+
+ if (typeof args.query !== "string" || args.query.length === 0) {
+ throw new Error(
+ 'Missing GraphQL query. Provide a query string, or run "enscli ensnode omnigraph schema" to explore the schema.',
+ );
+ }
+
+ let variables: Record = {};
+ if (typeof args.variables === "string" && args.variables.length > 0) {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(args.variables);
+ } catch {
+ throw new Error("Invalid --variables: expected a JSON object string.");
+ }
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
+ throw new Error("Invalid --variables: expected a JSON object, not an array or scalar.");
+ }
+ variables = parsed as Record;
+ }
+
+ const client = getEnsNodeClient(args);
+ const result = await client.omnigraph.query>({
+ query: args.query,
+ variables,
+ });
+ printResult(result, args);
+ }),
+});
diff --git a/packages/enscli/src/commands/ensrainbow/count.ts b/packages/enscli/src/commands/ensrainbow/count.ts
new file mode 100644
index 0000000000..1d309e87cb
--- /dev/null
+++ b/packages/enscli/src/commands/ensrainbow/count.ts
@@ -0,0 +1,24 @@
+import { defineCommand } from "citty";
+
+import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk";
+
+import { ensRainbowArgs, outputArgs } from "../../lib/args";
+import { resolveEnsRainbowUrl } from "../../lib/config";
+import { printResult, runSafely } from "../../lib/output";
+
+export const count = defineCommand({
+ meta: {
+ name: "count",
+ description: "Report the number of healable labels known to ENSRainbow",
+ },
+ args: {
+ ...ensRainbowArgs,
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(async () => {
+ const client = new EnsRainbowApiClient({ endpointUrl: resolveEnsRainbowUrl(args) });
+ const result = await client.count();
+ printResult(result, args);
+ }),
+});
diff --git a/packages/enscli/src/commands/ensrainbow/heal.ts b/packages/enscli/src/commands/ensrainbow/heal.ts
new file mode 100644
index 0000000000..ecb5bc5dd9
--- /dev/null
+++ b/packages/enscli/src/commands/ensrainbow/heal.ts
@@ -0,0 +1,31 @@
+import { defineCommand } from "citty";
+
+import { EnsRainbowApiClient } from "@ensnode/ensrainbow-sdk";
+
+import { ensRainbowArgs, outputArgs } from "../../lib/args";
+import { resolveEnsRainbowUrl } from "../../lib/config";
+import { printResult, runSafely } from "../../lib/output";
+import { assertCleanIdentifier } from "../../lib/validate";
+
+export const heal = defineCommand({
+ meta: {
+ name: "heal",
+ description: "Heal a labelHash to its original label via ENSRainbow",
+ },
+ args: {
+ labelhash: {
+ type: "positional",
+ required: true,
+ description: "The labelHash to heal (0x⦠or an encoded [hash])",
+ },
+ ...ensRainbowArgs,
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(async () => {
+ assertCleanIdentifier(args.labelhash, "labelhash");
+ const client = new EnsRainbowApiClient({ endpointUrl: resolveEnsRainbowUrl(args) });
+ const result = await client.heal(args.labelhash);
+ printResult(result, args);
+ }),
+});
diff --git a/packages/enscli/src/commands/ensrainbow/index.ts b/packages/enscli/src/commands/ensrainbow/index.ts
new file mode 100644
index 0000000000..a52dd236fa
--- /dev/null
+++ b/packages/enscli/src/commands/ensrainbow/index.ts
@@ -0,0 +1,15 @@
+import { defineCommand } from "citty";
+
+import { count } from "./count";
+import { heal } from "./heal";
+
+export const ensrainbow = defineCommand({
+ meta: {
+ name: "ensrainbow",
+ description: "Interact with an ENSRainbow instance (label healing)",
+ },
+ subCommands: {
+ heal,
+ count,
+ },
+});
diff --git a/packages/enscli/src/commands/labelhash.ts b/packages/enscli/src/commands/labelhash.ts
new file mode 100644
index 0000000000..0e81f6c7ba
--- /dev/null
+++ b/packages/enscli/src/commands/labelhash.ts
@@ -0,0 +1,28 @@
+import { defineCommand } from "citty";
+import { asInterpretedLabel, labelhashInterpretedLabel } from "enssdk";
+
+import { outputArgs } from "../lib/args";
+import { printResult, runSafely } from "../lib/output";
+import { assertCleanIdentifier } from "../lib/validate";
+
+export const labelhash = defineCommand({
+ meta: {
+ name: "labelhash",
+ description: "Compute the LabelHash of a single Label",
+ },
+ args: {
+ label: {
+ type: "positional",
+ required: true,
+ description: "The Label to labelhash (e.g. vitalik)",
+ },
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(() => {
+ assertCleanIdentifier(args.label, "label");
+ const label = asInterpretedLabel(args.label);
+ const labelHash = labelhashInterpretedLabel(label);
+ printResult({ label, labelHash }, args, (data: { labelHash: string }) => data.labelHash);
+ }),
+});
diff --git a/packages/enscli/src/commands/namehash.ts b/packages/enscli/src/commands/namehash.ts
new file mode 100644
index 0000000000..cfa7a9e44c
--- /dev/null
+++ b/packages/enscli/src/commands/namehash.ts
@@ -0,0 +1,28 @@
+import { defineCommand } from "citty";
+import { asInterpretedName, namehashInterpretedName } from "enssdk";
+
+import { outputArgs } from "../lib/args";
+import { printResult, runSafely } from "../lib/output";
+import { assertCleanIdentifier } from "../lib/validate";
+
+export const namehash = defineCommand({
+ meta: {
+ name: "namehash",
+ description: "Compute the Node of a Name",
+ },
+ args: {
+ name: {
+ type: "positional",
+ required: true,
+ description: "The Name to namehash (e.g. vitalik.eth)",
+ },
+ ...outputArgs,
+ },
+ run: ({ args }) =>
+ runSafely(() => {
+ assertCleanIdentifier(args.name, "name");
+ const name = asInterpretedName(args.name);
+ const node = namehashInterpretedName(name);
+ printResult({ name, node }, args, (data: { node: string }) => data.node);
+ }),
+});
diff --git a/packages/enscli/src/example-commands.ts b/packages/enscli/src/example-commands.ts
new file mode 100644
index 0000000000..cc9329c26a
--- /dev/null
+++ b/packages/enscli/src/example-commands.ts
@@ -0,0 +1,165 @@
+/**
+ * Single source of truth for the example commands shown in the `enscli` agent skill.
+ *
+ * Rendered into `packages/ensskills/skills/enscli/SKILL.md` by the ensskills `generate` script
+ * (each {@link EnscliExampleGroup} maps to an `AUTOGEN` region), and executed against the
+ * integration test env by `cli.integration.test.ts` so the skill only ever ships commands that run.
+ *
+ * Keep this file dependency-free (plain data): the generator imports it directly via `tsx`.
+ */
+
+/** SKILL.md `AUTOGEN` region an example renders into. */
+export type EnscliExampleGroup =
+ | "omnigraph"
+ | "omnigraph-schema"
+ | "indexing-status"
+ | "ensrainbow"
+ | "datasources"
+ | "hash";
+
+/**
+ * Backend a command needs to execute: `none` runs offline (hashing / bundled-schema introspection),
+ * `ensnode` needs an ENSNode instance, `ensrainbow` needs an ENSRainbow instance. The integration
+ * test supplies the matching devnet URL, overriding any namespace baked into {@link EnscliExample.args}.
+ */
+export type EnscliExampleBackend = "none" | "ensnode" | "ensrainbow";
+
+export interface EnscliExample {
+ /** Stable identifier; also the integration test case name. */
+ id: string;
+ /** Comment rendered above the command in the SKILL.md code block. */
+ comment: string;
+ /** argv passed to `enscli` (everything after the binary name). */
+ args: string[];
+ group: EnscliExampleGroup;
+ backend: EnscliExampleBackend;
+}
+
+export const ENSCLI_EXAMPLE_COMMANDS: EnscliExample[] = [
+ {
+ id: "omnigraph-inline-query",
+ comment: "Inline query (default namespace: mainnet)",
+ args: ["ensnode", "omnigraph", `{ domain(by: { name: "vitalik.eth" }) { owner { address } } }`],
+ group: "omnigraph",
+ backend: "ensnode",
+ },
+ {
+ id: "omnigraph-query-with-variables",
+ comment: "With variables",
+ args: [
+ "ensnode",
+ "omnigraph",
+ `query D($n: InterpretedName!) {
+ domain(by: { name: $n }) {
+ canonical { name { interpreted } }
+ resolve { records { addresses(coinTypes: [60]) { address } } }
+ }
+}`,
+ "--variables",
+ `{"n":"vitalik.eth"}`,
+ ],
+ group: "omnigraph",
+ backend: "ensnode",
+ },
+ {
+ id: "schema-overview",
+ comment: "root query fields + the major types",
+ args: ["ensnode", "omnigraph", "schema"],
+ group: "omnigraph-schema",
+ backend: "none",
+ },
+ {
+ id: "schema-type",
+ comment: "a type's fields, with descriptions",
+ args: ["ensnode", "omnigraph", "schema", "Domain"],
+ group: "omnigraph-schema",
+ backend: "none",
+ },
+ {
+ id: "schema-field",
+ comment: "a single field",
+ args: ["ensnode", "omnigraph", "schema", "Domain.canonical"],
+ group: "omnigraph-schema",
+ backend: "none",
+ },
+ {
+ id: "schema-search",
+ comment: "find types/fields by keyword",
+ args: ["ensnode", "omnigraph", "schema", "--search", "primary"],
+ group: "omnigraph-schema",
+ backend: "none",
+ },
+ {
+ id: "indexing-status-default",
+ comment: "Default namespace (mainnet)",
+ args: ["ensnode", "indexing-status"],
+ group: "indexing-status",
+ backend: "ensnode",
+ },
+ {
+ id: "indexing-status-namespace",
+ comment: "A specific namespace",
+ args: ["ensnode", "indexing-status", "--namespace", "sepolia-v2"],
+ group: "indexing-status",
+ backend: "ensnode",
+ },
+ {
+ id: "ensrainbow-heal",
+ comment: "Heal a labelHash to its original label",
+ args: [
+ "ensrainbow",
+ "heal",
+ "0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc",
+ ],
+ group: "ensrainbow",
+ backend: "ensrainbow",
+ },
+ {
+ id: "ensrainbow-count",
+ comment: "Count the labels ENSRainbow can heal",
+ args: ["ensrainbow", "count"],
+ group: "ensrainbow",
+ backend: "ensrainbow",
+ },
+ {
+ id: "datasources-identify",
+ comment: "Identify a well-known contract by address (default namespace: mainnet, offline)",
+ args: ["datasources", "identify", "0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e"],
+ group: "datasources",
+ backend: "none",
+ },
+ {
+ id: "datasources-identify-chain-scoped",
+ comment: "Scope to a chain with chainId:address (eip155:1:0x⦠also accepted)",
+ args: ["datasources", "identify", "1:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85"],
+ group: "datasources",
+ backend: "none",
+ },
+ {
+ id: "datasources-identify-namespace",
+ comment: "Search a different namespace",
+ args: [
+ "datasources",
+ "identify",
+ "0x94f523b8261b815b87effcf4d18e6abef18d6e4b",
+ "--namespace",
+ "sepolia",
+ ],
+ group: "datasources",
+ backend: "none",
+ },
+ {
+ id: "namehash",
+ comment: "Compute the Node of a Name (offline)",
+ args: ["namehash", "vitalik.eth"],
+ group: "hash",
+ backend: "none",
+ },
+ {
+ id: "labelhash",
+ comment: "Compute the LabelHash of a Label (offline)",
+ args: ["labelhash", "vitalik"],
+ group: "hash",
+ backend: "none",
+ },
+];
diff --git a/packages/enscli/src/graphql-sdl.d.ts b/packages/enscli/src/graphql-sdl.d.ts
new file mode 100644
index 0000000000..28c44d8af5
--- /dev/null
+++ b/packages/enscli/src/graphql-sdl.d.ts
@@ -0,0 +1,5 @@
+// Ambient declaration for importing .graphql SDL files as strings (bundled via esbuild's text loader).
+declare module "*.graphql" {
+ const content: string;
+ export default content;
+}
diff --git a/packages/enscli/src/lib/args.ts b/packages/enscli/src/lib/args.ts
new file mode 100644
index 0000000000..1573c9b297
--- /dev/null
+++ b/packages/enscli/src/lib/args.ts
@@ -0,0 +1,44 @@
+import type { ArgsDef } from "citty";
+
+/** Args for selecting which ENS namespace a command operates on. */
+export const namespaceArgs = {
+ namespace: {
+ type: "string",
+ alias: "n",
+ description: "ENS namespace: mainnet, sepolia, sepolia-v2, or ens-test-env (default: mainnet)",
+ },
+} satisfies ArgsDef;
+
+/**
+ * Args for selecting which ENSNode instance a command talks to.
+ *
+ * Resolution precedence (see {@link ./config}): CLI flag > process env > `.env` > namespace default.
+ */
+export const ensnodeArgs = {
+ ...namespaceArgs,
+ "ensnode-url": {
+ type: "string",
+ description: "ENSNode instance URL (overrides the namespace default; or set ENSNODE_URL)",
+ },
+} satisfies ArgsDef;
+
+/**
+ * Args for selecting which ENSRainbow instance a command talks to.
+ *
+ * Resolution precedence (see {@link ./config}): CLI flag > process env > `.env` > default.
+ */
+export const ensRainbowArgs = {
+ "ensrainbow-url": {
+ type: "string",
+ description: "ENSRainbow instance URL (or set ENSRAINBOW_URL)",
+ },
+} satisfies ArgsDef;
+
+/** Args controlling output format, shared by every command. */
+export const outputArgs = {
+ output: {
+ type: "string",
+ alias: "o",
+ description: 'Output format: "json" or "pretty" (default: json when piped, pretty in a TTY)',
+ },
+} satisfies ArgsDef;
diff --git a/packages/enscli/src/lib/config.test.ts b/packages/enscli/src/lib/config.test.ts
new file mode 100644
index 0000000000..8620e31fd9
--- /dev/null
+++ b/packages/enscli/src/lib/config.test.ts
@@ -0,0 +1,87 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { resolveEnsNodeUrl, resolveEnsRainbowUrl, resolveNamespace } from "./config";
+
+const ENV_KEYS = ["NAMESPACE", "ENSNODE_URL", "ENSRAINBOW_URL"] as const;
+
+let saved: Record;
+
+beforeEach(() => {
+ saved = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
+ for (const key of ENV_KEYS) process.env[key] = "";
+});
+
+afterEach(() => {
+ for (const key of ENV_KEYS) {
+ if (saved[key] === undefined) delete process.env[key];
+ else process.env[key] = saved[key];
+ }
+});
+
+describe("resolveNamespace", () => {
+ it("defaults to mainnet", () => {
+ expect(resolveNamespace({})).toBe("mainnet");
+ });
+
+ it("reads the --namespace flag", () => {
+ expect(resolveNamespace({ namespace: "sepolia" })).toBe("sepolia");
+ });
+
+ it("falls back to the NAMESPACE env var", () => {
+ process.env.NAMESPACE = "sepolia-v2";
+ expect(resolveNamespace({})).toBe("sepolia-v2");
+ });
+
+ it("prefers the flag over the env var", () => {
+ process.env.NAMESPACE = "sepolia";
+ expect(resolveNamespace({ namespace: "mainnet" })).toBe("mainnet");
+ });
+
+ it("throws on an unknown namespace", () => {
+ expect(() => resolveNamespace({ namespace: "bogus" })).toThrow(/Invalid namespace/);
+ });
+});
+
+describe("resolveEnsNodeUrl", () => {
+ it("uses the hosted default for the namespace", () => {
+ expect(resolveEnsNodeUrl({}).href).toBe("https://api.alpha.ensnode.io/");
+ expect(resolveEnsNodeUrl({ namespace: "sepolia-v2" }).href).toBe(
+ "https://api.v2-sepolia.ensnode.io/",
+ );
+ });
+
+ it("throws a directive error for namespaces without a hosted default", () => {
+ expect(() => resolveEnsNodeUrl({ namespace: "ens-test-env" })).toThrow(/--ensnode-url/);
+ });
+
+ it("prefers an explicit --ensnode-url over the namespace default", () => {
+ expect(resolveEnsNodeUrl({ "ensnode-url": "http://localhost:4334" }).href).toBe(
+ "http://localhost:4334/",
+ );
+ });
+
+ it("falls back to the ENSNODE_URL env var", () => {
+ process.env.ENSNODE_URL = "http://localhost:9999";
+ expect(resolveEnsNodeUrl({ namespace: "ens-test-env" }).href).toBe("http://localhost:9999/");
+ });
+});
+
+describe("resolveEnsRainbowUrl", () => {
+ it("prefers an explicit --ensrainbow-url", () => {
+ expect(resolveEnsRainbowUrl({ "ensrainbow-url": "http://localhost:3223" }).href).toBe(
+ "http://localhost:3223/",
+ );
+ });
+
+ it("falls back to the ENSRAINBOW_URL env var", () => {
+ process.env.ENSRAINBOW_URL = "http://localhost:9998";
+ expect(resolveEnsRainbowUrl({}).href).toBe("http://localhost:9998/");
+ });
+
+ it("prefers the flag over the env var", () => {
+ process.env.ENSRAINBOW_URL = "http://localhost:9998";
+ expect(resolveEnsRainbowUrl({ "ensrainbow-url": "http://localhost:3223" }).href).toBe(
+ "http://localhost:3223/",
+ );
+ });
+});
diff --git a/packages/enscli/src/lib/config.ts b/packages/enscli/src/lib/config.ts
new file mode 100644
index 0000000000..0948ae0bd4
--- /dev/null
+++ b/packages/enscli/src/lib/config.ts
@@ -0,0 +1,72 @@
+import { existsSync, readFileSync } from "node:fs";
+import { resolve } from "node:path";
+import { parseEnv } from "node:util";
+
+import { type ENSNamespaceId, ENSNamespaceIds } from "@ensnode/datasources";
+import { getDefaultEnsNodeUrl } from "@ensnode/ensnode-sdk";
+import { DEFAULT_ENSRAINBOW_URL } from "@ensnode/ensrainbow-sdk";
+
+/**
+ * `.env` reader. Parses `cwd/.env` once (via Node's built-in {@link parseEnv}) into a map and never
+ * mutates `process.env`, so the resolution precedence (CLI flag > process env > `.env`) stays
+ * explicit at each call site.
+ */
+let dotEnv: Record | null = null;
+function readDotEnv(): Record {
+ if (dotEnv) return dotEnv;
+ const path = resolve(process.cwd(), ".env");
+ dotEnv = existsSync(path) ? parseEnv(readFileSync(path, "utf8")) : {};
+ return dotEnv;
+}
+
+/** Reads an env var with `.env` fallback (real env wins). Empty strings are treated as unset. */
+function fromEnv(key: string): string | undefined {
+ const value = process.env[key] ?? readDotEnv()[key];
+ return value !== undefined && value.length > 0 ? value : undefined;
+}
+
+/** Reads a CLI flag from citty's parsed args, tolerating both kebab and camelCase keys. */
+function flag(args: Record, name: string): string | undefined {
+ const camel = name.replace(/-([a-z])/g, (_match, c: string) => c.toUpperCase());
+ const value = args[name] ?? args[camel];
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+const NAMESPACES = Object.values(ENSNamespaceIds);
+function isNamespace(value: string): value is ENSNamespaceId {
+ return (NAMESPACES as string[]).includes(value);
+}
+
+/** Resolves the ENS namespace from `--namespace` / `NAMESPACE`, defaulting to mainnet. */
+export function resolveNamespace(args: Record): ENSNamespaceId {
+ const raw = flag(args, "namespace") ?? fromEnv("NAMESPACE") ?? ENSNamespaceIds.Mainnet;
+ if (!isNamespace(raw)) {
+ throw new Error(`Invalid namespace "${raw}". Expected one of: ${NAMESPACES.join(", ")}.`);
+ }
+ return raw;
+}
+
+/**
+ * Resolves the ENSNode instance URL: explicit `--ensnode-url` / `ENSNODE_URL` wins, otherwise the
+ * hosted default for the namespace. Namespaces without a hosted default (e.g. ens-test-env) throw a
+ * clear error directing the user to pass a URL.
+ */
+export function resolveEnsNodeUrl(args: Record): URL {
+ const explicit = flag(args, "ensnode-url") ?? fromEnv("ENSNODE_URL");
+ if (explicit) return new URL(explicit);
+
+ const namespace = resolveNamespace(args);
+ try {
+ return getDefaultEnsNodeUrl(namespace);
+ } catch {
+ throw new Error(
+ `No hosted ENSNode instance is available for namespace "${namespace}". Pass --ensnode-url or set ENSNODE_URL.`,
+ );
+ }
+}
+
+/** Resolves the ENSRainbow instance URL: explicit `--ensrainbow-url` / `ENSRAINBOW_URL` wins. */
+export function resolveEnsRainbowUrl(args: Record): URL {
+ const explicit = flag(args, "ensrainbow-url") ?? fromEnv("ENSRAINBOW_URL");
+ return new URL(explicit ?? DEFAULT_ENSRAINBOW_URL);
+}
diff --git a/packages/enscli/src/lib/get-ensnode-client.ts b/packages/enscli/src/lib/get-ensnode-client.ts
new file mode 100644
index 0000000000..575804c092
--- /dev/null
+++ b/packages/enscli/src/lib/get-ensnode-client.ts
@@ -0,0 +1,13 @@
+import { createEnsNodeClient } from "enssdk/core";
+import { omnigraph } from "enssdk/omnigraph";
+
+import { resolveEnsNodeUrl } from "./config";
+
+/**
+ * Builds the ENSNode Omnigraph client for the instance resolved from `args` (see
+ * {@link resolveEnsNodeUrl}). Shared by the `ensnode` subcommands that query the Omnigraph.
+ */
+export function getEnsNodeClient(args: Record) {
+ const url = resolveEnsNodeUrl(args).href;
+ return createEnsNodeClient({ url }).extend(omnigraph);
+}
diff --git a/packages/enscli/src/lib/output.test.ts b/packages/enscli/src/lib/output.test.ts
new file mode 100644
index 0000000000..4e45834dbd
--- /dev/null
+++ b/packages/enscli/src/lib/output.test.ts
@@ -0,0 +1,30 @@
+import { afterEach, describe, expect, it } from "vitest";
+
+import { resolveFormat } from "./output";
+
+function setTTY(value: boolean): void {
+ Object.defineProperty(process.stdout, "isTTY", { value, configurable: true });
+}
+
+const originalIsTTY = process.stdout.isTTY;
+afterEach(() => {
+ Object.defineProperty(process.stdout, "isTTY", { value: originalIsTTY, configurable: true });
+});
+
+describe("resolveFormat", () => {
+ it("honors an explicit --output", () => {
+ expect(resolveFormat({ output: "json" })).toBe("json");
+ expect(resolveFormat({ output: "pretty" })).toBe("pretty");
+ });
+
+ it("throws on an invalid --output", () => {
+ expect(() => resolveFormat({ output: "yaml" })).toThrow(/Expected "json" or "pretty"/);
+ });
+
+ it("defaults to json when piped and pretty in a TTY", () => {
+ setTTY(false);
+ expect(resolveFormat({})).toBe("json");
+ setTTY(true);
+ expect(resolveFormat({})).toBe("pretty");
+ });
+});
diff --git a/packages/enscli/src/lib/output.ts b/packages/enscli/src/lib/output.ts
new file mode 100644
index 0000000000..48094c2925
--- /dev/null
+++ b/packages/enscli/src/lib/output.ts
@@ -0,0 +1,53 @@
+import { toJson } from "@ensnode/ensnode-sdk";
+
+/**
+ * Resolves the output format: explicit `--output` wins, otherwise `json` when stdout is piped (the
+ * agent case) and `pretty` in an interactive TTY.
+ */
+export function resolveFormat(args: Record): "json" | "pretty" {
+ const explicit = args.output;
+ if (explicit === "json" || explicit === "pretty") return explicit;
+ if (explicit !== undefined) {
+ throw new Error(`Invalid --output "${String(explicit)}". Expected "json" or "pretty".`);
+ }
+ return process.stdout.isTTY ? "pretty" : "json";
+}
+
+/**
+ * Prints a command result. In `pretty` mode, `prettyText` (when provided) renders a human-friendly
+ * form; otherwise the full structured payload is printed as indented JSON.
+ */
+export function printResult(
+ data: T,
+ args: Record,
+ prettyText?: (data: T) => string,
+): void {
+ if (resolveFormat(args) === "pretty" && prettyText) {
+ process.stdout.write(`${prettyText(data)}\n`);
+ } else {
+ process.stdout.write(`${toJson(data, { pretty: true })}\n`);
+ }
+}
+
+/** Streams rows as NDJSON (one JSON object per line) for paginated/list output. */
+export function printNdjson(rows: unknown[]): void {
+ for (const row of rows) {
+ process.stdout.write(`${toJson(row)}\n`);
+ }
+}
+
+/** Writes a structured error to stderr and exits non-zero. */
+export function fail(error: unknown): never {
+ const message = error instanceof Error ? error.message : String(error);
+ process.stderr.write(`${toJson({ error: { message } }, { pretty: true })}\n`);
+ process.exit(1);
+}
+
+/** Runs a command body, routing any thrown error through {@link fail}. */
+export async function runSafely(fn: () => unknown | Promise): Promise {
+ try {
+ await fn();
+ } catch (error) {
+ fail(error);
+ }
+}
diff --git a/packages/enscli/src/lib/validate.test.ts b/packages/enscli/src/lib/validate.test.ts
new file mode 100644
index 0000000000..a08a67d1ce
--- /dev/null
+++ b/packages/enscli/src/lib/validate.test.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { assertCleanIdentifier } from "./validate";
+
+describe("assertCleanIdentifier", () => {
+ it("accepts ordinary ENS names and labels", () => {
+ expect(() => assertCleanIdentifier("vitalik.eth", "name")).not.toThrow();
+ expect(() => assertCleanIdentifier("vitalik", "label")).not.toThrow();
+ expect(() => assertCleanIdentifier("[abcd1234]", "labelhash")).not.toThrow();
+ });
+
+ it.each(["vitalik?.eth", "vitalik#.eth", "name%2e", "a\tb", "a\nb"])(
+ "rejects hallucinated/forbidden characters: %j",
+ (value) => {
+ expect(() => assertCleanIdentifier(value, "name")).toThrow(/forbidden characters/);
+ },
+ );
+});
diff --git a/packages/enscli/src/lib/validate.ts b/packages/enscli/src/lib/validate.ts
new file mode 100644
index 0000000000..2e18cdd395
--- /dev/null
+++ b/packages/enscli/src/lib/validate.ts
@@ -0,0 +1,17 @@
+/**
+ * Throws if `value` contains characters an agent should never produce inside a name, label, or hash
+ * but frequently hallucinates: control characters (0x00β0x1F, 0x7F), and `?`/`#`/`%` from pasting URL
+ * fragments or double-encoding. Reject them before any network call so failures are loud and local
+ * rather than silently mis-resolved.
+ */
+export function assertCleanIdentifier(value: string, label: string): void {
+ for (const char of value) {
+ const code = char.codePointAt(0) ?? 0;
+ const isControlChar = code <= 0x1f || code === 0x7f;
+ if (isControlChar || char === "?" || char === "#" || char === "%") {
+ throw new Error(
+ `Invalid ${label}: contains forbidden characters (control characters, "?", "#", or "%").`,
+ );
+ }
+ }
+}
diff --git a/packages/enscli/src/main.ts b/packages/enscli/src/main.ts
new file mode 100644
index 0000000000..5123d8fdc6
--- /dev/null
+++ b/packages/enscli/src/main.ts
@@ -0,0 +1,21 @@
+import { defineCommand } from "citty";
+
+import { datasources } from "./commands/datasources/index";
+import { ensnode } from "./commands/ensnode/index";
+import { ensrainbow } from "./commands/ensrainbow/index";
+import { labelhash } from "./commands/labelhash";
+import { namehash } from "./commands/namehash";
+
+export const main = defineCommand({
+ meta: {
+ name: "enscli",
+ description: "An agent- and human-friendly CLI for ENS, ENSNode, and the Omnigraph API.",
+ },
+ subCommands: {
+ ensnode,
+ ensrainbow,
+ datasources,
+ namehash,
+ labelhash,
+ },
+});
diff --git a/packages/enscli/tsconfig.json b/packages/enscli/tsconfig.json
new file mode 100644
index 0000000000..a28576239b
--- /dev/null
+++ b/packages/enscli/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@ensnode/shared-configs/tsconfig.lib.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": ["src/**/*"],
+ "exclude": ["dist"]
+}
diff --git a/packages/enscli/tsup.config.ts b/packages/enscli/tsup.config.ts
new file mode 100644
index 0000000000..577bb9a01d
--- /dev/null
+++ b/packages/enscli/tsup.config.ts
@@ -0,0 +1,19 @@
+import { defineConfig } from "tsup";
+
+export default defineConfig({
+ entry: { cli: "src/cli.ts" },
+ format: ["esm"],
+ platform: "node",
+ target: "es2022",
+ bundle: true,
+ // Bundle everything (incl. workspace packages) into a single self-contained bin, so `npx enscli`
+ // needs no dependency resolution and dev runs don't hit the deps' TypeScript source.
+ noExternal: [/.*/],
+ // Inline the Omnigraph SDL as a string at build time (used by `omnigraph schema`).
+ loader: { ".graphql": "text" },
+ splitting: false,
+ sourcemap: true,
+ dts: false,
+ clean: true,
+ outDir: "./dist",
+});
diff --git a/packages/enscli/vitest.config.ts b/packages/enscli/vitest.config.ts
new file mode 100644
index 0000000000..ce487d95b8
--- /dev/null
+++ b/packages/enscli/vitest.config.ts
@@ -0,0 +1,8 @@
+import { configDefaults, defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ exclude: [...configDefaults.exclude, "**/*.integration.test.ts"],
+ },
+});
diff --git a/packages/enscli/vitest.integration.config.ts b/packages/enscli/vitest.integration.config.ts
new file mode 100644
index 0000000000..a8c7169e55
--- /dev/null
+++ b/packages/enscli/vitest.integration.config.ts
@@ -0,0 +1,9 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["src/**/*.integration.test.ts"],
+ testTimeout: 30_000,
+ },
+});
diff --git a/packages/ensdb-sdk/LICENSE b/packages/ensdb-sdk/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ensdb-sdk/LICENSE
+++ b/packages/ensdb-sdk/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/enskit/LICENSE b/packages/enskit/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/enskit/LICENSE
+++ b/packages/enskit/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/ensnode-sdk/LICENSE b/packages/ensnode-sdk/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ensnode-sdk/LICENSE
+++ b/packages/ensnode-sdk/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/ensnode-sdk/src/ensnode/deployments.ts b/packages/ensnode-sdk/src/ensnode/deployments.ts
index 35deafe4e6..849f913b4d 100644
--- a/packages/ensnode-sdk/src/ensnode/deployments.ts
+++ b/packages/ensnode-sdk/src/ensnode/deployments.ts
@@ -10,6 +10,11 @@ export const DEFAULT_ENSNODE_URL_MAINNET = "https://api.alpha.ensnode.io" as con
*/
export const DEFAULT_ENSNODE_URL_SEPOLIA = "https://api.alpha-sepolia.ensnode.io" as const;
+/**
+ * Default ENSNode URL for Sepolia-V2
+ */
+export const DEFAULT_ENSNODE_URL_SEPOLIA_V2 = "https://api.v2-sepolia.ensnode.io" as const;
+
/**
* Gets the default ENSNode URL for the provided ENSNamespaceId.
*
@@ -26,6 +31,8 @@ export const getDefaultEnsNodeUrl = (namespace?: ENSNamespaceId): URL => {
return new URL(DEFAULT_ENSNODE_URL_MAINNET);
case ENSNamespaceIds.Sepolia:
return new URL(DEFAULT_ENSNODE_URL_SEPOLIA);
+ case ENSNamespaceIds.SepoliaV2:
+ return new URL(DEFAULT_ENSNODE_URL_SEPOLIA_V2);
default:
throw new Error(
`ENSNamespaceId ${effectiveNamespace} does not have a default ENSNode URL defined`,
diff --git a/packages/ensrainbow-sdk/LICENSE b/packages/ensrainbow-sdk/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ensrainbow-sdk/LICENSE
+++ b/packages/ensrainbow-sdk/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/enssdk/LICENSE b/packages/enssdk/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/enssdk/LICENSE
+++ b/packages/enssdk/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/enssdk/src/omnigraph/generated/schema.graphql b/packages/enssdk/src/omnigraph/generated/schema.graphql
index f94400f849..81bc4ece1e 100644
--- a/packages/enssdk/src/omnigraph/generated/schema.graphql
+++ b/packages/enssdk/src/omnigraph/generated/schema.graphql
@@ -312,7 +312,7 @@ interface Domain {
label: Label!
"""
- If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
+ If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
"""
owner: Account
@@ -343,7 +343,7 @@ interface Domain {
resolver: DomainResolver!
"""
- All Domains that are direct descendents of this Domain in the namegraph.
+ All Domains that are direct descendants of this Domain in the namegraph.
"""
subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection
@@ -548,7 +548,7 @@ type ENSv1Domain implements Domain {
node: Node!
"""
- If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
+ If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
"""
owner: Account
@@ -584,7 +584,7 @@ type ENSv1Domain implements Domain {
rootRegistryOwner: Account
"""
- All Domains that are direct descendents of this Domain in the namegraph.
+ All Domains that are direct descendants of this Domain in the namegraph.
"""
subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection
@@ -664,7 +664,7 @@ type ENSv2Domain implements Domain {
label: Label!
"""
- If this is an ENSv1Domain, this is the effective owner of the Domain. If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
+ If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
"""
owner: Account
@@ -700,7 +700,7 @@ type ENSv2Domain implements Domain {
resolver: DomainResolver!
"""
- All Domains that are direct descendents of this Domain in the namegraph.
+ All Domains that are direct descendants of this Domain in the namegraph.
"""
subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection
diff --git a/packages/ensskills/LICENSE b/packages/ensskills/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ensskills/LICENSE
+++ b/packages/ensskills/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/ensskills/README.md b/packages/ensskills/README.md
index 60cd87e96c..65bb0d7648 100644
--- a/packages/ensskills/README.md
+++ b/packages/ensskills/README.md
@@ -1,5 +1,49 @@
# ensskills
-This package name is reserved for the [ENSNode](https://ensnode.io) project by [NameHash Labs](https://namehashlabs.org).
+Versioned agent skills for ENS β teach AI coding agents (Claude Code, Cursor, Codex, β¦) the ENS Omnigraph.
-For more information, visit [ensnode.io](https://ensnode.io).
+`ensskills` ships `SKILL.md` bundles under `skills/`. Install them one of two ways β both **pin to an exact version**, never a moving `main`, so skills stay in lockstep with your ENSNode suite.
+
+### In an npm project β `skills-npm`
+
+[`skills-npm`](https://github.com/antfu/skills-npm) symlinks the pinned package into your agent directories:
+
+```jsonc
+// package.json
+{
+ "devDependencies": {
+ "ensskills": "1.15.1",
+ "skills-npm": "^1",
+ },
+ "scripts": {
+ "prepare": "skills-npm",
+ },
+}
+```
+
+```bash
+npm install # symlinks the skills for your detected agents
+```
+
+### Without npm β Vercel's `skills` CLI
+
+Not in a Node project? [`skills`](https://github.com/vercel-labs/skills) installs straight from this repo. Point it at the `packages/ensskills/skills` directory on the matching **release tag** (the `v` prefix is how you pin without npm):
+
+```bash
+# install every ENS skill, pinned to the matching release
+npx skills add https://github.com/namehash/ensnode/tree/v1.15.1/packages/ensskills/skills --skill '*'
+
+# or list them first, then pick the ones you want
+npx skills add https://github.com/namehash/ensnode/tree/v1.15.1/packages/ensskills/skills --list
+npx skills add https://github.com/namehash/ensnode/tree/v1.15.1/packages/ensskills/skills --skill omnigraph
+```
+
+The skills drive [`enscli`](https://www.npmjs.com/package/enscli) for live lookups. The `omnigraph` skill's schema reference and example queries are autogenerated via `pnpm -F ensskills generate`.
+
+See the [ensskills documentation](https://ensnode.io/docs/integrate/integration-options/ensskills) for details.
+
+## License
+
+Licensed under the MIT License, Copyright Β© 2025-present [NameHash Labs](https://namehashlabs.org).
+
+See [LICENSE](./LICENSE) for more information.
diff --git a/packages/ensskills/package.json b/packages/ensskills/package.json
index f81240c921..68c97f2ea3 100644
--- a/packages/ensskills/package.json
+++ b/packages/ensskills/package.json
@@ -1,12 +1,41 @@
{
"name": "ensskills",
"version": "1.15.1",
- "description": "Reserved for the ENSNode project by NameHash Labs. See https://ensnode.io",
+ "type": "module",
+ "description": "Agent skills for ENS β install with skills-npm to teach AI coding agents the ENS Omnigraph",
+ "license": "MIT",
"repository": {
"type": "git",
- "url": "https://github.com/namehash/ensnode.git",
+ "url": "git+https://github.com/namehash/ensnode.git",
"directory": "packages/ensskills"
},
- "license": "MIT",
- "homepage": "https://ensnode.io"
+ "homepage": "https://ensnode.io",
+ "keywords": [
+ "ENS",
+ "ENSNode",
+ "Omnigraph",
+ "agent",
+ "skills",
+ "SKILL.md"
+ ],
+ "files": [
+ "skills"
+ ],
+ "scripts": {
+ "generate": "tsx scripts/generate.ts",
+ "lint": "biome check --write .",
+ "lint:ci": "biome ci",
+ "typecheck": "tsgo --noEmit"
+ },
+ "devDependencies": {
+ "@ensnode/datasources": "workspace:*",
+ "@ensnode/ensnode-sdk": "workspace:*",
+ "@ensnode/shared-configs": "workspace:*",
+ "@types/node": "catalog:",
+ "enssdk": "workspace:*",
+ "graphql": "^16.11.0",
+ "prettier": "catalog:",
+ "tsx": "^4.7.1",
+ "typescript": "catalog:"
+ }
}
diff --git a/packages/ensskills/scripts/generate.ts b/packages/ensskills/scripts/generate.ts
new file mode 100644
index 0000000000..076b480d50
--- /dev/null
+++ b/packages/ensskills/scripts/generate.ts
@@ -0,0 +1,221 @@
+/**
+ * Regenerates the autogenerated regions of the agent skills:
+ * - `omnigraph` SKILL.md: a condensed schema reference (from the SDL bundled with enssdk) and the
+ * vetted example queries (imported directly from the ensnode-sdk source).
+ * - `enscli` SKILL.md: the example commands, imported from the enscli source and grouped into one
+ * `AUTOGEN` region per command section.
+ *
+ * The single sources are integration-tested in their owning packages, so the skills only ship
+ * queries/commands that actually run.
+ *
+ * Run via `pnpm -F ensskills generate` (wired into the root `pnpm generate`).
+ */
+import { readFileSync, writeFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ buildSchema,
+ type GraphQLField,
+ type GraphQLNamedType,
+ type GraphQLSchema,
+ isInterfaceType,
+ isObjectType,
+} from "graphql";
+import prettier from "prettier";
+
+const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
+const SDL_PATH = resolve(SCRIPT_DIR, "../../enssdk/src/omnigraph/generated/schema.graphql");
+const SKILL_PATH = resolve(SCRIPT_DIR, "../skills/omnigraph/SKILL.md");
+const EXAMPLE_QUERIES_PATH = resolve(
+ SCRIPT_DIR,
+ "../../ensnode-sdk/src/omnigraph-api/example-queries.ts",
+);
+const ENSCLI_SKILL_PATH = resolve(SCRIPT_DIR, "../skills/enscli/SKILL.md");
+const ENSCLI_EXAMPLE_COMMANDS_PATH = resolve(SCRIPT_DIR, "../../enscli/src/example-commands.ts");
+
+interface ExampleQuery {
+ id: string;
+ query: string;
+ variables: { default: Record };
+}
+
+interface EnscliExample {
+ id: string;
+ comment: string;
+ args: string[];
+ group: string;
+}
+
+/** Types rendered with full field listings; everything else is listed by name only. */
+const CORE_TYPES = [
+ "Domain",
+ "DomainCanonical",
+ "Account",
+ "Resolver",
+ "DomainResolver",
+ "Registry",
+ "Permissions",
+ "ReverseResolve",
+ "ForwardResolve",
+ "ResolvedRecords",
+ "PrimaryNameRecord",
+];
+
+function oneLine(description: string | null | undefined): string {
+ return description ? description.replace(/\s+/g, " ").trim() : "";
+}
+
+function renderFieldLine(field: GraphQLField): string {
+ const args =
+ field.args.length > 0 ? `(${field.args.map((a) => `${a.name}: ${a.type}`).join(", ")})` : "";
+ const description = oneLine(field.description);
+ return `- ${field.name}${args}: ${field.type}${description ? ` β ${description}` : ""}`;
+}
+
+function renderType(type: GraphQLNamedType): string {
+ if (!isObjectType(type) && !isInterfaceType(type)) return "";
+ const lines = [`#### ${type.name}`];
+ if (type.description) lines.push(`_${oneLine(type.description)}_`);
+ for (const field of Object.values(type.getFields())) lines.push(renderFieldLine(field));
+ return lines.join("\n");
+}
+
+function buildSchemaReference(schema: GraphQLSchema): string {
+ const queryType = schema.getQueryType();
+ const sections: string[] = [];
+
+ if (queryType) {
+ const queryFields = Object.values(queryType.getFields()).map(renderFieldLine);
+ sections.push(["### Query (entry points)", ...queryFields].join("\n"));
+ }
+
+ const coreSections: string[] = [];
+ for (const name of CORE_TYPES) {
+ const type = schema.getType(name);
+ if (type) {
+ const rendered = renderType(type);
+ if (rendered) coreSections.push(rendered);
+ }
+ }
+ sections.push(["### Core types", coreSections.join("\n\n")].join("\n\n"));
+
+ const otherTypes = Object.values(schema.getTypeMap())
+ .filter((type) => isObjectType(type) && !type.name.startsWith("__"))
+ .map((type) => type.name)
+ .filter(
+ (name) =>
+ name !== queryType?.name &&
+ !CORE_TYPES.includes(name) &&
+ !name.endsWith("Connection") &&
+ !name.endsWith("ConnectionEdge") &&
+ !name.endsWith("Edge") &&
+ !name.endsWith("Payload"),
+ )
+ .sort();
+ sections.push(
+ [
+ "### Other types",
+ "Run `npx enscli ensnode omnigraph schema ` for fields of:",
+ "",
+ otherTypes.map((name) => `\`${name}\``).join(", "),
+ ].join("\n"),
+ );
+
+ return sections.join("\n\n");
+}
+
+async function buildExamples(): Promise {
+ // load dynamically to avoid tsconfig root error
+ const { GRAPHQL_API_EXAMPLE_QUERIES } = (await import(EXAMPLE_QUERIES_PATH)) as {
+ GRAPHQL_API_EXAMPLE_QUERIES: ExampleQuery[];
+ };
+ // Skip "hello-world": it's the playground welcome blurb, not a reusable query pattern.
+ return GRAPHQL_API_EXAMPLE_QUERIES.filter((example) => example.id !== "hello-world")
+ .map((example) => {
+ const query = example.query.trim();
+ const variables = JSON.stringify(example.variables.default, null, 2);
+ return [
+ `### ${example.id}`,
+ "```graphql",
+ query,
+ "```",
+ "Variables:",
+ "```json",
+ variables,
+ "```",
+ ].join("\n");
+ })
+ .join("\n\n");
+}
+
+/** Renders an argv element as a shell token, single-quoting anything that isn't plainly bare-safe. */
+function shellQuote(arg: string): string {
+ if (/^[A-Za-z0-9_./:-]+$/.test(arg)) return arg;
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
+}
+
+async function buildEnscliExamplesByGroup(): Promise> {
+ const { ENSCLI_EXAMPLE_COMMANDS } = (await import(ENSCLI_EXAMPLE_COMMANDS_PATH)) as {
+ ENSCLI_EXAMPLE_COMMANDS: EnscliExample[];
+ };
+ const byGroup = new Map();
+ for (const example of ENSCLI_EXAMPLE_COMMANDS) {
+ const examples = byGroup.get(example.group) ?? [];
+ examples.push(example);
+ byGroup.set(example.group, examples);
+ }
+ const rendered = new Map();
+ for (const [group, examples] of byGroup) {
+ const block = examples
+ .map(
+ (example) => `# ${example.comment}\nnpx enscli ${example.args.map(shellQuote).join(" ")}`,
+ )
+ .join("\n\n");
+ rendered.set(group, ["```bash", block, "```"].join("\n"));
+ }
+ return rendered;
+}
+
+function replaceRegion(content: string, region: string, replacement: string, path: string): string {
+ const start = ``;
+ const end = ``;
+ const pattern = new RegExp(
+ `${start.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[\\s\\S]*?${end.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`,
+ );
+ if (!pattern.test(content)) {
+ throw new Error(`Missing AUTOGEN region "${region}" in ${path}`);
+ }
+ return content.replace(pattern, `${start}\n${replacement}\n${end}`);
+}
+
+/**
+ * Writes a SKILL.md, formatted with Prettier so the output matches the linted committed state.
+ * Without this the raw region markdown drifts from `pnpm lint` (Prettier reflows markdown), so every
+ * `pnpm generate` would leave a noisy, lint-only diff.
+ */
+async function writeFormatted(path: string, content: string): Promise {
+ const options = await prettier.resolveConfig(path);
+ writeFileSync(path, await prettier.format(content, { ...options, filepath: path }));
+}
+
+async function main(): Promise {
+ const schema = buildSchema(readFileSync(SDL_PATH, "utf8"));
+ let content = readFileSync(SKILL_PATH, "utf8");
+ content = replaceRegion(content, "SCHEMA", buildSchemaReference(schema), SKILL_PATH);
+ content = replaceRegion(content, "EXAMPLES", await buildExamples(), SKILL_PATH);
+ await writeFormatted(SKILL_PATH, content);
+ console.log(`Updated ${SKILL_PATH}`);
+
+ let enscliContent = readFileSync(ENSCLI_SKILL_PATH, "utf8");
+ for (const [group, block] of await buildEnscliExamplesByGroup()) {
+ enscliContent = replaceRegion(enscliContent, group, block, ENSCLI_SKILL_PATH);
+ }
+ await writeFormatted(ENSCLI_SKILL_PATH, enscliContent);
+ console.log(`Updated ${ENSCLI_SKILL_PATH}`);
+}
+
+main().catch((error) => {
+ console.error(error);
+ process.exit(1);
+});
diff --git a/packages/ensskills/skills/base/SKILL.md b/packages/ensskills/skills/base/SKILL.md
new file mode 100644
index 0000000000..38aa217455
--- /dev/null
+++ b/packages/ensskills/skills/base/SKILL.md
@@ -0,0 +1,22 @@
+---
+name: base
+description: Shared working conventions every ENS skill assumes β prefer the ENS Omnigraph for reads, use enssdk for the easy-to-get-wrong primitives, prefer `Node` over "namehash" in user-facing communication, follow the ENSNode terminology reference, and report results without leaking internal procedure. Foundational dependency of every other ENS skill.
+---
+
+# ENS Skills β Base Conventions
+
+Cross-cutting working agreements shared by every skill in this suite. They are not about any one tool β they are the defaults each ENS skill assumes you are already following. Load this first; the domain skills build on it.
+
+## Dependencies
+
+None β this is the foundational skill every other ENS skill depends on.
+
+## Conventions
+
+- **Trust the skills over your priors about ENS.** Your background assumptions about how ENS works are frequently wrong β that is the reason these skills exist: to instruct you precisely on how to understand, navigate, and integrate ENS. When a skill, a schema description, or a field contract states a semantic, take it at face value; do not override it with raw-protocol intuition or "verify" what it already guarantees. A common trap: the raw on-chain owner of a wrapped `.eth` name is the NameWrapper contract, but the Omnigraph's `Domain.owner` already resolves through wrapping to the effective owner β so it is never the NameWrapper/registrar address. Importing the raw-contract fact into the abstraction produces needless extra calls and wrong conclusions. If your prior contradicts a skill, the skill is right.
+- **Prefer the Omnigraph for reads.** Answer ENS questions by querying the Omnigraph (one GraphQL request over a unified ENSv1 + ENSv2, multichain index) rather than calling registry/resolver contracts, RPC, or the legacy ENS Subgraph from first principles. The **`omnigraph`** skill is the query model; **`enscli`** / **`enssdk`** run it.
+- **Use `enssdk` for the easy-to-get-wrong primitives.** Name normalization (ENSIP-15), namehash/labelhash, name/label handling, and address parsing all have sharp edges β use the `enssdk` helpers instead of hand-rolling them (never `toLowerCase()` a name yourself).
+- **Prefer `Node` over "namehash" in user-facing communication.** The `namehash` function produces a name's 32-byte on-chain identifier; call that result the **`Node`**. Reserve "namehash" for the function, not its output.
+- **Definitions follow the ENSNode Terminology Reference.** When a term is ambiguous, defer to https://ensnode.io/docs/reference/terminology rather than inventing your own usage.
+- **Load the docs when you need more than these skills cover.** The full ENSNode documentation is published for LLMs at https://ensnode.io/llms-full.txt (entire docs in one file) with an indexed table of contents at https://ensnode.io/llms.txt β fetch these when a question reaches beyond what the skills describe.
+- **Report the result, not the procedure.** Relay only facts that change the user's understanding of the **result** β e.g. an input was rejected as unnormalizable, or a name resolves unexpectedly. Do **not** annotate correct results with the internal steps that produced them (normalizing, hashing offline, field selection, checking exit codes); those are operating instructions for you, not facts for the user, and surfacing them is noise at best and leaks how you are steered at worst.
diff --git a/packages/ensskills/skills/ens-protocol/SKILL.md b/packages/ensskills/skills/ens-protocol/SKILL.md
new file mode 100644
index 0000000000..94fa8d2c6f
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/SKILL.md
@@ -0,0 +1,116 @@
+---
+name: ens-protocol
+description: How the ENS protocol works at a conceptual level β names and the nametree, normalization, namehash/labelhash, the registry/resolver/registrar architecture, forward & reverse resolution, primary names, records (addresses/text/contenthash), name ownership (NFTs, the NameWrapper), and ENS across many chains (L2 subdomains, DNS-imported names, CCIP-Read). Read this for protocol fundamentals before querying, displaying, or integrating ENS.
+---
+
+# ENS Protocol
+
+The **Ethereum Name Service (ENS)** maps human-readable names (`vitalik.eth`) to machine-readable data β addresses on many chains, text records, content hashes, and more. It is a hierarchical naming system, a **superset of DNS** (any DNS name like `example.com` can be an ENS name), and the records a name points to are owned and controlled by that name's owner.
+
+This skill explains how the protocol works so you can reason about ENS questions β and write integrations that are correct the first time.
+
+When writing ENS code, use **enssdk** for the operations that are easy to get wrong β name normalization, namehash/labelhash, name/label handling, and address parsing β instead of hand-rolling them; the relevant `enssdk` helper is named at each step below. To _read_ ENS state, use the **omnigraph** skill (one GraphQL query answers most questions); this skill is the model behind it.
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology).
+
+## Dependencies
+
+- **`base`** β the shared working conventions every ENS skill assumes (prefer the ENS Omnigraph for reads, use `enssdk` primitives, `Node` terminology, output hygiene).
+
+This is the foundational _protocol_ skill the other ENS skills build on. To read live ENS state, use **`omnigraph`**; to write ENS code, use **`enssdk`** (both depend on this skill).
+
+## The nametree
+
+ENS names are dot-separated **labels** read right-to-left from an unnamed **root**: `vitalik.eth` is the label `vitalik` under the TLD `eth` under the root. Every name is a node in this tree; a name's direct children are its **subnames** (a.k.a. subdomains), e.g. `pay.vitalik.eth`. Owning a name lets you create subnames under it. `.eth` is the native ENS TLD; most other TLDs are imported from DNS.
+
+Two hashes are frequently used for identification on-chain:
+
+- **labelhash** β `keccak256` of a single label (`enssdk`: `labelhashInterpretedLabel`). The result of this function is a `LabelHash`
+- **namehash** (ENSIP-1) β a recursive hash of the whole name into a 32-byte **node**, the on-chain identifier for that name (`enssdk`: `namehashInterpretedName`). `namehash("vitalik.eth")` walks the tree hashing each label. The result of this function is a `Node` (but is often (confusingly) referred to as the name's 'namehash' β in this documentation and in your communication with users, always prefer using `Node` to refer to the result of the `namehash` function).
+
+Always hash a normalized/Interpreted value, never raw input. Because Domains are keyed by these hashes on-chain, in ENSv1 a label string is frequently **unknown** (you have only its hash). See [references/names-and-hashing.md](references/names-and-hashing.md).
+
+## Normalization (always)
+
+Before hashing, comparing, or displaying a name you must **normalize** it per [ENSIP-15](https://docs.ens.domains/ensip/15). Never `toLowerCase()` a name. Normalization makes `Vitalik.eth` and `vitalik.eth` resolve to the same node and defends against homoglyph spoofing (e.g. a Cyrillic `Π°` posing as Latin `a`). Two names are "the same" only after both are normalized.
+
+Don't run the algorithm yourself; `enssdk` wraps `@adraffy/ens-normalize`. Validate with `isNormalizedName` / `isNormalizedLabel`, and coerce raw user input into a safe **Interpreted Name** with `asInterpretedName` (or into a safe **Interpreted Label** with `asInterpretedLabel`) β the branded type the SDK's hashing and query helpers require. See [references/names-and-hashing.md](references/names-and-hashing.md).
+
+## Architecture: Registry, Resolver, Registrar
+
+### ENSv1 Registry
+
+Records the **owner** and the address of that name's **resolver**, keyed by `node`.
+
+### ENSv2 Registry
+
+An ERC1155 contract that manages a set of **Domains**.
+
+### Resolver
+
+The contract a name points to that actually **stores the records** (addresses, text, contenthash). Different names can use different Resolvers; Resolvers are pluggable and many names may (and do) use the same Resolver. A Resolver prefixes records by `node`, and can store records for any number of `node`s.
+
+### Registrar
+
+A contract that **owns a name and mints subdomains** under it with some policy. The `.eth` registrar issues second-level `.eth` names as **ERC-721 NFTs** (so they are tradable and expire/renew).
+
+### NameWrapper (ENSv1-only)
+
+The **NameWrapper** can wrap any name into an **ERC-1155 NFT** with permission "fuses". Other registrars (Basenames on Base, Linea names, 3DNS, etc.) issue names on their own chains/systems. Lineanames also includes a `NameWrapper` contract.
+
+## Records
+
+A name's resolver can store many record types (ENSIP-5 and friends):
+
+- **Address records** β the chain address(es) the name points to. ENS is **multichain**: a name can hold a different address per chain, keyed by a numeric **coinType** (ENSIP-9 / ENSIP-11), which supports all crypto chains, not just EVM chains. CoinType `60` is always the CoinType used to reference the ENS Root Chain for the respective ENS Namespace (i.e. CoinType 60 on ENS Namespace `mainnet` stores the Domain's preferred address for the ETH Mainnet chain. On the `sepolia` ENS Namespace, CoinType 60 stores the Domain's preferred address for the Sepolia chain). The "default" EVM CoinType `0x8000_000` record is used as a fallback by _some_ Resolvers when a specific EVM CoinType isn't defined.
+- **Text records** β arbitrary key/value strings: `avatar`, `url`, `description`, `com.twitter`, `com.github`, `email`, etc.
+- **Contenthash** β a pointer to decentralized content (IPFS/Arweave/Swarm), e.g. for `name.eth` websites.
+
+In code, validate and normalize addresses with `enssdk`'s `toNormalizedAddress` / `isNormalizedAddress` rather than comparing raw strings. See [references/records.md](references/records.md).
+
+## Resolution
+
+- **Forward resolution** β name β records (`vitalik.eth` β its ETH address, avatar, β¦). This is the common direction.
+- **Reverse resolution** β address β a **primary name** (the name the address owner chose to display). An address can claim _any_ name as its reverse record, so a reverse result is **only trustworthy after forward-verifying it**: resolve the claimed name and confirm it points back to the same address. Skipping this is the single most common ENS integration bug.
+- **Primary names are multichain** (ENSIP-19): an address can set a primary name per chain, not only on mainnet.
+- **Owner β resolved address.** The address that _owns_ a name (controls it in the registry / holds its NFT) is set independently from the address the name _resolves to_ (its coinType-`60` address record). They are frequently different **by design** β e.g. holding a name in a cold/hardware wallet or multisig while resolving it to a hot wallet β so a mismatch is normal and often good practice, not a sign of bad/test data. Never infer the owner from the resolved address (or vice versa); read each from its own field.
+
+Modern resolution goes through a **Universal Resolver** that also handles **offchain / L2 names** via **CCIP-Read** (EIP-3668) and **wildcard** resolution (ENSIP-10) β this is how subdomains living on an L2 or in an offchain database (Basenames, `uni.eth`, `cb.id`, β¦) resolve seamlessly. You generally don't call registry/resolver contracts by hand, and should prefer using the `UniversalResolver` in most (all) cases. See [references/resolution.md](references/resolution.md).
+
+## ENS is Multichain
+
+ENS is not "just mainnet." Names and subdomains live across L1, L2s, and offchain systems, and DNS names are ENS names too. Practical consequences:
+
+- A name's records and its primary name can differ per chain.
+- Some names resolve only through CCIP-Read (their data isn't on mainnet at all).
+- Reconciling registries, wrappers, resolvers, and chains by hand is exactly what an indexer like ENSNode (and the omnigraph skill) does for you β prefer that over first-principles chain queries.
+
+## ENSv1 and ENSv2
+
+**ENSv2 does not replace ENSv1 β the two coexist onchain at the same time.** Building correctly means reading a _unified_ view of both, not one or the other.
+
+- **ENSv1**: names are a flat mapping of `namehash β state` on Ethereum mainnet (the registry + resolvers described above), with subdomains on other chains (Basenames, Lineanames, 3DNS) stitched in.
+- **ENSv2**: names become a **Namegraph** β a graph of `Registry β Domain β Registry β β¦` rather than a flat table β with much of the system on an L2, native subname delegation, and first-class onchain **Permissions** (roles governing who can do what).
+
+After ENSv2 launches there can be **two** onchain Domains for the same name (one per version); a name lookup starts at the ENSv2 root and returns whichever Domain resolution would use. So a Name is **not** a stable identifier. The closest thing to a stable identifier is, for ENSv1 `(chainId, registryAddress, node)` and for ENSv2 `(chainId, registryAddress, storageId)`.
+
+The fundamentals in this skill β names, normalization, hashing, the resolver/record model, forward/reverse resolution β hold across both versions. The safest way to stay version-agnostic is to read the unified data through the omnigraph instead of hardcoding ENSv1 assumptions. See [references/ensv1-and-ensv2.md](references/ensv1-and-ensv2.md).
+
+## References
+
+Pull these only when a task needs the depth:
+
+| Topic | File |
+| ----------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
+| Names, labels, normalization, namehash/labelhash, unknown labels | [references/names-and-hashing.md](references/names-and-hashing.md) |
+| Registry / resolver / registrar, NFTs, NameWrapper, subdomains, subregistries | [references/architecture.md](references/architecture.md) |
+| Forward/reverse resolution, primary names, Universal Resolver, CCIP-Read, coinTypes | [references/resolution.md](references/resolution.md) |
+| All record types β address/text/avatar/contenthash/ABI/pubkey/interface/DNS/name | [references/records.md](references/records.md) |
+| ENSv1 vs ENSv2 β coexistence, Namegraph, two-domains, resolving across both | [references/ensv1-and-ensv2.md](references/ensv1-and-ensv2.md) |
+
+Terminology: [ENSNode Terminology](https://ensnode.io/docs/reference/terminology) Β· Specs: [ENSIPs](https://docs.ens.domains/ensips)
+
+## Related skills
+
+- **omnigraph** β query live ENS state (names, addresses, records, primary names, ownership) in one GraphQL request. This protocol model is what its data shapes reflect.
+- **enssdk** β the TypeScript SDK over the omnigraph (typed queries, hashing, normalization helpers).
diff --git a/packages/ensskills/skills/ens-protocol/references/architecture.md b/packages/ensskills/skills/ens-protocol/references/architecture.md
new file mode 100644
index 0000000000..d6ebc2f00a
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/references/architecture.md
@@ -0,0 +1,44 @@
+# Architecture: registry, resolver, registrar
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology). The core roles below (Registry, Resolver, Registrar) are the standard ENS roles; **Subregistry**, **Subregistrar**, and **Shadow Registry** are ENSNode's canonical extensions for the multichain/offchain state that modern ENS spreads across many contracts.
+
+The protocol separates three concerns on purpose: _who owns a name_ (Registry), _where its records live_ (Resolver), and _who hands names out_ (Registrar). A name's full state is frequently spread across more than one contract and chain, so reading ENS correctly means combining them.
+
+## Registry
+
+The core contract. For each **node** it records:
+
+- the **owner** of that name,
+- the address of that name's **resolver**, and
+- the registry/owner for that name's subnames.
+
+The registry holds **pointers**, not records. Resolution starts here: look up a node to find its resolver.
+
+In ENSv1 the registry is effectively a flat `namehash β state` mapping on Ethereum mainnet. In ENSv2 names form a **graph** β `Registry β Domain β Registry β β¦`, a registry-of-registries β so a registry can natively point to the child registries that manage its subnames. ENSv1 names have no real on-chain subname registry of their own; ENSNode _models_ that relationship rather than reading it from a contract. See [ensv1-and-ensv2.md](ensv1-and-ensv2.md).
+
+## Resolver
+
+The contract a name points to that **stores the actual records** β addresses, text, contenthash (see [records.md](records.md)). Resolvers are pluggable: different names can use different resolver implementations, and a name's owner can change which resolver it uses.
+
+**Assigned vs effective resolver.** The resolver a name has _assigned_ is not necessarily the resolver that actually answers for it. The **effective** resolver is determined by following ENS Forward Resolution and ENSIP-10 (wildcard resolution) up the nametree β a name with no assigned resolver can still resolve via an ancestor. Never read records from a name's assigned resolver in isolation; that operation is **not** ENS Forward Resolution and will silently miss wildcard and offchain names. Resolve through the protocol (or the omnigraph's resolution fields, which do this for you) β see [resolution.md](resolution.md).
+
+## Registrar
+
+A contract that **owns a name and issues subnames** under it under some policy. Examples:
+
+- The **`.eth` registrar** (the BaseRegistrar) issues second-level `.eth` names (`name.eth`) as **ERC-721 NFTs**, which are tradable and have expiry, a grace period, and renewal. Basenames and Lineanames use their own BaseRegistrar deployments.
+- The **NameWrapper** can wrap any name into an **ERC-1155 NFT** and attach permission **fuses** that restrict what the owner (or parent) may do.
+
+## Subnames
+
+Owning any name lets you create **subnames** (a.k.a. subdomains) beneath it (`pay.vitalik.eth` under `vitalik.eth`) and assign them owners and resolvers. This delegation is recursive down the nametree.
+
+## Subregistry and Subregistrar (ENSNode canonical terms)
+
+State for a set of subnames frequently lives **outside** the core Registry. ENSNode names this canonically:
+
+- A **Subregistry** is any data structure outside the Registry that manages supplemental state for a set of subnames. A name can be associated with more than one subregistry, so its **full state must be combined across the Registry and each subregistry**. For example, the state of direct subnames of `.eth` is spread across the Registry, the **BaseRegistrar** (ERC-721 NFTs + expiry for `.eth` names), and the **NameWrapper** (ERC-1155 NFTs, fuses, and expiry for the whole tree). The ENS protocol defines no standard for subregistries: they can live on L1, on L2s, or offchain (a database β or even a Google Sheet), and there is no standardized way to discover or query them.
+- A **Subregistrar** is any system that is a Registrar _or_ that writes to a subregistry β e.g. the ETHRegistrarController (which writes to the BaseRegistrar), the L2 issuance contracts for `base.eth` / `linea.eth`, the offchain issuers for `uni.eth` / `cb.id`, an NFT marketplace trading a name (each trade mutates subregistry state), or any DNS registrar (ENS is a superset of DNS).
+- A **Shadow Registry** is a subregistry implemented as a contract exposing the same interface as the Registry, used as a CCIP-Read source for ENSIP-10 wildcard subnames β e.g. Basenames mirrors a subset of `base.eth` state in an L2 registry contract. This is how an L2's subnames stay resolvable from mainnet (see [resolution.md](resolution.md)).
+
+The practical upshot: a name's full state can be **spread across the Registry plus one or more subregistries on different chains or offchain**. Reading ENS correctly means combining them β which is exactly what an indexer like ENSNode (and the omnigraph) does for you.
diff --git a/packages/ensskills/skills/ens-protocol/references/ensv1-and-ensv2.md b/packages/ensskills/skills/ens-protocol/references/ensv1-and-ensv2.md
new file mode 100644
index 0000000000..adbabd4c82
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/references/ensv1-and-ensv2.md
@@ -0,0 +1,42 @@
+# ENSv1 and ENSv2
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology).
+
+ENSv2 does not replace ENSv1 β both are live onchain at once, with substantially different data models, so an integration must read a **unified** view of both. This page details how they differ and what that means for your code.
+
+## ENSv1 (live today)
+
+- A name maps to state via a flat **`namehash β state`** table (a Nametable): the Registry stores each node's owner and resolver, resolvers store records.
+- It lives on **Ethereum mainnet**, but a name's full state is often spread across **subregistries** on other systems β `.eth` (BaseRegistrar + NameWrapper), Basenames on Base, Lineanames on Linea, 3DNS on Optimism, offchain issuers like `uni.eth` / `cb.id`. See [architecture.md](architecture.md).
+- There is no protocol-level way to discover or query those subregistries; reconciling them is exactly what an indexer does.
+
+## ENSv2 (in progress)
+
+- A name is a node in a **Namegraph** β a graph of `Registry β Domain β Registry β Domain β β¦` rather than a flat table. The graph can be cyclic, and many _disjoint_ Namegraphs exist (one per root: ENSv1 root, ENSv2 root, Basenames, etc.).
+- Much of the system moves onto an **L2**, with a **registry-of-registries** structure and **native subname delegation** (any Registry can point a name at a child Registry).
+- **Permissions are first-class onchain**: contracts like the Registry and resolvers carry role bitmaps governing who may do what on a resource β readable as structured data, not inferred.
+
+The ENSNode `unigraph` plugin builds **one** unified model (the **Unigraph**) over all of this: two Namegraphs (ENSv1 root and ENSv2 root) plus the multichain subregistries, stitched together using ENS resolution semantics. Navigating from `eth` β `vitalik.eth` β below looks identical regardless of whether the underlying entity is ENSv1 or ENSv2, on mainnet or an L2.
+
+## The "two domains" consequence
+
+After ENSv2 launches, the same name can have **two** onchain Domains β one in the ENSv1 Namegraph and one in the ENSv2 Namegraph. A lookup **by name** starts at the **ENSv2 root** and returns whichever Domain forward resolution would use; once a `.eth` name is reserved on the ENSv2 side, that ENSv2 Domain is the "real" one (and, before per-name migration, its resolver forwards resolution back to the ENSv1 Namegraph).
+
+Practical rules for writing integrations:
+
+- **A name is not a stable identifier.** Re-parenting/re-aliasing means a name can point at a different Domain over time. Reference a specific onchain entity by its stable **`id`**; look up **by name** only when you want "whatever this resolves to right now." (In the omnigraph: `domain(by: { id })` vs `domain(by: { name })`.)
+- **Resolve through the current Universal Resolver**, not ENSv1-only contracts β using the old path risks stale/incorrect results once ENSv2 is live. In practice this is handled for you by an up-to-date resolution stack or by reading resolved records through the omnigraph (which internally implements accelerated, standards-correct resolution including CCIP-Read).
+- **Don't bake in ENSv1-only assumptions** (e.g. "a name is one object", "all state is on mainnet"). Query the unified data and select per-version fields only when you need them.
+
+## Version-specific fields (omnigraph)
+
+In the omnigraph, `Domain`, `Registry`, and `Registration` are interfaces with concrete ENSv1/ENSv2 implementations (`ENSv1Domain` / `ENSv2Domain`, etc.). Shared fields work unconditionally; reach version-specific fields via inline fragments, and filter a query to one version with `version: ENSv1` / `version: ENSv2` where supported. The **omnigraph** and **enssdk** skills cover the query shapes.
+
+## Learn more (ENS team)
+
+- [ENSv2 overview](https://ens.domains/ensv2)
+- [ENSv2 architecture](https://ens.domains/blog/post/ensv2-architecture)
+- [Names are no longer single objects](https://ens.domains/blog/post/names-are-no-longer-single-objects)
+- [ENSv2 contracts](https://docs.ens.domains/contracts/ensv2/overview/)
+
+ENSv2 specifics are still evolving; treat exact contract/field details as moving and confirm against the docs above. The protocol fundamentals in this skill hold across both versions.
diff --git a/packages/ensskills/skills/ens-protocol/references/names-and-hashing.md b/packages/ensskills/skills/ens-protocol/references/names-and-hashing.md
new file mode 100644
index 0000000000..7247987d97
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/references/names-and-hashing.md
@@ -0,0 +1,40 @@
+# Names, labels, and hashing
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology). Use **enssdk** for all of the operations on this page β don't hand-roll normalization, hashing, or label parsing; the SDK encodes the rules and branded types that keep them correct.
+
+## Name and label
+
+- A **name** is a human-readable string of dot-separated **labels** read right-to-left, e.g. `vitalik.eth` has labels `vitalik` and `eth`. A name may or may not be normalized.
+- A **label** is one segment. Labels are arbitrary Unicode strings.
+
+## Normalization (ENSIP-15)
+
+**Normalization** canonicalizes a name before it is hashed, compared, or trusted, per [ENSIP-15](https://docs.ens.domains/ensip/15) as implemented by `@adraffy/ens-normalize`. It is **not** `toLowerCase()` β it folds case, handles emoji/ZWJ correctly, and rejects confusable/homoglyph constructions (e.g. a Cyrillic `Π°` posing as Latin `a`).
+
+- A **Normalized Label/Name** is the canonical form. Only a normalized name produces a _valid_ node.
+- The normalization algorithm evolves with Unicode releases, so a label can become normalizable in a newer version. When two systems compare names/labels, they must use the **same** normalization version or guarantees break.
+- Compare names for equality **only after normalizing both**.
+
+In `enssdk`: check with `isNormalizedName` / `isNormalizedLabel`; turn raw input into a branded **Interpreted Name/Label** with `asInterpretedName` / `asInterpretedLabel` (an Interpreted value is a normalized literal, or an Encoded LabelHash when the literal is unknown/unnormalized). The `InterpretedName` / `InterpretedLabel` types are what the hashing and ENS Omnigraph query helpers accept, so coercing at the input boundary makes the rest of an integration correct by construction.
+
+## namehash and labelhash
+
+ENS stores hashes, not strings.
+
+- **labelhash** β `keccak256` of a single label. `labelhash("vitalik")` = `0xaf2caa1cβ¦7c7103cc`.
+- **namehash** ([ENSIP-1](https://docs.ens.domains/ensip/1)) β recursively hashes a name into a 32-byte **node**, the on-chain identifier: `namehash(label.parent) = keccak256(namehash(parent) ++ labelhash(label))`, with `namehash("") = 0x00β¦00` (the root). `namehash("vitalik.eth")` = `0xee6c4522β¦53475835`.
+
+The node is **not** a stable reference to a label string: passing an unnormalized name to namehash yields an _invalid_ node.
+
+In `enssdk`: `namehashInterpretedName(name)` and `labelhashInterpretedLabel(label)` hash an Interpreted value (use `labelhashLiteralLabel` for a raw literal). Always hash an Interpreted/normalized value, not raw user input.
+
+## Known vs unknown labels, Encoded LabelHash
+
+Because only hashes are registered, the human-readable label behind a node is sometimes **unknown** β you have its labelhash but not its text. (Healing services like ENSRainbow and contract events can recover many of these.)
+
+- An **Unknown Label** is displayed as an **Encoded LabelHash**: the hex labelhash wrapped in square brackets, `[731f7025β¦0663b22]`.
+- A name containing an Encoded LabelHash can be traversed in the nametree but is **not resolvable** (forward resolution needs literal labels) β see [resolution.md](resolution.md).
+
+In `enssdk`: `encodeLabelHash` produces the `[β¦]` form, `isEncodedLabelHash` detects it, and `parseLabelHashOrEncodedLabelHash` accepts either a bare labelhash or the encoded form β handy when a label may or may not be known.
+
+ENSNode further distinguishes _Literal_ / _Interpreted_ / _Beautified_ forms of labels and names for indexing and display; see the [terminology reference](https://ensnode.io/docs/reference/terminology) if you need that precision.
diff --git a/packages/ensskills/skills/ens-protocol/references/records.md b/packages/ensskills/skills/ens-protocol/references/records.md
new file mode 100644
index 0000000000..f2049d9069
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/references/records.md
@@ -0,0 +1,47 @@
+# Records
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology).
+
+A name's **resolver** (its _effective_ resolver β see [architecture.md](architecture.md)) stores its records. Each record type is defined by its own standard and is stable across the protocol. Any record can be **unset/null** β that is distinct from "the name doesn't exist" or "the name isn't resolvable." Reading records is ENS Forward Resolution; in practice resolve through the protocol or the omnigraph rather than reading a resolver contract directly.
+
+## Address records (ENSIP-9, ENSIP-11)
+
+The chain address(es) a name points to. Originally a single Ethereum address (`addr(node)`, ENSIP-1 / EIP-137); **multichain** addresses (`addr(node, coinType)`, [ENSIP-9](https://docs.ens.domains/ensip/9) / EIP-2304) let one name hold a different address per chain, keyed by a numeric **coinType** (see [resolution.md](resolution.md#multichain-addresses-cointypes)). Non-EVM coinTypes follow SLIP-44; EVM chains derive their coinType from the chain id per [ENSIP-11](https://docs.ens.domains/ensip/11). Ethereum mainnet is coinType `60`.
+
+In code, validate/normalize an address with `enssdk`'s `toNormalizedAddress` (throws on invalid) or `isNormalizedAddress`, and compare normalized values β never raw, mixed-case strings.
+
+## Text records (ENSIP-5)
+
+Arbitrary key β string values (`text(node, key)`, [ENSIP-5](https://docs.ens.domains/ensip/5) / EIP-634). Keys are conventions, not an enum β a resolver can store any key. Standardized **global keys**: `avatar`, `description`, `display`, `email`, `keywords`, `mail`, `notice`, `location`, `phone`, `url`; `header` (banner image) is [ENSIP-18](https://docs.ens.domains/ensip/18). **Service keys** use reverse-DNS notation: `com.twitter`, `com.github`, `com.discord`, `com.linkedin`, `org.telegram`, `io.keybase`, etc.
+
+## Avatar (ENSIP-12)
+
+The `avatar` text record is a URI, not a plain image URL ([ENSIP-12](https://docs.ens.domains/ensip/12)). It may be `https://`, `ipfs://`, `data:`, or an `eip155:` NFT reference. Rendering it as a bare ` ` is wrong β the scheme must be resolved, and for NFT avatars ownership should be verified before display.
+
+## Contenthash (ENSIP-7)
+
+A pointer to decentralized content (`contenthash(node)`, [ENSIP-7](https://docs.ens.domains/ensip/7) / EIP-1577) β IPFS, Arweave, Swarm, etc. This is what lets `name.eth` serve a website via ENS-aware gateways and browsers.
+
+## ABI records (ENSIP-4)
+
+A contract ABI stored under the name (`ABI(node, contentTypes)`, [ENSIP-4](https://docs.ens.domains/ensip/4) / EIP-205). `contentTypes` is a bitmask selecting the encoding (e.g. JSON, zlib-compressed JSON, CBOR), so a dapp can look up a contract's interface by ENS name.
+
+## Public key (pubkey)
+
+A SECP256k1 public key as an `(x, y)` pair (`pubkey(node)`). Used by applications that key encryption or signing off an ENS name.
+
+## Interface records (EIP-165)
+
+Discovery of the contract that implements a given [EIP-165](https://eips.ethereum.org/EIPS/eip-165) interface for a name (`interfaceImplementer(node, interfaceID)`). Lets a client find, say, the contract handling a particular standard for `name.eth`.
+
+## DNS records & zonehash
+
+ENS resolvers can also store DNS resource records and a **DNS zonehash** (a pointer to the name's DNSSEC zone). This backs DNS-over-ENS and DNS-imported names, where a DNS name's records are served through an ENS resolver.
+
+## The name record (reverse resolution)
+
+The `name(node)` record holds the name used in **reverse resolution** (address β name). The omnigraph surfaces this as `reverseName` to avoid confusion with the human-readable name. A reverse claim is only trustworthy after **forward-verifying** it (see [resolution.md](resolution.md)) β the `name` record alone is attacker-settable.
+
+## Reading records via the omnigraph
+
+The omnigraph's resolution fields return all of these record types in one query β `addresses`, `texts`, `contenthash`, `abi`, `pubkey`, `interfaces`, `dnszonehash`, and `reverseName` β already resolved through the effective resolver, so you don't read resolver contracts or follow ENSIP-10 yourself.
diff --git a/packages/ensskills/skills/ens-protocol/references/resolution.md b/packages/ensskills/skills/ens-protocol/references/resolution.md
new file mode 100644
index 0000000000..330ee7edfd
--- /dev/null
+++ b/packages/ensskills/skills/ens-protocol/references/resolution.md
@@ -0,0 +1,35 @@
+# Resolution: forward, reverse, primary names
+
+Definitions follow the [ENSNode Terminology Reference](https://ensnode.io/docs/reference/terminology).
+
+## Forward resolution (name β records)
+
+Given a name, find its records (address, avatar, β¦). The flow: namehash the (normalized) name β ask the Registry for that node's resolver β read records from the resolver.
+
+A **Resolvable Name** is one that forward resolution accepts: only literal label segments, each label β€ 255 bytes. A name containing an **Encoded LabelHash** (an unknown label) can be traversed in the nametree but is **not** resolvable β its records cannot be fetched.
+
+## Reverse resolution (address β name) and primary names
+
+An address can publish a **primary name** β the name it wants shown β via a reverse record under the `addr.reverse` namespace. Reverse resolution reads that record.
+
+**Critical:** anyone can set their reverse record to _any_ name. A reverse result is trustworthy only after **forward-verification**: resolve the claimed name and confirm it points back to the same address. Display the primary name only if it round-trips. Skipping this is the most common ENS bug.
+
+Primary names are **multichain** ([ENSIP-19](https://docs.ens.domains/ensip/19)): an address can set a different primary name per chain, not just on Ethereum mainnet.
+
+## Universal Resolver, CCIP-Read, wildcards
+
+You normally do **not** call the Registry and resolver contracts directly. A **Universal Resolver** performs the registry lookup, resolver call, and reverse-with-verification in one entry point, and it transparently handles names whose data lives offchain or on an L2:
+
+- **CCIP-Read** ([EIP-3668](https://eips.ethereum.org/EIPS/eip-3668)) β a resolver can defer to an offchain gateway, which returns signed data verified on-chain. This is how offchain/L2 subnames resolve seamlessly.
+- **Wildcard resolution** ([ENSIP-10](https://docs.ens.domains/ensip/10)) β a parent's resolver can answer for subnames that have no record of their own, enabling subnames issued in bulk (often via CCIP-Read).
+
+Because of CCIP-Read, some names' data **isn't on mainnet at all** β querying mainnet contracts directly will miss them. Use a resolver-aware client or an indexer.
+
+## Multichain addresses (coinTypes)
+
+Address records are keyed by a numeric **coinType**:
+
+- [ENSIP-9](https://docs.ens.domains/ensip/9) maps SLIP-44 coin types (e.g. `60` = Ethereum mainnet, `0` = Bitcoin).
+- [ENSIP-11](https://docs.ens.domains/ensip/11) encodes EVM chains so a name can carry a distinct address per L2.
+
+So "the address for `alice.eth`" is incomplete β it's the address _for a given chain_.
diff --git a/packages/ensskills/skills/enscli/SKILL.md b/packages/ensskills/skills/enscli/SKILL.md
new file mode 100644
index 0000000000..2b58f5b20b
--- /dev/null
+++ b/packages/ensskills/skills/enscli/SKILL.md
@@ -0,0 +1,185 @@
+---
+name: enscli
+description: Drive the enscli command-line tool to read ENS data β run ENS Omnigraph GraphQL queries, explore the schema offline, compute namehash/labelhash, heal labels via ENSRainbow, and check ENSNode indexing status. Use this when executing ENS lookups from a shell (the omnigraph skill authors queries; this skill runs them).
+---
+
+# enscli
+
+`enscli` is the terminal entry point to ENS. It wraps [`enssdk`](https://www.npmjs.com/package/enssdk) and the ENS Omnigraph so you can run GraphQL queries, explore the schema, compute hashes, and heal labels against any ENSNode instance β no install step beyond `npx`, no script to write first.
+
+It is built to be driven by an agent: predictable flags, machine-readable output, offline schema introspection, and loud structured errors. v0 is **read-only** (no mutations).
+
+This skill is about _running_ queries and the other CLI commands.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the conceptual model (names, hashing, normalization, resolution, records) behind every command here.
+- **`omnigraph`** β **required before running ANY `ensnode omnigraph` query, no exceptions.** It carries the schema reference and vetted example queries, and shows how to traverse relationships in a single query. Don't author field selections from memory β field semantics are easy to get wrong.
+
+## Output contract
+
+Rely on this when parsing results:
+
+- **JSON when piped, pretty in a TTY.** When stdout is not a terminal (the agent case) every command prints JSON; interactively you get a friendlier rendering. Force either with `--output json` / `--output pretty` (alias `-o`).
+- **Structured errors.** Failures print `{ "error": { "message": "β¦" } }` to stderr and exit non-zero. Always check the exit code.
+- **Input hardening.** Names, labels, and hashes containing control characters or `?` / `#` / `%` are rejected before any network call, so a hallucinated identifier fails locally and loudly rather than silently mis-resolving.
+
+## Selecting an ENSNode instance
+
+`ensnode` commands talk to an ENSNode instance. The URL is resolved with precedence **`--ensnode-url` flag β `ENSNODE_URL` env β `.env` β namespace default**. `--namespace` (alias `-n`, or the `NAMESPACE` env var) picks a NameHash-hosted instance:
+
+| Namespace | Hosted default |
+| -------------- | -------------------------------------- |
+| `mainnet` | `https://api.alpha.ensnode.io` |
+| `sepolia` | `https://api.alpha-sepolia.ensnode.io` |
+| `sepolia-v2` | `https://api.v2-sepolia.ensnode.io` |
+| `ens-test-env` | _(none β pass `--ensnode-url`)_ |
+
+`ens-test-env` has no hosted default, so it fails fast asking for `--ensnode-url`. ENSRainbow commands resolve their URL the same way via `--ensrainbow-url` / `ENSRAINBOW_URL`, defaulting to the hosted ENSRainbow.
+
+## Commands
+
+```text
+enscli
+ ensnode
+ omnigraph run a raw GraphQL query (--variables '')
+ omnigraph schema [Type[.field]] explore the bundled schema offline (--search )
+ indexing-status fetch an ENSNode instance's indexing status
+ ensrainbow
+ heal heal a labelHash to its original label
+ count count healable labels known to ENSRainbow
+ datasources
+ identify identify a well-known ENS contract by address (offline)
+ namehash compute the Node of a Name
+ labelhash compute the LabelHash of a single Label
+```
+
+### `ensnode omnigraph `
+
+The query string is the exact GraphQL payload β zero translation. Pass variables as a JSON object string. GraphQL is natively field-masked: select only the fields you need to keep responses (and your context) small.
+
+> **With the `omnigraph` skill loaded (see dependencies above), still confirm every field you didn't copy verbatim from a vetted example** β check it offline with `enscli ensnode omnigraph schema `. A previous query succeeding does **not** license guessing the next one β the trap is to copy one canned example, get an answer, then hand-author the follow-up from memory. The two things most often wrong when guessed: **required input args** (a field rejects the call because it needs a `where`/`by` argument) and **non-leaf fields** (an object field needs a sub-selection, not a bare name).
+
+
+
+```bash
+# Inline query (default namespace: mainnet)
+npx enscli ensnode omnigraph '{ domain(by: { name: "vitalik.eth" }) { owner { address } } }'
+
+# With variables
+npx enscli ensnode omnigraph 'query D($n: InterpretedName!) {
+ domain(by: { name: $n }) {
+ canonical { name { interpreted } }
+ resolve { records { addresses(coinTypes: [60]) { address } } }
+ }
+}' --variables '{"n":"vitalik.eth"}'
+```
+
+
+
+Target a specific namespace or instance with the same query β `--namespace sepolia` (a hosted default) or `--ensnode-url http://localhost:4334` (any instance):
+
+```bash
+npx enscli ensnode omnigraph '{ ... }' --namespace sepolia
+npx enscli ensnode omnigraph '{ ... }' --ensnode-url http://localhost:4334
+```
+
+Resolution lives in the graph β select `Domain.resolve` (records) and `Account.resolve` (primary names) inline rather than as separate calls. See the **omnigraph** skill for the full schema and query patterns.
+
+### `ensnode omnigraph schema [Type[.field]]`
+
+The schema ships with the CLI; explore it offline before writing a query rather than guessing field names.
+
+
+
+```bash
+# root query fields + the major types
+npx enscli ensnode omnigraph schema
+
+# a type's fields, with descriptions
+npx enscli ensnode omnigraph schema Domain
+
+# a single field
+npx enscli ensnode omnigraph schema Domain.canonical
+
+# find types/fields by keyword
+npx enscli ensnode omnigraph schema --search primary
+```
+
+
+
+### `ensnode indexing-status`
+
+
+
+```bash
+# Default namespace (mainnet)
+npx enscli ensnode indexing-status
+
+# A specific namespace
+npx enscli ensnode indexing-status --namespace sepolia-v2
+```
+
+
+
+### `ensrainbow heal ` / `ensrainbow count`
+
+Recover the original label behind a labelHash (accepts a `0xβ¦` hash or an encoded `[hash]`), or count how many labels ENSRainbow can heal.
+
+
+
+```bash
+# Heal a labelHash to its original label
+npx enscli ensrainbow heal 0xaf2caa1c2ca1d027f1ac823b529d0a67cd144264b2789fa2ea4d63a67c7103cc
+
+# Count the labels ENSRainbow can heal
+npx enscli ensrainbow count
+```
+
+
+
+### `datasources identify `
+
+Identify a well-known ENS contract by address β given `0xabcβ¦`, report which datasource contract it is across the namespace's chains. Fully offline (the catalog ships with the CLI). Accepts a bare address, a chain-scoped `chainId:address`, or full CAIP-10 `eip155:chainId:address`; `--namespace` (default `mainnet`) selects which namespace to search.
+
+Output is `{ query, matches }`. **A miss is not an error: `matches` is `[]` and the exit code is `0`** β branch on `matches.length`, don't rely on the exit code. Each match carries `namespace`, `datasource`, `contract`, `chainId`, `chain`, `address`, and the CAIP-10 `accountId`. Contracts indexed only by event (no fixed address) can't be identified and are never returned.
+
+
+
+```bash
+# Identify a well-known contract by address (default namespace: mainnet, offline)
+npx enscli datasources identify 0x00000000000c2e074ec69a0dfb2997ba6c7d2e1e
+
+# Scope to a chain with chainId:address (eip155:1:0x⦠also accepted)
+npx enscli datasources identify 1:0x57f1887a8bf19b14fc0df6fd9b2acc9af147ea85
+
+# Search a different namespace
+npx enscli datasources identify 0x94f523b8261b815b87effcf4d18e6abef18d6e4b --namespace sepolia
+```
+
+
+
+### `namehash ` / `labelhash `
+
+Compute ENS hashes locally (no network). Inputs are normalized and validated via `enssdk` β never `toLowerCase()` a name yourself.
+
+
+
+```bash
+# Compute the Node of a Name (offline)
+npx enscli namehash vitalik.eth
+
+# Compute the LabelHash of a Label (offline)
+npx enscli labelhash vitalik
+```
+
+
+
+## Related skills
+
+See **dependencies** above for `ens-protocol` and `omnigraph`. Also related:
+
+- **enssdk** β the TypeScript SDK `enscli` wraps, for in-app integration instead of the shell.
diff --git a/packages/ensskills/skills/enskit/SKILL.md b/packages/ensskills/skills/enskit/SKILL.md
new file mode 100644
index 0000000000..8df903d004
--- /dev/null
+++ b/packages/ensskills/skills/enskit/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: enskit
+description: Reference for building ENS React UIs with enskit (urql-based ENS Omnigraph hooks like useOmnigraphQuery, cache directives, infinite pagination). Coming soon.
+---
+
+# enskit (coming soon)
+
+A dedicated `enskit` React integration skill is planned (`useOmnigraphQuery`, the urql-based client, Omnigraph cache directives, infinite pagination). Until then, lean on the skills it depends on.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the protocol model behind the data `enskit` renders.
+- **`omnigraph`** β the underlying query model `enskit`'s hooks execute.
diff --git a/packages/ensskills/skills/enssdk/SKILL.md b/packages/ensskills/skills/enssdk/SKILL.md
new file mode 100644
index 0000000000..400e967342
--- /dev/null
+++ b/packages/ensskills/skills/enssdk/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: enssdk
+description: Reference for integrating ENS into JavaScript/TypeScript apps with the enssdk library (typed ENS Omnigraph client via gql.tada, hashing, normalization). Coming soon.
+---
+
+# enssdk (coming soon)
+
+A dedicated `enssdk` integration skill is planned (typed Omnigraph queries with `gql.tada`, client setup, hashing/normalization helpers). Until then, lean on the skills it depends on.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the underlying protocol (names, hashing, normalization, resolution) the SDK's helpers and types reflect.
+- **`omnigraph`** β the query model and data shapes `enssdk` exposes in TypeScript.
diff --git a/packages/ensskills/skills/migrate-to-omnigraph/SKILL.md b/packages/ensskills/skills/migrate-to-omnigraph/SKILL.md
new file mode 100644
index 0000000000..546dd33a81
--- /dev/null
+++ b/packages/ensskills/skills/migrate-to-omnigraph/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: migrate-to-omnigraph
+description: Guide for migrating an app from the legacy ENS Subgraph API to the ENS Omnigraph (rewriting queries, flattening connections, offsetβcursor pagination). Coming soon.
+---
+
+# migrate-to-omnigraph (coming soon)
+
+A dedicated migration skill is planned: rewriting legacy ENS Subgraph queries onto the ENS Omnigraph, adapting application logic (flattening connections, offsetβcursor pagination), and opening feature requests for any gaps. Until then, lean on the skills it depends on.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the protocol model behind both the legacy Subgraph and the Omnigraph.
+- **`omnigraph`** β the target datamodel and query patterns you're migrating to.
diff --git a/packages/ensskills/skills/omnigraph/SKILL.md b/packages/ensskills/skills/omnigraph/SKILL.md
new file mode 100644
index 0000000000..b7dc798f6d
--- /dev/null
+++ b/packages/ensskills/skills/omnigraph/SKILL.md
@@ -0,0 +1,994 @@
+---
+name: omnigraph
+description: Query ENS data (names, addresses, records, primary names, ownership, registrations, subnames, resolvers) via the ENS Omnigraph β a single GraphQL API that unifies ENSv1 and ENSv2 across all chains. Use whenever a task needs to read ENS state, instead of querying chains/the subgraph from first principles.
+---
+
+# ENS Omnigraph
+
+The **ENS Omnigraph** is a single GraphQL API (Relay spec) over an ENSNode index that answers almost any ENS question in one well-crafted query. It presents a **unified ENSv1 + ENSv2 datamodel across every chain** (mainnet, Basenames, Lineanames, 3DNS, β¦), so you do not have to reconcile registries, wrappers, resolvers, or chains yourself β the server does the wrangling.
+
+**Reach for the Omnigraph instead of** querying contracts/RPC directly, the legacy ENS Subgraph, or stitching together multiple calls. One query typically replaces a whole pipeline.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the protocol this API models (names and the nametree, normalization, resolution, registries/resolvers/registrars, records). Read it first if the data shapes below don't yet make sense.
+
+To _run_ the queries you author here, use a runner: **`enscli`** from a shell (every example below uses it), or **`enssdk`** from TypeScript. Those runners depend on this skill, not the other way around.
+
+## How to run a query
+
+Use the `enscli` CLI (no install step beyond `npx`). It prints JSON when piped (ideal for parsing) and exits non-zero on error.
+
+```bash
+# A raw query (the string is the exact GraphQL payload β zero translation)
+npx enscli ensnode omnigraph '{ domain(by: { name: "vitalik.eth" }) { canonical { name { interpreted } } owner { address } } }'
+
+# With variables
+npx enscli ensnode omnigraph 'query D($n: InterpretedName!){ domain(by:{name:$n}){ owner { address } } }' --variables '{"n":"nick.eth"}'
+
+# Pick a namespace (default: mainnet). Hosted: mainnet, sepolia, sepolia-v2.
+npx enscli ensnode omnigraph '{ ... }' --namespace sepolia
+
+# Point at a specific instance instead of a hosted default
+npx enscli ensnode omnigraph '{ ... }' --ensnode-url http://localhost:4334
+```
+
+You can also POST `{ "query": "...", "variables": {...} }` to `/api/omnigraph` on any ENSNode instance β but prefer `enscli`, which handles namespaces, URLs, and JSON output for you. The **enscli** skill is the full CLI reference (output contract, namespace/URL resolution, every command).
+
+## Discover the schema (no network)
+
+The schema ships with the CLI. Explore it before writing a query β never guess field names.
+
+```bash
+npx enscli ensnode omnigraph schema # root query fields + the major types
+npx enscli ensnode omnigraph schema Domain # a type's fields, with descriptions
+npx enscli ensnode omnigraph schema Domain.canonical # a single field
+npx enscli ensnode omnigraph schema --search primary # find types/fields by keyword
+```
+
+A condensed reference is also inlined below.
+
+## Core concepts
+
+- **Domain** β a node in the ENS nametree. `domain(by: { name })` or `domain(by: { id })`. `canonical` carries the name/path/node when the Domain is reachable by forward resolution (null otherwise). `owner`, `subdomains`, `registration`, `resolver`, and `events` hang off it.
+- **Account** β an address. `account(by: { address })` exposes `domains`, `permissions`, and resolution (`resolve`).
+- **Resolution lives in the graph.** Don't resolve separately β select it inline:
+ - `Domain.resolve { records { addresses(coinTypes: [60]) { address } texts(keys: ["avatar"]) { value } } }` β forward resolution (records) for a name.
+ - `Account.resolve { primaryNames(where: { chainNames: [ETHEREUM, BASE] }) { name { interpreted } } }` β reverse resolution (primary names) for an address.
+- **Registration** is a union: `BaseRegistrarRegistration` (.eth, Basenames, Lineanames), `NameWrapperRegistration`, etc. Use inline fragments (`... on BaseRegistrarRegistration { ... }`).
+- **Relay pagination** β connections expose `edges { node }`, `pageInfo { hasNextPage endCursor }`, and `totalCount`. Paginate with `first` + `after: `. (No offset pagination.)
+- **Field selection is the budget.** GraphQL returns exactly the fields you select β request only what you need to keep responses (and your context) small.
+
+## When the Omnigraph can't express it
+
+If a question genuinely isn't expressible in the Omnigraph schema, the underlying ENS state is also queryable via Unigraph SQL over ENSDb (the `unigraph-sql` skill). Prefer the Omnigraph first; escalate to SQL only for shapes the GraphQL surface doesn't support.
+
+## Schema reference
+
+
+
+### Query (entry points)
+
+- account(by: AccountByInput!): Account β Identify an Account by ID or Address.
+- domain(by: DomainIdInput!): Domain β Identify a Domain by Name or DomainId
+- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: DomainsWhereInput!): QueryDomainsConnection β Find Canonical Domains by Name.
+- permissions(by: PermissionsIdInput!): Permissions β Identify Permissions by ID or AccountId.
+- registry(by: RegistryIdInput!): Registry β Identify a Registry by ID or AccountId. If querying by `contract`, only concrete Registries will be returned.
+- resolver(by: ResolverIdInput!): Resolver β Identify a Resolver by ID or AccountId.
+- root: Registry! β The Root Registry for this namespace. It will be the ENSv2 Root Registry when defined, otherwise the ENSv1 Root Registry.
+
+### Core types
+
+#### Domain
+
+_Represents a Domain, i.e. an individual Label within the ENS namegraph. It may or may not be Canonical. It may be an ENSv1Domain or an ENSv2Domain._
+
+- canonical: DomainCanonical β Metadata (name, path, and node) related to the Domain's canonicality, if known. Null when the Domain is not in the canonical nametree.
+- events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): DomainEventsConnection β All Events associated with this Domain.
+- id: DomainId! β A unique and stable reference to this Domain.
+- label: Label! β The Label associated with this Domain in the ENS Namegraph.
+- owner: Account β If this is an ENSv1Domain, this is the effective owner of the Domain (derived from the Registry, the Registrar, or the NameWrapper, in that order). If this is an ENSv2Domain, this is the on-chain owner address (the HCA account address if used).
+- parent: Domain β The Domain that this Domain's parent Registry declares as its Canonical Domain, if any. Follows a single unidirectional pointer (`Registry.canonicalDomainId`) and does NOT enforce bidirectional canonical-edge agreement: a non-canonical Domain may have a non-null `parent`, and a canonical Domain's `parent` may itself be non-canonical. Null when the parent Registry does not declare a Canonical Domain.
+- registration: Registration β The latest Registration for this Domain, if exists.
+- registrations(after: String, before: String, first: Int, last: Int): DomainRegistrationsConnection β All Registrations for a Domain, including the latest Registration.
+- registry: Registry! β The Registry under which this Domain exists.
+- resolve(accelerate: Boolean): ForwardResolve! β Resolve protocol-level data for this Domain.
+- resolver: DomainResolver! β Resolver relationship metadata for this Domain.
+- subdomains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: SubdomainsWhereInput): DomainSubdomainsConnection β All Domains that are direct descendants of this Domain in the namegraph.
+- subregistry: Registry β The Registry this Domain declares as its Subregistry, if exists.
+
+#### DomainCanonical
+
+_Canonicality metadata for a Domain, including its name, depth, path, and node (namehash)._
+
+- depth: Int! β The depth of this Domain, i.e. the number of labels in this Domain's Canonical Name (e.g. 2 for `vitalik.eth`).
+- name: CanonicalName! β The Canonical Name for this Domain.
+- node: Node! β The namehash of this Domain's Canonical Name. Note that this is NOT a stable reference to this Domain; use `Domain.id`.
+- path: [Domain!]! β The Canonical Path from this Domain to the ENS Root, rootβleaf inclusive of this Domain.
+
+#### Account
+
+_Represents an individual Account, keyed by its Address._
+
+- address: Address! β An EVM Address that uniquely identifies this Account on-chain.
+- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: AccountDomainsWhereInput): AccountDomainsConnection β The Domains that are owned by the Account.
+- events(after: String, before: String, first: Int, last: Int, where: AccountEventsWhereInput): AccountEventsConnection β All Events for which this Account is the HCA-aware `sender` (i.e. `Event.sender`).
+- id: Address! β A unique reference to this Account.
+- permissions(after: String, before: String, first: Int, last: Int, where: AccountPermissionsWhereInput): AccountPermissionsConnection β The Permissions granted to this Account, optionally filtered to Permissions in a specific contract.
+- registryPermissions(after: String, before: String, first: Int, last: Int): AccountRegistryPermissionsConnection β The Permissions on Registries granted to this Account.
+- resolve(accelerate: Boolean): ReverseResolve! β Resolve primary names for this Account.
+- resolverPermissions(after: String, before: String, first: Int, last: Int): AccountResolverPermissionsConnection β The Permissions on Resolvers granted to this Account.
+
+#### Resolver
+
+_A Resolver represents a Resolver contract on-chain._
+
+- bridged: Registry β If Resolver is a Bridged Resolver, the Registry to which it Bridges resolution.
+- contract: AccountId! β Contract metadata for this Resolver.
+- events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): ResolverEventsConnection β All Events associated with this Resolver.
+- id: ResolverId! β A unique reference to this Resolver.
+- permissions: Permissions β Permissions granted by this Resolver.
+- records(after: String, before: String, first: Int, last: Int): ResolverRecordsConnection β ResolverRecords issued by this Resolver.
+- records\_(by: NameOrNodeInput!): ResolverRecords β Identify a ResolverRecord by `name` or `node`.
+
+#### DomainResolver
+
+_Metadata describing this Domain's relationship to its Resolver(s)._
+
+- assigned: Resolver β The Resolver that this Domain has assigned, if any. NOTE that this is the Domain's _assigned_ Resolver, _not_ its _effective_ Resolver, which can only be determined by following ENS Forward Resolution and ENSIP-10. Do NOT use this Domain-Resolver relationship in isolation to resolve records, that operation is NOT ENS Forward Resolution.
+
+#### Registry
+
+_A Registry represents a Registry contract in the ENS namegraph. It may be an ENSv1Registry (a concrete ENSv1 Registry contract), an ENSv1VirtualRegistry (the virtual Registry managed by an ENSv1 domain that has children), or an ENSv2Registry._
+
+- canonical: Boolean! β Whether the Registry is Canonical.
+- contract: AccountId! β Contract metadata for this Registry. If this is an ENSv1VirtualRegistry, this will reference the concrete Registry contract under which the parent Domain exists.
+- domains(after: String, before: String, first: Int, last: Int, order: DomainsOrderInput, where: RegistryDomainsWhereInput): RegistryDomainsConnection β The Domains managed by this Registry.
+- id: RegistryId! β A unique reference to this Registry.
+- parents(after: String, before: String, first: Int, last: Int): RegistryParentsConnection β The Domains for which this Registry is a Subregistry.
+- permissions: Permissions β The Permissions managed by this Registry.
+
+#### Permissions
+
+_Permissions_
+
+- contract: AccountId! β The contract within which these Permissions are granted.
+- events(after: String, before: String, first: Int, last: Int, where: EventsWhereInput): PermissionsEventsConnection β All Events associated with these Permissions.
+- id: PermissionsId! β A unique reference to this Permission.
+- resources(after: String, before: String, first: Int, last: Int): PermissionsResourcesConnection β All PermissionResources managed by this contract.
+- root: PermissionsResource! β The Root Resource.
+
+#### ReverseResolve
+
+_Nested account resolution container exposing primary name resolution._
+
+- acceleration: AccelerationStatus! β Whether protocol acceleration was requested and attempted for this reverse resolution.
+- primaryName(by: PrimaryNameByInput!): PrimaryNameRecord! β The primary name for this Account on a specific coin type or chain name.
+- primaryNames(where: PrimaryNamesWhereInput!): [PrimaryNameRecord!]! β Primary names for this Account on the requested coin types or chain names.
+- trace: JSON! β Protocol trace tree emitted by reverse resolution, represented as JSON for schema stability. This data model should be expected to experience breaking changes.
+
+#### ForwardResolve
+
+_Nested domain resolution container exposing resolved data for the domain._
+
+- acceleration: AccelerationStatus! β Whether protocol acceleration was requested and attempted for this resolution.
+- profile: DomainProfile β The interpreted profile of an ENS name. Returns null when the name is not resolvable (non-canonical, unnormalized, or no profile records were selected).
+- records: ResolvedRecords β Resolved ENS records via the ENS protocol. Null when the name is not resolvable (non-canonical, unnormalized, or no records field was selected).
+- trace: JSON β Protocol trace tree emitted by resolution, represented as untyped JSON for schema stability. This data model should be expected to experience breaking changes.
+
+#### ResolvedRecords
+
+_Records resolved for a specific ENS name via the ENS protocol._
+
+- abi(contentTypeMask: BigInt!): ResolvedAbiRecord β The first stored ABI matching the requested content-type bitmask, or null if not set.
+- addresses(coinTypes: [CoinType!]!): [ResolvedAddressRecord!]! β Resolved address records for the requested coin types.
+- contenthash: Hex β The ENSIP-7 contenthash record raw bytes, or null if not set.
+- dnszonehash: Hex β The IDNSZoneResolver zonehash raw bytes, or null if not set.
+- interfaces(ids: [InterfaceId!]!): [ResolvedInterfaceRecord!]! β Resolved ERC-165 interface implementer records for the requested ids.
+- pubkey: ResolvedPubkeyRecord β The PubkeyResolver (x, y) pair, or null if not set.
+- reverseName: String β The `name` record value used in Reverse Resolution (ENSIP-19), or null if not set. To reduce a common point of developer confusion the Omnigraph API represents this as the `reverseName` rather than the `name` record which is what this field actually resolves to onchain.
+- texts(keys: [String!]!): [ResolvedRawTextRecord!]! β Resolved text records for the requested keys.
+- version: BigInt β The IVersionableResolver version, or null if not set or unavailable.
+
+#### PrimaryNameRecord
+
+_An ENSIP-19 primary name for an Account on a specific coin type._
+
+- chainName: ChainName β The chain corresponding to `coinType`, or null when `coinType` is not represented in `ChainName`.
+- coinType: CoinType! β The canonical ENSIP-9 coin type for this primary name lookup.
+- name: CanonicalName β The validated primary name for this Account on this coin type, or null if none is set.
+- resolve: ForwardResolve! β Forward resolve data for this primary name.
+
+### Other types
+
+Run `npx enscli ensnode omnigraph schema ` for fields of:
+
+`AccelerationStatus`, `AccountId`, `BaseRegistrarRegistration`, `CanonicalName`, `DomainProfile`, `ENSv1Domain`, `ENSv1Registry`, `ENSv1VirtualRegistry`, `ENSv2Domain`, `ENSv2Registry`, `ENSv2RegistryRegistration`, `ENSv2RegistryReservation`, `Event`, `Label`, `NameWrapperRegistration`, `PageInfo`, `PermissionsResource`, `PermissionsUser`, `ProfileAddresses`, `ProfileAvatar`, `ProfileHeader`, `ProfileSocialAccount`, `ProfileSocials`, `ProfileWebsite`, `RegistryPermissionsUser`, `Renewal`, `ResolvedAbiRecord`, `ResolvedAddressRecord`, `ResolvedInterfaceRecord`, `ResolvedPubkeyRecord`, `ResolvedRawTextRecord`, `ResolverPermissionsUser`, `ResolverRecords`, `ThreeDNSRegistration`, `WrappedBaseRegistrarRegistration`
+
+
+
+## Example queries
+
+These are vetted, copy-pasteable patterns. Adapt the selection set to your needs.
+
+
+
+### find-domains
+
+```graphql
+query FindDomains($name: DomainsNameFilter!, $order: DomainsOrderInput) {
+ domains(where: { name: $name }, order: $order, first: 20) {
+ edges {
+ node {
+ __typename
+ id
+ label {
+ interpreted
+ hash
+ }
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+
+ registration {
+ expiry
+ event {
+ timestamp
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": {
+ "starts_with": "vitalik"
+ },
+ "order": {
+ "by": "NAME",
+ "dir": "DESC"
+ }
+}
+```
+
+### domain-by-name
+
+```graphql
+query DomainByName($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ __typename
+ id
+ label {
+ interpreted
+ hash
+ }
+ canonical {
+ name {
+ interpreted
+ }
+ node
+ path {
+ id
+ }
+ }
+ owner {
+ address
+ }
+ subregistry {
+ contract {
+ chainId
+ address
+ }
+ }
+
+ ... on ENSv1Domain {
+ rootRegistryOwner {
+ address
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "eth"
+}
+```
+
+### domain-registration
+
+```graphql
+query DomainRegistration($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ canonical {
+ name {
+ interpreted
+ }
+ }
+
+ registration {
+ __typename
+ id
+ start
+ expiry
+ expired
+ referrer
+ registrar {
+ chainId
+ address
+ }
+ registrant {
+ address
+ }
+ renewals(first: 5) {
+ totalCount
+ edges {
+ node {
+ duration
+ base
+ premium
+ referrer
+ }
+ }
+ }
+
+ # ENSv1 .eth registrations (also Basenames & Lineanames)
+ ... on BaseRegistrarRegistration {
+ baseCost
+ premium
+ isInGracePeriod
+ # present when the .eth name is wrapped by the NameWrapper
+ wrapped {
+ fuses
+ tokenId
+ }
+ }
+
+ # names held natively in the NameWrapper
+ ... on NameWrapperRegistration {
+ fuses
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "vitalik.eth"
+}
+```
+
+### domain-records
+
+```graphql
+query DomainRecords($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ canonical {
+ name {
+ interpreted
+ }
+ }
+ resolve {
+ records {
+ addresses(coinTypes: [60]) {
+ coinType
+ address
+ }
+ texts(keys: ["description"]) {
+ key
+ value
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "vitalik.eth"
+}
+```
+
+### domain-subdomains
+
+```graphql
+query DomainSubdomains($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+ subdomains(first: 10) {
+ edges {
+ node {
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "eth"
+}
+```
+
+### subdomains-pagination
+
+```graphql
+query SubdomainsPagination($first: Int!, $after: String) {
+ domain(by: { name: "eth" }) {
+ canonical {
+ name {
+ interpreted
+ }
+ }
+
+ # paginate child names: pass pageInfo.endCursor back as $after for the next page
+ subdomains(first: $first, after: $after) {
+ totalCount
+ pageInfo {
+ hasNextPage
+ endCursor
+ }
+ edges {
+ cursor
+ node {
+ canonical {
+ name {
+ interpreted
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "first": 10,
+ "after": null
+}
+```
+
+### domain-events
+
+```graphql
+query DomainEvents($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ events {
+ totalCount
+ edges {
+ node {
+ from
+ to
+ topics
+ data
+ timestamp
+ transactionHash
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "newowner.eth"
+}
+```
+
+### domains-by-address
+
+```graphql
+query AccountDomains($address: Address!) {
+ account(by: { address: $address }) {
+ domains {
+ edges {
+ node {
+ label {
+ interpreted
+ }
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
+}
+```
+
+### account-primary-names
+
+```graphql
+query AccountPrimaryNames($address: Address!) {
+ account(by: { address: $address }) {
+ address
+ resolve {
+ primaryNames(where: { chainNames: [ETHEREUM, BASE] }) {
+ coinType
+ chainName
+ name {
+ interpreted
+ beautified
+ }
+ resolve {
+ records {
+ addresses(coinTypes: [60]) {
+ coinType
+ address
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
+}
+```
+
+### account-events
+
+```graphql
+query AccountEvents($address: Address!) {
+ account(by: { address: $address }) {
+ events {
+ totalCount
+ edges {
+ node {
+ topics
+ data
+ timestamp
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
+}
+```
+
+### registry-domains
+
+```graphql
+query RegistryDomains($registry: AccountIdInput!) {
+ registry(by: { contract: $registry }) {
+ domains {
+ edges {
+ node {
+ label {
+ interpreted
+ }
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "registry": {
+ "chainId": 31337,
+ "address": "0x8f86403a4de0bb5791fa46b8e795c547942fe4cf"
+ }
+}
+```
+
+### permissions-by-contract
+
+```graphql
+query PermissionsByContract($contract: AccountIdInput!) {
+ permissions(by: { contract: $contract }) {
+ resources {
+ edges {
+ node {
+ resource
+ users {
+ edges {
+ node {
+ id
+ user {
+ address
+ }
+ roles
+ }
+ }
+ }
+ }
+ }
+ }
+ events {
+ totalCount
+ edges {
+ node {
+ topics
+ data
+ timestamp
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "contract": {
+ "chainId": 31337,
+ "address": "0x21df544947ba3e8b3c32561399e88b52dc8b2823"
+ }
+}
+```
+
+### permissions-by-user
+
+```graphql
+query PermissionsByUser($address: Address!) {
+ account(by: { address: $address }) {
+ permissions {
+ edges {
+ node {
+ resource
+ roles
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
+}
+```
+
+### account-resolver-permissions
+
+```graphql
+query AccountResolverPermissions($address: Address!) {
+ account(by: { address: $address }) {
+ resolverPermissions {
+ edges {
+ node {
+ resolver {
+ contract {
+ address
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"
+}
+```
+
+### domain-resolver
+
+```graphql
+query DomainResolver($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ resolver {
+ assigned {
+ records {
+ edges {
+ node {
+ node
+ keys
+ coinTypes
+ }
+ }
+ }
+ permissions {
+ resources {
+ edges {
+ node {
+ resource
+ users {
+ edges {
+ node {
+ user {
+ address
+ }
+ roles
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ events {
+ totalCount
+ edges {
+ node {
+ topics
+ data
+ timestamp
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "vitalik.eth"
+}
+```
+
+### resolver-by-address
+
+```graphql
+query ResolverByAddress($contract: AccountIdInput!) {
+ resolver(by: { contract: $contract }) {
+ id
+ contract {
+ chainId
+ address
+ }
+
+ # records this resolver stores, keyed by node
+ records(first: 5) {
+ totalCount
+ edges {
+ node {
+ node
+ name
+ keys
+ coinTypes
+ }
+ }
+ }
+
+ events {
+ totalCount
+ edges {
+ node {
+ topics
+ data
+ timestamp
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "contract": {
+ "chainId": 1,
+ "address": "0xf29100983e058b709f3d539b0c765937b804ac15"
+ }
+}
+```
+
+### namegraph
+
+```graphql
+query Namegraph {
+ root {
+ id
+ domains {
+ edges {
+ node {
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+
+ subdomains {
+ edges {
+ node {
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+
+ subdomains {
+ edges {
+ node {
+ canonical {
+ name {
+ interpreted
+ beautified
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{}
+```
+
+### account-migrated-names
+
+```graphql
+query AccountMigratedNames($address: Address!) {
+ account(by: { address: $address }) {
+ v1DomainsCount: domains(where: { version: ENSv1 }) {
+ totalCount
+ }
+ v2DomainsCount: domains(where: { version: ENSv2 }) {
+ totalCount
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "address": "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
+}
+```
+
+### eth-by-version
+
+```graphql
+query GetEthDomains {
+ domains(where: { name: { eq: "eth" } }) {
+ edges {
+ node {
+ __typename
+ id
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{}
+```
+
+### domain-profile
+
+```graphql
+query DomainProfile($name: InterpretedName!) {
+ domain(by: { name: $name }) {
+ resolve {
+ profile {
+ description
+ avatar {
+ httpUrl
+ }
+ addresses {
+ ethereum
+ }
+ socials {
+ github {
+ handle
+ httpUrl
+ }
+ }
+ website {
+ httpUrl
+ }
+ email
+ }
+ }
+ }
+}
+```
+
+Variables:
+
+```json
+{
+ "name": "vitalik.eth"
+}
+```
+
+
diff --git a/packages/ensskills/skills/unigraph-sql/SKILL.md b/packages/ensskills/skills/unigraph-sql/SKILL.md
new file mode 100644
index 0000000000..9e18abb68c
--- /dev/null
+++ b/packages/ensskills/skills/unigraph-sql/SKILL.md
@@ -0,0 +1,16 @@
+---
+name: unigraph-sql
+description: Guide for querying live ENS state via SQL over ENSDb (ENS Unigraph) for question shapes the ENS Omnigraph GraphQL API doesn't express. Coming soon β prefer the omnigraph skill first; escalate to SQL only when needed.
+---
+
+# unigraph-sql (coming soon)
+
+A dedicated skill for ENS Unigraph SQL over ENSDb query patterns. This skill is planned: for question shapes the Omnigraph doesn't express, let the user know and suggest they [open an issue](https://github.com/namehash/ensnode/issues/new) to request the specific feature.
+
+## Dependencies
+
+This skill depends on the following sibling skills β load them first:
+
+- **`base`** β the shared working conventions every ENS skill assumes.
+- **`ens-protocol`** β the protocol model behind the ENS state you're querying.
+- **`omnigraph`** β try it first; escalate to SQL only for shapes the GraphQL surface can't express.
diff --git a/packages/ensskills/tsconfig.json b/packages/ensskills/tsconfig.json
new file mode 100644
index 0000000000..2356f45463
--- /dev/null
+++ b/packages/ensskills/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@ensnode/shared-configs/tsconfig.lib.json",
+ "compilerOptions": {
+ "rootDir": "."
+ },
+ "include": ["scripts/**/*"],
+ "exclude": ["dist"]
+}
diff --git a/packages/integration-test-env/LICENSE b/packages/integration-test-env/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/integration-test-env/LICENSE
+++ b/packages/integration-test-env/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/namehash-ui/LICENSE b/packages/namehash-ui/LICENSE
index 0d70998c14..08d139577c 100644
--- a/packages/namehash-ui/LICENSE
+++ b/packages/namehash-ui/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2026 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/ponder-sdk/LICENSE b/packages/ponder-sdk/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ponder-sdk/LICENSE
+++ b/packages/ponder-sdk/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/ponder-subgraph/LICENSE b/packages/ponder-subgraph/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/ponder-subgraph/LICENSE
+++ b/packages/ponder-subgraph/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/packages/shared-configs/LICENSE b/packages/shared-configs/LICENSE
index 24d66814d7..08d139577c 100644
--- a/packages/shared-configs/LICENSE
+++ b/packages/shared-configs/LICENSE
@@ -1,6 +1,6 @@
MIT License
-Copyright (c) 2025 NameHash
+Copyright (c) 2026 NameHash Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1310e3ab33..a5f8f562f5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -924,6 +924,18 @@ importers:
specifier: 'catalog:'
version: 5.9.3
+ examples/ensskills-example:
+ devDependencies:
+ enscli:
+ specifier: workspace:*
+ version: link:../../packages/enscli
+ ensskills:
+ specifier: workspace:*
+ version: link:../../packages/ensskills
+ skills-npm:
+ specifier: ^1
+ version: 1.1.1
+
examples/omnigraph-graphql-example:
devDependencies:
'@types/node':
@@ -995,7 +1007,42 @@ importers:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(jsdom@27.0.1(postcss@8.5.12))(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3))
- packages/enscli: {}
+ packages/enscli:
+ dependencies:
+ '@ensnode/datasources':
+ specifier: workspace:*
+ version: link:../datasources
+ '@ensnode/ensnode-sdk':
+ specifier: workspace:*
+ version: link:../ensnode-sdk
+ '@ensnode/ensrainbow-sdk':
+ specifier: workspace:*
+ version: link:../ensrainbow-sdk
+ citty:
+ specifier: ^0.1.6
+ version: 0.1.6
+ enssdk:
+ specifier: workspace:*
+ version: link:../enssdk
+ graphql:
+ specifier: ^16.11.0
+ version: 16.11.0
+ devDependencies:
+ '@ensnode/shared-configs':
+ specifier: workspace:*
+ version: link:../shared-configs
+ '@types/node':
+ specifier: 'catalog:'
+ version: 24.10.9
+ tsup:
+ specifier: 'catalog:'
+ version: 8.5.0(jiti@2.6.1)(postcss@8.5.12)(tsx@4.22.3)(typescript@5.9.3)(yaml@2.8.3)
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
+ vitest:
+ specifier: 'catalog:'
+ version: 4.1.8(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(jsdom@27.0.1(postcss@8.5.12))(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3))
packages/ensdb-sdk:
dependencies:
@@ -1187,7 +1234,35 @@ importers:
specifier: 'catalog:'
version: 4.1.8(@opentelemetry/api@1.9.0(patch_hash=4b2adeefaf7c22f9987d0a125d69cab900719bec7ed7636648bea6947107033a))(@types/node@24.10.9)(jsdom@27.0.1(postcss@8.5.12))(vite@7.3.2(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.22.3)(yaml@2.8.3))
- packages/ensskills: {}
+ packages/ensskills:
+ devDependencies:
+ '@ensnode/datasources':
+ specifier: workspace:*
+ version: link:../datasources
+ '@ensnode/ensnode-sdk':
+ specifier: workspace:*
+ version: link:../ensnode-sdk
+ '@ensnode/shared-configs':
+ specifier: workspace:*
+ version: link:../shared-configs
+ '@types/node':
+ specifier: 'catalog:'
+ version: 24.10.9
+ enssdk:
+ specifier: workspace:*
+ version: link:../enssdk
+ graphql:
+ specifier: ^16.11.0
+ version: 16.11.0
+ prettier:
+ specifier: 'catalog:'
+ version: 3.6.2
+ tsx:
+ specifier: ^4.7.1
+ version: 4.22.3
+ typescript:
+ specifier: 'catalog:'
+ version: 5.9.3
packages/integration-test-env:
dependencies:
@@ -3805,6 +3880,9 @@ packages:
'@protobufjs/utf8@1.1.1':
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
+ '@quansync/fs@1.0.0':
+ resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
+
'@radix-ui/number@1.1.1':
resolution: {integrity: sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==}
@@ -5933,6 +6011,10 @@ packages:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
+ cac@7.0.0:
+ resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
+ engines: {node: '>=20.19.0'}
+
caip@1.1.1:
resolution: {integrity: sha512-a3v5lteUUOoyRI0U6qe5ayCCGkF2mCmJ5zQMDnOD2vRjgRg6sm9p8TsRC2h4D4beyqRN9RYniphAPnj/+jQC6g==}
@@ -6023,6 +6105,9 @@ packages:
resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==}
engines: {node: '>=8'}
+ citty@0.1.6:
+ resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
+
cjs-module-lexer@2.2.0:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
@@ -6869,6 +6954,10 @@ packages:
exsolve@1.0.7:
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
+ extend-shallow@2.0.1:
+ resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==}
+ engines: {node: '>=0.10.0'}
+
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -7172,6 +7261,10 @@ packages:
resolution: {integrity: sha512-cvVIBILwuoSyD54U4cF/UXDh5yAobhNV/tPygI4lZhgOIJQE/WLWC4waBRb4I6bDVYb3OVx3lfHbaQOEoUD5sg==}
engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+ gray-matter@4.0.3:
+ resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
+ engines: {node: '>=6.0'}
+
guess-json-indent@3.0.1:
resolution: {integrity: sha512-LWZ3Vr8BG7DHE3TzPYFqkhjNRw4vYgFSsv2nfMuHklAlOfiy54/EwiDQuQfFVLxENCVv20wpbjfTayooQHrEhQ==}
engines: {node: '>=18.18.0'}
@@ -7419,6 +7512,10 @@ packages:
engines: {node: '>=20'}
hasBin: true
+ is-extendable@0.1.1:
+ resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==}
+ engines: {node: '>=0.10.0'}
+
is-extglob@2.1.1:
resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==}
engines: {node: '>=0.10.0'}
@@ -7597,6 +7694,10 @@ packages:
khroma@2.1.0:
resolution: {integrity: sha512-Ls993zuzfayK269Svk9hzpeGUKob/sIgZzyHYdjQoAdQetRKpOLj+k/QQQ/6Qi0Yz65mlROrfd+Ev+1+7dz9Kw==}
+ kind-of@6.0.3:
+ resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==}
+ engines: {node: '>=0.10.0'}
+
kleur@4.1.5:
resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==}
engines: {node: '>=6'}
@@ -8834,6 +8935,9 @@ packages:
quansync@0.2.11:
resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==}
+ quansync@1.0.0:
+ resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -9164,6 +9268,10 @@ packages:
scule@1.3.0:
resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==}
+ section-matter@1.0.0:
+ resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==}
+ engines: {node: '>=4'}
+
secure-json-parse@4.1.0:
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
@@ -9250,6 +9358,10 @@ packages:
engines: {node: '>=20.19.5', npm: '>=10.8.2'}
hasBin: true
+ skills-npm@1.1.1:
+ resolution: {integrity: sha512-qbRSzorWK3fVdpKzZquehYlWzmC29/MOh8AqWoX7NAZiCSAX/RuTpyqAml7hWBe7NM9DDx472kZ0hKO7CCXRkA==}
+ hasBin: true
+
slash@3.0.0:
resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
engines: {node: '>=8'}
@@ -9401,6 +9513,10 @@ packages:
resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==}
engines: {node: '>=12'}
+ strip-bom-string@1.0.0:
+ resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==}
+ engines: {node: '>=0.10.0'}
+
strip-bom@3.0.0:
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
engines: {node: '>=4'}
@@ -9767,6 +9883,12 @@ packages:
ultrahtml@1.6.0:
resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==}
+ unconfig-core@7.5.0:
+ resolution: {integrity: sha512-Su3FauozOGP44ZmKdHy2oE6LPjk51M/TRRjHv2HNCWiDvfvCoxC2lno6jevMA91MYAdCdwP05QnWdWpSbncX/w==}
+
+ unconfig@7.5.0:
+ resolution: {integrity: sha512-oi8Qy2JV4D3UQ0PsopR28CzdQ3S/5A1zwsUwp/rosSbfhJ5z7b90bIyTwi/F7hCLD4SGcZVjDzd4XoUQcEanvA==}
+
uncrypto@0.1.3:
resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==}
@@ -10353,6 +10475,10 @@ packages:
utf-8-validate:
optional: true
+ xdg-basedir@5.1.0:
+ resolution: {integrity: sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==}
+ engines: {node: '>=12'}
+
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
@@ -13269,6 +13395,10 @@ snapshots:
'@protobufjs/utf8@1.1.1': {}
+ '@quansync/fs@1.0.0':
+ dependencies:
+ quansync: 1.0.0
+
'@radix-ui/number@1.1.1': {}
'@radix-ui/primitive@1.1.3': {}
@@ -15954,6 +16084,8 @@ snapshots:
cac@6.7.14: {}
+ cac@7.0.0: {}
+
caip@1.1.1: {}
call-bind-apply-helpers@1.0.2:
@@ -16045,6 +16177,10 @@ snapshots:
ci-info@4.4.0: {}
+ citty@0.1.6:
+ dependencies:
+ consola: 3.4.2
+
cjs-module-lexer@2.2.0: {}
class-variance-authority@0.7.1:
@@ -16840,7 +16976,6 @@ snapshots:
'@esbuild/win32-arm64': 0.28.0
'@esbuild/win32-ia32': 0.28.0
'@esbuild/win32-x64': 0.28.0
- optional: true
escalade@3.2.0: {}
@@ -16941,6 +17076,10 @@ snapshots:
exsolve@1.0.7: {}
+ extend-shallow@2.0.1:
+ dependencies:
+ is-extendable: 0.1.1
+
extend@3.0.2: {}
extendable-error@0.1.7: {}
@@ -17276,6 +17415,13 @@ snapshots:
graphql@16.8.2: {}
+ gray-matter@4.0.3:
+ dependencies:
+ js-yaml: 3.14.2
+ kind-of: 6.0.3
+ section-matter: 1.0.0
+ strip-bom-string: 1.0.0
+
guess-json-indent@3.0.1: {}
h3@1.15.10:
@@ -17641,6 +17787,8 @@ snapshots:
is-docker@4.0.0: {}
+ is-extendable@0.1.1: {}
+
is-extglob@2.1.1: {}
is-fullwidth-code-point@3.0.0: {}
@@ -17793,6 +17941,8 @@ snapshots:
khroma@2.1.0: {}
+ kind-of@6.0.3: {}
+
kleur@4.1.5: {}
klona@2.0.6: {}
@@ -19313,6 +19463,8 @@ snapshots:
quansync@0.2.11: {}
+ quansync@1.0.0: {}
+
queue-microtask@1.2.3: {}
quick-format-unescaped@4.0.4: {}
@@ -19765,6 +19917,11 @@ snapshots:
scule@1.3.0: {}
+ section-matter@1.0.0:
+ dependencies:
+ extend-shallow: 2.0.1
+ kind-of: 6.0.3
+
secure-json-parse@4.1.0: {}
semver-compare@1.0.0: {}
@@ -19908,6 +20065,17 @@ snapshots:
arg: 5.0.2
sax: 1.5.0
+ skills-npm@1.1.1:
+ dependencies:
+ '@clack/prompts': 1.4.0
+ cac: 7.0.0
+ gray-matter: 4.0.3
+ picocolors: 1.1.1
+ tinyglobby: 0.2.16
+ unconfig: 7.5.0
+ xdg-basedir: 5.1.0
+ yaml: 2.8.3
+
slash@3.0.0: {}
slash@5.1.0: {}
@@ -20076,6 +20244,8 @@ snapshots:
dependencies:
ansi-regex: 6.2.2
+ strip-bom-string@1.0.0: {}
+
strip-bom@3.0.0: {}
strip-final-newline@2.0.0: {}
@@ -20418,7 +20588,6 @@ snapshots:
esbuild: 0.28.0
optionalDependencies:
fsevents: 2.3.3
- optional: true
tw-animate-css@1.4.0: {}
@@ -20456,6 +20625,19 @@ snapshots:
ultrahtml@1.6.0: {}
+ unconfig-core@7.5.0:
+ dependencies:
+ '@quansync/fs': 1.0.0
+ quansync: 1.0.0
+
+ unconfig@7.5.0:
+ dependencies:
+ '@quansync/fs': 1.0.0
+ defu: 6.1.6
+ jiti: 2.6.1
+ quansync: 1.0.0
+ unconfig-core: 7.5.0
+
uncrypto@0.1.3: {}
undici-types@5.26.5: {}
@@ -21052,6 +21234,8 @@ snapshots:
ws@8.20.1: {}
+ xdg-basedir@5.1.0: {}
+
xml-name-validator@5.0.0: {}
xml-naming@0.1.0: {}