nx-content
v1.3.1
Published
Content Manager — fetch instruction, prompt, and block content from local folder or git repo
Maintainers
Readme
nx-content
Content Manager — resolve instruction, prompt, and block content from a local folder or a Git repo. Built for Node.js 18+, TypeScript strict mode, ESM + CJS.
Use it to:
- Read/write by key —
get(key)returns string content;set(key, content)writes to local backend - Resolve content keys (e.g.
skills/my-skill.instructions) to file content - List keys with optional prefix —
listKeys(prefix?)for catalog and audits - Deterministic namespaced keys —
skillTaskPromptKey(skillKey),skillInstructionsKey(skillKey) - Variant resolution — optional
variant(e.g. prod, beta); triesvariants/<variant>/<key>then<key> - Support local and Git backends with configurable precedence (
dev= local wins,prod= git wins) - Inline block includes with
<< path >>in files - Export to .metadata —
exportToMetadata(options)to sync content to a local metadata tree (files or manifest) - Push to Git —
pushToRemote(options?)to commit and push local content to the remote (content root must be a git repo) - Cache resolved content in memory (TTL configurable)
- Drop in where a content registry is expected (e.g. ai-gateway)
Install
npm install nx-contentYou can also install under the alias content-x (same package, same API):
npm install content-x@npm:nx-contentRequirements: Node.js >= 18.
Quick start
import { ContentResolver, init, getDefaultResolver } from 'nx-content';
// 1. Initialize (creates content root + readme)
const manager = await init({ localRoot: './.content' });
// 2. Resolver (recommended API)
const resolver = new ContentResolver({ localRoot: './.content' });
const { text } = await resolver.resolveInstructions('skills/my-skill.instructions');
// 3. Singleton from env — reads all options from .env automatically
const defaultResolver = getDefaultResolver();
const prompt = (await defaultResolver.resolvePrompt('prompts/default')).text;Env-ready (works out of the box)
- Defaults: Works with no config: local root
<cwd>/.content, default mode isdev(local wins, Git is fallback), cache 1 year, no git unless you setGITHUB_REPO_URL. - .env: On first import, the package loads
.envfrom the current working directory (viadotenv). All options can be set there; see.env.example. No nx-config2 (or any other config layer) is required — use it only if you already rely on it elsewhere.
Content keys
- A key is a path relative to the content root, without file extension.
- Resolution tries an exact file match for keys that do not end with
.md/.json, then falls back to${key}.md, then${key}.json.
Examples:
| Key | Resolved file |
|-----|----------------|
| skills/my-skill.instructions | skills/my-skill.instructions (exact) or skills/my-skill.instructions.md (or .json) |
| prompts/summarize | prompts/summarize.md |
| blocks/greeting | blocks/greeting.md |
| readme.md | readme.md (exact) |
Key–path contract: Key K corresponds to path P under the content root: P is the key with normalized separators (forward slashes). Use keyToPath(key) (sync, no I/O) for the canonical path (K if it ends with .md/.json, otherwise K + '.md'), or getPathForKey(key) (async) for the actual path when the file exists locally (or the canonical path otherwise). This lets you run git commands (e.g. git log -- <path>) or implement version history yourself.
Key detection: A string is treated as a key only if, after trim, it is non-empty and contains no whitespace. Otherwise it is treated as literal text and returned as-is.
Deterministic namespaced keys: For catalog enrichment, validation, and audits, use the helpers so keys are consistent:
skillTaskPromptKey(skillKey)→skills/tasks/<skill-key>/promptskillInstructionsKey(skillKey)→skills/<skill-key>/instructionsnormalizeKeySegment(segment)stripsskills/prefix and.instructionssuffix and sanitizes for path use.
Example: skillTaskPromptKey('my-skill') → skills/tasks/my-skill/prompt; store the file at .content/skills/tasks/my-skill/prompt.md.
Export to metadata
Sync resolved content to a local path for integration with metadata pipelines:
exportToMetadata(options?)— onContentManagerorContentResolver.- Options:
basePath(default'.metadata'),keys(default: all fromlistKeys()),format:'files'(one file per key underbasePath/content/) or'manifest'(singlecontent-manifest.json). - Returns
{ written: string[]; errors: Array<{ key, error }> }. Missing or invalid keys are inerrors; successful keys inwritten.
Push to Git
Commit and push content written with set() to a Git remote:
pushToRemote(options?)— onContentManagerorContentResolver. Requires localRoot to be a git repository (clone the repo and use it as content root, or rungit initthere).- Options:
message(default"Update content (nx-content)"),remote(default"origin"),branch(default from config or"main"). - When
gitRepoUrlandgitTokenare configured, the remote URL is set with auth before push so HTTPS push works. - Returns
{ pushed: boolean; commitHash?: string; noChanges?: boolean }.noChanges: truewhen there was nothing to commit.
Version history (git)
When the content root is a git repository, you can list versions, read at a ref, and restore a file to a previous version:
getPathForKey(key)— Returns the relative path under the content root for the key (key–path contract). Use for git commands or custom tooling.getVersions(key)— ReturnsVersionEntry[](sha, message, date, author) for the file backing the key. Empty if not a git repo.getAtRef(key, ref)— Returns raw content of the file at git refref(commit sha, tag, or branch). Does not change the working tree. Throws if the file did not exist at that ref.setActiveVersion(key, ref, options?)— Checkouts the file atrefso the working tree matches that version; optionally pass{ commit: true, message?: string }to commit. Does not push (callpushToRemote()when you want to publish).
await resolver.set('prompts/new', 'Content here.');
const result = await resolver.pushToRemote({ message: 'Add new prompt' });
// result.pushed === true, result.commitHash setBlock includes
Inside any content file you can include another file with double angle brackets:
<< blocks/greeting >>The manager replaces << path >> with that file’s full content. Paths use / or \; they are relative to the content root. Nested includes are supported (max depth 10; circular includes throw).
API
ContentResolver (recommended)
| Method | Description |
|--------|-------------|
| get(key) | Read content by key; returns raw string. Non-keys returned as-is. |
| set(key, content) | Write content by key (local backend only). |
| resolveInstructions(key) | Resolve instruction key → ResolvedContent. If not a key, returns { text: key }. |
| resolvePrompt(key) | Same for prompt key. |
| hasInstructionKey(key) | true if the key exists (no full read). |
| listKeys(prefix?) | List keys, optionally filtered by prefix (e.g. skills/). |
| resolveInstructionsBlock(blockName, agentId, taskTypeId?, configOverride?) | Resolve a block with fallback: task → agent → generic. If configOverride is non-empty, returns it. |
| normalizeSkillId(skillId) | Normalize to canonical key (e.g. skills/<id>.instructions). |
| isKey(value) | Whether the string is a content key (no whitespace, non-empty). |
| keyToPath(key) | Canonical relative path for key (sync, no I/O). See key–path contract. |
| getContentRoot() | Absolute content root path. |
| getContentRegistry() / getContentManager() | Underlying ContentManager when enabled. |
| getPathForKey(key) | Relative path under content root for the key (key–path contract). null if no local backend. |
| getVersions(key) | Git version history for the file backing the key. Empty if not a git repo. |
| getAtRef(key, ref) | Raw content of the file at git ref (sha, tag, or branch). |
| setActiveVersion(key, ref, options?) | Checkout file at ref; optional { commit?, message? }. |
| exportToMetadata(options?) | Sync/export content to local .metadata path (files or manifest). |
| pushToRemote(options?) | Commit and push local content to Git (content root must be a repo). |
| isEnabled() | Whether the manager is configured. |
ContentManager
| Method | Description |
|--------|-------------|
| get(key) | Read content by key; returns raw string. |
| set(key, content) | Write content by key (local backend only). |
| resolve(key) | Resolve key to ResolvedContent (cache + block includes). |
| exists(key) | Whether the key exists. |
| listKeys(prefix?) | List keys, optionally filtered by prefix. |
| normalizeSkillId(skillId) | Canonical key for skill. |
| resolveInstructionsBlock(blockName, agentId, taskTypeId?, configOverride?) | Block resolution with fallback. |
| ping() | Backend reachable (e.g. root exists). |
| listAll() | All relative paths under the root. |
| getPathForKey(key) | Relative path under content root for the key. null if no local backend. |
| getVersions(key) | Git version history for the file (VersionEntry[]). Empty if not a git repo. |
| getAtRef(key, ref) | Raw content at git ref. |
| setActiveVersion(key, ref, options?) | Checkout file at ref; optional commit. |
| invalidateKey(key) / invalidateAll() | Clear cache. |
| exportToMetadata(options?) | Sync/export content to local .metadata path (files or manifest). |
| pushToRemote(options?) | Commit and push local content to Git (content root must be a repo). |
| getContentRoot() | Absolute local root. |
| isEnabled() | Whether a backend is configured. |
Functions
| Function | Description |
|----------|-------------|
| init(config?) | Create content root dir, write readme, return ContentManager. Idempotent. |
| isKey(value) | Key detection (no whitespace, non-empty). |
| isKeyLike(value) | Heuristic: content looks like a key (safety net); values containing { are treated as content. |
| keyToPath(key) | Canonical relative path for key (key–path contract, no I/O). |
| normalizeKeySegment(segment) | Normalize segment for namespaced keys (strip skills/ prefix, .instructions suffix). |
| skillTaskPromptKey(skillKey) | Deterministic key skills/tasks/<skill-key>/prompt. |
| skillInstructionsKey(skillKey) | Deterministic key skills/<skill-key>/instructions. |
| runDiagnostics(manager) | Returns DiagnosticsResult: enabled, localRoot, backendReachable, availableKeys, error. |
| getDefaultResolver() | Singleton ContentResolver from env. |
Types
- ContentManagerConfig —
localRoot,gitRepoUrl,gitToken,gitBranch,gitClonePath,mode,variant,cacheTtlMs - ResolvedContent —
{ text: string; metadata?: ContentMetadata } - ContentMetadata —
key,path,backend,version,variant,rawContent - ExportToMetadataOptions —
basePath?(default'.metadata'),keys?(default: all fromlistKeys()),format?: 'files' | 'manifest' - ExportToMetadataResult —
{ written: string[]; errors: Array<{ key: string; error: Error }> } - PushToRemoteOptions —
message?,remote?,branch? - PushToRemoteResult —
{ pushed: boolean; commitHash?; noChanges? } - VersionEntry —
{ sha: string; message: string; date: string; author?: string } - SetActiveVersionOptions —
{ commit?: boolean; message?: string } - SetActiveVersionResult —
{ updated: boolean } - ContentBackend — interface:
read,exists,listAll,listKeys?,ping,location - DiagnosticsResult —
enabled,localRoot,backendReachable,availableKeys,error - GitBackendConfig —
repoUrl,token?,branch?,clonePath?
Errors (all extend ContentManagerError, have .code)
| Class | Code | When |
|-------|------|------|
| ContentNotFoundError | CONTENT_NOT_FOUND | Key not found in any backend. |
| ContentManagerNotAvailableError | CONTENT_MANAGER_NOT_AVAILABLE | No backend configured. |
| ContentBackendError | CONTENT_BACKEND_ERROR | I/O or git failure. |
| ContentInvalidError | CONTENT_INVALID | Stored content is key-like or empty (never return key as content). |
Configuration
Options (ContentManagerConfig)
| Option | Env fallback | Default | Description |
|--------|----------------|--------|-------------|
| localRoot | CONTENT_REGISTRY_LOCAL_ROOT | <cwd>/.content | Content root (resolved to absolute). |
| gitRepoUrl | GITHUB_REPO_URL (only when no config or empty config) | — | Git repo URL (SSH or HTTPS). Shorthand: owner/repo is normalized to https://github.com/owner/repo. When you pass a config object, omitting gitRepoUrl means no Git (env is not used). Set to null or '' to explicitly disable Git. |
| gitToken | GITHUB_TOKEN | — | GitHub PAT for HTTPS. |
| gitBranch | CONTENT_REGISTRY_GIT_BRANCH | main | Branch to use. |
| gitClonePath | CONTENT_REGISTRY_GIT_LOCAL_CLONE_PATH | os.tmpdir()/nx-content-git | Local clone path. |
| mode | CONTENT_REGISTRY_MODE | dev | Default is dev (local-first: local wins, Git is fallback). prod = git wins. |
| variant | CONTENT_REGISTRY_VARIANT | — | Optional. When set, resolution tries variants/<variant>/<key> then <key>. |
| cacheTtlMs | CONTENT_REGISTRY_CACHE_TTL_MS | 1 year | Cache TTL in ms. |
Environment variables
Same names as above; used when the corresponding option is not passed. All paths from config/env are resolved to absolute at init.
Recommended folder layout
.content/
skills/
my-skill.instructions.md
prompts/
default.md
blocks/
greeting.md
disclaimer.md
variants/ # optional: when variant is set (e.g. prod, beta)
prod/
prompt.mdTesting
# Unit + integration (local)
npm test
# Integration only
npm run test:integration
# Watch
npm run test:watch- Git (read): Integration tests that clone a public repo run without a token. Tests that need a private repo or token run only when
GITHUB_TOKEN(and optionallyGITHUB_REPO_URL) are set; otherwise skipped. - Git (push): The push-to-remote integration test runs only when both
GITHUB_TOKENandGITHUB_PUSH_REPO_URLare set (use a repo you have write access to); otherwise skipped.
Build
npm run buildProduces:
dist/esm/— ESMdist/cjs/— CommonJS ("type": "commonjs")dist/types/— Declaration files
License
MIT
