world-building-framework 0.2.1 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -3
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.js +8 -4
- package/dist/cli/commands/refresh.js +5 -1
- package/dist/cli/index.js +18 -1
- package/dist/cli/managed-files.d.ts +11 -1
- package/dist/cli/managed-files.js +27 -5
- package/dist/cli/vault-path.d.ts +1 -1
- package/dist/cli/vault-path.js +5 -25
- package/dist/core/vault/read.js +6 -2
- package/dist/mcp/server.d.ts +20 -0
- package/dist/mcp/server.js +140 -0
- package/package.json +2 -1
- package/skills/world-vault/SKILL.md +8 -0
- package/templates/AGENTS.md +9 -1
package/README.md
CHANGED
|
@@ -6,6 +6,10 @@ Build a fictional world as a folder of Markdown files, and publish it as a wiki
|
|
|
6
6
|
|
|
7
7
|
You need [Node.js 22](https://nodejs.org/) or newer and git.
|
|
8
8
|
|
|
9
|
+
**With an AI agent:** in an empty folder, ask it: "Set up a world here with the npm package world-building-framework." It follows the steps in the next section and installs the skills it needs itself.
|
|
10
|
+
|
|
11
|
+
**By hand:**
|
|
12
|
+
|
|
9
13
|
```
|
|
10
14
|
mkdir my-world
|
|
11
15
|
cd my-world
|
|
@@ -14,7 +18,7 @@ npx world-building-framework init --agent claude
|
|
|
14
18
|
npm install
|
|
15
19
|
```
|
|
16
20
|
|
|
17
|
-
`init` sets the folder up as a world. `--agent claude` also installs skills for Claude Code. Leave
|
|
21
|
+
`init` sets the folder up as a world. `--agent claude` also installs skills for Claude Code; for another agent, use `--skills-dir <folder>` with the folder that agent reads project skills from. Leave both out if you don't want skills; the generated `AGENTS.md` works with any agent.
|
|
18
22
|
|
|
19
23
|
`init` never overwrites a file you already have. It creates:
|
|
20
24
|
- `package.json`, with the framework version pinned
|
|
@@ -28,7 +32,21 @@ Then put it on GitHub:
|
|
|
28
32
|
|
|
29
33
|
1. Create a GitHub repo, commit everything (including `package-lock.json` and `.wbf/managed.json`), and push.
|
|
30
34
|
2. In the repo on GitHub: **Settings → Pages → Source: GitHub Actions**. On a free account, Pages needs the repo to be public.
|
|
31
|
-
3. Every push to your default branch now publishes the wiki to `https://<you>.github.io/<repo>/wiki/`, with a landing page at `https://<you>.github.io/<repo>/`.
|
|
35
|
+
3. Every push to your default branch now publishes the wiki to `https://<you>.github.io/<repo>/wiki/`, with a landing page at `https://<you>.github.io/<repo>/`. To publish without a push, run it by hand: **Actions → Publish wiki → Run workflow**.
|
|
36
|
+
|
|
37
|
+
You don't enter the URL anywhere; the workflow asks GitHub for it. For a custom domain, set it under **Settings → Pages → Custom domain**; the next run picks it up.
|
|
38
|
+
|
|
39
|
+
## For AI agents: setting up a world
|
|
40
|
+
|
|
41
|
+
If a user asks you to set up a world with this package:
|
|
42
|
+
|
|
43
|
+
1. In the folder they chose (run `git init` first if it isn't a git repo), run `npx world-building-framework init`, adding the option for **your own** skills:
|
|
44
|
+
- Claude Code: `--agent claude`
|
|
45
|
+
- any other agent that reads Agent Skills (`SKILL.md`): `--skills-dir <your project skills folder>`, relative to the repo. Only use a folder you know your agent reads; if you don't know one, leave the option out.
|
|
46
|
+
- Add `--title "<the world's name>"` if the user named it.
|
|
47
|
+
2. Run `npm install`.
|
|
48
|
+
3. Read the generated `AGENTS.md` and, if installed, the `world-vault` skill, then follow them for all further work.
|
|
49
|
+
4. Tell the user the remaining steps from "Then put it on GitHub" above.
|
|
32
50
|
|
|
33
51
|
## Writing your world
|
|
34
52
|
|
|
@@ -49,8 +67,31 @@ Then put it on GitHub:
|
|
|
49
67
|
|
|
50
68
|
Run `npx wbf` only inside a world folder. Elsewhere it would fetch an unrelated npm package that happens to be called `wbf`.
|
|
51
69
|
|
|
70
|
+
**With an AI agent that has no terminal (MCP):** see [Agents without a terminal](#agents-without-a-terminal-mcp) below.
|
|
71
|
+
|
|
52
72
|
**In Obsidian:** open the world folder as a vault to browse and read. Create and change files through `wbf`, so they get valid UUID names and checked links.
|
|
53
73
|
|
|
74
|
+
## Agents without a terminal (MCP)
|
|
75
|
+
|
|
76
|
+
Some AI apps can't run commands, but can use tools from an MCP server. `wbf mcp` offers the same functions as the commands above as tools: `schema`, `search`, `read`, `validate`, `create`, `update` and `list_variants`. The app starts the server itself; you only add it to the app's MCP settings once. You need [Node.js 22](https://nodejs.org/) or newer installed.
|
|
77
|
+
|
|
78
|
+
For Claude Desktop, open **Settings → Developer → Edit Config** and add:
|
|
79
|
+
|
|
80
|
+
```json
|
|
81
|
+
{
|
|
82
|
+
"mcpServers": {
|
|
83
|
+
"my-world": {
|
|
84
|
+
"command": "npx",
|
|
85
|
+
"args": ["-y", "world-building-framework@0.2.2", "mcp", "--vault", "C:\\Users\\you\\worlds\\my-world"]
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
- Use the **absolute path** to your world folder, and the framework version from your world's `package.json`, so the app checks files with the same rules as your published wiki. If they differ, the `schema` tool says so.
|
|
92
|
+
- Restart the app afterwards. Other apps use the same `command` and `args`; see their docs for where they go.
|
|
93
|
+
- Setting a world up (`init`, `npm install`) and publishing (commit and push) still happen outside the app, as described above.
|
|
94
|
+
|
|
54
95
|
## How a world is organized
|
|
55
96
|
|
|
56
97
|
- **Files are named by UUID,** e.g. `7c1e9b2a-….md`. The readable name is the `title` field, so two characters can share a name.
|
|
@@ -81,6 +122,8 @@ Run `npx wbf` only inside a world folder. Elsewhere it would fetch an unrelated
|
|
|
81
122
|
|
|
82
123
|
**Every wiki is public.** Anyone who knows or guesses a variant's address can open it. Variants decide what each wiki contains; they don't lock anything.
|
|
83
124
|
|
|
125
|
+
**Variants filter whole files.** A file that's included is shown in full, body text and all. Put a secret in its own file with narrower tags rather than in the text of a file everyone sees. A link to a file a wiki leaves out turns into plain text, so its title still shows there.
|
|
126
|
+
|
|
84
127
|
## Preview locally
|
|
85
128
|
|
|
86
129
|
```
|
|
@@ -98,7 +141,7 @@ npm install world-building-framework@latest
|
|
|
98
141
|
npx wbf refresh
|
|
99
142
|
```
|
|
100
143
|
|
|
101
|
-
`refresh` updates the files `init` generated (the workflow, `AGENTS.md`, skills) to the new version. It leaves any file you've edited alone and lists it. `npx wbf init --agent <name>` adds skills for another agent later.
|
|
144
|
+
`refresh` updates the files `init` generated (the workflow, `AGENTS.md`, skills) to the new version. It leaves any file you've edited alone and lists it. `npx wbf init --agent <name>` or `--skills-dir <folder>` adds skills for another agent later; `refresh` keeps those current too.
|
|
102
145
|
|
|
103
146
|
## License
|
|
104
147
|
|
|
@@ -5,6 +5,8 @@ export interface InitOptions {
|
|
|
5
5
|
title?: string;
|
|
6
6
|
/** Agents to install skills for (see AGENT_SKILL_DIRS). */
|
|
7
7
|
agents?: string[];
|
|
8
|
+
/** Repo-relative folders to install skills into directly, for agents without a shortcut. */
|
|
9
|
+
skillsDirs?: string[];
|
|
8
10
|
/** Branch the publish workflow runs on; defaults to the git repo's current branch, else "main". */
|
|
9
11
|
branch?: string;
|
|
10
12
|
}
|
|
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
|
|
|
3
3
|
import { readFile, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { WORLD_CONFIG_FILENAME } from "../../core/config/index.js";
|
|
6
|
-
import { AGENT_SKILL_DIRS, contentHash, readFrameworkVersion, readManifest, renderManagedFiles, writeManifest, writeRepoFile, PACKAGE_ROOT, } from "../managed-files.js";
|
|
6
|
+
import { AGENT_SKILL_DIRS, contentHash, normalizeSkillsDir, readFrameworkVersion, readManifest, renderManagedFiles, writeManifest, writeRepoFile, PACKAGE_ROOT, } from "../managed-files.js";
|
|
7
7
|
const PACKAGE_NAME = "world-building-framework";
|
|
8
8
|
function titleFromFolder(dir) {
|
|
9
9
|
const words = path.basename(path.resolve(dir)).split(/[-_\s]+/).filter(Boolean);
|
|
@@ -29,9 +29,11 @@ export async function runInit(options = {}) {
|
|
|
29
29
|
const agents = [...new Set(options.agents ?? [])];
|
|
30
30
|
for (const agent of agents) {
|
|
31
31
|
if (!AGENT_SKILL_DIRS[agent]) {
|
|
32
|
-
throw new Error(`Unknown agent "${agent}". Supported: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}`
|
|
32
|
+
throw new Error(`Unknown agent "${agent}". Supported: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}. ` +
|
|
33
|
+
"For another agent, give its project skills folder instead: --skills-dir <folder>");
|
|
33
34
|
}
|
|
34
35
|
}
|
|
36
|
+
const skillsDirs = [...new Set((options.skillsDirs ?? []).map(normalizeSkillsDir))];
|
|
35
37
|
const version = await readFrameworkVersion();
|
|
36
38
|
const outcome = { dir, created: [], updated: [], skipped: [], nextSteps: [] };
|
|
37
39
|
const exists = (rel) => existsSync(path.join(dir, rel));
|
|
@@ -94,6 +96,7 @@ export async function runInit(options = {}) {
|
|
|
94
96
|
const settings = {
|
|
95
97
|
branch: options.branch ?? previous?.branch ?? detectBranch(dir),
|
|
96
98
|
agents: [...new Set([...(previous?.agents ?? []), ...agents])],
|
|
99
|
+
skillsDirs: [...new Set([...(previous?.skillsDirs ?? []), ...skillsDirs])],
|
|
97
100
|
};
|
|
98
101
|
const manifest = { frameworkVersion: version, ...settings, files: { ...(previous?.files ?? {}) } };
|
|
99
102
|
for (const [rel, content] of await renderManagedFiles(version, settings)) {
|
|
@@ -112,8 +115,9 @@ export async function runInit(options = {}) {
|
|
|
112
115
|
"Commit everything, including package-lock.json and .wbf/managed.json.",
|
|
113
116
|
"On GitHub: Settings → Pages → Source: GitHub Actions. Then push to publish.",
|
|
114
117
|
];
|
|
115
|
-
if (agents.length === 0) {
|
|
116
|
-
outcome.nextSteps.push(`Optional: install skills for your AI agent with npx wbf init --agent <${Object.keys(AGENT_SKILL_DIRS).join("|")}
|
|
118
|
+
if (settings.agents.length === 0 && settings.skillsDirs.length === 0) {
|
|
119
|
+
outcome.nextSteps.push(`Optional: install skills for your AI agent with npx wbf init --agent <${Object.keys(AGENT_SKILL_DIRS).join("|")}>, ` +
|
|
120
|
+
"or for any other agent with npx wbf init --skills-dir <its project skills folder>.");
|
|
117
121
|
}
|
|
118
122
|
return outcome;
|
|
119
123
|
}
|
|
@@ -25,7 +25,11 @@ export async function runRefresh(options = {}) {
|
|
|
25
25
|
kept: [],
|
|
26
26
|
retired: [],
|
|
27
27
|
};
|
|
28
|
-
const rendered = await renderManagedFiles(toVersion, {
|
|
28
|
+
const rendered = await renderManagedFiles(toVersion, {
|
|
29
|
+
branch: manifest.branch,
|
|
30
|
+
agents: manifest.agents,
|
|
31
|
+
skillsDirs: manifest.skillsDirs,
|
|
32
|
+
});
|
|
29
33
|
const files = {};
|
|
30
34
|
for (const [rel, content] of rendered) {
|
|
31
35
|
const newHash = contentHash(content);
|
package/dist/cli/index.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
3
|
import { Command } from "commander";
|
|
4
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
+
import { createMcpServer } from "../mcp/server.js";
|
|
4
6
|
import { runSchema } from "./commands/schema.js";
|
|
5
7
|
import { runInit } from "./commands/init.js";
|
|
6
8
|
import { runRefresh } from "./commands/refresh.js";
|
|
@@ -35,9 +37,16 @@ program
|
|
|
35
37
|
.option("--dir <path>", "folder to set up (default: current folder)")
|
|
36
38
|
.option("--title <title>", "the world's display title (default: derived from the folder name)")
|
|
37
39
|
.option("--agent <name>", "install skills for this AI agent; repeat for several", (value, previous) => [...previous, value], [])
|
|
40
|
+
.option("--skills-dir <folder>", "install skills into this repo-relative folder, for an agent --agent doesn't know; repeat for several", (value, previous) => [...previous, value], [])
|
|
38
41
|
.option("--branch <name>", "branch the publish workflow runs on (default: the repo's current branch, else main)")
|
|
39
42
|
.action(handle(async (opts) => {
|
|
40
|
-
const outcome = await runInit({
|
|
43
|
+
const outcome = await runInit({
|
|
44
|
+
dir: opts.dir,
|
|
45
|
+
title: opts.title,
|
|
46
|
+
agents: opts.agent,
|
|
47
|
+
skillsDirs: opts.skillsDir,
|
|
48
|
+
branch: opts.branch,
|
|
49
|
+
});
|
|
41
50
|
console.log(JSON.stringify(outcome, null, 2));
|
|
42
51
|
}));
|
|
43
52
|
program
|
|
@@ -154,4 +163,12 @@ program
|
|
|
154
163
|
});
|
|
155
164
|
console.log(JSON.stringify(outcome, null, 2));
|
|
156
165
|
}));
|
|
166
|
+
program
|
|
167
|
+
.command("mcp")
|
|
168
|
+
.description("Run an MCP server over stdio, offering the vault commands as tools to agents without a terminal. Started by the agent's host, not by hand")
|
|
169
|
+
.option("-v, --vault <path>", "world folder (default: WBF_VAULT_DIR, else the current folder)")
|
|
170
|
+
.action(handle(async (opts) => {
|
|
171
|
+
// stdout carries the protocol from here on; nothing else may write to it.
|
|
172
|
+
await createMcpServer({ vault: opts.vault, version }).connect(new StdioServerTransport());
|
|
173
|
+
}));
|
|
157
174
|
await program.parseAsync(process.argv);
|
|
@@ -10,7 +10,17 @@ export declare const AGENT_SKILL_DIRS: Record<string, string>;
|
|
|
10
10
|
export interface ManagedSettings {
|
|
11
11
|
branch: string;
|
|
12
12
|
agents: string[];
|
|
13
|
+
/** Skill folders given directly (`--skills-dir`), for agents without a shortcut in AGENT_SKILL_DIRS. Missing in manifests from 0.2.1 and older. */
|
|
14
|
+
skillsDirs?: string[];
|
|
13
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Checks a `--skills-dir` value and returns it repo-relative with forward
|
|
18
|
+
* slashes, e.g. ".agent\skills\" -> ".agent/skills". It must stay inside the
|
|
19
|
+
* repo: no absolute path, no "..".
|
|
20
|
+
*/
|
|
21
|
+
export declare function normalizeSkillsDir(dir: string): string;
|
|
22
|
+
/** Every folder skills get copied into: the agents' known folders plus any given directly, without duplicates. */
|
|
23
|
+
export declare function skillTargets(settings: ManagedSettings): string[];
|
|
14
24
|
/** The record of generated files, kept in the world repo (committed) at MANIFEST_PATH. */
|
|
15
25
|
export interface Manifest extends ManagedSettings {
|
|
16
26
|
frameworkVersion: string;
|
|
@@ -27,7 +37,7 @@ export declare function readFrameworkVersion(): Promise<string>;
|
|
|
27
37
|
/**
|
|
28
38
|
* The files wbf owns in a world repo, rendered for this framework version:
|
|
29
39
|
* the publish workflow, AGENTS.md, and the package's skills copied into each
|
|
30
|
-
*
|
|
40
|
+
* skill folder (see skillTargets). Keyed by repo-relative path.
|
|
31
41
|
*/
|
|
32
42
|
export declare function renderManagedFiles(version: string, settings: ManagedSettings): Promise<Map<string, string>>;
|
|
33
43
|
export declare function readManifest(rootDir: string): Promise<Manifest | undefined>;
|
|
@@ -13,6 +13,31 @@ export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.
|
|
|
13
13
|
export const AGENT_SKILL_DIRS = {
|
|
14
14
|
claude: ".claude/skills",
|
|
15
15
|
};
|
|
16
|
+
/**
|
|
17
|
+
* Checks a `--skills-dir` value and returns it repo-relative with forward
|
|
18
|
+
* slashes, e.g. ".agent\skills\" -> ".agent/skills". It must stay inside the
|
|
19
|
+
* repo: no absolute path, no "..".
|
|
20
|
+
*/
|
|
21
|
+
export function normalizeSkillsDir(dir) {
|
|
22
|
+
if (path.isAbsolute(dir) || /^[a-zA-Z]:/.test(dir)) {
|
|
23
|
+
throw new Error(`Skills folder "${dir}" must be relative to the world repo, e.g. .agent/skills`);
|
|
24
|
+
}
|
|
25
|
+
const parts = dir.split(/[\\/]+/).filter((part) => part !== "" && part !== ".");
|
|
26
|
+
if (parts.length === 0 || parts.includes("..")) {
|
|
27
|
+
throw new Error(`Skills folder "${dir}" must be a folder inside the world repo, e.g. .agent/skills`);
|
|
28
|
+
}
|
|
29
|
+
return parts.join("/");
|
|
30
|
+
}
|
|
31
|
+
/** Every folder skills get copied into: the agents' known folders plus any given directly, without duplicates. */
|
|
32
|
+
export function skillTargets(settings) {
|
|
33
|
+
const targets = settings.agents.map((agent) => {
|
|
34
|
+
const target = AGENT_SKILL_DIRS[agent];
|
|
35
|
+
if (!target)
|
|
36
|
+
throw new Error(`Unknown agent "${agent}". Supported: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}`);
|
|
37
|
+
return target;
|
|
38
|
+
});
|
|
39
|
+
return [...new Set([...targets, ...(settings.skillsDirs ?? [])])];
|
|
40
|
+
}
|
|
16
41
|
export const MANIFEST_PATH = ".wbf/managed.json";
|
|
17
42
|
/**
|
|
18
43
|
* Content hash, ignoring line endings: git may check a file out with CRLF on
|
|
@@ -44,7 +69,7 @@ async function listFiles(dir, prefix = "") {
|
|
|
44
69
|
/**
|
|
45
70
|
* The files wbf owns in a world repo, rendered for this framework version:
|
|
46
71
|
* the publish workflow, AGENTS.md, and the package's skills copied into each
|
|
47
|
-
*
|
|
72
|
+
* skill folder (see skillTargets). Keyed by repo-relative path.
|
|
48
73
|
*/
|
|
49
74
|
export async function renderManagedFiles(version, settings) {
|
|
50
75
|
const files = new Map();
|
|
@@ -52,10 +77,7 @@ export async function renderManagedFiles(version, settings) {
|
|
|
52
77
|
files.set("AGENTS.md", await template("AGENTS.md", version, settings.branch));
|
|
53
78
|
const skillsDir = path.join(PACKAGE_ROOT, "skills");
|
|
54
79
|
const skillFiles = await listFiles(skillsDir);
|
|
55
|
-
for (const
|
|
56
|
-
const target = AGENT_SKILL_DIRS[agent];
|
|
57
|
-
if (!target)
|
|
58
|
-
throw new Error(`Unknown agent "${agent}". Supported: ${Object.keys(AGENT_SKILL_DIRS).join(", ")}`);
|
|
80
|
+
for (const target of skillTargets(settings)) {
|
|
59
81
|
for (const rel of skillFiles) {
|
|
60
82
|
files.set(`${target}/${rel}`, await readFile(path.join(skillsDir, rel), "utf8"));
|
|
61
83
|
}
|
package/dist/cli/vault-path.d.ts
CHANGED
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* that are useful without a vault (e.g. `schema`).
|
|
4
4
|
*/
|
|
5
5
|
export declare function findVaultDir(cliOption: string | undefined): string | undefined;
|
|
6
|
-
/** Resolves which vault to operate on: explicit flag, then WBF_VAULT_DIR, then the current folder if it has a world.config.json
|
|
6
|
+
/** Resolves which vault to operate on: explicit flag, then WBF_VAULT_DIR, then the current folder if it has a world.config.json. */
|
|
7
7
|
export declare function resolveVaultDir(cliOption: string | undefined): string;
|
package/dist/cli/vault-path.js
CHANGED
|
@@ -1,24 +1,6 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { WORLD_CONFIG_FILENAME } from "../core/config/index.js";
|
|
4
|
-
function findSingleVaultUnderVaultsDir() {
|
|
5
|
-
const vaultsRoot = path.join(process.cwd(), "vaults");
|
|
6
|
-
let entries;
|
|
7
|
-
try {
|
|
8
|
-
entries = readdirSync(vaultsRoot).filter((name) => {
|
|
9
|
-
try {
|
|
10
|
-
return statSync(path.join(vaultsRoot, name)).isDirectory();
|
|
11
|
-
}
|
|
12
|
-
catch {
|
|
13
|
-
return false;
|
|
14
|
-
}
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
catch {
|
|
18
|
-
return undefined;
|
|
19
|
-
}
|
|
20
|
-
return entries.length === 1 ? path.join(vaultsRoot, entries[0]) : undefined;
|
|
21
|
-
}
|
|
22
4
|
/** The current folder, if it's a vault — i.e. it has a world.config.json (the vault repo is the project, see claude-dev/DISTRIBUTION_PLAN.md). */
|
|
23
5
|
function currentFolderIfVault() {
|
|
24
6
|
const cwd = process.cwd();
|
|
@@ -29,16 +11,14 @@ function currentFolderIfVault() {
|
|
|
29
11
|
* that are useful without a vault (e.g. `schema`).
|
|
30
12
|
*/
|
|
31
13
|
export function findVaultDir(cliOption) {
|
|
32
|
-
|
|
33
|
-
// world-repo layout fully replaces it.
|
|
34
|
-
return cliOption ?? process.env.WBF_VAULT_DIR ?? currentFolderIfVault() ?? findSingleVaultUnderVaultsDir();
|
|
14
|
+
return cliOption ?? process.env.WBF_VAULT_DIR ?? currentFolderIfVault();
|
|
35
15
|
}
|
|
36
|
-
/** Resolves which vault to operate on: explicit flag, then WBF_VAULT_DIR, then the current folder if it has a world.config.json
|
|
16
|
+
/** Resolves which vault to operate on: explicit flag, then WBF_VAULT_DIR, then the current folder if it has a world.config.json. */
|
|
37
17
|
export function resolveVaultDir(cliOption) {
|
|
38
18
|
const vaultDir = findVaultDir(cliOption);
|
|
39
19
|
if (!vaultDir) {
|
|
40
|
-
throw new Error("No vault specified — run this inside a
|
|
41
|
-
"
|
|
20
|
+
throw new Error("No vault specified — run this inside a world (a folder with world.config.json; `wbf init` creates one), " +
|
|
21
|
+
"pass --vault <path>, or set WBF_VAULT_DIR.");
|
|
42
22
|
}
|
|
43
23
|
return vaultDir;
|
|
44
24
|
}
|
package/dist/core/vault/read.js
CHANGED
|
@@ -42,8 +42,12 @@ export async function findVaultFilePath(vaultDir, id) {
|
|
|
42
42
|
}
|
|
43
43
|
/** Reads and parses one vault file by id. Never validates — see write.ts. */
|
|
44
44
|
export async function readVaultFile(vaultDir, id) {
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// Looked up among the vault's UUID-named files, never joined into a path: an
|
|
46
|
+
// id like "../notes" must not reach a file outside the vault.
|
|
47
|
+
const filePath = await findVaultFilePath(vaultDir, id);
|
|
48
|
+
if (!filePath)
|
|
49
|
+
throw new Error(`No file with id "${id}" in this vault.`);
|
|
50
|
+
return parseVaultFile(id, await readFile(filePath, "utf8"));
|
|
47
51
|
}
|
|
48
52
|
/**
|
|
49
53
|
* Reads every vault file: UUID-named `.md` files anywhere under vaultDir.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
/**
|
|
3
|
+
* What every agent connecting to the server is told up front: the
|
|
4
|
+
* world-vault skill's rules (skills/world-vault/SKILL.md), in terms of this
|
|
5
|
+
* server's tools instead of CLI commands.
|
|
6
|
+
*/
|
|
7
|
+
export declare const MCP_INSTRUCTIONS = "This server reads and writes a world built with the World Building Framework: a vault of entity and event files, named by UUID. Use only these tools to read or change it; they validate everything before it's written.\n\n1. Call `schema` before writing anything. It lists every field, its shape, and an example input.\n2. Call `search` before creating something, to avoid duplicates and to get the UUIDs you link with.\n3. Links are always \"[[<uuid>|<current title>]]\", in frontmatter and in body text. Never a bare [[Name]].\n4. Before `create` or `update`, show the user the frontmatter and body you're about to write, and wait for their go-ahead.\n5. `update` is for corrections only (a typo, a wrong tag, a missing link). Call `read` first and send the file's complete new content: any field you leave out is removed. Changing a title rewrites links showing the old title in other files.\n6. A change in the story is never an update. Create a new entity file with `supersedes` (a link to the entity's latest version) and `causedBy` (a link to the event that caused the change). Other files keep linking to the entity's first file.\n7. Each published wiki (see `list_variants`) includes or leaves out whole files by their tags, and shows an included file in full. Keep secrets in their own files with narrower tags, never in the text of a file a wider audience sees.\n\nErrors in a result block a write; warnings don't, but mention them to the user.";
|
|
8
|
+
export interface McpServerOptions {
|
|
9
|
+
/** World folder; else WBF_VAULT_DIR, else the server's working folder if it has a world.config.json. */
|
|
10
|
+
vault?: string;
|
|
11
|
+
/** This framework's version, reported to clients. */
|
|
12
|
+
version: string;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The `wbf mcp` server (claude-dev/DISTRIBUTION_PLAN.md, component 6): the
|
|
16
|
+
* CLI's vault commands as MCP tools, for agents without a terminal. Each tool
|
|
17
|
+
* calls the same function as its CLI command; only `read` is extra, since an
|
|
18
|
+
* agent without a shell can't open a file to prepare an `update`.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createMcpServer(options: McpServerOptions): McpServer;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { readVaultFile } from "../core/vault/index.js";
|
|
6
|
+
import { runSchema } from "../cli/commands/schema.js";
|
|
7
|
+
import { runSearch } from "../cli/commands/search.js";
|
|
8
|
+
import { runValidate } from "../cli/commands/validate.js";
|
|
9
|
+
import { runCreate } from "../cli/commands/create.js";
|
|
10
|
+
import { runUpdate } from "../cli/commands/update.js";
|
|
11
|
+
import { loadWorldConfig } from "../core/config/index.js";
|
|
12
|
+
import { findVaultDir, resolveVaultDir } from "../cli/vault-path.js";
|
|
13
|
+
/**
|
|
14
|
+
* What every agent connecting to the server is told up front: the
|
|
15
|
+
* world-vault skill's rules (skills/world-vault/SKILL.md), in terms of this
|
|
16
|
+
* server's tools instead of CLI commands.
|
|
17
|
+
*/
|
|
18
|
+
export const MCP_INSTRUCTIONS = `This server reads and writes a world built with the World Building Framework: a vault of entity and event files, named by UUID. Use only these tools to read or change it; they validate everything before it's written.
|
|
19
|
+
|
|
20
|
+
1. Call \`schema\` before writing anything. It lists every field, its shape, and an example input.
|
|
21
|
+
2. Call \`search\` before creating something, to avoid duplicates and to get the UUIDs you link with.
|
|
22
|
+
3. Links are always "[[<uuid>|<current title>]]", in frontmatter and in body text. Never a bare [[Name]].
|
|
23
|
+
4. Before \`create\` or \`update\`, show the user the frontmatter and body you're about to write, and wait for their go-ahead.
|
|
24
|
+
5. \`update\` is for corrections only (a typo, a wrong tag, a missing link). Call \`read\` first and send the file's complete new content: any field you leave out is removed. Changing a title rewrites links showing the old title in other files.
|
|
25
|
+
6. A change in the story is never an update. Create a new entity file with \`supersedes\` (a link to the entity's latest version) and \`causedBy\` (a link to the event that caused the change). Other files keep linking to the entity's first file.
|
|
26
|
+
7. Each published wiki (see \`list_variants\`) includes or leaves out whole files by their tags, and shows an included file in full. Keep secrets in their own files with narrower tags, never in the text of a file a wider audience sees.
|
|
27
|
+
|
|
28
|
+
Errors in a result block a write; warnings don't, but mention them to the user.`;
|
|
29
|
+
function toolResult({ result, failed }) {
|
|
30
|
+
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], ...(failed ? { isError: true } : {}) };
|
|
31
|
+
}
|
|
32
|
+
/** Runs a tool body, turning a thrown error into an error result the agent can read, like the CLI's `{"error": ...}`. */
|
|
33
|
+
async function run(body) {
|
|
34
|
+
try {
|
|
35
|
+
return toolResult(await body());
|
|
36
|
+
}
|
|
37
|
+
catch (err) {
|
|
38
|
+
return toolResult({ result: { error: err.message }, failed: true });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** A note when the world pins a different framework version than the one running this server, else undefined. */
|
|
42
|
+
async function versionMismatch(vaultDir, running) {
|
|
43
|
+
if (!vaultDir)
|
|
44
|
+
return undefined;
|
|
45
|
+
try {
|
|
46
|
+
const pkg = JSON.parse(await readFile(path.join(vaultDir, "package.json"), "utf8"));
|
|
47
|
+
const pinned = pkg.dependencies?.["world-building-framework"];
|
|
48
|
+
if (!pinned || pinned === running)
|
|
49
|
+
return undefined;
|
|
50
|
+
return `This server runs world-building-framework ${running}, but the world pins ${pinned}. The published wiki is built with ${pinned}, so validation can differ slightly. Update the world (npm install world-building-framework@${running}, then wbf refresh), or start the server at the pinned version.`;
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const fileInput = {
|
|
57
|
+
frontmatter: z.record(z.string(), z.unknown()).describe("The file's frontmatter fields. See the schema tool."),
|
|
58
|
+
body: z.string().optional().describe("Markdown body text. Links inside it use the same [[<uuid>|Title]] form."),
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The `wbf mcp` server (claude-dev/DISTRIBUTION_PLAN.md, component 6): the
|
|
62
|
+
* CLI's vault commands as MCP tools, for agents without a terminal. Each tool
|
|
63
|
+
* calls the same function as its CLI command; only `read` is extra, since an
|
|
64
|
+
* agent without a shell can't open a file to prepare an `update`.
|
|
65
|
+
*/
|
|
66
|
+
export function createMcpServer(options) {
|
|
67
|
+
const server = new McpServer({ name: "world-building-framework", version: options.version }, { instructions: MCP_INSTRUCTIONS });
|
|
68
|
+
const vault = options.vault;
|
|
69
|
+
const readOnly = { readOnlyHint: true, openWorldHint: false };
|
|
70
|
+
server.registerTool("schema", {
|
|
71
|
+
title: "Show the file schema",
|
|
72
|
+
description: "Every frontmatter field with its shape and whether it's required, this world's entityType list, and example inputs for create. Call it before writing anything.",
|
|
73
|
+
annotations: readOnly,
|
|
74
|
+
}, () => run(async () => {
|
|
75
|
+
const text = await runSchema(vault);
|
|
76
|
+
const note = await versionMismatch(findVaultDir(vault), options.version);
|
|
77
|
+
return { result: note ? `${text}\n\nNote: ${note}` : text };
|
|
78
|
+
}));
|
|
79
|
+
server.registerTool("search", {
|
|
80
|
+
title: "Search the world",
|
|
81
|
+
description: "Keyword search over titles, aliases and text. Each result has the file's id (the UUID you link with), title and score, and for entities entityType and tags.",
|
|
82
|
+
inputSchema: {
|
|
83
|
+
query: z.string().min(1).describe("Search terms"),
|
|
84
|
+
tags: z.array(z.string()).optional().describe("Only files carrying all of these tags"),
|
|
85
|
+
limit: z.number().int().positive().optional().describe("Maximum number of results"),
|
|
86
|
+
},
|
|
87
|
+
annotations: readOnly,
|
|
88
|
+
}, ({ query, tags, limit }) => run(async () => ({ result: await runSearch(vault, query, { tags, limit }) })));
|
|
89
|
+
server.registerTool("read", {
|
|
90
|
+
title: "Read a file",
|
|
91
|
+
description: "One file's id, frontmatter and body. Call it before update, which needs the file's complete new content.",
|
|
92
|
+
inputSchema: { id: z.string().describe("The file's UUID") },
|
|
93
|
+
annotations: readOnly,
|
|
94
|
+
}, ({ id }) => run(async () => {
|
|
95
|
+
const file = await readVaultFile(resolveVaultDir(vault), id);
|
|
96
|
+
return { result: { id: file.id, frontmatter: file.frontmatter, body: file.body } };
|
|
97
|
+
}));
|
|
98
|
+
server.registerTool("validate", {
|
|
99
|
+
title: "Validate",
|
|
100
|
+
description: "Checks without writing anything. With frontmatter (and body): a candidate file, exactly as create would check it. With id: one existing file. With neither: the whole world.",
|
|
101
|
+
inputSchema: {
|
|
102
|
+
id: z.string().optional().describe("Check one existing file"),
|
|
103
|
+
frontmatter: fileInput.frontmatter.optional(),
|
|
104
|
+
body: fileInput.body,
|
|
105
|
+
},
|
|
106
|
+
annotations: readOnly,
|
|
107
|
+
}, ({ id, frontmatter, body }) => run(async () => {
|
|
108
|
+
const candidate = frontmatter ? { frontmatter, body } : undefined;
|
|
109
|
+
const outcome = await runValidate(vault, { id: candidate ? undefined : id, candidate });
|
|
110
|
+
const valid = Array.isArray(outcome) ? outcome.every((o) => o.valid) : outcome.valid;
|
|
111
|
+
return { result: outcome, failed: !valid };
|
|
112
|
+
}));
|
|
113
|
+
server.registerTool("create", {
|
|
114
|
+
title: "Create a file",
|
|
115
|
+
description: "Creates a new entity or event file under a new UUID, after the same checks as validate; on any error nothing is written. Returns the new id. Show the user the content and get their go-ahead first.",
|
|
116
|
+
inputSchema: fileInput,
|
|
117
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
118
|
+
}, ({ frontmatter, body }) => run(async () => {
|
|
119
|
+
const outcome = await runCreate(vault, { frontmatter, body });
|
|
120
|
+
return { result: outcome, failed: !outcome.written };
|
|
121
|
+
}));
|
|
122
|
+
server.registerTool("update", {
|
|
123
|
+
title: "Correct a file",
|
|
124
|
+
description: "Replaces an existing file's whole content, for corrections only; a change in the story is a new version (create with supersedes and causedBy). Call read first and send the complete content: fields you leave out are removed. type can't change. A changed title is also rewritten in other files' links. Show the user the change and get their go-ahead first.",
|
|
125
|
+
inputSchema: { id: z.string().describe("The file's UUID"), ...fileInput },
|
|
126
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
127
|
+
}, ({ id, frontmatter, body }) => run(async () => {
|
|
128
|
+
const outcome = await runUpdate(vault, id, { frontmatter, body });
|
|
129
|
+
return { result: outcome, failed: !outcome.written };
|
|
130
|
+
}));
|
|
131
|
+
server.registerTool("list_variants", {
|
|
132
|
+
title: "List the wikis",
|
|
133
|
+
description: "The build variants in world.config.json, by name: one published wiki each, e.g. a player wiki and a GM wiki. A file is in a wiki if it carries all of the variant's tags (and, for events, its date is inside the dateRange); {} includes everything. Use it to see which audiences a file's tags reach.",
|
|
134
|
+
annotations: readOnly,
|
|
135
|
+
}, () => run(async () => {
|
|
136
|
+
const config = await loadWorldConfig(resolveVaultDir(vault));
|
|
137
|
+
return { result: config.buildVariants ?? {} };
|
|
138
|
+
}));
|
|
139
|
+
return server;
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "world-building-framework",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Build, search and publish fictional worlds from a vault of Markdown files: the wbf CLI and its core engine.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"type": "module",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"prepublishOnly": "npm run typecheck && npm test"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
+
"@modelcontextprotocol/sdk": "^1.32.2",
|
|
35
36
|
"better-sqlite3": "^13.0.3",
|
|
36
37
|
"commander": "^15.0.0",
|
|
37
38
|
"gray-matter": "^4.0.3",
|
|
@@ -98,3 +98,11 @@ When an event changes something about an entity (it looks different, its status
|
|
|
98
98
|
Both fields are required together. The vault keeps every version, and the published wiki shows the whole history. Never overwrite an entity file's body to reflect an in-world change; that erases history the vault is designed to keep.
|
|
99
99
|
|
|
100
100
|
Other files always link to an entity's **original, first-ever** file (its anchor), never to a later version. If you're not sure which id is the anchor, follow the file's `supersedes` chain back. Or reuse whatever id you already have for that entity: that's always the anchor once created.
|
|
101
|
+
|
|
102
|
+
## 9. Secrets: each wiki shows whole files
|
|
103
|
+
|
|
104
|
+
A world can publish several wikis (build variants in `world.config.json`, e.g. a player wiki and a GM wiki). They filter by **file**, using its `tags` and, for events, its date. Everything inside a file that's included is shown, body text and all.
|
|
105
|
+
|
|
106
|
+
- Before writing, check which wikis the file will appear in: compare its `tags` with each variant's `tags` in `world.config.json`.
|
|
107
|
+
- Keep anything meant for fewer readers (a GM secret, a later plot twist) out of files the wider audience sees. Put it in its own file with narrower tags, and link to it from there.
|
|
108
|
+
- A link to a file a wiki leaves out turns into plain text, so its title still shows. Give secret files titles that don't give the secret away.
|
package/templates/AGENTS.md
CHANGED
|
@@ -6,6 +6,14 @@ This repo is a world built with the World Building Framework (version __WBF_VERS
|
|
|
6
6
|
- Run `npx wbf schema` before writing anything, `npx wbf search <terms>` before creating something, and `npx wbf --help` for every command.
|
|
7
7
|
- Never hand-write or hand-edit a vault `.md` file. Only `wbf create` and `wbf update` validate content, and only `create` generates the required UUID filename.
|
|
8
8
|
- Show the user what you're about to write, and wait for their go-ahead, before `create` or `update`.
|
|
9
|
-
|
|
9
|
+
|
|
10
|
+
## Skills
|
|
11
|
+
|
|
12
|
+
The `world-vault` skill (open Agent Skills format, `SKILL.md`) has the full workflow. If your project skills folder doesn't have it yet, install it for yourself, then tell the user to commit the new files:
|
|
13
|
+
|
|
14
|
+
- Claude Code: `npx wbf init --agent claude`
|
|
15
|
+
- Any other agent that reads `SKILL.md` skills: `npx wbf init --skills-dir <your project skills folder>`, relative to this repo. Only use a folder you know your agent reads.
|
|
16
|
+
|
|
17
|
+
`init` only adds what's missing here and never overwrites a file.
|
|
10
18
|
|
|
11
19
|
Generated by `wbf init`. `npx wbf refresh` keeps it current while it's unedited.
|