dotmd-cli 0.77.2 → 0.77.3

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/bin/dotmd.mjs CHANGED
@@ -160,6 +160,12 @@ host gets that a different way:
160
160
  with "plugin not installed"), and the README's slash commands only
161
161
  work from inside a session. Without the \`claude\` CLI on PATH the
162
162
  two in-session commands are printed instead.
163
+ It also repairs a plugin Claude lists as "failed to load: Marketplace
164
+ dotmd not found" — an install record whose marketplace registration
165
+ is gone — by re-adding the marketplace and updating the plugin. If
166
+ Claude refuses the marketplace, ~/.claude/settings.json declares it
167
+ under extraKnownMarketplaces with a source that no longer matches;
168
+ dotmd names the field but never edits that file.
163
169
 
164
170
  opencode Writes one auto-discovered plugin file. OpenCode has no plugin
165
171
  registry but globs \`{plugin,plugins}/*.{ts,js}\` under its global
@@ -190,7 +196,11 @@ reports a missing integration but never installs one.`,
190
196
  The plugin and CLI ship in lockstep; a release bumps both. Updating the plugin
191
197
  requires a session restart (or /reload-plugins) to apply. The plugin step needs
192
198
  the \`claude\` CLI on PATH — otherwise it prints the \`/plugin update\` command to
193
- run from a session instead.`,
199
+ run from a session instead. The OpenCode file is refreshed in the same run when
200
+ it is present and behind. The hosts are independent, so a failing step does not
201
+ stop the others: every step runs, the failures are listed together, and the
202
+ exit code is 1. A plugin whose marketplace registration is gone gets the
203
+ marketplace re-added before the update (see \`dotmd install claude\`).`,
194
204
 
195
205
  misuse: `dotmd misuse — read the cross-repo guard log (~/.claude/logs/dotmd-misuse.log)
196
206
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotmd-cli",
3
- "version": "0.77.2",
3
+ "version": "0.77.3",
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",
@@ -23,6 +23,7 @@ import path from 'node:path';
23
23
  import { fileURLToPath } from 'node:url';
24
24
  import { hostSessionSource } from './util.mjs';
25
25
  import { readEnv, stateDir } from './naming.mjs';
26
+ import { planMarketplaceRepair } from './update.mjs';
26
27
 
27
28
  export const GENERATED_MARKER = 'dotmd-generated:';
28
29
  const PLUGIN_FILENAME = 'dotmd.js';
@@ -233,6 +234,19 @@ export function degradedIdentityNotice(repoRoot, opts = {}) {
233
234
  export const CLAUDE_MARKETPLACE = 'reowens/dotmd';
234
235
  export const CLAUDE_PLUGIN_ID = 'dotmd@dotmd';
235
236
 
237
+ // Why `claude plugin marketplace add` can refuse a marketplace that is not
238
+ // even registered: settings.json may still DECLARE it (extraKnownMarketplaces),
239
+ // and Claude rejects an add whose source differs from that declaration in any
240
+ // fetch-shaping field. dotmd never writes that declaration and will not edit
241
+ // it, so the most it can do is name the field.
242
+ export function claudeMarketplaceRefusalHint(marketplace = 'dotmd') {
243
+ return [
244
+ `Claude refused to register marketplace "${marketplace}". If ~/.claude/settings.json declares it under`,
245
+ `extraKnownMarketplaces, its source must be exactly {"source":"github","repo":"${CLAUDE_MARKETPLACE}"} —`,
246
+ 'a stray "path", "ref" or header there makes the add fail and the installed plugin fail to load.',
247
+ ];
248
+ }
249
+
236
250
  // Pure planner, mirroring planUpdate: the orchestration is unit-testable and
237
251
  // the side effects stay in the caller.
238
252
  export function planClaudeInstall({ installed, hasClaude, remove = false } = {}) {
@@ -242,6 +256,13 @@ export function planClaudeInstall({ installed, hasClaude, remove = false } = {})
242
256
  ? [{ kind: 'run', cmd: ['claude', 'plugin', 'uninstall', installed.id] }]
243
257
  : [{ kind: 'manual', lines: [`/plugin uninstall ${installed.id}`] }];
244
258
  }
259
+ // An install record whose marketplace registration is gone is not an
260
+ // installed plugin — Claude lists it as "failed to load". Skipping here with
261
+ // "already installed" left the user with no dotmd verb that could repair it.
262
+ if (installed?.marketplaceRegistered === false) {
263
+ return planMarketplaceRepair(installed, { hasClaude, verb: 'update' })
264
+ .map(step => (step.kind === 'marketplace' || step.kind === 'plugin' ? { ...step, kind: 'run', step: step.kind } : step));
265
+ }
245
266
  if (installed) return [{ kind: 'skip', reason: `dotmd plugin already installed (${installed.version ?? 'unknown version'})` }];
246
267
  // The marketplace has to be registered before the plugin resolves; adding one
247
268
  // already present is a no-op, so this stays safe to re-run.
package/src/install.mjs CHANGED
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { bold, dim, green, yellow } from './color.mjs';
6
6
  import {
7
- CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, installOpencodePlugin, opencodeDetected,
7
+ CLAUDE_MARKETPLACE, CLAUDE_PLUGIN_ID, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeDetected,
8
8
  opencodeStatus, planClaudeInstall, removeOpencodePlugin,
9
9
  } from './host-integration.mjs';
10
10
  import { readInstalledPlugin } from './update.mjs';
@@ -24,7 +24,14 @@ function flagValue(argv, name) {
24
24
  function hostStates() {
25
25
  const plugin = readInstalledPlugin();
26
26
  return {
27
- claude: { installed: Boolean(plugin), version: plugin?.version ?? null, id: plugin?.id ?? CLAUDE_PLUGIN_ID },
27
+ claude: {
28
+ installed: Boolean(plugin),
29
+ version: plugin?.version ?? null,
30
+ id: plugin?.id ?? CLAUDE_PLUGIN_ID,
31
+ marketplace: plugin?.marketplace ?? null,
32
+ // false = record present, marketplace gone: Claude shows "failed to load".
33
+ marketplaceRegistered: plugin ? plugin.marketplaceRegistered : null,
34
+ },
28
35
  opencode: { ...opencodeStatus({ version: pkg.version }), detected: opencodeDetected() },
29
36
  };
30
37
  }
@@ -38,7 +45,11 @@ function reportStatus(json) {
38
45
  const { claude, opencode } = states;
39
46
  process.stdout.write(`${bold('dotmd host integrations')} ${dim(`CLI ${pkg.version}`)}\n\n`);
40
47
 
41
- process.stdout.write(` claude ${claude.installed ? green(claude.version ?? 'installed') : yellow('not installed')}\n`);
48
+ const claudeBroken = claude.installed && claude.marketplaceRegistered === false;
49
+ const claudeState = !claude.installed ? yellow('not installed')
50
+ : claudeBroken ? yellow(`${claude.version ?? 'installed'} — marketplace "${claude.marketplace}" not registered, plugin fails to load`)
51
+ : green(claude.version ?? 'installed');
52
+ process.stdout.write(` claude ${claudeState}\n`);
42
53
  process.stdout.write(` ${dim(claude.installed ? claude.id : 'plugin: SessionStart primer, PreToolUse guard, workflow skill')}\n`);
43
54
 
44
55
  const ocState = opencode.foreign ? yellow('unmanaged file present')
@@ -49,7 +60,7 @@ function reportStatus(json) {
49
60
  process.stdout.write(` ${dim(opencode.path)}\n`);
50
61
 
51
62
  const todo = [];
52
- if (!claude.installed) todo.push('dotmd install claude');
63
+ if (!claude.installed || claudeBroken) todo.push('dotmd install claude');
53
64
  if (!opencode.exists || opencode.stale) todo.push('dotmd install opencode');
54
65
  if (todo.length) {
55
66
  process.stdout.write('\n');
@@ -73,10 +84,11 @@ function installClaude(argv, dryRun, json) {
73
84
  for (const step of steps) {
74
85
  if (step.kind === 'skip') { process.stdout.write(`${dim('skip:')} ${step.reason}\n`); continue; }
75
86
  if (step.kind === 'manual') {
76
- process.stdout.write(`${yellow('claude CLI not on PATH')} — run these from a Claude Code session:\n`);
87
+ process.stdout.write(`${yellow(step.reason ?? 'claude CLI not on PATH')} — run these from a Claude Code session:\n`);
77
88
  for (const line of step.lines) process.stdout.write(` ${bold(line)}\n`);
78
89
  continue;
79
90
  }
91
+ if (step.reason) process.stdout.write(`${step.reason}\n`);
80
92
  if (dryRun) { process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`)); continue; }
81
93
  process.stdout.write(dim(`$ ${step.cmd.join(' ')}\n`));
82
94
  const result = spawnSync(executableName(step.cmd[0]), step.cmd.slice(1), {
@@ -85,6 +97,11 @@ function installClaude(argv, dryRun, json) {
85
97
  ran = true;
86
98
  if (result.status !== 0) {
87
99
  process.stdout.write(yellow(`(claude exited ${result.status ?? '?'})\n`));
100
+ // Each later step here depends on this one (the plugin resolves through
101
+ // the marketplace), so unlike `update` there is nothing independent left.
102
+ if (step.step === 'marketplace' || step.cmd.includes('marketplace')) {
103
+ for (const line of claudeMarketplaceRefusalHint(readInstalledPlugin()?.marketplace)) process.stdout.write(yellow(`${line}\n`));
104
+ }
88
105
  process.exitCode = 1;
89
106
  return;
90
107
  }
package/src/update.mjs CHANGED
@@ -5,7 +5,7 @@ import os from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { green, dim, yellow } from './color.mjs';
7
7
  import { executableName, which } from './util.mjs';
8
- import { installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
8
+ import { CLAUDE_MARKETPLACE, claudeMarketplaceRefusalHint, installOpencodePlugin, opencodeStatus } from './host-integration.mjs';
9
9
 
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
11
  const pkg = JSON.parse(readFileSync(path.join(__dirname, '..', 'package.json'), 'utf8'));
@@ -50,10 +50,54 @@ export function readInstalledPluginRecords(opts = {}) {
50
50
  }
51
51
  }
52
52
 
53
+ // Claude Code's marketplace registry, keyed by marketplace name. A plugin id
54
+ // is `<plugin>@<marketplace>`, and the plugin only loads while its marketplace
55
+ // is registered here — an install record alone is not an installed plugin.
56
+ export function readKnownMarketplaces(opts = {}) {
57
+ const home = opts.home || os.homedir();
58
+ const file = path.join(home, '.claude', 'plugins', 'known_marketplaces.json');
59
+ try {
60
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
61
+ return parsed && typeof parsed === 'object' ? parsed : {};
62
+ } catch {
63
+ return {};
64
+ }
65
+ }
66
+
67
+ // `marketplaceRegistered: false` is the state `claude plugin list` shows as
68
+ // "failed to load: Marketplace dotmd not found": the install record survived,
69
+ // the marketplace registration did not (a settings.json declaration that no
70
+ // longer matches, a wiped registry). Reading only the install record called
71
+ // that "installed", so `dotmd install claude` skipped the one repair it owns
72
+ // and `dotmd update` ran a `plugin update` that could only fail.
53
73
  export function readInstalledPlugin(opts = {}) {
54
74
  const records = readInstalledPluginRecords(opts);
55
75
  if (!records) return null;
56
- return { id: records.id, version: records.entries[0]?.version ?? null };
76
+ const marketplace = records.id.split('@')[1] || null;
77
+ const known = readKnownMarketplaces(opts);
78
+ return {
79
+ id: records.id,
80
+ version: records.entries[0]?.version ?? null,
81
+ marketplace,
82
+ marketplaceRegistered: Boolean(marketplace && known[marketplace]),
83
+ };
84
+ }
85
+
86
+ // The steps that put a plugin whose marketplace registration is gone back on
87
+ // its feet. Only the marketplace dotmd publishes has a source dotmd knows; a
88
+ // plugin installed from some other marketplace names a source we cannot guess.
89
+ export function planMarketplaceRepair(plugin, { hasClaude, verb }) {
90
+ const reason = `marketplace "${plugin.marketplace}" is not registered, so the installed plugin cannot load`;
91
+ if (plugin.marketplace !== 'dotmd') {
92
+ return [{ kind: 'skip', reason: `${reason} — re-add that marketplace (dotmd does not know its source), then rerun` }];
93
+ }
94
+ if (!hasClaude) {
95
+ return [{ kind: 'manual', reason, lines: [`/plugin marketplace add ${CLAUDE_MARKETPLACE}`, `/plugin ${verb} ${plugin.id}`] }];
96
+ }
97
+ return [
98
+ { kind: 'marketplace', reason: `${reason} — re-adding it first`, cmd: ['claude', 'plugin', 'marketplace', 'add', CLAUDE_MARKETPLACE] },
99
+ { kind: 'plugin', needs: 'marketplace', cmd: ['claude', 'plugin', verb, plugin.id] },
100
+ ];
57
101
  }
58
102
 
59
103
  // Decide which steps `dotmd update` should run. Pure — no side effects — so the
@@ -69,6 +113,8 @@ export function planUpdate(opts, ctx) {
69
113
  if (!opts.cliOnly) {
70
114
  if (!ctx.plugin) {
71
115
  steps.push({ kind: 'skip', reason: 'dotmd plugin not installed — skipping plugin update' });
116
+ } else if (ctx.plugin.marketplaceRegistered === false) {
117
+ steps.push(...planMarketplaceRepair(ctx.plugin, { hasClaude: ctx.hasClaude, verb: 'update' }));
72
118
  } else if (!ctx.hasClaude) {
73
119
  steps.push({ kind: 'skip', reason: `claude CLI not found — run \`/plugin update ${ctx.plugin.id}\` from a session instead` });
74
120
  } else {
@@ -102,6 +148,9 @@ export function runUpdate(argv, _config, opts = {}) {
102
148
  : cmp < 0 ? yellow('behind — run `dotmd update`')
103
149
  : yellow('ahead — CLI is behind');
104
150
  process.stdout.write(`dotmd plugin: ${plugin.version ?? '?'} (${plugin.id}) ${tag}\n`);
151
+ if (plugin.marketplaceRegistered === false) {
152
+ process.stdout.write(yellow(` marketplace "${plugin.marketplace}" is not registered — the plugin fails to load; run \`dotmd install claude\`\n`));
153
+ }
105
154
  } else {
106
155
  process.stdout.write(dim('dotmd plugin: not installed — `dotmd install claude`\n'));
107
156
  }
@@ -117,24 +166,41 @@ export function runUpdate(argv, _config, opts = {}) {
117
166
  if (opts.dryRun) {
118
167
  for (const step of steps) {
119
168
  if (step.kind === 'skip') process.stdout.write(dim(`[dry-run] skip: ${step.reason}\n`));
169
+ else if (step.kind === 'manual') for (const line of step.lines) process.stdout.write(dim(`[dry-run] Run from a session: ${line}\n`));
120
170
  else if (step.kind === 'opencode') process.stdout.write(dim(`[dry-run] Would refresh: ${step.path}\n`));
121
171
  else process.stdout.write(dim(`[dry-run] Would run: ${step.cmd.join(' ')}\n`));
122
172
  }
123
173
  return;
124
174
  }
175
+ // The hosts are independent: a failed `claude plugin update` says nothing
176
+ // about the OpenCode file, so every step runs and the failures are reported
177
+ // together at the end. Stopping at the first one left OpenCode stale behind a
178
+ // Claude registry problem — silently, since the abort said nothing about the
179
+ // steps it never reached.
125
180
  let ran = false;
126
- let failed = false;
181
+ const failures = [];
182
+ const failedKinds = new Set();
127
183
  for (const s of steps) {
128
184
  if (s.kind === 'skip') {
129
185
  process.stdout.write(dim(`skip: ${s.reason}\n`));
130
186
  continue;
131
187
  }
188
+ if (s.kind === 'manual') {
189
+ process.stdout.write(`${yellow(s.reason)} — run these from a Claude Code session:\n`);
190
+ for (const line of s.lines) process.stdout.write(` ${line}\n`);
191
+ continue;
192
+ }
193
+ if (s.needs && failedKinds.has(s.needs)) {
194
+ process.stdout.write(dim(`skip: ${s.cmd.join(' ')} — the ${s.needs} step it depends on failed\n`));
195
+ continue;
196
+ }
132
197
  if (s.kind === 'opencode') {
133
198
  const result = installOpencodePlugin({ version: pkg.version });
134
199
  process.stdout.write(dim(`refreshed opencode integration → ${pkg.version} ${result.path}\n`));
135
200
  ran = true;
136
201
  continue;
137
202
  }
203
+ if (s.reason) process.stdout.write(dim(`${s.reason}\n`));
138
204
  process.stdout.write(dim(`$ ${s.cmd.join(' ')}\n`));
139
205
  const r = spawnSync(executableName(s.cmd[0]), s.cmd.slice(1), {
140
206
  stdio: 'inherit',
@@ -142,13 +208,21 @@ export function runUpdate(argv, _config, opts = {}) {
142
208
  });
143
209
  ran = true;
144
210
  if (r.status !== 0) {
145
- failed = true;
211
+ failedKinds.add(s.kind);
212
+ failures.push(s);
146
213
  process.stdout.write(yellow(`(${s.cmd[0]} exited ${r.status ?? '?'})\n`));
147
- break;
214
+ if (s.kind === 'marketplace') for (const line of claudeMarketplaceRefusalHint(plugin?.marketplace)) process.stdout.write(yellow(`${line}\n`));
215
+ }
216
+ }
217
+ if (failures.length) {
218
+ process.stdout.write(yellow(`\n${failures.length === 1 ? '1 step' : `${failures.length} steps`} failed: ${failures.map(s => s.cmd.join(' ')).join('; ')}\n`));
219
+ if (ran && failures.length < steps.filter(s => s.kind !== 'skip' && s.kind !== 'manual').length) {
220
+ process.stdout.write(dim('the other steps completed; restart your Claude Code session (or /reload-plugins) to apply them.\n'));
148
221
  }
222
+ process.exitCode = 1;
223
+ return;
149
224
  }
150
- if (ran && !failed) {
225
+ if (ran) {
151
226
  process.stdout.write(green('\n✓ restart your Claude Code session (or /reload-plugins) to apply.\n'));
152
227
  }
153
- if (failed) process.exitCode = 1;
154
228
  }