Skip to content

Commit 36ea20d

Browse files
committed
fix(doctor): detect plugins installed via native harness mechanisms
doctor only read the CLI tracking file (~/.agents/.nodesource-installed.json), written by the direct install path. For plugin/package-owned harnesses the recommended path is the native mechanism (codex/claude/agy plugin install, pi install), which records state in harness-specific locations the doctor never inspected — so it reported everything as missing even when `codex plugin list` showed installed, enabled. Add an optional detectNativePlugin() to HarnessAdapter and implement it per harness (codex config.toml, claude installed_plugins.json, antigravity staged dir, pi package). When a native plugin is present, skills/MCPs are reported green from the plugin and a new Plugin line shows status + install hint. Health still allows the direct fallback install on plugin-owned harnesses.
1 parent 57d2d70 commit 36ea20d

12 files changed

Lines changed: 553 additions & 87 deletions

File tree

packages/core/src/harnesses/antigravity-adapter.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
1-
import type { HarnessAdapter, McpConfig } from './harness-adapter.js'
1+
import { existsSync } from 'node:fs'
2+
import path from 'node:path'
3+
import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js'
24
import type { HarnessType } from '../types.js'
35
import { resolveHome } from '../utils/path.js'
46
import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js'
57

8+
const PLUGIN_NAME = 'nsolid-plugin'
9+
610
export class AntigravityAdapter implements HarnessAdapter {
711
readonly name: HarnessType = 'antigravity'
812

913
getMcpConfigPath (): string {
1014
return resolveHome('~/.gemini/antigravity-cli/mcp_config.json')
1115
}
1216

17+
getPluginsPath (): string {
18+
// `agy plugin install` stages native plugins under the antigravity-cli root.
19+
return resolveHome('~/.gemini/antigravity-cli/plugins')
20+
}
21+
1322
getSkillsPath (): string {
1423
// Antigravity loads global skills from ~/.gemini/antigravity-cli/skills/ (per
1524
// https://antigravity.google/docs/cli-plugins), the same root as the MCP config
@@ -34,4 +43,19 @@ export class AntigravityAdapter implements HarnessAdapter {
3443
// symmetric with the Claude/Codex/OpenCode adapters.
3544
writeAdapterMcpConfig(this.name, config)
3645
}
46+
47+
/**
48+
* Antigravity stages a native plugin at
49+
* `~/.gemini/antigravity-cli/plugins/nsolid-plugin/`. There is no separate
50+
* enable flag, so `installed` follows from the staged directory existing.
51+
*/
52+
detectNativePlugin (): NativePluginStatus {
53+
const status: NativePluginStatus = { installed: false, label: PLUGIN_NAME }
54+
const staged = path.join(this.getPluginsPath(), PLUGIN_NAME)
55+
if (existsSync(staged)) {
56+
status.installed = true
57+
status.enabled = true
58+
}
59+
return status
60+
}
3761
}

packages/core/src/harnesses/claude-adapter.ts

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1-
import type { HarnessAdapter, McpConfig } from './harness-adapter.js'
1+
import { existsSync } from 'node:fs'
2+
import path from 'node:path'
3+
import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js'
24
import type { HarnessType } from '../types.js'
35
import { resolveHome } from '../utils/path.js'
46
import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js'
7+
import { readJsonFile } from '../utils/config.js'
8+
9+
const PLUGIN_ID = 'nsolid-plugin@nodesource'
10+
const MARKETPLACE_NAME = 'nodesource'
511

612
export class ClaudeAdapter implements HarnessAdapter {
713
readonly name: HarnessType = 'claude'
@@ -10,6 +16,10 @@ export class ClaudeAdapter implements HarnessAdapter {
1016
return resolveHome('~/.claude.json')
1117
}
1218

19+
getPluginsDir (): string {
20+
return resolveHome('~/.claude/plugins')
21+
}
22+
1323
getSkillsPath (): string {
1424
return resolveHome('~/.claude/skills/')
1525
}
@@ -25,4 +35,67 @@ export class ClaudeAdapter implements HarnessAdapter {
2535
async writeMcpConfig (config: McpConfig): Promise<void> {
2636
writeAdapterMcpConfig(this.name, config)
2737
}
38+
39+
/**
40+
* Claude Code records native plugins in
41+
* `~/.claude/plugins/installed_plugins.json` and an enable map in
42+
* `~/.claude.json` (`enabledPlugins`). The cloned marketplace lives at
43+
* `~/.claude/plugins/marketplaces/<name>/`. The installed_plugins schema has
44+
* varied across versions (a map or a `{plugins:[...]}` array), so each is
45+
* tolerated; an explicit entry or the marketplace clone counts as installed,
46+
* and `enabled` is set only when an enabled map explicitly lists the id.
47+
*/
48+
detectNativePlugin (): NativePluginStatus {
49+
const status: NativePluginStatus = { installed: false, label: PLUGIN_ID }
50+
const installedPath = path.join(this.getPluginsDir(), 'installed_plugins.json')
51+
52+
try {
53+
const data = readJsonFile<unknown>(installedPath)
54+
const ids = extractPluginIds(data)
55+
if (ids.includes(PLUGIN_ID)) {
56+
status.installed = true
57+
}
58+
} catch {
59+
// Unreadable installed_plugins.json — fall through.
60+
}
61+
62+
try {
63+
const settings = readJsonFile<Record<string, unknown>>(this.getMcpConfigPath())
64+
const enabledPlugins = settings?.enabledPlugins
65+
if (enabledPlugins && typeof enabledPlugins === 'object' && !Array.isArray(enabledPlugins)) {
66+
const map = enabledPlugins as Record<string, unknown>
67+
if (map[PLUGIN_ID] === true) {
68+
status.installed = true
69+
status.enabled = true
70+
}
71+
}
72+
} catch {
73+
// settings.json unreadable — enabled stays undefined.
74+
}
75+
76+
if (!status.installed) {
77+
const clone = path.join(this.getPluginsDir(), 'marketplaces', MARKETPLACE_NAME)
78+
if (existsSync(clone)) status.installed = true
79+
}
80+
81+
return status
82+
}
83+
}
84+
85+
function extractPluginIds (data: unknown): string[] {
86+
if (!data || typeof data !== 'object') return []
87+
if (Array.isArray(data)) return data.filter((v): v is string => typeof v === 'string')
88+
89+
const obj = data as Record<string, unknown>
90+
const arr = obj.plugins
91+
if (Array.isArray(arr)) {
92+
return arr.flatMap((v) => {
93+
if (typeof v === 'string') return [v]
94+
if (v && typeof v === 'object' && typeof (v as { id?: unknown }).id === 'string') {
95+
return [(v as { id: string }).id]
96+
}
97+
return []
98+
})
99+
}
100+
return Object.keys(obj)
28101
}

packages/core/src/harnesses/codex-adapter.ts

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1-
import type { HarnessAdapter, McpConfig } from './harness-adapter.js'
1+
import { existsSync } from 'node:fs'
2+
import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js'
23
import type { HarnessType } from '../types.js'
34
import { resolveHome } from '../utils/path.js'
45
import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js'
6+
import { readTomlFile } from '../utils/config.js'
7+
8+
const PLUGIN_KEY = 'nsolid-plugin@nodesource'
9+
const MARKETPLACE_NAME = 'nodesource'
510

611
export class CodexAdapter implements HarnessAdapter {
712
readonly name: HarnessType = 'codex'
@@ -25,4 +30,48 @@ export class CodexAdapter implements HarnessAdapter {
2530
async writeMcpConfig (config: McpConfig): Promise<void> {
2631
writeAdapterMcpConfig(this.name, config)
2732
}
33+
34+
/**
35+
* Codex records a native plugin install in `~/.codex/config.toml`:
36+
* [plugins."nsolid-plugin@nodesource"]
37+
* enabled = true
38+
* and the marketplace under `[marketplaces.nodesource]`, with the cloned
39+
* repo at `~/.codex/.tmp/marketplaces/nodesource/`. Any of those signals
40+
* counts as installed; only the `enabled = true` flag sets `enabled`.
41+
*/
42+
detectNativePlugin (): NativePluginStatus {
43+
const status: NativePluginStatus = { installed: false, label: PLUGIN_KEY }
44+
try {
45+
const data = readTomlFile<Record<string, unknown>>(this.getMcpConfigPath())
46+
if (data) {
47+
const plugins = data.plugins
48+
const entry = plugins && typeof plugins === 'object' && !Array.isArray(plugins)
49+
? (plugins as Record<string, unknown>)[PLUGIN_KEY]
50+
: undefined
51+
const enabled = entry && typeof entry === 'object'
52+
? (entry as { enabled?: unknown }).enabled === true
53+
: false
54+
const marketplaces = data.marketplaces
55+
const hasMarketplace = marketplaces && typeof marketplaces === 'object' && !Array.isArray(marketplaces)
56+
? Object.prototype.hasOwnProperty.call(marketplaces, MARKETPLACE_NAME)
57+
: false
58+
59+
if (enabled) {
60+
status.installed = true
61+
status.enabled = true
62+
} else if (hasMarketplace) {
63+
status.installed = true
64+
}
65+
}
66+
} catch {
67+
// Corrupt or unreadable config — fall through to the on-disk clone check.
68+
}
69+
70+
if (!status.installed) {
71+
const clone = resolveHome(`~/.codex/.tmp/marketplaces/${MARKETPLACE_NAME}/bundle.json`)
72+
if (existsSync(clone)) status.installed = true
73+
}
74+
75+
return status
76+
}
2877
}

packages/core/src/harnesses/harness-adapter.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,35 @@ import type { McpServerConfig, NormalizedMcpConfig } from '../mcp/mcp-config-mer
44
export type { McpServerConfig }
55
export type McpConfig = NormalizedMcpConfig
66

7+
/**
8+
* Result of probing whether the nsolid plugin is installed as a *native*
9+
* plugin/package of a harness (e.g. `codex plugin install`,
10+
* `claude plugin install`, `agy plugin install`, `pi install npm:...`).
11+
*
12+
* Native installs are owned by the harness CLI, not the shared tracking file
13+
* at `~/.agents/.nodesource-installed.json`, so `doctor` detects them here
14+
* rather than via tracking. Detection is best-effort and read-only: any
15+
* adapter that can't determine the state returns `{ installed: false }`.
16+
*/
17+
export interface NativePluginStatus {
18+
installed: boolean
19+
/** True only when the harness records the plugin as explicitly enabled. */
20+
enabled?: boolean
21+
/** Human label like `nsolid-plugin@nodesource` shown on the Plugin line. */
22+
label?: string
23+
}
24+
725
export interface HarnessAdapter {
826
readonly name: HarnessType
927
getMcpConfigPath(): string | null
1028
getSkillsPath(): string
1129
supportsMcp(): boolean
1230
readMcpConfig(): Promise<McpConfig>
1331
writeMcpConfig(config: McpConfig): Promise<void>
32+
/**
33+
* Detect whether the nsolid plugin is installed as a native plugin/package.
34+
* Optional: harnesses that don't have a native plugin model (e.g. opencode)
35+
* omit it, and `doctor` then treats the plugin line as N/A for them.
36+
*/
37+
detectNativePlugin?(): NativePluginStatus
1438
}

packages/core/src/harnesses/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,4 @@ export function getAdapter (harness: HarnessType): HarnessAdapter {
2121
}
2222
}
2323

24-
export type { HarnessAdapter, McpConfig, McpServerConfig } from './harness-adapter.js'
24+
export type { HarnessAdapter, McpConfig, McpServerConfig, NativePluginStatus } from './harness-adapter.js'

packages/core/src/harnesses/pi-adapter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { HarnessAdapter, McpConfig } from './harness-adapter.js'
1+
import type { HarnessAdapter, McpConfig, NativePluginStatus } from './harness-adapter.js'
22
import type { HarnessType } from '../types.js'
33
import { resolveHome } from '../utils/path.js'
44
import { writeAdapterMcpConfig, readExistingConfig } from '../mcp/mcp-config-writer.js'
5+
import { piPluginInstalled, PI_PLUGIN_PACKAGE_NAME } from './pi-plugin-detector.js'
56

67
export class PiAdapter implements HarnessAdapter {
78
readonly name: HarnessType = 'pi'
@@ -25,4 +26,14 @@ export class PiAdapter implements HarnessAdapter {
2526
async writeMcpConfig (config: McpConfig): Promise<void> {
2627
writeAdapterMcpConfig(this.name, config)
2728
}
29+
30+
/**
31+
* Pi is package-owned: detection follows from an installed
32+
* `nsolid-pi-plugin` npm package (see pi-plugin-detector). There is no
33+
* separate enable flag, so `installed` implies `enabled`.
34+
*/
35+
detectNativePlugin (): NativePluginStatus {
36+
const installed = piPluginInstalled()
37+
return { installed, enabled: installed ? true : undefined, label: PI_PLUGIN_PACKAGE_NAME }
38+
}
2839
}
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import os from 'node:os'
2+
import path from 'node:path'
3+
import { existsSync } from 'node:fs'
4+
import { readJsonFile } from '../utils/config.js'
5+
6+
/**
7+
* Native package detection for the Pi Agent harness.
8+
*
9+
* Pi is package-owned: `pi install npm:nsolid-pi-plugin` installs skills via a
10+
* real npm package rather than the shared CLI tracking file. These helpers
11+
* discover that package on disk (from `~/.pi/agent/settings.json` `packages`
12+
* entries or the canonical npm install location) so both `doctor` and the
13+
* Pi adapter can report it without the CLI tracking file.
14+
*
15+
* Extracted from `index.ts` so the Pi adapter and `doctor()` share one source
16+
* of truth.
17+
*/
18+
19+
export const PI_PLUGIN_PACKAGE_NAME = 'nsolid-pi-plugin'
20+
21+
function readPiPackageSourceEntries (settingsPath: string): string[] {
22+
let settings: { packages?: Array<string | { source?: string }> } | null = null
23+
try {
24+
settings = readJsonFile<{ packages?: Array<string | { source?: string }> }>(settingsPath)
25+
} catch {
26+
return []
27+
}
28+
if (!settings || !Array.isArray(settings.packages)) return []
29+
30+
return settings.packages
31+
.map((entry) => typeof entry === 'string' ? entry : entry.source)
32+
.filter((source): source is string => typeof source === 'string' && source.length > 0)
33+
}
34+
35+
function packageNameFromNpmSource (source: string): string | null {
36+
if (!source.startsWith('npm:')) return null
37+
const spec = source.slice('npm:'.length)
38+
if (spec.startsWith('@')) {
39+
const [scope, name] = spec.split('/')
40+
if (!scope || !name) return null
41+
return `${scope}/${name.split('@')[0]}`
42+
}
43+
return spec.split('@')[0] || null
44+
}
45+
46+
function resolvePiPackageRootFromSource (source: string, settingsDir: string): string | null {
47+
const npmPackageName = packageNameFromNpmSource(source)
48+
if (npmPackageName) {
49+
if (npmPackageName !== PI_PLUGIN_PACKAGE_NAME) return null
50+
return path.join(settingsDir, 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME)
51+
}
52+
53+
if (source.startsWith('git:') || source.startsWith('http://') || source.startsWith('https://') || source.startsWith('ssh://')) {
54+
return null
55+
}
56+
57+
return path.resolve(settingsDir, source)
58+
}
59+
60+
export function findPiPluginPackageRoots (): string[] {
61+
const settingsPaths = [
62+
path.join(os.homedir(), '.pi', 'agent', 'settings.json'),
63+
path.resolve('.pi', 'settings.json'),
64+
]
65+
const roots = new Set<string>()
66+
67+
for (const settingsPath of settingsPaths) {
68+
const settingsDir = path.dirname(settingsPath)
69+
for (const source of readPiPackageSourceEntries(settingsPath)) {
70+
const root = resolvePiPackageRootFromSource(source, settingsDir)
71+
if (root) roots.add(root)
72+
}
73+
}
74+
75+
roots.add(path.join(os.homedir(), '.pi', 'agent', 'npm', 'node_modules', PI_PLUGIN_PACKAGE_NAME))
76+
return [...roots]
77+
}
78+
79+
/**
80+
* Skill roots declared by each installed Pi package. A package without an
81+
* explicit `pi.skills` array defaults to `./skills` (matching the package
82+
* generator and the install test).
83+
*/
84+
export function findPiPluginSkillRoots (): string[] {
85+
const skillRoots: string[] = []
86+
for (const packageRoot of findPiPluginPackageRoots()) {
87+
const pkgPath = path.join(packageRoot, 'package.json')
88+
let pkg: { name?: string; pi?: { skills?: string[] } } | null = null
89+
try {
90+
pkg = readJsonFile<{ name?: string; pi?: { skills?: string[] } }>(pkgPath)
91+
} catch {
92+
continue
93+
}
94+
if (pkg?.name !== PI_PLUGIN_PACKAGE_NAME) continue
95+
96+
const configuredSkillRoots = Array.isArray(pkg.pi?.skills) && pkg.pi!.skills.length > 0
97+
? pkg.pi!.skills
98+
: ['./skills']
99+
for (const skillRoot of configuredSkillRoots) {
100+
skillRoots.push(path.resolve(packageRoot, skillRoot))
101+
}
102+
}
103+
return skillRoots
104+
}
105+
106+
/** True when a nsolid-pi-plugin package root exists on disk. */
107+
export function piPluginInstalled (): boolean {
108+
return findPiPluginPackageRoots().some((root) => {
109+
try {
110+
return existsSync(path.join(root, 'package.json'))
111+
} catch {
112+
return false
113+
}
114+
})
115+
}

0 commit comments

Comments
 (0)