dotmd-cli 0.74.6 → 0.75.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 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
- The recommended Claude Code setup is the dotmd plugin:
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 and OpenCode session IDs are recognized automatically. Other hosts
468
- must set DOTMD_SESSION_ID; anonymous ownership mutations fail closed.
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
@@ -1724,6 +1773,7 @@ async function main() {
1724
1773
  if (command === 'hud') { const { runHud } = await import('../src/hud.mjs'); runHud(restArgs, config); return; }
1725
1774
  if (command === 'guard') { const { runGuard } = await import('../src/guard.mjs'); await runGuard(restArgs, config, { dryRun }); return; }
1726
1775
  if (command === 'update') { const { runUpdate } = await import('../src/update.mjs'); runUpdate(restArgs, config, { dryRun }); return; }
1776
+ if (command === 'install') { const { runInstall } = await import('../src/install.mjs'); runInstall(restArgs, config, { dryRun }); return; }
1727
1777
  if (command === 'misuse') { const { runMisuse } = await import('../src/misuse-read.mjs'); runMisuse(restArgs, config); return; }
1728
1778
  if (command === 'journal') { const { runJournal } = await import('../src/journal-read.mjs'); runJournal(restArgs, config); return; }
1729
1779
  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.74.6",
3
+ "version": "0.75.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
  ],
@@ -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,211 @@
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 { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import os from 'node:os';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+ import { hostSessionSource } from './util.mjs';
24
+
25
+ export const GENERATED_MARKER = 'dotmd-generated:';
26
+ const PLUGIN_FILENAME = 'dotmd.js';
27
+ const ASSET = path.resolve(fileURLToPath(import.meta.url), '..', '..', 'assets', 'opencode', 'plugin.js');
28
+
29
+ // OpenCode's own resolution order for its global config directory. Verified
30
+ // against the shipped binary: `OPENCODE_CONFIG_DIR` is an explicit override it
31
+ // consults directly, and the default is the XDG location.
32
+ export function opencodeConfigDir(env = process.env, homedir = os.homedir()) {
33
+ const explicit = env.OPENCODE_CONFIG_DIR?.trim();
34
+ if (explicit) return path.resolve(explicit);
35
+ const xdg = env.XDG_CONFIG_HOME?.trim();
36
+ return xdg ? path.join(path.resolve(xdg), 'opencode') : path.join(homedir, '.config', 'opencode');
37
+ }
38
+
39
+ // Both spellings are globbed, so an existing one is adopted rather than
40
+ // creating a second plugin directory beside the user's own plugins.
41
+ export function opencodePluginDir(configDir) {
42
+ for (const name of ['plugin', 'plugins']) {
43
+ const candidate = path.join(configDir, name);
44
+ if (existsSync(candidate)) return candidate;
45
+ }
46
+ return path.join(configDir, 'plugin');
47
+ }
48
+
49
+ export function opencodePluginPath(opts = {}) {
50
+ const { env = process.env, homedir = os.homedir(), dir } = opts;
51
+ if (dir) return path.join(path.resolve(dir), PLUGIN_FILENAME);
52
+ return path.join(opencodePluginDir(opencodeConfigDir(env, homedir)), PLUGIN_FILENAME);
53
+ }
54
+
55
+ function banner(version) {
56
+ return [
57
+ `// ${GENERATED_MARKER}${version}`,
58
+ '// Generated by `dotmd install opencode`. Refreshed by `dotmd update`.',
59
+ '// Hand edits are overwritten — remove with `dotmd install opencode --remove`.',
60
+ '',
61
+ ].join('\n');
62
+ }
63
+
64
+ export function renderOpencodePlugin(version) {
65
+ return banner(version) + readFileSync(ASSET, 'utf8');
66
+ }
67
+
68
+ // The version a generated file was written by, or null when the file is absent
69
+ // or carries no dotmd banner. A file without the banner is the user's own and
70
+ // is never written or removed — same rule the retired slash-command scaffolding
71
+ // followed.
72
+ export function installedVersion(filePath) {
73
+ let contents;
74
+ try { contents = readFileSync(filePath, 'utf8'); } catch { return null; }
75
+ const marker = contents.indexOf(GENERATED_MARKER);
76
+ if (marker < 0) return null;
77
+ const rest = contents.slice(marker + GENERATED_MARKER.length);
78
+ return rest.split('\n', 1)[0].trim() || null;
79
+ }
80
+
81
+ export function opencodeStatus(opts = {}) {
82
+ const { version, ...rest } = opts;
83
+ const filePath = opencodePluginPath(rest);
84
+ const exists = existsSync(filePath);
85
+ const installed = exists ? installedVersion(filePath) : null;
86
+ return {
87
+ path: filePath,
88
+ exists,
89
+ version: installed,
90
+ // Present but unmarked: the user put a `dotmd.js` there themselves.
91
+ foreign: exists && installed === null,
92
+ stale: installed !== null && version !== undefined && installed !== version,
93
+ };
94
+ }
95
+
96
+ // Is OpenCode plausibly in use on this machine? Used only to decide whether to
97
+ // mention the integration — never to install anything.
98
+ export function opencodeDetected(opts = {}) {
99
+ const { env = process.env, homedir = os.homedir() } = opts;
100
+ if (env.OPENCODE || env.OPENCODE_PID) return true;
101
+ const configDir = opencodeConfigDir(env, homedir);
102
+ if (!existsSync(configDir)) return false;
103
+ try { return readdirSync(configDir).length > 0; } catch { return false; }
104
+ }
105
+
106
+ export function installOpencodePlugin(opts = {}) {
107
+ const { version, dryRun = false, force = false, ...rest } = opts;
108
+ const status = opencodeStatus({ version, ...rest });
109
+ if (status.foreign && !force) {
110
+ return { ...status, action: 'refused', reason: 'a dotmd.js without a dotmd banner is already there — it was not written by dotmd' };
111
+ }
112
+ if (status.exists && !status.stale && !status.foreign) {
113
+ return { ...status, action: 'current' };
114
+ }
115
+ const action = status.exists ? 'updated' : 'installed';
116
+ if (!dryRun) {
117
+ mkdirSync(path.dirname(status.path), { recursive: true });
118
+ writeFileSync(status.path, renderOpencodePlugin(version), 'utf8');
119
+ }
120
+ return { ...status, action, version };
121
+ }
122
+
123
+ // What identity does dotmd actually see here, and can it tell this session
124
+ // apart from its siblings? This report exists because the OpenCode breakage was
125
+ // invisible until a verb failed: dotmd looked for variables OpenCode never set,
126
+ // and nothing surfaced that until `use` refused to run. Anyone on any host can
127
+ // now check what dotmd resolved instead of discovering it through a failure.
128
+ export function describeSessionIdentity(opts = {}) {
129
+ const { env = process.env, homedir = os.homedir(), version } = opts;
130
+ const source = hostSessionSource(env);
131
+ const opencode = opencodeStatus({ env, homedir, version });
132
+ const underOpencode = Boolean(env.OPENCODE || env.OPENCODE_PID);
133
+
134
+ if (!source) {
135
+ return {
136
+ id: null, scope: 'none', source: null, host: null,
137
+ summary: 'no session identity — `use`, `set`, `baton` and `archive` fail closed',
138
+ advice: underOpencode
139
+ ? ['dotmd install opencode', 'or set DOTMD_SESSION_ID for this shell']
140
+ : ['set DOTMD_SESSION_ID for this shell or host session', 'known hosts: dotmd install'],
141
+ };
142
+ }
143
+
144
+ const advice = [];
145
+ // The distinction that matters: a process- or terminal-scoped id is shared by
146
+ // every session under it, so those sessions can release each other's plans.
147
+ if (source.scope === 'process' && source.host === 'OpenCode') {
148
+ advice.push(opencode.exists
149
+ // The plugin is there but this shell was spawned before it loaded, so it
150
+ // still carries the old identity — say so rather than leave a warning
151
+ // marker with no explanation.
152
+ ? 'restart OpenCode — this session predates the installed integration'
153
+ : 'dotmd install opencode — for a per-session identity');
154
+ } else if (source.scope === 'terminal') {
155
+ advice.push('every agent session in this terminal shares this id — set DOTMD_SESSION_ID per session');
156
+ }
157
+ return {
158
+ id: source.id,
159
+ scope: source.scope,
160
+ source: source.variable,
161
+ host: source.host,
162
+ summary: source.scope === 'session'
163
+ ? `per-session identity from ${source.variable}`
164
+ : `${source.scope}-scoped identity from ${source.variable} — sessions sharing it can release each other's plans`,
165
+ advice,
166
+ };
167
+ }
168
+
169
+ // --- Claude Code -----------------------------------------------------------
170
+ //
171
+ // Claude Code has a real plugin registry, so dotmd drives `claude plugin`
172
+ // rather than writing files. What it lacked was any CLI path to the FIRST
173
+ // install: `dotmd update` deliberately skips the plugin step when nothing is
174
+ // installed ("dotmd plugin not installed"), and the README's two slash commands
175
+ // only work from inside a session. So a user who installed the CLI from npm had
176
+ // no way to discover, from the CLI, that the plugin exists.
177
+
178
+ export const CLAUDE_MARKETPLACE = 'reowens/dotmd';
179
+ export const CLAUDE_PLUGIN_ID = 'dotmd@dotmd';
180
+
181
+ // Pure planner, mirroring planUpdate: the orchestration is unit-testable and
182
+ // the side effects stay in the caller.
183
+ export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
184
+ if (remove) {
185
+ if (!installed) return [{ kind: 'skip', reason: 'dotmd plugin is not installed' }];
186
+ return hasClaude
187
+ ? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
188
+ : [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
189
+ }
190
+ if (installed) return [{ kind: 'skip', reason: `dotmd plugin already installed (${installed.version ?? 'unknown version'})` }];
191
+ // The marketplace has to be registered before the plugin resolves; adding one
192
+ // already present is a no-op, so this stays safe to re-run.
193
+ if (!hasClaude) {
194
+ return [{ kind: 'manual', lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin install ${CLAUDE_PLUGIN_ID}`] }];
195
+ }
196
+ return [
197
+ { kind: 'run', cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
198
+ { kind: 'run', cmd: ['claude', 'plugin', 'install', CLAUDE_PLUGIN_ID] },
199
+ ];
200
+ }
201
+
202
+ export function removeOpencodePlugin(opts = {}) {
203
+ const { dryRun = false, force = false, ...rest } = opts;
204
+ const status = opencodeStatus(rest);
205
+ if (!status.exists) return { ...status, action: 'absent' };
206
+ if (status.foreign && !force) {
207
+ return { ...status, action: 'refused', reason: 'not a dotmd-generated file' };
208
+ }
209
+ if (!dryRun) rmSync(status.path, { force: true });
210
+ return { ...status, action: 'removed' };
211
+ }
@@ -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 candidates = [
15
- ['DOTMD_SESSION_ID', 'dotmd'],
16
- ['CLAUDE_CODE_SESSION_ID', 'claude'],
17
- ['CLAUDE_SESSION_ID', 'claude'],
18
- ['OPENCODE_SESSION_ID', 'opencode'],
19
- ['OPENCODE_SESSION', 'opencode'],
20
- ['TERM_SESSION_ID', 'term'],
21
- ];
22
- for (const [name, host] of candidates) {
23
- const value = env[name]?.trim();
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\n'));
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 steps = planUpdate({ cliOnly, pluginOnly }, { plugin, hasClaude: which('claude'), hasNpm: which('npm') });
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
- if (process.env.DOTMD_SESSION_ID) return process.env.DOTMD_SESSION_ID;
10
- if (process.env.CLAUDE_CODE_SESSION_ID) return process.env.CLAUDE_CODE_SESSION_ID;
11
- if (process.env.CLAUDE_SESSION_ID) return process.env.CLAUDE_SESSION_ID;
12
- if (process.env.TERM_SESSION_ID) return `term:${process.env.TERM_SESSION_ID}`;
13
- return `shell:${os.userInfo().username}@${os.hostname()}`;
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) {