@balacode/mental 0.2.0

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.
Files changed (53) hide show
  1. package/.claude-plugin/marketplace.json +17 -0
  2. package/.claude-plugin/plugin.json +22 -0
  3. package/.cursor-plugin/plugin.json +21 -0
  4. package/.mcp.json +8 -0
  5. package/CHANGELOG.md +42 -0
  6. package/LICENSE +21 -0
  7. package/README.md +277 -0
  8. package/assets/logo.svg +19 -0
  9. package/bin/cli.mjs +135 -0
  10. package/bin/commands/attention.mjs +139 -0
  11. package/bin/commands/decide.mjs +104 -0
  12. package/bin/commands/doctor.mjs +150 -0
  13. package/bin/commands/heartbeat.mjs +21 -0
  14. package/bin/commands/hooks.mjs +41 -0
  15. package/bin/commands/install.mjs +86 -0
  16. package/bin/commands/journal.mjs +54 -0
  17. package/bin/commands/link.mjs +18 -0
  18. package/bin/commands/list.mjs +51 -0
  19. package/bin/commands/local.mjs +118 -0
  20. package/bin/commands/note.mjs +61 -0
  21. package/bin/commands/reindex.mjs +48 -0
  22. package/bin/commands/remap.mjs +76 -0
  23. package/bin/commands/search.mjs +55 -0
  24. package/bin/commands/serve.mjs +16 -0
  25. package/bin/commands/show.mjs +61 -0
  26. package/bin/commands/split.mjs +56 -0
  27. package/bin/commands/status.mjs +136 -0
  28. package/bin/commands/uninstall.mjs +58 -0
  29. package/bin/commands/where.mjs +29 -0
  30. package/bin/lib/args.mjs +117 -0
  31. package/bin/lib/bindings.mjs +404 -0
  32. package/bin/lib/entry.mjs +35 -0
  33. package/bin/lib/git.mjs +149 -0
  34. package/bin/lib/heartbeat.mjs +118 -0
  35. package/bin/lib/hooks.mjs +144 -0
  36. package/bin/lib/ignore.mjs +122 -0
  37. package/bin/lib/import-legacy.mjs +183 -0
  38. package/bin/lib/index.mjs +574 -0
  39. package/bin/lib/install-cli.mjs +100 -0
  40. package/bin/lib/install-skills.mjs +120 -0
  41. package/bin/lib/mcp.mjs +389 -0
  42. package/bin/lib/okf.mjs +746 -0
  43. package/bin/lib/output.mjs +112 -0
  44. package/bin/lib/pkg.mjs +22 -0
  45. package/bin/lib/resolve.mjs +302 -0
  46. package/bin/lib/uninstall.mjs +56 -0
  47. package/hooks/session-start.sh +4 -0
  48. package/mcp.json +11 -0
  49. package/package.json +43 -0
  50. package/plugin.json +21 -0
  51. package/rules/mental.mdc +18 -0
  52. package/skills/mental/SKILL.md +277 -0
  53. package/skills/mental/references/templates.md +186 -0
@@ -0,0 +1,76 @@
1
+ /**
2
+ * `mental remap` — list bindings, or point this clone at an existing UUID.
3
+ * Flags, not a standing TTY session: `--to <id>` or `--from <id>`.
4
+ */
5
+ import { findGitRoot, getRemoteUrl } from "../lib/git.mjs";
6
+ import { loadBindings, remapToBinding } from "../lib/bindings.mjs";
7
+ import { printResult } from "../lib/output.mjs";
8
+
9
+ export function formatBindings(home) {
10
+ const data = loadBindings(home);
11
+ if (!data.bindings.length) return "(no bindings)";
12
+ return data.bindings
13
+ .map((b) =>
14
+ [
15
+ `${b.id} ${b.name || ""}`.trim(),
16
+ ` origins ${(b.origins || []).join(", ") || "—"}`,
17
+ ` paths ${(b.paths || []).join(", ") || "—"}`,
18
+ b.store ? ` store ${b.store}` : null,
19
+ ]
20
+ .filter(Boolean)
21
+ .join("\n"),
22
+ )
23
+ .join("\n\n");
24
+ }
25
+
26
+ export function cmdRemap(args, io = {}) {
27
+ const stdout = io.stdout ?? process.stdout;
28
+ const home = args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null;
29
+ const cwd = args.cwd ?? process.cwd();
30
+ const env = args.env ?? process.env;
31
+ if (!home) {
32
+ printResult(stdout, args.json, false, undefined, {
33
+ code: "no-home",
34
+ message: "HOME is unset; Mental will not write.",
35
+ });
36
+ return 1;
37
+ }
38
+
39
+ const to =
40
+ (typeof args.flags?.to === "string" && args.flags.to) ||
41
+ (typeof args.flags?.from === "string" && args.flags.from) ||
42
+ null;
43
+
44
+ if (!to) {
45
+ const list = formatBindings(home);
46
+ printResult(stdout, args.json, true, { bindings: loadBindings(home).bindings }, undefined, () =>
47
+ `${list}\n\nPoint this clone: mental remap --to <id>`,
48
+ );
49
+ return 0;
50
+ }
51
+
52
+ const gitRoot = findGitRoot(cwd, { env });
53
+ if (!gitRoot) {
54
+ printResult(stdout, args.json, false, undefined, {
55
+ code: "not-git",
56
+ message: "mental remap needs a git repository.",
57
+ });
58
+ return 1;
59
+ }
60
+
61
+ const origin = getRemoteUrl(gitRoot, "origin", { env });
62
+ const result = remapToBinding({ home, gitRoot, toId: to, origin });
63
+ if (!result.ok) {
64
+ printResult(stdout, args.json, false, undefined, { code: result.code, message: result.message });
65
+ return 1;
66
+ }
67
+ printResult(
68
+ stdout,
69
+ args.json,
70
+ true,
71
+ { id: result.id, gitRoot, origin },
72
+ undefined,
73
+ () => `this clone → ${result.id}`,
74
+ );
75
+ return 0;
76
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `mental search` — query the derived index (sqlite) or scan OKF files.
3
+ */
4
+ import { resolveBundle } from "../lib/resolve.mjs";
5
+ import { searchBundle } from "../lib/index.mjs";
6
+ import { printResult } from "../lib/output.mjs";
7
+
8
+ export function cmdSearch(args, io = {}) {
9
+ const stdout = io.stdout ?? process.stdout;
10
+ const q = args.rest.join(" ").trim();
11
+ if (!q) {
12
+ printResult(stdout, args.json, false, undefined, {
13
+ code: "usage",
14
+ message: "mental search requires a query",
15
+ });
16
+ return 1;
17
+ }
18
+ const resolved = resolveBundle({
19
+ cwd: args.cwd ?? process.cwd(),
20
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
21
+ env: args.env ?? process.env,
22
+ dir: args.dir ?? null,
23
+ write: false,
24
+ });
25
+ if (!resolved.ok) {
26
+ printResult(stdout, args.json, false, undefined, resolved.error);
27
+ return 1;
28
+ }
29
+ const type = typeof args.flags?.type === "string" ? args.flags.type : undefined;
30
+ const status = typeof args.flags?.status === "string" ? args.flags.status : undefined;
31
+ const tag = typeof args.flags?.tag === "string" ? args.flags.tag : undefined;
32
+ const kind = typeof args.flags?.kind === "string" ? args.flags.kind : undefined;
33
+ const found = searchBundle({
34
+ root: resolved.data.root,
35
+ id: resolved.data.id,
36
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
37
+ env: args.env ?? process.env,
38
+ q,
39
+ type,
40
+ status,
41
+ tag,
42
+ kind,
43
+ });
44
+ const data = { ...resolved.data, q, ...found };
45
+ printResult(stdout, args.json, true, data, undefined, (d) => {
46
+ if (d.hits.length === 0) return `no hits for ${d.q} (${d.backend})`;
47
+ return d.hits
48
+ .map((h) => {
49
+ const line = `[${h.type}] ${h.title} (${h.path})`;
50
+ return h.snippet ? `${line}\n ${h.snippet}` : line;
51
+ })
52
+ .join("\n");
53
+ });
54
+ return 0;
55
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * `mental serve` — optional MCP stdio wrapping the CLI commands
3
+ * (heartbeat/where/status/search/list/show/journal/attention/decide/note).
4
+ */
5
+ import { serveMcp } from "../lib/mcp.mjs";
6
+
7
+ export function cmdServe(args, io = {}) {
8
+ return serveMcp({
9
+ cwd: args.cwd,
10
+ home: args.home,
11
+ env: args.env,
12
+ dir: args.dir ?? null,
13
+ stdin: io.stdin,
14
+ stdout: io.stdout,
15
+ });
16
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * `mental show <path>` — one OKF file relative to the bundle root.
3
+ */
4
+ import { resolveBundle } from "../lib/resolve.mjs";
5
+ import { readBundleFile } from "../lib/okf.mjs";
6
+ import { listBacklinks } from "../lib/index.mjs";
7
+ import { printResult, kindLine } from "../lib/output.mjs";
8
+
9
+ export function cmdShow(args, io = {}) {
10
+ const stdout = io.stdout ?? process.stdout;
11
+ const rel = (args.rest[0] || (typeof args.flags?.path === "string" ? args.flags.path : "")).trim();
12
+ if (!rel) {
13
+ printResult(stdout, args.json, false, undefined, {
14
+ code: "usage",
15
+ message: "mental show requires a path relative to the bundle root",
16
+ });
17
+ return 1;
18
+ }
19
+ const resolved = resolveBundle({
20
+ cwd: args.cwd ?? process.cwd(),
21
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
22
+ env: args.env ?? process.env,
23
+ dir: args.dir ?? null,
24
+ write: false,
25
+ });
26
+ if (!resolved.ok) {
27
+ printResult(stdout, args.json, false, undefined, resolved.error);
28
+ return 1;
29
+ }
30
+ const file = readBundleFile(resolved.data.root, rel);
31
+ if (!file.ok) {
32
+ printResult(stdout, args.json, false, undefined, file.error);
33
+ return 1;
34
+ }
35
+ const home = args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null;
36
+ const backlinks = listBacklinks({
37
+ root: resolved.data.root,
38
+ path: file.data.path,
39
+ id: resolved.data.id,
40
+ home,
41
+ env: args.env ?? process.env,
42
+ });
43
+ const payload = {
44
+ ...resolved.data,
45
+ path: file.data.path,
46
+ frontmatter: file.data.data,
47
+ body: file.data.body,
48
+ backlinks,
49
+ };
50
+ printResult(stdout, args.json, true, payload, undefined, (d) => {
51
+ const title = typeof d.frontmatter.title === "string" ? d.frontmatter.title : d.path;
52
+ const type = typeof d.frontmatter.type === "string" ? d.frontmatter.type : "";
53
+ const head = type ? `${title} [${type}]` : title;
54
+ const linked =
55
+ d.backlinks.length === 0
56
+ ? ""
57
+ : `\n\nLinked from:\n${d.backlinks.map((b) => ` [${b.type}] ${b.title} (${b.path})`).join("\n")}`;
58
+ return `${kindLine("read", head)}\n${d.path}\n\n${d.body.trim() || "(empty)"}${linked}`;
59
+ });
60
+ return 0;
61
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `mental split` — this clone gets a new UUID (empty or `--copy` of current OKF).
3
+ */
4
+ import { findGitRoot, getRemoteUrl } from "../lib/git.mjs";
5
+ import { projectSliceDir, splitBinding } from "../lib/bindings.mjs";
6
+ import { bundleName, copyOkfTree, ensureSkeleton } from "../lib/okf.mjs";
7
+ import { resolveBundle } from "../lib/resolve.mjs";
8
+ import { printResult } from "../lib/output.mjs";
9
+
10
+ export function cmdSplit(args, io = {}) {
11
+ const stdout = io.stdout ?? process.stdout;
12
+ const home = args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null;
13
+ const cwd = args.cwd ?? process.cwd();
14
+ const env = args.env ?? process.env;
15
+ if (!home) {
16
+ printResult(stdout, args.json, false, undefined, {
17
+ code: "no-home",
18
+ message: "HOME is unset; Mental will not write.",
19
+ });
20
+ return 1;
21
+ }
22
+
23
+ const gitRoot = findGitRoot(cwd, { env });
24
+ if (!gitRoot) {
25
+ printResult(stdout, args.json, false, undefined, {
26
+ code: "not-git",
27
+ message: "mental split needs a git repository.",
28
+ });
29
+ return 1;
30
+ }
31
+
32
+ const copy = Boolean(args.flags?.copy);
33
+ const prior = resolveBundle({ cwd, home, env, dir: args.dir ?? null, write: true });
34
+ if (!prior.ok) {
35
+ printResult(stdout, args.json, false, undefined, prior.error);
36
+ return 1;
37
+ }
38
+ const fromId = prior.data.id;
39
+ const fromRoot = prior.data.root;
40
+
41
+ const origin = getRemoteUrl(gitRoot, "origin", { env });
42
+ const split = splitBinding({ home, gitRoot, origin });
43
+ const dest = split.dest;
44
+ if (copy && fromRoot) copyOkfTree(fromRoot, dest);
45
+ ensureSkeleton(dest, { name: bundleName(dest, split.id) });
46
+
47
+ printResult(
48
+ stdout,
49
+ args.json,
50
+ true,
51
+ { id: split.id, fromId, gitRoot, copied: copy, root: dest, dest: projectSliceDir(home, split.id) },
52
+ undefined,
53
+ () => `split ${fromId || "—"} → ${split.id}${copy ? " (copied OKF)" : ""}`,
54
+ );
55
+ return 0;
56
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * `mental status` — derive git + latest Resume + open/deferred decisions + notes.
3
+ * Writes `status/current.md` as a disposable cache. OKF files remain SoT.
4
+ */
5
+ import { writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { resolveBundle } from "../lib/resolve.mjs";
8
+ import { gitSnapshot } from "../lib/git.mjs";
9
+ import {
10
+ bundleName,
11
+ ensureSkeleton,
12
+ latestJournalHandoff,
13
+ listNotes,
14
+ listOpenAttention,
15
+ listOpenDecisions,
16
+ localDate,
17
+ renderStatus,
18
+ } from "../lib/okf.mjs";
19
+ import { printResult } from "../lib/output.mjs";
20
+
21
+ function formatGit(git, gitRoot) {
22
+ if (!gitRoot) return "Not a git repository.";
23
+ const branch = git.branch || "(unknown branch)";
24
+ const dirty = git.dirty ? "uncommitted changes" : "clean";
25
+ const recent = git.recent.length ? git.recent.map((l) => ` ${l}`).join("\n") : " (no commits)";
26
+ return `${branch}; ${dirty}\n${recent}`;
27
+ }
28
+
29
+ function formatHuman(data) {
30
+ const air =
31
+ data.attention.length === 0
32
+ ? " none"
33
+ : data.attention.map((a) => {
34
+ const tag = a.status === "later" ? "later" : a.kind || a.status;
35
+ return ` - [${tag}] ${a.title} (${a.path})`;
36
+ }).join("\n");
37
+ const dec =
38
+ data.openDecisions.length === 0
39
+ ? " none"
40
+ : data.openDecisions.map((d) => ` - [${d.status}] ${d.title} (${d.path})`).join("\n");
41
+ const notes =
42
+ data.notes.length === 0
43
+ ? " none"
44
+ : data.notes.map((n) => ` - ${n.title} (${n.path})`).join("\n");
45
+ return [
46
+ `root: ${data.root}`,
47
+ `mode: ${data.mode}`,
48
+ `git: ${data.git.branch || "—"} ${data.git.dirty ? "(dirty)" : "(clean)"}`,
49
+ `resume: ${data.resume || "—"}`,
50
+ `against: ${data.against || "—"}`,
51
+ `now: ${data.latestOutcome || "—"}`,
52
+ `in the air:`,
53
+ air,
54
+ `unsettled:`,
55
+ dec,
56
+ `notes:`,
57
+ notes,
58
+ ].join("\n");
59
+ }
60
+
61
+ /**
62
+ * @param {{ json: boolean, dir?: string, cwd?: string, home?: string, env?: NodeJS.ProcessEnv }} args
63
+ * @returns {number}
64
+ */
65
+ export function cmdStatus(args, io = {}) {
66
+ const stdout = io.stdout ?? process.stdout;
67
+ const resolved = resolveBundle({
68
+ cwd: args.cwd ?? process.cwd(),
69
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
70
+ env: args.env ?? process.env,
71
+ dir: args.dir ?? null,
72
+ write: true,
73
+ });
74
+ if (!resolved.ok) {
75
+ printResult(stdout, args.json, false, undefined, resolved.error);
76
+ return 1;
77
+ }
78
+
79
+ const { data: where } = resolved;
80
+ const now = new Date();
81
+ ensureSkeleton(where.root, { name: bundleName(where.root, where.id || "project"), now });
82
+ const git = gitSnapshot(where.gitRoot, { env: args.env ?? process.env });
83
+ const handoff = latestJournalHandoff(where.root);
84
+ const openDecisions = listOpenDecisions(where.root);
85
+ const attention = listOpenAttention(where.root);
86
+ const notes = listNotes(where.root);
87
+ const name = bundleName(where.root, where.id || "project");
88
+ const inFlight = formatGit(git, where.gitRoot);
89
+ const resume = handoff.resume || "No journal yet — start work, then `mental journal` at the task boundary.";
90
+ const latestOutcome = handoff.outcome || "No journal sections yet.";
91
+ const against = handoff.against || null;
92
+
93
+ writeFileSync(
94
+ join(where.root, "status", "current.md"),
95
+ renderStatus({
96
+ name,
97
+ date: localDate(now),
98
+ ts: now.toISOString(),
99
+ now: latestOutcome,
100
+ inFlight,
101
+ decisions: openDecisions.map((d) => ({
102
+ title: d.title,
103
+ file: d.file,
104
+ status: d.status,
105
+ })),
106
+ attention: attention.map((a) => ({
107
+ title: a.title,
108
+ file: a.file,
109
+ status: a.status,
110
+ kind: a.kind,
111
+ })),
112
+ notes: notes.map((n) => ({
113
+ title: n.title,
114
+ file: n.file,
115
+ status: n.status,
116
+ description: n.description,
117
+ })),
118
+ resume,
119
+ against,
120
+ }),
121
+ );
122
+
123
+ const payload = {
124
+ ...where,
125
+ git: { branch: git.branch, dirty: git.dirty, recent: git.recent },
126
+ resume,
127
+ latestOutcome,
128
+ against,
129
+ attention,
130
+ openDecisions,
131
+ notes,
132
+ statusFile: "status/current.md",
133
+ };
134
+ printResult(stdout, args.json, true, payload, undefined, formatHuman);
135
+ return 0;
136
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * `mental uninstall` — remove skill/rule/hooks Mental copied into user agent dirs.
3
+ * Does not delete ~/.mental unless `--delete-data DELETE`.
4
+ */
5
+ import { existsSync, rmSync } from "node:fs";
6
+ import { userMentalDir } from "../lib/bindings.mjs";
7
+ import { uninstallSkills } from "../lib/uninstall.mjs";
8
+ import { disableHooks } from "../lib/hooks.mjs";
9
+ import { disableMcp } from "../lib/mcp.mjs";
10
+ import { printResult } from "../lib/output.mjs";
11
+
12
+ export function cmdUninstall(args, io = {}) {
13
+ const stdout = io.stdout ?? process.stdout;
14
+ const home = args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null;
15
+ if (!home) {
16
+ printResult(stdout, args.json, false, undefined, {
17
+ code: "no-home",
18
+ message: "HOME is unset; nothing to uninstall.",
19
+ });
20
+ return 1;
21
+ }
22
+
23
+ const deleteData = Boolean(args.flags?.["delete-data"]);
24
+ const confirm = typeof args.flags?.confirm === "string" ? args.flags.confirm : args.rest[0] || "";
25
+ if (deleteData && confirm !== "DELETE") {
26
+ printResult(stdout, args.json, false, undefined, {
27
+ code: "usage",
28
+ message: "Refusing to delete OKF. Pass --delete-data --confirm DELETE to wipe ~/.mental.",
29
+ });
30
+ return 1;
31
+ }
32
+
33
+ const skills = uninstallSkills({
34
+ home,
35
+ projectDir: args.flags?.project ? args.cwd ?? process.cwd() : null,
36
+ });
37
+ const hooks = disableHooks(home);
38
+ const mcp = disableMcp(home);
39
+ let wiped = null;
40
+ if (deleteData && confirm === "DELETE") {
41
+ const root = userMentalDir(home);
42
+ if (existsSync(root)) {
43
+ rmSync(root, { recursive: true, force: true });
44
+ wiped = root;
45
+ }
46
+ }
47
+
48
+ printResult(
49
+ stdout,
50
+ args.json,
51
+ true,
52
+ { removed: skills.removed, hooks: hooks.written, mcp: mcp.written, wiped },
53
+ undefined,
54
+ () =>
55
+ `removed ${skills.removed.length} skill/rule path(s)${wiped ? `\nwiped ${wiped}` : "\nOKF left in place (~/.mental)"}`,
56
+ );
57
+ return 0;
58
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * `mental where` — print the active OKF bundle. Agents call this first.
3
+ */
4
+ import { resolveBundle } from "../lib/resolve.mjs";
5
+ import { formatWhere, printResult } from "../lib/output.mjs";
6
+
7
+ /**
8
+ * @param {{ json: boolean, dir?: string, cwd?: string, home?: string, env?: NodeJS.ProcessEnv }} args
9
+ * @param {{ stdout?: NodeJS.WritableStream, stderr?: NodeJS.WritableStream }} [io]
10
+ * @returns {number} exit code
11
+ */
12
+ export function cmdWhere(args, io = {}) {
13
+ const stdout = io.stdout ?? process.stdout;
14
+ const resolved = resolveBundle({
15
+ cwd: args.cwd ?? process.cwd(),
16
+ home: args.home ?? process.env.HOME ?? process.env.USERPROFILE ?? null,
17
+ env: args.env ?? process.env,
18
+ dir: args.dir ?? null,
19
+ write: false,
20
+ });
21
+
22
+ if (!resolved.ok) {
23
+ printResult(stdout, args.json, false, undefined, resolved.error);
24
+ return 1;
25
+ }
26
+
27
+ printResult(stdout, args.json, true, resolved.data, undefined, formatWhere);
28
+ return 0;
29
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Shared CLI argument helpers and usage text.
3
+ */
4
+ import { CMD, VERSION } from "./pkg.mjs";
5
+
6
+ export function usage() {
7
+ return `${CMD} v${VERSION} — local-first OKF continuity
8
+
9
+ Usage:
10
+ ${CMD} Heartbeat (TTY): resume, last outcome, git, residue, open decisions
11
+ ${CMD} heartbeat Same pulse (agents: --json). Cheap reload — not a notes dump
12
+ ${CMD} where Active bundle (root, id, mode) — read-only
13
+ ${CMD} status Git + latest Resume + residue + open decisions + notes
14
+ ${CMD} search <q> Query notes/journal/decisions/attention (sqlite or scan)
15
+ ${CMD} list List concepts (--type --status --tag --kind)
16
+ ${CMD} show <path> One file, relative to the bundle root (includes backlinks)
17
+ ${CMD} journal Append today's journal section (--against PLAN.md)
18
+ ${CMD} attention Create or update residue in the air (--kind --status resolved)
19
+ ${CMD} decide Scaffold a decision file
20
+ ${CMD} note Scaffold a note
21
+ ${CMD} local Create ./.mental after ignore check (--import copies home, --move switches store)
22
+ ${CMD} remap List UUID bindings, or --to <id> / --from <id> for this clone
23
+ ${CMD} split New UUID for this clone (--copy keeps OKF files)
24
+ ${CMD} link Point this clone at --to <id>
25
+ ${CMD} install Skill + rule + put ${CMD} on PATH (overrides previous)
26
+ ${CMD} uninstall Remove installed skill/rule/hooks (OKF stays unless --delete-data DELETE)
27
+ ${CMD} hooks on|off Optional session-start hooks (default off)
28
+ ${CMD} serve Optional MCP stdio (heartbeat/where/status/search/list/show/journal/attention/decide/note)
29
+ ${CMD} doctor PATH, bindings, ignore, skill, index
30
+ ${CMD} reindex Rebuild derived sqlite index from OKF files
31
+
32
+ TTY: no args prints a one-shot heartbeat and exits. Named commands are one-shot.
33
+ Non-TTY / agents: always pass --json. Do not grep OKF / YAML.
34
+
35
+ Global flags:
36
+ --json Machine-readable { ok, data } | { ok, error }
37
+ --dir <path> Override resolve (same as MENTAL_DIR)
38
+ -h, --help Show this help
39
+ -v, --version Print version
40
+
41
+ Privacy: default store is ~/.mental (never commit). Project ./.mental
42
+ only after \`${CMD} local\`. Leftover ./.mental is normalized into
43
+ ~/.mental/projects/<uuid> and indexed (source is not deleted).
44
+ Never store secrets.
45
+ `;
46
+ }
47
+
48
+ /**
49
+ * Parse argv into a structured args object.
50
+ * Global flags may appear before or after the command.
51
+ *
52
+ * @param {string[]} argv
53
+ */
54
+ export function parseArgv(argv) {
55
+ const args = {
56
+ command: null,
57
+ json: false,
58
+ dir: undefined,
59
+ help: false,
60
+ version: false,
61
+ /** @type {string[]} */
62
+ rest: [],
63
+ /** @type {Record<string, string | boolean>} */
64
+ flags: {},
65
+ };
66
+
67
+ const takesValue = new Set([
68
+ "--dir",
69
+ "--title",
70
+ "--body",
71
+ "--resume",
72
+ "--status",
73
+ "--slug",
74
+ "--description",
75
+ "--from",
76
+ "--to",
77
+ "--against",
78
+ "--kind",
79
+ "--path",
80
+ "--type",
81
+ "--tag",
82
+ "--confirm",
83
+ ]);
84
+
85
+ for (let i = 0; i < argv.length; i++) {
86
+ const a = argv[i];
87
+ if (a === "-h" || a === "--help") {
88
+ args.help = true;
89
+ } else if (a === "-v" || a === "--version") {
90
+ args.version = true;
91
+ } else if (a === "--json") {
92
+ args.json = true;
93
+ } else if (a === "--dir") {
94
+ const v = argv[++i];
95
+ if (v == null) throw new Error("--dir requires a path");
96
+ args.dir = v;
97
+ } else if (takesValue.has(a)) {
98
+ const v = argv[++i];
99
+ if (v == null) throw new Error(`${a} requires a value`);
100
+ args.flags[a.slice(2)] = v;
101
+ } else if (a.startsWith("--") && a.includes("=")) {
102
+ const eq = a.indexOf("=");
103
+ const key = a.slice(2, eq);
104
+ args.flags[key] = a.slice(eq + 1);
105
+ if (key === "dir") args.dir = a.slice(eq + 1);
106
+ if (key === "json") args.json = true;
107
+ } else if (a.startsWith("--")) {
108
+ args.flags[a.slice(2)] = true;
109
+ } else if (!args.command) {
110
+ args.command = a;
111
+ } else {
112
+ args.rest.push(a);
113
+ }
114
+ }
115
+
116
+ return args;
117
+ }