create-evda 0.1.2 → 0.1.4

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 CHANGED
@@ -14,7 +14,7 @@ npx create-evda my-evda
14
14
 
15
15
  ## Setup
16
16
 
17
- On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off.
17
+ On a terminal, `create-evda` asks one question at a time: what to call your agent (Iris unless you rename it), who you are, what you want this for, how it should talk to you, and which AI you will use. It shows where that AI's training setting is, links the provider's own page, and does not finish until you type yes. The answers go into "What this EVÐA is" at the top of `AGENTS.md`, in your words. It suggests dictation: Apple's built in dictation, or Wispr Flow with data collection turned off. Last, it offers to bring in what an AI you already use knows about you; see below.
18
18
 
19
19
  Unattended, nothing is asked:
20
20
 
@@ -27,7 +27,39 @@ npx create-evda my-evda --yes --agent-name Iris --owner "Dana" --purpose "why wo
27
27
 
28
28
  ## Any AI
29
29
 
30
- `AGENTS.md` is the one source. Codex, Cursor, GitHub Copilot, Windsurf, Zed, opencode and the other tools on agents.md read it directly. For the two that do not, the scaffold writes a one line loader: `CLAUDE.md` for Claude Code and `GEMINI.md` for Gemini CLI, each `@AGENTS.md`. Aider takes `aider --read AGENTS.md`. A chat app with projects (ChatGPT, Claude) cannot read the folder: paste `AGENTS.md` into the project's instructions and add your record files; what you write there stays in the app until you copy it into `record/`.
30
+ `AGENTS.md` is the one source. Codex, Cursor, GitHub Copilot, Windsurf, Zed, opencode and the other tools on agents.md read it directly. For the two that do not, the scaffold writes a one line loader: `CLAUDE.md` for Claude Code and `GEMINI.md` for Gemini CLI, each `@AGENTS.md`. Aider takes `aider --read AGENTS.md`. The Claude desktop app reads it too, once its Filesystem extension is installed and pointed at this folder (Settings, then Extensions, then Filesystem). ChatGPT and Claude in the browser or app, used through a Project, cannot read or write this folder: a Project's instructions field holds text for that conversation only, and pasting `AGENTS.md` into it does not run an EVDA, because nothing you write there reaches `record/`. Bring what a chat app like that already knows about you in once, as an import, instead: see "Bringing in what an AI already knows" below.
31
+
32
+ ## Bringing in what an AI already knows
33
+
34
+ Setup ends by asking whether to bring in what an AI you already talk to knows about you. Say yes and it prints a prompt: paste that into ChatGPT, Claude, or whatever you use, and it writes out what it remembers or has guessed about you, in plain text. Bring the answer to your first session and your agent files it verbatim under `record/imports/YYYY-MM-DD-<source>.md`, with the source and date at the top. It is filed as what that AI said, never as your own words, and every line in it is a claim to check with you over time, not a fact.
35
+
36
+ ## Talk to it from the AI you already use
37
+
38
+ `evda mcp` serves your record as an MCP server on stdio, so Claude Code, Codex and the Claude desktop app can read and write it from any folder. EVÐA holds no API key and calls no model: the AI is yours, signed in as you, and it reaches the record only through these tools.
39
+
40
+ evda_operating_layer AGENTS.md, read first every session
41
+ evda_recent the last few days of the record
42
+ evda_write your words, verbatim, appended to today's daily file. It takes no path and never edits
43
+ evda_import what another AI said about you, filed under record/imports with its source and date
44
+ evda_search plain text search inside the record, on this machine
45
+ evda_circles your circles and their policies
46
+ evda_propose_circle a proposal file under proposals/. Never a circle, never a grant
47
+ evda_doctor evda doctor, as a report
48
+
49
+ Every write appends, refuses anything shaped like a password, key or token, and says the path it wrote. `evda connect` prints the setup for your record, with its path filled in:
50
+
51
+ ```bash
52
+ claude mcp add --scope user evda -- /path/to/node /path/to/evda.mjs mcp ~/my-evda # Claude Code
53
+ codex mcp add evda -- /path/to/node /path/to/evda.mjs mcp ~/my-evda # Codex
54
+ ```
55
+
56
+ For the Claude desktop app, add the same command under `mcpServers` in `claude_desktop_config.json` (Settings, then Developer, then Edit Config) and restart the app. `create-evda` prints all three at the end of setup.
57
+
58
+ **The skill.** `skills/evda/SKILL.md` (Agent Skills format) tells Claude Code to act as Iris through the tools: load the operating layer first, file verbatim, never quiz, circles only on your yes. `evda skill install` copies it to `~/.claude/skills/evda/`; setup offers it.
59
+
60
+ **The organizer.** `evda organize` goes through what you left inward. It copies each dated capture, verbatim, into the daily file of its date, once, and appends a report to `reports/organize/<date>.md`: people a recent note mentions, imports to check, undated notes, notes under no role. It moves nothing. With `--with-ai` it hands the judgment to your own `claude -p`, with the skill and read-only EVÐA tools, and appends its answer to the report. `evda look-back` does the same for the week.
61
+
62
+ **Routines.** Setup offers a morning organize and a weekly look back, each optional, and schedules nothing without a yes. `evda routine morning` prints what to put in a local routine in the Claude desktop app (Code tab, then Routines, then New routine, then Local). `evda routine morning --install` writes and loads a launchd job instead, on a Mac. A cloud routine cannot read local files, so it cannot reach a record.
31
63
 
32
64
  ## Circles and other people
33
65
 
@@ -35,7 +67,7 @@ Everything is private until you share it. A circle is a part of the record you c
35
67
 
36
68
  ## The package
37
69
 
38
- `@evda/person-record` in this repository, published as `create-evda`. The person layer over the substrate. An EVÐA is a directory that knows what it is: a person's record in their own words, a ledger of reads about them held loosely, and the operating instructions beside them, so that any agent on any model can open it and act correctly with no outside context. The files are the truth. Nothing here is required to read or write a record; `evda doctor` says whether one is well formed and `evda reindex` rebuilds the derived directory, and both can be thrown away. It has no runtime dependencies (`test/deps.test.mjs` asserts it) and never makes a network call.
70
+ `@evda/person-record` in this repository, published as `create-evda`. The person layer over the substrate. An EVÐA is a directory that knows what it is: a person's record in their own words, a ledger of reads about them held loosely, and the operating instructions beside them, so that any agent on any model can open it and act correctly with no outside context. The files are the truth. Nothing here is required to read or write a record; `evda doctor` says whether one is well formed and `evda reindex` rebuilds the derived directory, and both can be thrown away. It has no runtime dependencies (`test/deps.test.mjs` asserts it) and never makes a network call. The MCP server is a small JSON-RPC implementation in `lib/mcp.mjs`, not the SDK, for the same reason.
39
71
 
40
72
  Design contract: Spec 04 (the hypothesis ledger), Spec 05 (the data layer), Spec 06 (the guarantee) and Spec 07 (holders, circles, references and views) in the EVÐA project. A short copy of what this package settles is in `docs/canon/person-record.md`.
41
73
 
@@ -93,6 +125,15 @@ The tree above is what the scaffolder creates and not a requirement. `evda.json`
93
125
  evda status [dir] one screen
94
126
  evda where which record the locator resolves to, and by which rule
95
127
  evda use <dir> point ~/.evda/current at a record
128
+ evda mcp [dir] serve the record over MCP on stdio for Claude Code, Codex or the Claude app
129
+ evda connect [dir] the MCP setup for each client, with this record's path
130
+ evda skill install [--project <dir>]
131
+ install the evda skill for Claude Code
132
+ evda organize [dir] [--dry-run] [--with-ai] [--model M]
133
+ file captures into their day and report what belongs where; moves nothing
134
+ evda look-back [dir] the week, read back by your own claude -p, appended to a report
135
+ evda routine <morning|weekly> [dir] [--launchd] [--install]
136
+ a Claude desktop routine, or a launchd job with --install
96
137
 
97
138
  The record is found through the locator: a directory argument, then `$EVDA_HOME`, then the nearest ancestor of the working directory holding `evda.json`, then `~/.evda/current`. Nothing assumes it was launched inside one.
98
139
 
package/bin/evda.mjs CHANGED
@@ -48,6 +48,19 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
48
48
  evda revoke <circle> --to <record-id> [dir]
49
49
  evda access <circle> --as <record-id> [dir]
50
50
  may that record read the circle? exit 0 yes, 2 refused, with why
51
+ evda mcp [dir] serve the record over MCP on stdio, for Claude Code, Codex or the
52
+ Claude desktop app. No key and no model call: the AI is the owner's
53
+ evda connect [dir] how to add that server to each client, with this record's path
54
+ evda skill install [--project <dir>]
55
+ install the evda skill for Claude Code (~/.claude/skills, or a project)
56
+ evda organize [dir] [--dry-run] [--with-ai] [--model M]
57
+ file captures into their day and write a report of what belongs where.
58
+ Moves nothing. --with-ai hands the judgment to the owner's claude -p
59
+ evda look-back [dir] [--model M]
60
+ the week, read back by the owner's claude -p, appended to a report
61
+ evda routine <morning|weekly> [dir] [--launchd] [--install]
62
+ print a scheduled routine for Claude, or a launchd fallback; --install
63
+ writes the launchd job, and nothing is scheduled without it
51
64
  evda status [dir] one screen summary
52
65
  evda where which EVDA the locator resolves to, and by which rule
53
66
  evda use <dir> point ~/.evda/current at a record
@@ -58,6 +71,14 @@ const HELP = `evda: operate an EVDA. The files are the truth; this is optional.
58
71
  Locator order: directory argument, $EVDA_HOME, the working directory's ancestors, ~/.evda/current.
59
72
  Exit codes: 0 ok, 1 the record has a problem and it was named, 2 refused (nothing written).`;
60
73
 
74
+ function packageVersion() {
75
+ try {
76
+ return JSON.parse(readFileSync(fileURLToPath(new URL("../package.json", import.meta.url)), "utf8")).version;
77
+ } catch {
78
+ return "0.0.0";
79
+ }
80
+ }
81
+
61
82
  // A positional argument is the record when it looks like a path or IS a directory on disk. A bare
62
83
  // name that exists (`evda doctor my-evda`) must not fall through to the pointer: on 2026-09-16 it
63
84
  // did, and `evda doctor scratch` read the record ~/.evda/current pointed at instead of `scratch/`.
@@ -67,7 +88,7 @@ function isDirArg(a) {
67
88
  }
68
89
 
69
90
  // `--name value` or `--name=value`. The value of a `--name value` pair is not a positional.
70
- const VALUE_FLAGS = ["to", "kind", "name", "role", "by", "as"];
91
+ const VALUE_FLAGS = ["to", "kind", "name", "role", "by", "as", "model", "project", "hour", "weekday"];
71
92
  function flagValue(args, name) {
72
93
  const eq = args.find((a) => a.startsWith(`--${name}=`));
73
94
  if (eq) return eq.slice(name.length + 3);
@@ -105,6 +126,54 @@ async function main(argv) {
105
126
  return mod.main(args);
106
127
  }
107
128
 
129
+ case "mcp": {
130
+ // The record as an MCP server on stdio. stdout is the protocol; everything else is stderr.
131
+ const { dir } = pickDir(args);
132
+ const { serve } = await import("../lib/mcp.mjs");
133
+ await serve({ dir, version: packageVersion() });
134
+ return 0;
135
+ }
136
+
137
+ case "organize": {
138
+ const { dir, flags } = pickDir(args);
139
+ const { organize } = await import("../lib/organize.mjs");
140
+ const r = await organize(openEvda(dir), { withAi: flags.includes("--with-ai"), dryRun: flags.includes("--dry-run"), model: flagValue(args, "model") });
141
+ console.log(r.summary);
142
+ return r.exit;
143
+ }
144
+
145
+ case "look-back": {
146
+ const { dir } = pickDir(args);
147
+ const { lookBack } = await import("../lib/organize.mjs");
148
+ const r = await lookBack(openEvda(dir), { model: flagValue(args, "model") });
149
+ console.log(r.summary);
150
+ return r.exit;
151
+ }
152
+
153
+ case "skill": {
154
+ // evda skill install [--project <dir>] copy the evda skill for Claude Code
155
+ const { installSkill } = await import("../lib/connect.mjs");
156
+ if (args[0] !== "install") throw new EvdaError("usage: evda skill install [--project <dir>]", { exit: 2 });
157
+ const project = flagValue(args, "project");
158
+ const r = installSkill({ project: project ? resolve(project) : null });
159
+ console.log(`${r.action} ${r.path}`);
160
+ return 0;
161
+ }
162
+
163
+ case "connect": {
164
+ // How to add this record's MCP server to each client, with the record's path filled in.
165
+ const { dir } = pickDir(args);
166
+ const { connectText } = await import("../lib/connect.mjs");
167
+ console.log(connectText(dir));
168
+ return 0;
169
+ }
170
+
171
+ case "routine": {
172
+ // evda routine <morning|weekly> [dir] [--launchd] [--install]
173
+ const { routineMain } = await import("../lib/routines.mjs");
174
+ return routineMain(args, { pickDir, flagValue });
175
+ }
176
+
108
177
  case "where": {
109
178
  const found = locate({});
110
179
  console.log(`${found.dir} (via ${found.via})`);
package/create-evda.mjs CHANGED
@@ -28,7 +28,9 @@ import { doctor } from "./lib/doctor.mjs";
28
28
  import { readPointer, setPointer, pointerPath } from "./lib/locate.mjs";
29
29
  import { FOLDER_MANIFEST } from "./lib/folders.mjs";
30
30
  import { EvdaError } from "./lib/errors.mjs";
31
- import { PLATFORMS, PLATFORM_KEYS, LOADERS, CHAT_APPS, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection } from "./lib/setup.mjs";
31
+ import { clients, installSkill } from "./lib/connect.mjs";
32
+ import { desktopInstructions } from "./lib/routines.mjs";
33
+ import { PLATFORMS, PLATFORM_KEYS, LOADERS, DESKTOP_FILESYSTEM, DICTATION, DEFAULT_AGENT_NAME, platformKey, fillOwnerSection, aiLine, interview, trainingSection } from "./lib/setup.mjs";
32
34
 
33
35
  const HERE = dirname(fileURLToPath(import.meta.url));
34
36
  const TEMPLATES = join(HERE, "templates");
@@ -54,12 +56,13 @@ const FOLDER_TYPES = {
54
56
  "record/people": "person",
55
57
  };
56
58
 
57
- const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--use] [--no-pointer]`;
59
+ const USAGE = `usage: create-evda <dir> [--agent-name NAME] [--owner NAME] [--purpose TEXT] [--voice TEXT] [--platform ${PLATFORM_KEYS.join("|")}] [--training-off] [--yes] [--name NAME] [--kind person|agent|company] [--id ATLAS-ID] [--layers record,ledger] [--skill] [--routines morning,weekly] [--use] [--no-pointer]`;
58
60
 
59
61
  export function parseArgs(argv) {
60
62
  const opts = {
61
63
  layers: ["record", "ledger"], force: false, name: null, owner: "", kind: "person", id: null, pointer: true, use: false,
62
64
  yes: false, trainingOff: false, agentName: null, purpose: "", voice: "", platform: null, platformName: null,
65
+ skill: null, routines: null,
63
66
  };
64
67
  const args = argv.flatMap((a) => {
65
68
  const eq = /^(--[a-z-]+)=([\s\S]*)$/.exec(a);
@@ -77,6 +80,14 @@ export function parseArgs(argv) {
77
80
  else if (a === "--use") opts.use = true;
78
81
  else if (a === "--yes" || a === "-y") opts.yes = true;
79
82
  else if (a === "--training-off") opts.trainingOff = true;
83
+ else if (a === "--skill") opts.skill = true;
84
+ else if (a === "--no-skill") opts.skill = false;
85
+ else if (a === "--no-routines") opts.routines = [];
86
+ else if (a === "--routines") {
87
+ opts.routines = value(++i, a).split(",").map((s) => s.trim()).filter(Boolean);
88
+ const bad = opts.routines.filter((r) => !["morning", "weekly"].includes(r));
89
+ if (bad.length) throw new EvdaError(`--routines takes morning, weekly or both, got ${bad.join(", ")}`, { exit: 2 });
90
+ }
80
91
  else if (a === "--name") opts.name = value(++i, a);
81
92
  else if (a === "--owner") opts.owner = value(++i, a);
82
93
  else if (a === "--agent-name") opts.agentName = value(++i, a);
@@ -243,13 +254,26 @@ export async function main(argv, io = null) {
243
254
  log(`Open it with ${agent}:`);
244
255
  for (const k of chosen) for (const line of PLATFORMS[k].open) log(` ${line}`);
245
256
  if (opts.platform && opts.platform !== "other") log(` ${PLATFORMS.other.open[0]}`);
246
- for (const line of CHAT_APPS) log(` ${line}`);
257
+ for (const line of DESKTOP_FILESYSTEM) log(` ${line}`);
247
258
  if (!opts.trainingOff) {
248
259
  log("");
249
260
  log(`Model training is not confirmed off yet, so ${agent} asks about it first. Where to turn it off:`);
250
261
  for (const k of chosen) log(` ${PLATFORMS[k].label}: ${PLATFORMS[k].training[0]}${PLATFORMS[k].url ? ` ${PLATFORMS[k].url}` : ""}`);
251
262
  }
252
263
  log("");
264
+ log("Or connect your AI to this record from any folder, through the evda MCP server. No key and no model call in EVÐA:");
265
+ for (const c of clients(dir)) if (c.key !== "project") { log(` ${c.label}:`); for (const l of c.text.split("\n")) log(` ${l}`); }
266
+ if (opts.skill) {
267
+ const r = installSkill({ env: io?.env ?? process.env });
268
+ log("");
269
+ log(`Skill: ${r.action} ${r.path}`);
270
+ }
271
+ for (const key of opts.routines ?? []) {
272
+ log("");
273
+ for (const l of desktopInstructions(key, dir).split("\n")) log(l);
274
+ log(`Or schedule it with launchd on a Mac: evda routine ${key} ${dir} --install`);
275
+ }
276
+ log("");
253
277
  log(`Then freewrite: what happened today, what is on your mind, what felt slightly off. Your words go into record/ as you talk. README.md says the rest.`);
254
278
  if (!asked) log(DICTATION);
255
279
  return 0;
@@ -0,0 +1,48 @@
1
+ // How each AI client adds the EVDA MCP server, given the command that starts it. No node: imports,
2
+ // so the EVDA app's webview renders the same text `evda connect` prints (lib/connect.mjs).
3
+
4
+ export const SERVER_NAME = "evda";
5
+
6
+ const shellQuote = (s) => (/^[A-Za-z0-9_./:@=+-]+$/.test(s) ? s : `'${String(s).replace(/'/g, "'\\''")}'`);
7
+ const tomlString = (s) => JSON.stringify(s);
8
+
9
+ // `desktopConfig` is where claude_desktop_config.json lives on this machine, when known.
10
+ export function clientsFor(command, args, { desktopConfig = null } = {}) {
11
+ const argv = [command, ...args].map(shellQuote).join(" ");
12
+ return [
13
+ {
14
+ key: "claude-code",
15
+ label: "Claude Code",
16
+ steps: ["Run this once, from any folder. --scope user makes your EVÐA available in every Claude Code session."],
17
+ text: `claude mcp add --scope user ${SERVER_NAME} -- ${argv}`,
18
+ file: null,
19
+ },
20
+ {
21
+ key: "codex",
22
+ label: "Codex",
23
+ steps: ["Run this once, or add the block under it to ~/.codex/config.toml."],
24
+ text: [
25
+ `codex mcp add ${SERVER_NAME} -- ${argv}`,
26
+ "",
27
+ `[mcp_servers.${SERVER_NAME}]`,
28
+ `command = ${tomlString(command)}`,
29
+ `args = [${args.map(tomlString).join(", ")}]`,
30
+ ].join("\n"),
31
+ file: "~/.codex/config.toml",
32
+ },
33
+ {
34
+ key: "claude-desktop",
35
+ label: "Claude desktop app",
36
+ steps: [`Open Settings, then Developer, then Edit Config, and add "${SERVER_NAME}" under "mcpServers". Quit the app fully and open it again.`],
37
+ text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { command, args } } }, null, 2),
38
+ file: desktopConfig ?? "claude_desktop_config.json",
39
+ },
40
+ {
41
+ key: "project",
42
+ label: "One folder only (.mcp.json)",
43
+ steps: ["Put this in .mcp.json at the root of a folder, and Claude Code offers the server in that folder only."],
44
+ text: JSON.stringify({ mcpServers: { [SERVER_NAME]: { type: "stdio", command, args } } }, null, 2),
45
+ file: ".mcp.json",
46
+ },
47
+ ];
48
+ }
@@ -0,0 +1,84 @@
1
+ // Connecting an AI to a record: the MCP server command, how each client adds it, and the evda skill.
2
+ //
3
+ // One source for every surface that shows these steps: `evda connect`, the end of create-evda, the
4
+ // README and the EVDA app all read from here, so no two of them can give different config.
5
+ //
6
+ // Checked against each client's own docs on 2026-09-17:
7
+ // Claude Code https://code.claude.com/docs/en/mcp (`claude mcp add ... -- cmd args`, .mcp.json)
8
+ // Codex https://developers.openai.com/codex/mcp (`codex mcp add ... -- cmd`, config.toml)
9
+ // Claude desktop https://modelcontextprotocol.io/quickstart/user (claude_desktop_config.json)
10
+ // Skills https://code.claude.com/docs/en/skills (~/.claude/skills/<name>/SKILL.md)
11
+
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
13
+ import { delimiter, dirname, join, resolve } from "node:path";
14
+ import { homedir } from "node:os";
15
+ import { fileURLToPath } from "node:url";
16
+ import { clientsFor, SERVER_NAME } from "./clients.mjs";
17
+
18
+ const HERE = dirname(fileURLToPath(import.meta.url));
19
+ export const PACKAGE_ROOT = resolve(HERE, "..");
20
+ export const SKILL_NAME = "evda";
21
+ export const SKILL_SOURCE = join(PACKAGE_ROOT, "skills", SKILL_NAME, "SKILL.md");
22
+ export { SERVER_NAME };
23
+
24
+ // The command a client runs to start the server for one record. When this package is installed
25
+ // somewhere stable, the absolute node and bin paths: a desktop app starts servers with a minimal
26
+ // PATH, so a bare `node` or `npx` may not be found. When it is running out of an npx cache, which
27
+ // npm may clear, the npx form instead.
28
+ export function serverCommand(recordDir, { execPath = process.execPath, bin = join(PACKAGE_ROOT, "bin", "evda.mjs"), env = process.env } = {}) {
29
+ const dir = resolve(recordDir);
30
+ let real = bin;
31
+ try { real = realpathSync(bin); } catch { /* not on disk yet, as in a test */ }
32
+ if (/[\\/]_npx[\\/]/.test(real)) return { command: "npx", args: ["-y", "-p", "create-evda@latest", "evda", "mcp", dir] };
33
+ return { command: stableNode(execPath, env), args: [real, "mcp", dir] };
34
+ }
35
+
36
+ // A `node` on PATH that is this same binary, when there is one: Homebrew's /opt/homebrew/bin/node
37
+ // survives an upgrade, and the versioned Cellar path process.execPath reports does not.
38
+ function stableNode(execPath, env) {
39
+ let target;
40
+ try { target = realpathSync(execPath); } catch { return execPath; }
41
+ for (const d of String(env.PATH ?? "").split(delimiter)) {
42
+ if (!d) continue;
43
+ const candidate = join(d, process.platform === "win32" ? "node.exe" : "node");
44
+ try { if (realpathSync(candidate) === target) return candidate; } catch { /* not here */ }
45
+ }
46
+ return execPath;
47
+ }
48
+
49
+ export function desktopConfigPath(platform = process.platform, env = process.env) {
50
+ if (platform === "darwin") return join(env.HOME ?? homedir(), "Library", "Application Support", "Claude", "claude_desktop_config.json");
51
+ if (platform === "win32") return join(env.APPDATA ?? join(homedir(), "AppData", "Roaming"), "Claude", "claude_desktop_config.json");
52
+ return null;
53
+ }
54
+
55
+ // Every client's setup for one record, as data. `text` fields are what a person copies.
56
+ export function clients(recordDir, opts = {}) {
57
+ const { command, args } = serverCommand(recordDir, opts);
58
+ return clientsFor(command, args, { desktopConfig: desktopConfigPath() });
59
+ }
60
+
61
+ export function connectText(recordDir, opts = {}) {
62
+ const lines = [`Connect your AI to ${resolve(recordDir)}. No key and no model call in EVDA: your AI reads and writes the record through these tools.`, ""];
63
+ for (const c of clients(recordDir, opts)) {
64
+ lines.push(`${c.label}:`);
65
+ for (const s of c.steps) lines.push(` ${s}`);
66
+ lines.push("", ...c.text.split("\n").map((l) => ` ${l}`), "");
67
+ }
68
+ lines.push(`Then install the skill for Claude Code so it acts as Iris through these tools: evda skill install`);
69
+ return lines.join("\n");
70
+ }
71
+
72
+ // Copy the evda skill into Claude Code's skills folder: the person's (~/.claude/skills) or one
73
+ // project's (<dir>/.claude/skills). An existing copy with the same bytes is left alone; a different
74
+ // one is replaced, because the skill is the installer's and carries no owner edits.
75
+ export function installSkill({ project = null, env = process.env } = {}) {
76
+ const base = project ? join(project, ".claude", "skills") : join(env.HOME ?? homedir(), ".claude", "skills");
77
+ const path = join(base, SKILL_NAME, "SKILL.md");
78
+ const shipped = readFileSync(SKILL_SOURCE, "utf8");
79
+ if (existsSync(path) && readFileSync(path, "utf8") === shipped) return { path, action: "already current:" };
80
+ const action = existsSync(path) ? "updated" : "installed";
81
+ mkdirSync(dirname(path), { recursive: true });
82
+ writeFileSync(path, shipped);
83
+ return { path, action };
84
+ }
package/lib/mcp.mjs ADDED
@@ -0,0 +1,258 @@
1
+ // `evda mcp`: the record as an MCP server over stdio, so the AI someone already uses (Claude Code,
2
+ // Codex, the Claude desktop app) can read and write their EVDA.
3
+ //
4
+ // No SDK. The stdio transport is newline-delimited JSON-RPC 2.0 on stdin and stdout, with logs on
5
+ // stderr, and a server that only answers requests needs initialize, ping, tools and resources. That
6
+ // is small enough to write here and keep the package at zero runtime dependencies
7
+ // (test/deps.test.mjs), which is what lets `npx create-evda` install nothing but itself.
8
+ //
9
+ // stdout carries protocol messages only. Nothing in this file or the tools it calls prints to
10
+ // stdout; doctor's report is captured into the tool result.
11
+
12
+ import { createInterface } from "node:readline";
13
+ import { SCHEMA_VERSION } from "./manifest.mjs";
14
+ import { openEvda } from "./manifest.mjs";
15
+ import { EvdaError } from "./errors.mjs";
16
+ import * as tools from "./tools.mjs";
17
+
18
+ // Newest first. The server answers with the client's version when it supports it, and with the
19
+ // newest otherwise; the client decides whether to continue.
20
+ export const PROTOCOL_VERSIONS = ["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"];
21
+ export const SERVER_NAME = "evda";
22
+
23
+ const INSTRUCTIONS = [
24
+ "This server is the owner's EVDA: their record, in plain files on this machine.",
25
+ "Call evda_operating_layer first and follow it for the whole session: it is who you are (Iris unless they renamed you) and how the record is kept.",
26
+ "File the owner's words verbatim with evda_write in the same turn they say them, and report the path it returns in the past tense.",
27
+ "Never quiz them. Never create a circle or grant; propose one with evda_propose_circle only when they ask to share, and wait for their yes.",
28
+ ].join(" ");
29
+
30
+ const str = (description) => ({ type: "string", description });
31
+
32
+ export const TOOLS = [
33
+ {
34
+ name: "evda_operating_layer",
35
+ title: "Read the operating layer",
36
+ description: "Returns AGENTS.md, the record's operating layer: who the agent is, what this EVDA is for in the owner's words, and how to keep the record. Read it first, every session.",
37
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
38
+ annotations: { readOnlyHint: true, openWorldHint: false },
39
+ run: (evda, args) => {
40
+ const r = tools.operatingLayer(evda, args);
41
+ return { text: r.text, data: { path: r.path } };
42
+ },
43
+ },
44
+ {
45
+ name: "evda_recent",
46
+ title: "Read the last few days",
47
+ description: "Returns every record file dated in the last N days (default 3), newest first, whole.",
48
+ inputSchema: { type: "object", properties: { days: { type: "integer", minimum: 1, maximum: 90, description: "How many days back, today included. Default 3." } }, additionalProperties: false },
49
+ annotations: { readOnlyHint: true, openWorldHint: false },
50
+ run: (evda, args) => {
51
+ const r = tools.recent(evda, args);
52
+ const body = r.files.map((f) => `<file path="${f.path}" date="${f.date}">\n${f.text}\n</file>`).join("\n\n");
53
+ const head = `${r.files.length} file(s) dated ${r.from} to ${r.to}${r.omitted ? `, ${r.omitted} more omitted for size (read them with evda_search)` : ""}.`;
54
+ return { text: r.files.length ? `${head}\n\n${body}` : `Nothing in the record is dated ${r.from} to ${r.to}.`, data: { from: r.from, to: r.to, paths: r.files.map((f) => f.path), omitted: r.omitted } };
55
+ },
56
+ },
57
+ {
58
+ name: "evda_write",
59
+ title: "File the owner's words",
60
+ description: "Appends the owner's words, exactly as they said them, to today's daily file under the local time, and returns the path. Append only: it takes no path and never edits or replaces anything. Refuses anything shaped like a password, key or token.",
61
+ inputSchema: { type: "object", properties: { words: str("The owner's words, verbatim: filler, hedges and typos intact. Never a summary.") }, required: ["words"], additionalProperties: false },
62
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
63
+ run: (evda, args) => {
64
+ const r = tools.write(evda, args);
65
+ return { text: `Filed to ${r.path}.`, data: r };
66
+ },
67
+ },
68
+ {
69
+ name: "evda_import",
70
+ title: "File an import from another AI",
71
+ description: "Files what another AI (ChatGPT, Claude, Gemini...) said it knows about the owner, verbatim, under imports/ with its source and date, and returns the path. An import is not the owner's words; each line is a claim to check with them over time.",
72
+ inputSchema: {
73
+ type: "object",
74
+ properties: { text: str("The other AI's answer, verbatim."), source: str("Which AI or app said it."), date: str("YYYY-MM-DD the other AI wrote it. Default today.") },
75
+ required: ["text", "source"],
76
+ additionalProperties: false,
77
+ },
78
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
79
+ run: (evda, args) => {
80
+ const r = tools.importText(evda, args);
81
+ return { text: `Filed the import to ${r.path}.`, data: r };
82
+ },
83
+ },
84
+ {
85
+ name: "evda_search",
86
+ title: "Search the record",
87
+ description: "Plain text search, case-insensitive, over the record, its imports and the ledger, on this machine. Nothing leaves it. Returns each matching line with its path and line number.",
88
+ inputSchema: { type: "object", properties: { query: str("Words to find."), limit: { type: "integer", minimum: 1, maximum: 500, description: "Most lines to return. Default 50." } }, required: ["query"], additionalProperties: false },
89
+ annotations: { readOnlyHint: true, openWorldHint: false },
90
+ run: (evda, args) => {
91
+ const r = tools.search(evda, args);
92
+ const lines = r.matches.map((m) => `${m.path}:${m.line}: ${m.text}`);
93
+ const tail = r.more ? `\n${r.more} more match(es) past the limit.` : "";
94
+ return { text: r.matches.length ? `${r.matches.length} match(es) in ${r.searched} file(s):\n${lines.join("\n")}${tail}` : `No match for ${JSON.stringify(r.query)} in ${r.searched} file(s).`, data: r };
95
+ },
96
+ },
97
+ {
98
+ name: "evda_circles",
99
+ title: "List circles",
100
+ description: "Lists the record's circles (the parts the owner chose to share), who may read each, what it covers and its policy rules. Read only.",
101
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
102
+ annotations: { readOnlyHint: true, openWorldHint: false },
103
+ run: (evda, args) => {
104
+ const r = tools.circles(evda, args);
105
+ if (!r.role) return { text: "This record has no circles layer. Everything in it is private.", data: r };
106
+ if (!r.circles.length) return { text: "No circles yet. Everything in the record is private.", data: r };
107
+ const lines = r.circles.map((c) => [
108
+ `${c.name} (${c.public ? "public" : "private"}): ${c.description ?? "no description"}`,
109
+ ` members: ${c.members.join(", ") || "none"}; maintainers: ${c.maintainers.join(", ") || "none"}`,
110
+ ...c.policy.map((p) => ` rule [${p.status}] ${p.rule}`),
111
+ ...c.problems.map((p) => ` problem: ${p}`),
112
+ ].join("\n"));
113
+ return { text: lines.join("\n"), data: r };
114
+ },
115
+ },
116
+ {
117
+ name: "evda_propose_circle",
118
+ title: "Propose a circle",
119
+ description: "Writes a proposal for a circle as a file under proposals/ for the owner to read. It never creates the circle, grants anyone access, or moves an entry: those wait for the owner's explicit yes. Use it only when the owner asks to share something.",
120
+ inputSchema: {
121
+ type: "object",
122
+ properties: {
123
+ name: str("Short name for the circle: letters, digits, - and _."),
124
+ who: str("Who may read it, in plain words."),
125
+ what: str("What it covers."),
126
+ policy: { type: "array", items: { type: "string" }, description: "Its rules, one sentence each, in the owner's words where possible." },
127
+ why: str("Why the owner wants it, in their words."),
128
+ },
129
+ required: ["name", "who", "what"],
130
+ additionalProperties: false,
131
+ },
132
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
133
+ run: (evda, args) => {
134
+ const r = tools.proposeCircle(evda, args);
135
+ return { text: `Wrote the proposal to ${r.path}. No circle was created and nobody was granted anything; that waits for the owner's yes.`, data: r };
136
+ },
137
+ },
138
+ {
139
+ name: "evda_doctor",
140
+ title: "Check the record",
141
+ description: "Runs evda doctor on the record and returns its report. Exit 0 means well formed.",
142
+ inputSchema: { type: "object", properties: {}, additionalProperties: false },
143
+ annotations: { readOnlyHint: true, openWorldHint: false },
144
+ run: (evda, args) => {
145
+ const r = tools.doctorReport(evda, args);
146
+ return { text: `${r.text}\n\nexit ${r.exit}`, data: { exit: r.exit }, isError: r.exit !== 0 };
147
+ },
148
+ },
149
+ ];
150
+
151
+ export const RESOURCES = [
152
+ { uri: "evda://agents", name: "AGENTS.md", title: "The operating layer", mimeType: "text/markdown", read: (evda) => tools.operatingLayer(evda).text },
153
+ {
154
+ uri: "evda://today",
155
+ name: "today",
156
+ title: "Today's daily file",
157
+ mimeType: "text/markdown",
158
+ read: (evda) => {
159
+ const r = tools.recent(evda, { days: 1 });
160
+ const daily = r.files.find((f) => /(^|\/)\d{4}-\d{2}-\d{2}\.md$/.test(f.path) && f.date === r.to);
161
+ return daily ? daily.text : `# ${r.to}\n\nNothing written today yet.\n`;
162
+ },
163
+ },
164
+ ];
165
+
166
+ const RPC = { PARSE: -32700, INVALID: -32600, METHOD: -32601, PARAMS: -32602, INTERNAL: -32603 };
167
+
168
+ // One request in, one response out (or null for a notification). Pure apart from the tools' own
169
+ // file writes, so tests drive it without a process.
170
+ export function handle(msg, ctx) {
171
+ const isRequest = msg && typeof msg === "object" && msg.jsonrpc === "2.0" && typeof msg.method === "string";
172
+ const hasId = msg && Object.prototype.hasOwnProperty.call(msg, "id") && msg.id !== null;
173
+ if (!isRequest) return hasId || !msg ? error(msg?.id ?? null, RPC.INVALID, "not a JSON-RPC 2.0 request") : null;
174
+ if (!hasId) return null; // notifications: initialized, cancelled. Nothing to answer.
175
+ const ok = (result) => ({ jsonrpc: "2.0", id: msg.id, result });
176
+ const p = msg.params ?? {};
177
+ try {
178
+ switch (msg.method) {
179
+ case "initialize": {
180
+ const asked = p.protocolVersion;
181
+ return ok({
182
+ protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
183
+ capabilities: { tools: { listChanged: false }, resources: { listChanged: false, subscribe: false } },
184
+ serverInfo: { name: SERVER_NAME, title: "EVDA", version: ctx.version },
185
+ instructions: INSTRUCTIONS,
186
+ });
187
+ }
188
+ case "ping":
189
+ return ok({});
190
+ case "tools/list":
191
+ return ok({ tools: TOOLS.map(({ run, ...t }) => t) });
192
+ case "tools/call": {
193
+ const tool = TOOLS.find((t) => t.name === p.name);
194
+ if (!tool) return error(msg.id, RPC.PARAMS, `unknown tool ${JSON.stringify(p.name)}`);
195
+ const args = p.arguments ?? {};
196
+ if (typeof args !== "object" || Array.isArray(args)) return error(msg.id, RPC.PARAMS, "arguments must be an object");
197
+ try {
198
+ const r = tool.run(ctx.evda(), args);
199
+ // Text only. A client that finds structuredContent may show the model that instead of the text
200
+ // (Claude Code did, 2026-09-17: evda_operating_layer reached the model as {"path":"AGENTS.md"}),
201
+ // so everything the model needs, paths included, is in the text.
202
+ return ok({ content: [{ type: "text", text: r.text }], isError: Boolean(r.isError) });
203
+ } catch (e) {
204
+ // A refusal is a tool result the model reads and acts on, not a protocol failure.
205
+ if (e instanceof EvdaError) return ok({ content: [{ type: "text", text: e.message }], isError: true });
206
+ throw e;
207
+ }
208
+ }
209
+ case "resources/list":
210
+ return ok({ resources: RESOURCES.map(({ read, ...r }) => r) });
211
+ case "resources/read": {
212
+ const res = RESOURCES.find((r) => r.uri === p.uri);
213
+ if (!res) return error(msg.id, -32002, `resource not found: ${p.uri}`);
214
+ return ok({ contents: [{ uri: res.uri, mimeType: res.mimeType, text: res.read(ctx.evda()) }] });
215
+ }
216
+ case "resources/templates/list":
217
+ return ok({ resourceTemplates: [] });
218
+ case "prompts/list":
219
+ return ok({ prompts: [] });
220
+ default:
221
+ return error(msg.id, RPC.METHOD, `method not found: ${msg.method}`);
222
+ }
223
+ } catch (e) {
224
+ ctx.log?.(e.stack ?? String(e));
225
+ return error(msg.id, RPC.INTERNAL, e instanceof EvdaError ? e.message : "internal error; the server's stderr has the detail");
226
+ }
227
+ }
228
+
229
+ function error(id, code, message) {
230
+ return { jsonrpc: "2.0", id, error: { code, message } };
231
+ }
232
+
233
+ // Serve on stdio until stdin closes. `dir` is the located record; it is reopened for every call so
234
+ // a record edited by hand between calls is read as it is now.
235
+ export function serve({ dir, version, input = process.stdin, output = process.stdout, log = (s) => process.stderr.write(`[evda mcp] ${s}\n`) }) {
236
+ const ctx = { version, log, evda: () => openEvda(dir) };
237
+ openEvda(dir); // refuse to start on something that is not a record
238
+ log(`serving ${dir} (tool schema ${SCHEMA_VERSION})`);
239
+ const rl = createInterface({ input, crlfDelay: Infinity });
240
+ const send = (m) => output.write(`${JSON.stringify(m)}\n`);
241
+ rl.on("line", (line) => {
242
+ if (line.trim() === "") return;
243
+ let msg;
244
+ try {
245
+ msg = JSON.parse(line);
246
+ } catch {
247
+ return send(error(null, RPC.PARSE, "parse error"));
248
+ }
249
+ if (Array.isArray(msg)) {
250
+ const out = msg.map((m) => handle(m, ctx)).filter(Boolean);
251
+ if (out.length) send(out);
252
+ return;
253
+ }
254
+ const r = handle(msg, ctx);
255
+ if (r) send(r);
256
+ });
257
+ return new Promise((resolveDone) => rl.on("close", resolveDone));
258
+ }