dotmd-cli 0.74.6 → 0.76.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.
- package/README.md +32 -1
- package/assets/opencode/plugin.js +97 -0
- package/bin/dotmd.mjs +64 -2
- package/package.json +2 -1
- package/scripts/postinstall.mjs +13 -0
- package/src/commands.mjs +4 -1
- package/src/doctor.mjs +42 -2
- package/src/host-integration.mjs +265 -0
- package/src/install.mjs +151 -0
- package/src/pickup.mjs +14 -15
- package/src/update.mjs +25 -15
- package/src/util.mjs +78 -6
package/README.md
CHANGED
|
@@ -23,9 +23,23 @@ npx dotmd-cli init # try it without installing
|
|
|
23
23
|
Maintainer release automation is POSIX-only because it uses Bash and POSIX
|
|
24
24
|
command-line tools. The published Node.js CLI remains cross-platform.
|
|
25
25
|
|
|
26
|
+
### Agent host setup
|
|
27
|
+
|
|
28
|
+
The CLI alone gives an agent no orientation and no session identity. Install the
|
|
29
|
+
integration for whichever host you run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dotmd install # what's installed for each host
|
|
33
|
+
dotmd install claude # Claude Code plugin (marketplace + plugin)
|
|
34
|
+
dotmd install opencode # OpenCode plugin (one auto-discovered file)
|
|
35
|
+
dotmd doctor --session # what identity dotmd sees here, and from where
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Both are one-time and global; `dotmd update` keeps them in step with the CLI.
|
|
39
|
+
|
|
26
40
|
### Claude Code Plugin
|
|
27
41
|
|
|
28
|
-
|
|
42
|
+
`dotmd install claude` runs the two steps below for you. From inside a session:
|
|
29
43
|
|
|
30
44
|
```text
|
|
31
45
|
/plugin marketplace add reowens/dotmd
|
|
@@ -36,6 +50,23 @@ The plugin provides SessionStart and SubagentStart orientation, a PreToolUse
|
|
|
36
50
|
guard, the canonical workflow skill, and `/plans`, `/docs`, `/prompts`, and
|
|
37
51
|
`/baton` commands.
|
|
38
52
|
|
|
53
|
+
### OpenCode Plugin
|
|
54
|
+
|
|
55
|
+
`dotmd install opencode` writes one plugin file into OpenCode's global config
|
|
56
|
+
directory, where OpenCode auto-discovers it — no `opencode.json` edit. It
|
|
57
|
+
supplies the two things the CLI cannot get on its own:
|
|
58
|
+
|
|
59
|
+
- **Per-session plan ownership.** OpenCode exports no session id to a tool
|
|
60
|
+
shell. Without the plugin, dotmd falls back to `OPENCODE_PID`, which names the
|
|
61
|
+
OpenCode *process* — so every session in one OpenCode instance shares an
|
|
62
|
+
identity and can release the others' in-session plans.
|
|
63
|
+
- **A session-start briefing**, the equivalent of Claude Code's SessionStart
|
|
64
|
+
hook. OpenCode's Claude Code compatibility covers skills and the system
|
|
65
|
+
prompt, not hooks, so nothing else runs `dotmd hud`.
|
|
66
|
+
|
|
67
|
+
Restart OpenCode after installing. The file is version-stamped; a `dotmd.js`
|
|
68
|
+
without that stamp is treated as hand-authored and is never overwritten.
|
|
69
|
+
|
|
39
70
|
The plugin requires a global CLI install because its hooks resolve `dotmd` from
|
|
40
71
|
`PATH`. A project devDependency is useful for npm scripts but does not put the
|
|
41
72
|
CLI on the hook's `PATH`.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// dotmd's OpenCode integration. Installed by `dotmd install opencode`, which
|
|
2
|
+
// copies this file (with a version banner prepended) to the OpenCode plugin
|
|
3
|
+
// directory, where OpenCode auto-discovers it — it globs
|
|
4
|
+
// `{plugin,plugins}/*.{ts,js}` under `.opencode/` and the global config dir, so
|
|
5
|
+
// no `opencode.json` edit is needed.
|
|
6
|
+
//
|
|
7
|
+
// TWO CONSTRAINTS, both load-bearing:
|
|
8
|
+
//
|
|
9
|
+
// 1. EXACTLY ONE EXPORT. OpenCode treats every export of a plugin module as a
|
|
10
|
+
// plugin factory and throws `Plugin export is not a function` on anything
|
|
11
|
+
// that isn't one — so an exported helper wouldn't just be untidy, it would
|
|
12
|
+
// be *called* as a second plugin. Helpers stay module-local.
|
|
13
|
+
//
|
|
14
|
+
// 2. NO HOOK MAY THROW. OpenCode awaits hook callbacks inside the request it
|
|
15
|
+
// is serving; a rejected promise fails the user's chat turn. Every hook
|
|
16
|
+
// body is wrapped, and a failure degrades to "dotmd does nothing here"
|
|
17
|
+
// rather than to a broken session.
|
|
18
|
+
//
|
|
19
|
+
// Runs under Bun inside the OpenCode process. Node builtins only, no deps.
|
|
20
|
+
|
|
21
|
+
import { execFile } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
// The primer is a nicety; identity is a correctness invariant. They are split
|
|
24
|
+
// across two hooks deliberately: `shell.env` is stable API, while
|
|
25
|
+
// `experimental.chat.system.transform` may be renamed by OpenCode. If it is,
|
|
26
|
+
// priming stops and ownership keeps working — the failure lands on the half
|
|
27
|
+
// that can afford it.
|
|
28
|
+
const PRIMER_TTL_MS = 60_000;
|
|
29
|
+
const PRIMER_TIMEOUT_MS = 5_000;
|
|
30
|
+
|
|
31
|
+
function dotmdExecutable() {
|
|
32
|
+
return process.platform === 'win32' ? 'dotmd.cmd' : 'dotmd';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function runHud(directory) {
|
|
36
|
+
return new Promise(resolve => {
|
|
37
|
+
let settled = false;
|
|
38
|
+
const done = value => { if (!settled) { settled = true; resolve(value); } };
|
|
39
|
+
try {
|
|
40
|
+
execFile(dotmdExecutable(), ['hud'], {
|
|
41
|
+
cwd: directory,
|
|
42
|
+
timeout: PRIMER_TIMEOUT_MS,
|
|
43
|
+
windowsHide: true,
|
|
44
|
+
env: { ...process.env, NO_COLOR: '1' },
|
|
45
|
+
}, (error, stdout) => done(error ? '' : (stdout ?? '').trim()));
|
|
46
|
+
} catch {
|
|
47
|
+
// `dotmd` not on PATH, spawn refused — no primer, no noise.
|
|
48
|
+
done('');
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export default async function dotmdOpencodePlugin({ directory }) {
|
|
54
|
+
// Keyed by session so a subagent session primes independently, the way
|
|
55
|
+
// SubagentStart does under Claude Code.
|
|
56
|
+
const primers = new Map();
|
|
57
|
+
|
|
58
|
+
async function primerFor(sessionId) {
|
|
59
|
+
const key = sessionId ?? '';
|
|
60
|
+
const cached = primers.get(key);
|
|
61
|
+
// Refreshed on a TTL rather than cached for the session's life: OpenCode
|
|
62
|
+
// rebuilds the system prompt every turn, so a once-only push would reach
|
|
63
|
+
// only the first request — and a never-refreshed one would keep announcing
|
|
64
|
+
// a pending prompt this session already consumed.
|
|
65
|
+
if (cached && Date.now() - cached.at < PRIMER_TTL_MS) return cached.text;
|
|
66
|
+
const text = await runHud(directory);
|
|
67
|
+
primers.set(key, { text, at: Date.now() });
|
|
68
|
+
return text;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
// Ownership identity. OpenCode sets no session-id variable of its own, and
|
|
73
|
+
// `OPENCODE_PID` — what dotmd falls back to without this plugin — names the
|
|
74
|
+
// OpenCode *process*, so every session in one TUI shares it and can release
|
|
75
|
+
// the others' plans. This is the only place the real session id is
|
|
76
|
+
// available to a tool shell.
|
|
77
|
+
'shell.env': async (input, output) => {
|
|
78
|
+
try {
|
|
79
|
+
if (input?.sessionID) output.env.DOTMD_SESSION_ID = `opencode:${input.sessionID}`;
|
|
80
|
+
// The OpenCode server process hosts the session and outlives every tool
|
|
81
|
+
// shell, so it is the process whose liveness answers "is this claim's
|
|
82
|
+
// owner still there?" — `dotmd doctor --claims` probes exactly this.
|
|
83
|
+
output.env.DOTMD_SESSION_PID = String(process.pid);
|
|
84
|
+
} catch { /* never break a shell over this */ }
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
// Session priming — the equivalent of the SessionStart hook that runs
|
|
88
|
+
// `dotmd hud` under Claude Code. Silent outside a dotmd repo (hud prints
|
|
89
|
+
// nothing and exits 0), so this is inert in unrelated projects.
|
|
90
|
+
'experimental.chat.system.transform': async (input, output) => {
|
|
91
|
+
try {
|
|
92
|
+
const text = await primerFor(input?.sessionID);
|
|
93
|
+
if (text) output.system.push(text);
|
|
94
|
+
} catch { /* priming is best-effort */ }
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
package/bin/dotmd.mjs
CHANGED
|
@@ -142,6 +142,44 @@ guard entirely with DOTMD_GUARD=0. Read the log with \`dotmd misuse\`; when one
|
|
|
142
142
|
rule trips ≥3× in 7 days in a repo, \`dotmd hud\` opens the next session there
|
|
143
143
|
with a one-line recap naming the habit to break.`,
|
|
144
144
|
|
|
145
|
+
install: `dotmd install [<host>] — install dotmd's integration into an agent host
|
|
146
|
+
|
|
147
|
+
dotmd install report what is installed for each known host
|
|
148
|
+
dotmd install claude install the Claude Code plugin (marketplace + plugin)
|
|
149
|
+
dotmd install opencode install/refresh the OpenCode plugin (global config dir)
|
|
150
|
+
dotmd install <host> --remove
|
|
151
|
+
dotmd install opencode --path <dir> write to a specific plugin directory
|
|
152
|
+
dotmd install opencode --force overwrite a dotmd.js dotmd did not write
|
|
153
|
+
|
|
154
|
+
The CLI on its own gives an agent no orientation and no session identity. Each
|
|
155
|
+
host gets that a different way:
|
|
156
|
+
|
|
157
|
+
claude Drives \`claude plugin marketplace add ${'reowens/dotmd'}\` +
|
|
158
|
+
\`claude plugin install dotmd@dotmd\`. This is the FIRST install;
|
|
159
|
+
\`dotmd update\` only refreshes a plugin already present (it skips
|
|
160
|
+
with "plugin not installed"), and the README's slash commands only
|
|
161
|
+
work from inside a session. Without the \`claude\` CLI on PATH the
|
|
162
|
+
two in-session commands are printed instead.
|
|
163
|
+
|
|
164
|
+
opencode Writes one auto-discovered plugin file. OpenCode has no plugin
|
|
165
|
+
registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
|
|
166
|
+
config dir, so no opencode.json edit is needed. It supplies two
|
|
167
|
+
things the CLI cannot get on its own:
|
|
168
|
+
- Per-session ownership identity. OpenCode exports no session id
|
|
169
|
+
to a tool shell, so without it every session in one OpenCode
|
|
170
|
+
process shares one identity and can release the others'
|
|
171
|
+
in-session plans.
|
|
172
|
+
- The \`dotmd hud\` primer at session start, the equivalent of
|
|
173
|
+
Claude Code's SessionStart hook. OpenCode's Claude Code
|
|
174
|
+
compatibility covers skills and the system prompt — not hooks —
|
|
175
|
+
so nothing else runs it.
|
|
176
|
+
The file is version-stamped and refreshed by \`dotmd update\`. A
|
|
177
|
+
\`dotmd.js\` without that stamp is treated as hand-authored and is
|
|
178
|
+
never overwritten or removed without --force.
|
|
179
|
+
|
|
180
|
+
Writing outside the repo is why this is an explicit verb: \`dotmd doctor\`
|
|
181
|
+
reports a missing integration but never installs one.`,
|
|
182
|
+
|
|
145
183
|
update: `dotmd update — update the dotmd CLI and the Claude Code plugin together
|
|
146
184
|
|
|
147
185
|
dotmd update npm i -g dotmd-cli + claude plugin update dotmd@dotmd
|
|
@@ -232,6 +270,7 @@ Create & Export:
|
|
|
232
270
|
|
|
233
271
|
Setup:
|
|
234
272
|
init Create starter config + docs directory
|
|
273
|
+
install [opencode] Install the agent-host integration (no arg = status)
|
|
235
274
|
update [--check|--cli-only|--plugin-only] Update the CLI + Claude Code plugin (--check reports skew, no network)
|
|
236
275
|
statuses [list|add|set|remove|migrate] Manage per-project status taxonomy
|
|
237
276
|
help statuses Full status vocabulary + unstuck-actions + transitions
|
|
@@ -464,8 +503,9 @@ another session's work.
|
|
|
464
503
|
a unique bare slug / basename across the doc roots (\`set paused auth-revamp\`).
|
|
465
504
|
Ambiguous slugs error with the candidate list instead of guessing.
|
|
466
505
|
When the path is omitted, exactly one plan must be owned by this session.
|
|
467
|
-
Claude Code
|
|
468
|
-
|
|
506
|
+
Claude Code session IDs are recognized automatically, as is OpenCode (via
|
|
507
|
+
OPENCODE_PID — per OpenCode process, not per session). Other hosts must set
|
|
508
|
+
DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
|
|
469
509
|
Pickup hooks use at-least-once delivery with a stable operationId; hook side
|
|
470
510
|
effects must deduplicate that ID.
|
|
471
511
|
|
|
@@ -743,6 +783,15 @@ Modes:
|
|
|
743
783
|
another machine — that are older than the duration.
|
|
744
784
|
That threshold is your judgement, not dotmd's: it
|
|
745
785
|
cannot tell a dead session from a slow one.
|
|
786
|
+
--session Read-only: what session identity dotmd resolved, from
|
|
787
|
+
which environment variable, and whether it names THIS
|
|
788
|
+
session or something coarser that its siblings share
|
|
789
|
+
(a host process, a terminal) — sessions sharing an id
|
|
790
|
+
can release each other's plans. Also reports whether
|
|
791
|
+
the current host's integration is installed. Run this
|
|
792
|
+
when a verb says "No authoritative session identity",
|
|
793
|
+
or on any host dotmd has never been tried on.
|
|
794
|
+
--session --json Machine-readable identity + host-integration state.
|
|
746
795
|
--statuses Read-only diagnostic: detect overloaded status
|
|
747
796
|
buckets where one status holds plans pursuing
|
|
748
797
|
multiple distinct unstuck-actions. Suggests how
|
|
@@ -1545,6 +1594,18 @@ async function main() {
|
|
|
1545
1594
|
throw err;
|
|
1546
1595
|
}
|
|
1547
1596
|
_resolvedConfig = config;
|
|
1597
|
+
// The one place that reaches an OpenCode session running without the
|
|
1598
|
+
// integration — whatever verb it reaches for first. stderr, so `--json`
|
|
1599
|
+
// consumers are untouched, and once per session so it informs rather than
|
|
1600
|
+
// nags. `install`/`doctor`/`update` are skipped: they report this themselves,
|
|
1601
|
+
// and telling `dotmd install opencode` to run `dotmd install opencode` is
|
|
1602
|
+
// noise.
|
|
1603
|
+
if (!['install', 'doctor', 'update', 'hud', 'guard'].includes(command)) {
|
|
1604
|
+
const { degradedIdentityNotice } = await import('../src/host-integration.mjs');
|
|
1605
|
+
const notice = degradedIdentityNotice(config?.repoRoot, { version: pkg.version });
|
|
1606
|
+
if (notice) process.stderr.write(`${notice}\n`);
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1548
1609
|
const suppressSideEffects = effectiveDryRun || command === 'hud' || passiveMachineContext;
|
|
1549
1610
|
Object.defineProperty(config, '_execution', {
|
|
1550
1611
|
value: { dryRun, passive: command === 'hud' || passiveMachineContext, suppressSideEffects, gitStaleness },
|
|
@@ -1724,6 +1785,7 @@ async function main() {
|
|
|
1724
1785
|
if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
|
|
1725
1786
|
if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
|
|
1726
1787
|
if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
|
|
1788
|
+
if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
|
|
1727
1789
|
if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
|
|
1728
1790
|
if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
|
|
1729
1791
|
if (command === 'pickup' || command === 'unpickup' || command === 'release' || command === 'finish') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotmd-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.76.0",
|
|
4
4
|
"description": "CLI for managing markdown documents with YAML frontmatter — index, query, validate, graph, export, lifecycle, and AI summaries.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"files": [
|
|
14
14
|
"bin/",
|
|
15
15
|
"src/",
|
|
16
|
+
"assets/",
|
|
16
17
|
"scripts/postinstall.mjs",
|
|
17
18
|
"dotmd.config.example.mjs"
|
|
18
19
|
],
|
package/scripts/postinstall.mjs
CHANGED
|
@@ -24,6 +24,16 @@ try {
|
|
|
24
24
|
} catch { return false; }
|
|
25
25
|
})();
|
|
26
26
|
|
|
27
|
+
// OpenCode's integration is a file dotmd writes, and nothing else would ever
|
|
28
|
+
// mention it — an OpenCode user who installs only the CLI gets no primer and
|
|
29
|
+
// a process-scoped session identity, silently. One line, never an install.
|
|
30
|
+
const hasOpencode = (() => {
|
|
31
|
+
try {
|
|
32
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
33
|
+
return spawnSync(cmd, ['opencode'], { encoding: 'utf8' }).status === 0;
|
|
34
|
+
} catch { return false; }
|
|
35
|
+
})();
|
|
36
|
+
|
|
27
37
|
if (process.env.DOTMD_AUTO_PLUGIN_UPDATE === '1' && hasClaude) {
|
|
28
38
|
spawnSync('claude', ['plugin', 'update', 'dotmd@dotmd'], { stdio: 'ignore', timeout: 60000 });
|
|
29
39
|
process.stdout.write('dotmd: refreshed the Claude Code plugin — restart your session (or /reload-plugins) to apply.\n');
|
|
@@ -35,6 +45,9 @@ try {
|
|
|
35
45
|
: 'dotmd CLI installed.';
|
|
36
46
|
process.stdout.write(`${nudge}\n`);
|
|
37
47
|
}
|
|
48
|
+
if (hasOpencode) {
|
|
49
|
+
process.stdout.write('dotmd: OpenCode detected — run `dotmd install opencode` for per-session plan ownership and a session-start briefing.\n');
|
|
50
|
+
}
|
|
38
51
|
} catch {
|
|
39
52
|
// Best effort only — never break the install.
|
|
40
53
|
}
|
package/src/commands.mjs
CHANGED
|
@@ -114,6 +114,9 @@ const definitions = [
|
|
|
114
114
|
command('export', mutates('external output path intentionally unrestricted'), 'mutate', [form('[file]', { args: positionals(0, 1), options: [value('--format'), value('--output'), value('--status'), value('--module'), value('--root'), value('--type')] })]),
|
|
115
115
|
command('guard', mutates('global misuse log; no managed document writes'), 'mutate', [form('')]),
|
|
116
116
|
command('update', mutates('global CLI and plugin installation state'), 'mutate', [form('', { options: [flag('--check'), flag('--cli-only'), flag('--plugin-only')] })]),
|
|
117
|
+
command('install', mutates('agent-host plugin directory outside the repo'), 'mutate', [
|
|
118
|
+
form('[host]', { args: positionals(0, 1), options: [flag('--remove'), flag('--force'), flag('--json'), value('--path')] }),
|
|
119
|
+
]),
|
|
117
120
|
command('status', mutates('managed source and same-root destination'), 'mutate', [form('<file> [status]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
|
|
118
121
|
command('set', mutates('managed source and same-root destination'), 'mutate', [form('<status> [file]', { args: positionals(1, 2), options: LIFECYCLE_OPTIONS })]),
|
|
119
122
|
command('ship', mutates('repository release/index paths and global release tooling'), 'mutate', [form('[patch|minor|major]', { args: positionals(0, 1) })]),
|
|
@@ -134,7 +137,7 @@ const definitions = [
|
|
|
134
137
|
command('migrate', mutates('managed source sweep'), 'mutate', [form('<field> <old> <new> [files...]', { args: positionals(3, Infinity), options: [flag('--show-files')] })]),
|
|
135
138
|
command('fix-refs', mutates('managed source sweep'), 'mutate', [form('', { options: [flag('--show-files')] })]),
|
|
136
139
|
command('sync-status', mutates('managed source sweep'), 'mutate', [form('[hubs...]', { args: positionals(0, Infinity), options: [flag('--adopt'), flag('--json')] })]),
|
|
137
|
-
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
140
|
+
command('doctor', mutates('managed sweeps, repo index, and maintenance config paths by mode'), 'mutate', [form('[path]', { args: positionals(0, 1), options: [flag('--apply', '--yes'), flag('--statuses'), optionalValue('--migrate-template'), flag('--migrate-prompts'), flag('--frontmatter-fix'), flag('--project'), flag('--transactions'), flag('--claims'), flag('--session'), value('--older-than'), flag('--json'), flag('--include-archived')] })]),
|
|
138
141
|
command('statuses', mutates('project config path; document scan is read-only'), 'mutate', [
|
|
139
142
|
form('list', { subcommands: ['list'], options: [value('--type'), flag('--json')] }),
|
|
140
143
|
form('add <name>', { subcommands: ['add'], args: positionals(1, 1), options: STATUS_PROPERTY_OPTIONS }),
|
package/src/doctor.mjs
CHANGED
|
@@ -7,15 +7,16 @@ import { runSet, runTouch } from './lifecycle.mjs';
|
|
|
7
7
|
import { buildIndex, collectDocFiles } from './index.mjs';
|
|
8
8
|
import { writeRenderedIndex } from './index-file.mjs';
|
|
9
9
|
import { renderCheck, renderManualFixes } from './render.mjs';
|
|
10
|
-
import { bold, dim, green, yellow } from './color.mjs';
|
|
10
|
+
import { bold, dim, green, red, yellow } from './color.mjs';
|
|
11
11
|
import { checkClaudeCommands, removeGeneratedSlashCommands } from './claude-commands.mjs';
|
|
12
12
|
import { checkSkillDrift } from './skill-drift.mjs';
|
|
13
13
|
import { runMigrateTemplate } from './migrate-template.mjs';
|
|
14
14
|
import { runMigratePrompts } from './migrate-prompts.mjs';
|
|
15
15
|
import { runFrontmatterFix } from './frontmatter-fix.mjs';
|
|
16
16
|
import { normalizeEol } from './frontmatter.mjs';
|
|
17
|
-
import { die, relTime, toRepoPath } from './util.mjs';
|
|
17
|
+
import { die, dotmdVersion, relTime, toRepoPath } from './util.mjs';
|
|
18
18
|
import { inspectTransactions, resolveTransactions } from './atomic-mutation.mjs';
|
|
19
|
+
import { describeSessionIdentity, opencodeStatus } from './host-integration.mjs';
|
|
19
20
|
import { availableSessionId, releaseVanishedPlanClaim, surveyOwnershipClaims } from './pickup.mjs';
|
|
20
21
|
|
|
21
22
|
// Tunable thresholds for `dotmd doctor --statuses` conflation detection.
|
|
@@ -209,6 +210,29 @@ async function runDoctorClaims(argv, config, opts = {}) {
|
|
|
209
210
|
}
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
// Read-only: what session identity does dotmd see, and is the current host's
|
|
214
|
+
// integration installed? Never writes — installing lives behind `dotmd install`
|
|
215
|
+
// because it touches state outside the repo.
|
|
216
|
+
function runDoctorSession(argv) {
|
|
217
|
+
const identity = describeSessionIdentity({ version: dotmdVersion() });
|
|
218
|
+
const oc = opencodeStatus({ version: dotmdVersion() });
|
|
219
|
+
if (argv.includes('--json')) {
|
|
220
|
+
process.stdout.write(JSON.stringify({ identity, hosts: { opencode: oc } }, null, 2) + '\n');
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(bold('Session identity\n'));
|
|
224
|
+
const mark = identity.scope === 'session' ? green('✓') : identity.id ? yellow('!') : red('✗');
|
|
225
|
+
process.stdout.write(` ${mark} ${identity.id ?? dim('none')}\n`);
|
|
226
|
+
process.stdout.write(` ${dim(identity.summary)}\n`);
|
|
227
|
+
for (const line of identity.advice) process.stdout.write(` → ${line}\n`);
|
|
228
|
+
|
|
229
|
+
process.stdout.write('\n' + bold('Host integration\n'));
|
|
230
|
+
if (oc.foreign) process.stdout.write(` ${yellow('!')} opencode: a dotmd.js dotmd did not write — ${oc.path}\n`);
|
|
231
|
+
else if (!oc.exists) process.stdout.write(` ${dim('·')} opencode: not installed — ${dim(oc.path)}\n`);
|
|
232
|
+
else process.stdout.write(` ${oc.stale ? yellow('!') : green('✓')} opencode: ${oc.version}${oc.stale ? ` (CLI is ${dotmdVersion()} — run \`dotmd update\`)` : ''}\n`);
|
|
233
|
+
process.stdout.write(dim(' Claude Code ships as a plugin — `dotmd install` reports both hosts.\n'));
|
|
234
|
+
}
|
|
235
|
+
|
|
212
236
|
export function runDoctor(argv, config, opts = {}) {
|
|
213
237
|
if (argv.includes('--project')) {
|
|
214
238
|
runDoctorProject(config, { json: argv.includes('--json') });
|
|
@@ -237,6 +261,10 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
237
261
|
if (argv.includes('--claims')) {
|
|
238
262
|
return runDoctorClaims(argv, config, opts);
|
|
239
263
|
}
|
|
264
|
+
if (argv.includes('--session')) {
|
|
265
|
+
runDoctorSession(argv);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
240
268
|
|
|
241
269
|
const { dryRun, testHooks } = opts;
|
|
242
270
|
// 0.37.0 (F4): the mode banner makes it impossible to mistake a preview run
|
|
@@ -325,6 +353,18 @@ export function runDoctor(argv, config, opts = {}) {
|
|
|
325
353
|
process.stdout.write('\n' + bold('Closeout guidance') + '\n');
|
|
326
354
|
process.stdout.write(manual);
|
|
327
355
|
}
|
|
356
|
+
|
|
357
|
+
// Not a numbered step and never auto-fixed: installing touches state outside
|
|
358
|
+
// the repo, which is `dotmd install`'s job. Doctor's part is making sure a
|
|
359
|
+
// degraded identity is something you're told about rather than something you
|
|
360
|
+
// find out when a verb fails. Silent when there is nothing to say.
|
|
361
|
+
const identity = describeSessionIdentity({ version: dotmdVersion() });
|
|
362
|
+
if (identity.advice.length) {
|
|
363
|
+
process.stdout.write('\n' + bold('Session identity') + '\n');
|
|
364
|
+
process.stdout.write(`${yellow('!')} ${identity.summary}\n`);
|
|
365
|
+
for (const line of identity.advice) process.stdout.write(dim(` → ${line}\n`));
|
|
366
|
+
process.stdout.write(dim(' `dotmd doctor --session` for the full picture.\n'));
|
|
367
|
+
}
|
|
328
368
|
}
|
|
329
369
|
|
|
330
370
|
function readJsonIfPresent(filePath) {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Installing dotmd's integration into agent hosts other than Claude Code.
|
|
2
|
+
//
|
|
3
|
+
// Claude Code gets its integration through a real plugin (`plugins/dotmd/`,
|
|
4
|
+
// installed by `claude plugin`). OpenCode has no equivalent registry, but it
|
|
5
|
+
// auto-discovers plugin files: it globs `{plugin,plugins}/*.{ts,js}` under the
|
|
6
|
+
// global config dir and under a project's `.opencode/`, with no config entry
|
|
7
|
+
// needed. So the integration ships as one generated file.
|
|
8
|
+
//
|
|
9
|
+
// It goes in the GLOBAL config dir, not per-repo. dotmd retired per-repo
|
|
10
|
+
// generated scaffolding once already (`.claude/commands`, see
|
|
11
|
+
// claude-commands.mjs) because a file copied into every repo drifts from the
|
|
12
|
+
// CLI that wrote it. One global file covers every repo and is refreshed by
|
|
13
|
+
// `dotmd update` in lockstep with the CLI.
|
|
14
|
+
//
|
|
15
|
+
// Writing outside the repo is why this is an explicit verb. `dotmd doctor`
|
|
16
|
+
// reports that the integration is missing and names the command; it never
|
|
17
|
+
// installs. Passive commands touch nothing.
|
|
18
|
+
|
|
19
|
+
import { createHash } from 'node:crypto';
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import os from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
import { hostSessionSource } from './util.mjs';
|
|
25
|
+
|
|
26
|
+
export const GENERATED_MARKER = 'dotmd-generated:';
|
|
27
|
+
const PLUGIN_FILENAME = 'dotmd.js';
|
|
28
|
+
const ASSET = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'assets', 'opencode', 'plugin.js');
|
|
29
|
+
|
|
30
|
+
// OpenCode's own resolution order for its global config directory. Verified
|
|
31
|
+
// against the shipped binary: `OPENCODE_CONFIG_DIR` is an explicit override it
|
|
32
|
+
// consults directly, and the default is the XDG location.
|
|
33
|
+
export function opencodeConfigDir(env = process.env, homedir = os.homedir()) {
|
|
34
|
+
const explicit = env.OPENCODE_CONFIG_DIR?.trim();
|
|
35
|
+
if (explicit) return path.resolve(explicit);
|
|
36
|
+
const xdg = env.XDG_CONFIG_HOME?.trim();
|
|
37
|
+
return xdg ? path.join(path.resolve(xdg), 'opencode') : path.join(homedir, '.config', 'opencode');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Both spellings are globbed, so an existing one is adopted rather than
|
|
41
|
+
// creating a second plugin directory beside the user's own plugins.
|
|
42
|
+
export function opencodePluginDir(configDir) {
|
|
43
|
+
for (const name of ['plugin', 'plugins']) {
|
|
44
|
+
const candidate = path.join(configDir, name);
|
|
45
|
+
if (existsSync(candidate)) return candidate;
|
|
46
|
+
}
|
|
47
|
+
return path.join(configDir, 'plugin');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function opencodePluginPath(opts = {}) {
|
|
51
|
+
const { env = process.env, homedir = os.homedir(), dir } = opts;
|
|
52
|
+
if (dir) return path.join(path.resolve(dir), PLUGIN_FILENAME);
|
|
53
|
+
return path.join(opencodePluginDir(opencodeConfigDir(env, homedir)), PLUGIN_FILENAME);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function banner(version) {
|
|
57
|
+
return [
|
|
58
|
+
`// ${GENERATED_MARKER}${version}`,
|
|
59
|
+
'// Generated by `dotmd install opencode`. Refreshed by `dotmd update`.',
|
|
60
|
+
'// Hand edits are overwritten — remove with `dotmd install opencode --remove`.',
|
|
61
|
+
'',
|
|
62
|
+
].join('\n');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function renderOpencodePlugin(version) {
|
|
66
|
+
return banner(version) + readFileSync(ASSET, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The version a generated file was written by, or null when the file is absent
|
|
70
|
+
// or carries no dotmd banner. A file without the banner is the user's own and
|
|
71
|
+
// is never written or removed — same rule the retired slash-command scaffolding
|
|
72
|
+
// followed.
|
|
73
|
+
export function installedVersion(filePath) {
|
|
74
|
+
let contents;
|
|
75
|
+
try { contents = readFileSync(filePath, 'utf8'); } catch { return null; }
|
|
76
|
+
const marker = contents.indexOf(GENERATED_MARKER);
|
|
77
|
+
if (marker < 0) return null;
|
|
78
|
+
const rest = contents.slice(marker + GENERATED_MARKER.length);
|
|
79
|
+
return rest.split('\n', 1)[0].trim() || null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function opencodeStatus(opts = {}) {
|
|
83
|
+
const { version, ...rest } = opts;
|
|
84
|
+
const filePath = opencodePluginPath(rest);
|
|
85
|
+
const exists = existsSync(filePath);
|
|
86
|
+
const installed = exists ? installedVersion(filePath) : null;
|
|
87
|
+
return {
|
|
88
|
+
path: filePath,
|
|
89
|
+
exists,
|
|
90
|
+
version: installed,
|
|
91
|
+
// Present but unmarked: the user put a `dotmd.js` there themselves.
|
|
92
|
+
foreign: exists && installed === null,
|
|
93
|
+
stale: installed !== null && version !== undefined && installed !== version,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Is OpenCode plausibly in use on this machine? Used only to decide whether to
|
|
98
|
+
// mention the integration — never to install anything.
|
|
99
|
+
export function opencodeDetected(opts = {}) {
|
|
100
|
+
const { env = process.env, homedir = os.homedir() } = opts;
|
|
101
|
+
if (env.OPENCODE || env.OPENCODE_PID) return true;
|
|
102
|
+
const configDir = opencodeConfigDir(env, homedir);
|
|
103
|
+
if (!existsSync(configDir)) return false;
|
|
104
|
+
try { return readdirSync(configDir).length > 0; } catch { return false; }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function installOpencodePlugin(opts = {}) {
|
|
108
|
+
const { version, dryRun = false, force = false, ...rest } = opts;
|
|
109
|
+
const status = opencodeStatus({ version, ...rest });
|
|
110
|
+
if (status.foreign && !force) {
|
|
111
|
+
return { ...status, action: 'refused', reason: 'a dotmd.js without a dotmd banner is already there — it was not written by dotmd' };
|
|
112
|
+
}
|
|
113
|
+
if (status.exists && !status.stale && !status.foreign) {
|
|
114
|
+
return { ...status, action: 'current' };
|
|
115
|
+
}
|
|
116
|
+
const action = status.exists ? 'updated' : 'installed';
|
|
117
|
+
if (!dryRun) {
|
|
118
|
+
mkdirSync(path.dirname(status.path), { recursive: true });
|
|
119
|
+
writeFileSync(status.path, renderOpencodePlugin(version), 'utf8');
|
|
120
|
+
}
|
|
121
|
+
return { ...status, action, version };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// What identity does dotmd actually see here, and can it tell this session
|
|
125
|
+
// apart from its siblings? This report exists because the OpenCode breakage was
|
|
126
|
+
// invisible until a verb failed: dotmd looked for variables OpenCode never set,
|
|
127
|
+
// and nothing surfaced that until `use` refused to run. Anyone on any host can
|
|
128
|
+
// now check what dotmd resolved instead of discovering it through a failure.
|
|
129
|
+
export function describeSessionIdentity(opts = {}) {
|
|
130
|
+
const { env = process.env, homedir = os.homedir(), version } = opts;
|
|
131
|
+
const source = hostSessionSource(env);
|
|
132
|
+
const opencode = opencodeStatus({ env, homedir, version });
|
|
133
|
+
const underOpencode = Boolean(env.OPENCODE || env.OPENCODE_PID);
|
|
134
|
+
|
|
135
|
+
if (!source) {
|
|
136
|
+
return {
|
|
137
|
+
id: null, scope: 'none', source: null, host: null,
|
|
138
|
+
summary: 'no session identity — `use`, `set`, `baton` and `archive` fail closed',
|
|
139
|
+
advice: underOpencode
|
|
140
|
+
? ['dotmd install opencode', 'or set DOTMD_SESSION_ID for this shell']
|
|
141
|
+
: ['set DOTMD_SESSION_ID for this shell or host session', 'known hosts: dotmd install'],
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const advice = [];
|
|
146
|
+
// The distinction that matters: a process- or terminal-scoped id is shared by
|
|
147
|
+
// every session under it, so those sessions can release each other's plans.
|
|
148
|
+
if (source.scope === 'process' && source.host === 'OpenCode') {
|
|
149
|
+
advice.push(opencode.exists
|
|
150
|
+
// The plugin is there but this shell was spawned before it loaded, so it
|
|
151
|
+
// still carries the old identity — say so rather than leave a warning
|
|
152
|
+
// marker with no explanation.
|
|
153
|
+
? 'restart OpenCode — this session predates the installed integration'
|
|
154
|
+
: 'dotmd install opencode — for a per-session identity');
|
|
155
|
+
} else if (source.scope === 'terminal') {
|
|
156
|
+
advice.push('every agent session in this terminal shares this id — set DOTMD_SESSION_ID per session');
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
id: source.id,
|
|
160
|
+
scope: source.scope,
|
|
161
|
+
source: source.variable,
|
|
162
|
+
host: source.host,
|
|
163
|
+
summary: source.scope === 'session'
|
|
164
|
+
? `per-session identity from ${source.variable}`
|
|
165
|
+
: `${source.scope}-scoped identity from ${source.variable} — sessions sharing it can release each other's plans`,
|
|
166
|
+
advice,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// --- Telling the session it is running degraded -----------------------------
|
|
171
|
+
//
|
|
172
|
+
// Every other surface here has to be sought out: `doctor`, `install`, the npm
|
|
173
|
+
// postinstall line. But the degraded mode is SILENT — with the OPENCODE_PID
|
|
174
|
+
// fallback in place, `use`/`set`/`baton` all work, they just share one identity
|
|
175
|
+
// across every session in the OpenCode process. Nobody goes looking for a
|
|
176
|
+
// diagnostic about a thing that appears to work, so this comes to them.
|
|
177
|
+
//
|
|
178
|
+
// Warning, not error. Failing closed would undo the fallback that unblocked
|
|
179
|
+
// OpenCode in the first place, and punish a setup that is working.
|
|
180
|
+
//
|
|
181
|
+
// Once per session per repo, via a marker under the gitignored .dotmd/. Silent
|
|
182
|
+
// under DOTMD_NO_HINTS=1, the switch the repeat-failure hints already use.
|
|
183
|
+
const NOTICE_DIR = 'notices';
|
|
184
|
+
|
|
185
|
+
function noticeMarkerPath(repoRoot, key) {
|
|
186
|
+
const digest = createHash('sha256').update(key).digest('hex').slice(0, 32);
|
|
187
|
+
return path.join(path.resolve(repoRoot), '.dotmd', NOTICE_DIR, `${digest}`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Returns the notice text the first time it applies in a session, then null.
|
|
191
|
+
// `record: false` answers without consuming the once-per-session budget.
|
|
192
|
+
export function degradedIdentityNotice(repoRoot, opts = {}) {
|
|
193
|
+
const { env = process.env, homedir = os.homedir(), version, record = true } = opts;
|
|
194
|
+
if (env.DOTMD_NO_HINTS === '1') return null;
|
|
195
|
+
// Cheap env check first: a user on any other host pays nothing for this.
|
|
196
|
+
if (!env.OPENCODE && !env.OPENCODE_PID) return null;
|
|
197
|
+
|
|
198
|
+
const identity = describeSessionIdentity({ env, homedir, version });
|
|
199
|
+
if (identity.scope === 'session') return null;
|
|
200
|
+
|
|
201
|
+
const installed = opencodeStatus({ env, homedir, version }).exists;
|
|
202
|
+
const lines = installed
|
|
203
|
+
? ['[dotmd] This OpenCode session started before the dotmd integration loaded, so its plan',
|
|
204
|
+
' ownership is still process-scoped. Restart OpenCode to pick it up.']
|
|
205
|
+
: ['[dotmd] OpenCode detected, dotmd integration not installed — plan ownership is scoped to',
|
|
206
|
+
' the OpenCode process, so another session in it can release plans this one claimed,',
|
|
207
|
+
' and no session-start briefing runs. Fix once: dotmd install opencode'];
|
|
208
|
+
|
|
209
|
+
if (!record) return lines.join('\n');
|
|
210
|
+
if (!repoRoot) return lines.join('\n');
|
|
211
|
+
const marker = noticeMarkerPath(repoRoot, `opencode:${identity.id}:${installed ? 'restart' : 'install'}`);
|
|
212
|
+
if (existsSync(marker)) return null;
|
|
213
|
+
try {
|
|
214
|
+
mkdirSync(path.dirname(marker), { recursive: true });
|
|
215
|
+
writeFileSync(marker, '', 'utf8');
|
|
216
|
+
} catch {
|
|
217
|
+
// Unwritable .dotmd: say it once anyway rather than stay silent about a
|
|
218
|
+
// real ownership weakness. Worst case it repeats.
|
|
219
|
+
}
|
|
220
|
+
return lines.join('\n');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- Claude Code -----------------------------------------------------------
|
|
224
|
+
//
|
|
225
|
+
// Claude Code has a real plugin registry, so dotmd drives `claude plugin`
|
|
226
|
+
// rather than writing files. What it lacked was any CLI path to the FIRST
|
|
227
|
+
// install: `dotmd update` deliberately skips the plugin step when nothing is
|
|
228
|
+
// installed ("dotmd plugin not installed"), and the README's two slash commands
|
|
229
|
+
// only work from inside a session. So a user who installed the CLI from npm had
|
|
230
|
+
// no way to discover, from the CLI, that the plugin exists.
|
|
231
|
+
|
|
232
|
+
export const CLAUDE_MARKETPLACE = 'reowens/dotmd';
|
|
233
|
+
export const CLAUDE_PLUGIN_ID = 'dotmd@dotmd';
|
|
234
|
+
|
|
235
|
+
// Pure planner, mirroring planUpdate: the orchestration is unit-testable and
|
|
236
|
+
// the side effects stay in the caller.
|
|
237
|
+
export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
|
|
238
|
+
if (remove) {
|
|
239
|
+
if (!installed) return [{ kind: 'skip', reason: 'dotmd plugin is not installed' }];
|
|
240
|
+
return hasClaude
|
|
241
|
+
? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
|
|
242
|
+
: [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
|
|
243
|
+
}
|
|
244
|
+
if (installed) return [{ kind: 'skip', reason: `dotmd plugin already installed (${installed.version ?? 'unknown version'})` }];
|
|
245
|
+
// The marketplace has to be registered before the plugin resolves; adding one
|
|
246
|
+
// already present is a no-op, so this stays safe to re-run.
|
|
247
|
+
if (!hasClaude) {
|
|
248
|
+
return [{ kind: 'manual', lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin install ${CLAUDE_PLUGIN_ID}`] }];
|
|
249
|
+
}
|
|
250
|
+
return [
|
|
251
|
+
{ kind: 'run', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
|
|
252
|
+
{ kind: 'run', cmd: ['claude', 'plugin', 'install', CLAUDE_PLUGIN_ID] },
|
|
253
|
+
];
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function removeOpencodePlugin(opts = {}) {
|
|
257
|
+
const { dryRun = false, force = false, ...rest } = opts;
|
|
258
|
+
const status = opencodeStatus(rest);
|
|
259
|
+
if (!status.exists) return { ...status, action: 'absent' };
|
|
260
|
+
if (status.foreign && !force) {
|
|
261
|
+
return { ...status, action: 'refused', reason: 'not a dotmd-generated file' };
|
|
262
|
+
}
|
|
263
|
+
if (!dryRun) rmSync(status.path, { force: true });
|
|
264
|
+
return { ...status, action: 'removed' };
|
|
265
|
+
}
|
package/src/install.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { bold, dim, green, yellow } from './color.mjs';
|
|
6
|
+
import {
|
|
7
|
+
CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, installOpencodePlugin, opencodeDetected,
|
|
8
|
+
opencodeStatus, planClaudeInstall, removeOpencodePlugin,
|
|
9
|
+
} from './host-integration.mjs';
|
|
10
|
+
import { readInstalledPlugin } from './update.mjs';
|
|
11
|
+
import { executableName, which } from './util.mjs';
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
15
|
+
|
|
16
|
+
const HOSTS = ['claude', 'opencode'];
|
|
17
|
+
const ALIASES = { 'claude-code': 'claude', claudecode: 'claude' };
|
|
18
|
+
|
|
19
|
+
function flagValue(argv, name) {
|
|
20
|
+
const i = argv.indexOf(name);
|
|
21
|
+
return i >= 0 ? argv[i + 1] : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hostStates() {
|
|
25
|
+
const plugin = readInstalledPlugin();
|
|
26
|
+
return {
|
|
27
|
+
claude: { installed: Boolean(plugin), version: plugin?.version ?? null, id: plugin?.id ?? CLAUDE_PLUGIN_ID },
|
|
28
|
+
opencode: { ...opencodeStatus({ version: pkg.version }), detected: opencodeDetected() },
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function reportStatus(json) {
|
|
33
|
+
const states = hostStates();
|
|
34
|
+
if (json) {
|
|
35
|
+
process.stdout.write(JSON.stringify({ cli: pkg.version, hosts: states }, null, 2) + '\n');
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const { claude, opencode } = states;
|
|
39
|
+
process.stdout.write(`${bold('dotmd host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
|
|
40
|
+
|
|
41
|
+
process.stdout.write(` claude ${claude.installed ? green(claude.version ?? 'installed') : yellow('not installed')}\n`);
|
|
42
|
+
process.stdout.write(` ${dim(claude.installed ? claude.id : 'plugin: SessionStart primer, PreToolUse guard, workflow skill')}\n`);
|
|
43
|
+
|
|
44
|
+
const ocState = opencode.foreign ? yellow('unmanaged file present')
|
|
45
|
+
: !opencode.exists ? yellow('not installed')
|
|
46
|
+
: opencode.stale ? yellow(`${opencode.version} — behind CLI ${pkg.version}`)
|
|
47
|
+
: green(opencode.version);
|
|
48
|
+
process.stdout.write(` opencode ${ocState}\n`);
|
|
49
|
+
process.stdout.write(` ${dim(opencode.path)}\n`);
|
|
50
|
+
|
|
51
|
+
const todo = [];
|
|
52
|
+
if (!claude.installed) todo.push('dotmd install claude');
|
|
53
|
+
if (!opencode.exists || opencode.stale) todo.push('dotmd install opencode');
|
|
54
|
+
if (todo.length) {
|
|
55
|
+
process.stdout.write('\n');
|
|
56
|
+
for (const cmd of todo) process.stdout.write(`Run ${bold(cmd)}\n`);
|
|
57
|
+
}
|
|
58
|
+
if (!opencode.exists) {
|
|
59
|
+
process.stdout.write(dim('\nWithout the OpenCode integration, every session in one OpenCode process\n'));
|
|
60
|
+
process.stdout.write(dim('shares one identity — so a session can release another session\'s plan —\n'));
|
|
61
|
+
process.stdout.write(dim('and no session-start primer runs.\n'));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function installClaude(argv, dryRun, json) {
|
|
66
|
+
const remove = argv.includes('--remove');
|
|
67
|
+
const steps = planClaudeInstall({ installed: readInstalledPlugin(), hasClaude: which('claude'), remove });
|
|
68
|
+
if (json) {
|
|
69
|
+
process.stdout.write(JSON.stringify({ host: 'claude', dryRun, steps }, null, 2) + '\n');
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
let ran = false;
|
|
73
|
+
for (const step of steps) {
|
|
74
|
+
if (step.kind === 'skip') { process.stdout.write(`${dim('skip:')} ${step.reason}\n`); continue; }
|
|
75
|
+
if (step.kind === 'manual') {
|
|
76
|
+
process.stdout.write(`${yellow('claude CLI not on PATH')} — run these from a Claude Code session:\n`);
|
|
77
|
+
for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`)); continue; }
|
|
81
|
+
process.stdout.write(dim(`$ ${step.cmd.join(' ')}\n`));
|
|
82
|
+
const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
|
|
83
|
+
stdio: 'inherit', shell: process.platform === 'win32',
|
|
84
|
+
});
|
|
85
|
+
ran = true;
|
|
86
|
+
if (result.status !== 0) {
|
|
87
|
+
process.stdout.write(yellow(`(claude exited ${result.status ?? '?'})\n`));
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (ran && !remove) process.stdout.write(green('\n✓ restart Claude Code (or /reload-plugins) to apply.\n'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function installOpencode(argv, dryRun, json) {
|
|
96
|
+
const force = argv.includes('--force');
|
|
97
|
+
const dir = flagValue(argv, '--path');
|
|
98
|
+
const result = argv.includes('--remove')
|
|
99
|
+
? removeOpencodePlugin({ dryRun, force, dir })
|
|
100
|
+
: installOpencodePlugin({ version: pkg.version, dryRun, force, dir });
|
|
101
|
+
|
|
102
|
+
if (json) {
|
|
103
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
104
|
+
if (result.action === 'refused') process.exitCode = 1;
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const prefix = dryRun ? dim('[dry-run] ') : '';
|
|
109
|
+
switch (result.action) {
|
|
110
|
+
case 'refused':
|
|
111
|
+
process.stderr.write(`${result.path}\n refused: ${result.reason}\n Pass --force to overwrite it.\n`);
|
|
112
|
+
process.exitCode = 1;
|
|
113
|
+
return result;
|
|
114
|
+
case 'current':
|
|
115
|
+
process.stdout.write(`${green('✓')} opencode integration already current (${result.version})\n${dim(result.path)}\n`);
|
|
116
|
+
return result;
|
|
117
|
+
case 'absent':
|
|
118
|
+
process.stdout.write(`${dim('nothing to remove:')} ${result.path}\n`);
|
|
119
|
+
return result;
|
|
120
|
+
case 'removed':
|
|
121
|
+
process.stdout.write(`${prefix}removed ${result.path}\n`);
|
|
122
|
+
process.stdout.write(dim('Restart OpenCode to apply. Sessions fall back to a process-scoped identity.\n'));
|
|
123
|
+
return result;
|
|
124
|
+
default:
|
|
125
|
+
process.stdout.write(`${prefix}${green('✓')} ${result.action} opencode integration (${pkg.version})\n${dim(result.path)}\n`);
|
|
126
|
+
if (dryRun) return result;
|
|
127
|
+
process.stdout.write('\nRestart OpenCode to apply. New sessions then get:\n');
|
|
128
|
+
process.stdout.write(` ${dim('·')} a per-session ownership identity (one session can no longer release another's plan)\n`);
|
|
129
|
+
process.stdout.write(` ${dim('·')} the ${bold('dotmd hud')} primer at session start, like Claude Code's SessionStart hook\n`);
|
|
130
|
+
process.stdout.write(dim('\nPlans already in-session were claimed under the old process-scoped identity;\n'));
|
|
131
|
+
process.stdout.write(dim('close them before restarting, or reclaim with --force afterwards.\n'));
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function runInstall(argv, _config, opts = {}) {
|
|
137
|
+
const json = argv.includes('--json');
|
|
138
|
+
const requested = argv.find(a => !a.startsWith('-'));
|
|
139
|
+
|
|
140
|
+
if (!requested) { reportStatus(json); return; }
|
|
141
|
+
const host = ALIASES[requested] ?? requested;
|
|
142
|
+
if (!HOSTS.includes(host)) {
|
|
143
|
+
process.stderr.write(`Unknown host "${requested}". Known: ${HOSTS.join(', ')}\n`);
|
|
144
|
+
process.exitCode = 1;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const dryRun = Boolean(opts.dryRun);
|
|
149
|
+
if (host === 'claude') { installClaude(argv, dryRun, json); return; }
|
|
150
|
+
installOpencode(argv, dryRun, json);
|
|
151
|
+
}
|
package/src/pickup.mjs
CHANGED
|
@@ -5,25 +5,22 @@ import { authorizeManagedSource, authorizeRepoGeneratedPath } from './managed-pa
|
|
|
5
5
|
import os from 'node:os';
|
|
6
6
|
import { currentProcessOwner, mutateFileSet, processOwnerLiveness, processStartIdentity, replaceSnapshot, snapshotFile, withPathLocks } from './atomic-mutation.mjs';
|
|
7
7
|
import { extractFrontmatter, parseSimpleFrontmatter } from './frontmatter.mjs';
|
|
8
|
-
import { asString, relTime } from './util.mjs';
|
|
8
|
+
import { asString, hostSessionId, relTime } from './util.mjs';
|
|
9
9
|
|
|
10
10
|
export const OWNERSHIP_SCHEMA = 2;
|
|
11
11
|
export const HOOK_DELIVERY_LEASE_MS = 30_000;
|
|
12
12
|
|
|
13
13
|
export function authoritativeSessionId(env = process.env) {
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (value) return host === 'term' ? `term:${value}` : value;
|
|
25
|
-
}
|
|
26
|
-
throw new Error('No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session.');
|
|
14
|
+
const id = hostSessionId(env);
|
|
15
|
+
if (id) return id;
|
|
16
|
+
// Name the host when we can recognize it. The generic "set DOTMD_SESSION_ID"
|
|
17
|
+
// is the fallback of last resort, and a poor one to reach for first: exported
|
|
18
|
+
// from a shell profile it gives every session in that shell ONE id, which is
|
|
19
|
+
// the collision the ownership record exists to prevent.
|
|
20
|
+
const host = env.OPENCODE || env.OPENCODE_PID ? 'opencode' : null;
|
|
21
|
+
throw new Error(host
|
|
22
|
+
? `No authoritative session identity. Run \`dotmd install ${host}\` to give each ${host} session its own, or set DOTMD_SESSION_ID for this shell.`
|
|
23
|
+
: 'No authoritative session identity. Set DOTMD_SESSION_ID for this shell or host session, or see `dotmd install` for supported hosts.');
|
|
27
24
|
}
|
|
28
25
|
|
|
29
26
|
export function availableSessionId(env = process.env) {
|
|
@@ -38,8 +35,10 @@ export function availableSessionId(env = process.env) {
|
|
|
38
35
|
// instead of an age threshold that cannot tell a three-day-dead session from a
|
|
39
36
|
// long-running one. Absent (a plain terminal, an unknown harness) is not an
|
|
40
37
|
// error — it yields null, which reads as 'unverifiable' and never auto-reclaims.
|
|
38
|
+
// `OPENCODE_PID` is the OpenCode server process, which is exactly that harness:
|
|
39
|
+
// it hosts the session and outlives every tool shell it spawns.
|
|
41
40
|
export function sessionProcessOwner(env = process.env) {
|
|
42
|
-
const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID)?.trim();
|
|
41
|
+
const raw = (env.DOTMD_SESSION_PID ?? env.CLAUDE_PID ?? env.OPENCODE_PID)?.trim();
|
|
43
42
|
const pid = Number(raw);
|
|
44
43
|
if (!raw || !Number.isInteger(pid) || pid <= 0) return null;
|
|
45
44
|
return {
|
package/src/update.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import path from 'node:path';
|
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { green, dim, yellow } from './color.mjs';
|
|
7
|
+
import { executableName, which } from './util.mjs';
|
|
8
|
+
import { installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
|
|
7
9
|
|
|
8
10
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
11
|
const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
|
|
@@ -72,23 +74,19 @@ export function planUpdate(opts, ctx) {
|
|
|
72
74
|
} else {
|
|
73
75
|
steps.push({ kind: 'plugin', cmd: ['claude', 'plugin', 'update', ctx.plugin.id] });
|
|
74
76
|
}
|
|
77
|
+
// The OpenCode integration is a file dotmd wrote, so it goes stale silently
|
|
78
|
+
// the moment the CLI moves on. Refresh it here — but only if it is already
|
|
79
|
+
// installed. `update` keeps hosts in lockstep; it never adopts a new one,
|
|
80
|
+
// which stays the job of the explicit `dotmd install`.
|
|
81
|
+
if (ctx.opencode?.exists && ctx.opencode.stale) {
|
|
82
|
+
steps.push({ kind: 'opencode', path: ctx.opencode.path });
|
|
83
|
+
} else if (ctx.opencode?.foreign) {
|
|
84
|
+
steps.push({ kind: 'skip', reason: `${ctx.opencode.path} was not written by dotmd — leaving it alone` });
|
|
85
|
+
}
|
|
75
86
|
}
|
|
76
87
|
return steps;
|
|
77
88
|
}
|
|
78
89
|
|
|
79
|
-
function which(bin) {
|
|
80
|
-
try {
|
|
81
|
-
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
82
|
-
return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
|
|
83
|
-
} catch {
|
|
84
|
-
return false;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function executableName(bin) {
|
|
89
|
-
return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
export function runUpdate(argv, _config, opts = {}) {
|
|
93
91
|
const check = argv.includes('--check');
|
|
94
92
|
const cliOnly = argv.includes('--cli-only');
|
|
@@ -105,15 +103,21 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
105
103
|
: yellow('ahead — CLI is behind');
|
|
106
104
|
process.stdout.write(`dotmd plugin: ${plugin.version ?? '?'} (${plugin.id}) ${tag}\n`);
|
|
107
105
|
} else {
|
|
108
|
-
process.stdout.write(dim('dotmd plugin: not installed
|
|
106
|
+
process.stdout.write(dim('dotmd plugin: not installed — `dotmd install claude`\n'));
|
|
109
107
|
}
|
|
108
|
+
const oc = opencodeStatus({ version: pkg.version });
|
|
109
|
+
if (!oc.exists) process.stdout.write(dim('dotmd opencode: not installed\n'));
|
|
110
|
+
else if (oc.foreign) process.stdout.write(`dotmd opencode: ${yellow('unmanaged file — not written by dotmd')}\n`);
|
|
111
|
+
else process.stdout.write(`dotmd opencode: ${oc.version} ${oc.stale ? yellow('behind — run `dotmd update`') : green('in sync')}\n`);
|
|
110
112
|
return;
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
const
|
|
115
|
+
const opencode = opencodeStatus({ version: pkg.version });
|
|
116
|
+
const steps = planUpdate({ cliOnly, pluginOnly }, { plugin, opencode, hasClaude: which('claude'), hasNpm: which('npm') });
|
|
114
117
|
if (opts.dryRun) {
|
|
115
118
|
for (const step of steps) {
|
|
116
119
|
if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
|
|
120
|
+
else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
|
|
117
121
|
else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`));
|
|
118
122
|
}
|
|
119
123
|
return;
|
|
@@ -125,6 +129,12 @@ export function runUpdate(argv, _config, opts = {}) {
|
|
|
125
129
|
process.stdout.write(dim(`skip: ${s.reason}\n`));
|
|
126
130
|
continue;
|
|
127
131
|
}
|
|
132
|
+
if (s.kind === 'opencode') {
|
|
133
|
+
const result = installOpencodePlugin({ version: pkg.version });
|
|
134
|
+
process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
|
|
135
|
+
ran = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
128
138
|
process.stdout.write(dim(`$ ${s.cmd.join(' ')}\n`));
|
|
129
139
|
const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
|
|
130
140
|
stdio: 'inherit',
|
package/src/util.mjs
CHANGED
|
@@ -1,16 +1,88 @@
|
|
|
1
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import os from 'node:os';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
4
6
|
import { dim } from './color.mjs';
|
|
5
7
|
|
|
8
|
+
// The one list of environment variables that can name the session dotmd is
|
|
9
|
+
// running inside. It lives here, in a leaf module, because both consumers must
|
|
10
|
+
// read the same list: `currentSessionId` below (journal attribution, which falls
|
|
11
|
+
// back to the shell) and `authoritativeSessionId` in pickup.mjs (plan ownership,
|
|
12
|
+
// which fails closed). Two hand-maintained copies is how the OpenCode entries
|
|
13
|
+
// drifted into naming variables OpenCode does not set.
|
|
14
|
+
//
|
|
15
|
+
// Order is most-specific-first. `OPENCODE_PID` is a real fallback, not a guess:
|
|
16
|
+
// OpenCode's CLI middleware sets it on the process every tool shell inherits,
|
|
17
|
+
// and it is per-OpenCode-process rather than per-session, so it sits below the
|
|
18
|
+
// session-scoped names above it and above `TERM_SESSION_ID` — a terminal id is
|
|
19
|
+
// shared by every agent run in that window and outlives all of them.
|
|
20
|
+
// `scope: 'session'` means the variable names one agent session; `'process'`
|
|
21
|
+
// and `'terminal'` are coarser — several sessions can share one, so they cannot
|
|
22
|
+
// tell two of them apart. `dotmd doctor --session` reports that distinction, and
|
|
23
|
+
// it is the whole reason `dotmd install opencode` exists.
|
|
24
|
+
const SESSION_ID_SOURCES = [
|
|
25
|
+
{ variable: 'DOTMD_SESSION_ID', prefix: null, scope: 'session', host: 'explicit override' },
|
|
26
|
+
{ variable: 'CLAUDE_CODE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
|
|
27
|
+
{ variable: 'CLAUDE_SESSION_ID', prefix: null, scope: 'session', host: 'Claude Code' },
|
|
28
|
+
{ variable: 'OPENCODE_SESSION_ID', prefix: null, scope: 'session', host: 'OpenCode' },
|
|
29
|
+
{ variable: 'OPENCODE_SESSION', prefix: null, scope: 'session', host: 'OpenCode' },
|
|
30
|
+
{ variable: 'OPENCODE_PID', prefix: 'opencode', scope: 'process', host: 'OpenCode' },
|
|
31
|
+
{ variable: 'TERM_SESSION_ID', prefix: 'term', scope: 'terminal', host: 'terminal' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// Which source named the session, with the id it produced — or null when the
|
|
35
|
+
// environment names none.
|
|
36
|
+
export function hostSessionSource(env = process.env) {
|
|
37
|
+
for (const source of SESSION_ID_SOURCES) {
|
|
38
|
+
const value = env[source.variable]?.trim();
|
|
39
|
+
if (!value) continue;
|
|
40
|
+
return { ...source, id: source.prefix ? `${source.prefix}:${value}` : value };
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// The session id the environment names, or null when it names none.
|
|
46
|
+
export function hostSessionId(env = process.env) {
|
|
47
|
+
return hostSessionSource(env)?.id ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
6
50
|
// Stable identifier for the current shell/agent session. Used for journal
|
|
7
51
|
// attribution and hint de-duplication — not for any plan locking.
|
|
8
52
|
export function currentSessionId() {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
53
|
+
return hostSessionId() ?? `shell:${os.userInfo().username}@${os.hostname()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// The running CLI's version, read once and memoized — several surfaces compare
|
|
57
|
+
// it against what a host integration was generated from.
|
|
58
|
+
let cachedVersion;
|
|
59
|
+
export function dotmdVersion() {
|
|
60
|
+
if (cachedVersion === undefined) {
|
|
61
|
+
try {
|
|
62
|
+
const pkgPath = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'package.json');
|
|
63
|
+
cachedVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version ?? null;
|
|
64
|
+
} catch {
|
|
65
|
+
cachedVersion = null;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return cachedVersion;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Is `bin` runnable from PATH? Used to decide whether dotmd can drive a host's
|
|
72
|
+
// own CLI or must print the in-session commands for the user to run instead.
|
|
73
|
+
export function which(bin) {
|
|
74
|
+
try {
|
|
75
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which';
|
|
76
|
+
return spawnSync(cmd, [bin], { encoding: 'utf8' }).status === 0;
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Windows resolves `foo` to `foo.cmd` only through a shell; spawn needs the
|
|
83
|
+
// real name.
|
|
84
|
+
export function executableName(bin) {
|
|
85
|
+
return process.platform === 'win32' && !bin.endsWith('.cmd') ? `${bin}.cmd` : bin;
|
|
14
86
|
}
|
|
15
87
|
|
|
16
88
|
export function escapeTable(value) {
|