@zhijiewang/openharness 2.8.0 → 2.9.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/data/registry.json +262 -0
- package/data/skills/code-review.md +19 -0
- package/data/skills/commit.md +17 -0
- package/data/skills/debug.md +24 -0
- package/data/skills/diagnose.md +24 -0
- package/data/skills/plan.md +25 -0
- package/data/skills/simplify.md +24 -0
- package/data/skills/tdd.md +22 -0
- package/dist/agents/roles.d.ts +12 -2
- package/dist/agents/roles.js +65 -6
- package/dist/commands/ai.js +27 -7
- package/dist/commands/skills.d.ts +1 -1
- package/dist/commands/skills.js +51 -6
- package/dist/components/App.js +7 -1
- package/dist/harness/config.d.ts +11 -0
- package/dist/harness/hooks.d.ts +14 -0
- package/dist/harness/hooks.js +47 -4
- package/dist/harness/marketplace.d.ts +77 -2
- package/dist/harness/marketplace.js +260 -38
- package/dist/harness/memory.d.ts +34 -0
- package/dist/harness/memory.js +96 -0
- package/dist/harness/plugins.d.ts +13 -3
- package/dist/harness/plugins.js +98 -17
- package/dist/harness/session-db.d.ts +8 -1
- package/dist/harness/session-db.js +24 -3
- package/dist/harness/skill-registry.d.ts +26 -2
- package/dist/harness/skill-registry.js +42 -4
- package/dist/tools/AgentTool/index.d.ts +2 -2
- package/dist/tools/DiagnosticsTool/index.d.ts +1 -1
- package/dist/tools/GrepTool/index.d.ts +6 -6
- package/dist/tools/MemoryTool/index.d.ts +6 -6
- package/dist/tools/MonitorTool/index.js +5 -1
- package/dist/types/permissions.js +104 -42
- package/dist/utils/bash-safety.d.ts +19 -0
- package/dist/utils/bash-safety.js +179 -1
- package/dist/utils/safe-env.d.ts +5 -1
- package/dist/utils/safe-env.js +19 -1
- package/package.json +3 -1
package/dist/commands/skills.js
CHANGED
|
@@ -1,9 +1,38 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Skill management commands — /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
2
|
+
* Skill management commands — /skills, /skill-create, /skill-delete, /skill-edit, /skill-search, /skill-install
|
|
3
3
|
*/
|
|
4
4
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { discoverSkills } from "../harness/plugins.js";
|
|
6
7
|
export function registerSkillCommands(register) {
|
|
8
|
+
register("skills", "List all available skills", () => {
|
|
9
|
+
const skills = discoverSkills();
|
|
10
|
+
if (skills.length === 0) {
|
|
11
|
+
return {
|
|
12
|
+
output: "No skills found. Create .oh/skills/*.md to add one, or run /skill-search to browse the registry.",
|
|
13
|
+
handled: true,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
// Group by source for readability
|
|
17
|
+
const lines = ["Available skills:"];
|
|
18
|
+
const sourceLabel = {
|
|
19
|
+
project: "[project]",
|
|
20
|
+
global: "[global]",
|
|
21
|
+
plugin: "[plugin]",
|
|
22
|
+
};
|
|
23
|
+
// Sort: bundled-style (project, no path under .oh) first, then by source then name
|
|
24
|
+
const sorted = [...skills].sort((a, b) => {
|
|
25
|
+
if (a.source !== b.source)
|
|
26
|
+
return a.source.localeCompare(b.source);
|
|
27
|
+
return a.name.localeCompare(b.name);
|
|
28
|
+
});
|
|
29
|
+
for (const s of sorted) {
|
|
30
|
+
const tag = sourceLabel[s.source] ?? `[${s.source}]`;
|
|
31
|
+
const desc = s.description ? `: ${s.description}` : "";
|
|
32
|
+
lines.push(` - ${s.name} ${tag}${desc}`);
|
|
33
|
+
}
|
|
34
|
+
return { output: lines.join("\n"), handled: true };
|
|
35
|
+
});
|
|
7
36
|
register("skill-create", "Create a new skill file", (args) => {
|
|
8
37
|
const name = args.trim();
|
|
9
38
|
if (!name)
|
|
@@ -92,10 +121,21 @@ How to confirm the skill worked correctly.
|
|
|
92
121
|
});
|
|
93
122
|
return { output: "Searching skills registry...", handled: true };
|
|
94
123
|
});
|
|
95
|
-
register("skill-install", "Install a skill from the registry", (args) => {
|
|
96
|
-
|
|
124
|
+
register("skill-install", "Install a skill from the registry. Use --accept-license=<SPDX> for non-permissive licenses.", (args) => {
|
|
125
|
+
// Parse: <name> [--accept-license=<SPDX>]
|
|
126
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
127
|
+
if (tokens.length === 0)
|
|
128
|
+
return { output: "Usage: /skill-install <name> [--accept-license=<SPDX>]", handled: true };
|
|
129
|
+
let name = "";
|
|
130
|
+
let acceptLicense;
|
|
131
|
+
for (const tok of tokens) {
|
|
132
|
+
if (tok.startsWith("--accept-license="))
|
|
133
|
+
acceptLicense = tok.slice("--accept-license=".length);
|
|
134
|
+
else if (!name)
|
|
135
|
+
name = tok;
|
|
136
|
+
}
|
|
97
137
|
if (!name)
|
|
98
|
-
return { output: "Usage: /skill-install <name>", handled: true };
|
|
138
|
+
return { output: "Usage: /skill-install <name> [--accept-license=<SPDX>]", handled: true };
|
|
99
139
|
import("../harness/skill-registry.js").then(async ({ fetchRegistry, installSkill }) => {
|
|
100
140
|
try {
|
|
101
141
|
const registry = await fetchRegistry();
|
|
@@ -104,8 +144,13 @@ How to confirm the skill worked correctly.
|
|
|
104
144
|
console.log(`Skill "${name}" not found in registry. Try /skill-search first.`);
|
|
105
145
|
return;
|
|
106
146
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
147
|
+
const result = await installSkill(skill, { acceptLicense });
|
|
148
|
+
if (result.ok) {
|
|
149
|
+
console.log(`Installed skill "${skill.name}" to ${result.filePath}`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
console.log(result.message);
|
|
153
|
+
}
|
|
109
154
|
}
|
|
110
155
|
catch (err) {
|
|
111
156
|
console.log(`Installation failed: ${err.message}`);
|
package/dist/components/App.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
2
2
|
import { useMemo } from "react";
|
|
3
3
|
import { getCompanionSystemPrompt, loadCompanionRuntime } from "../cybergotchi/config.js";
|
|
4
4
|
import { readOhConfig } from "../harness/config.js";
|
|
5
|
-
import { loadMemories, memoriesToPrompt } from "../harness/memory.js";
|
|
5
|
+
import { claudeMdToPrompt, loadClaudeMdHierarchy, loadMemories, memoriesToPrompt } from "../harness/memory.js";
|
|
6
6
|
import { detectProject, projectContextToPrompt } from "../harness/onboarding.js";
|
|
7
7
|
import { discoverSkills, skillsToPrompt } from "../harness/plugins.js";
|
|
8
8
|
import { loadRulesAsPrompt } from "../harness/rules.js";
|
|
@@ -59,6 +59,12 @@ export default function App({ provider, tools, permissionMode, systemPrompt, mod
|
|
|
59
59
|
const rulesPrompt = loadRulesAsPrompt();
|
|
60
60
|
if (rulesPrompt)
|
|
61
61
|
parts.push(rulesPrompt);
|
|
62
|
+
// CLAUDE.md: hierarchical project instructions (Anthropic convention).
|
|
63
|
+
// Additive with OH's own memory system — both layers inject into the prompt.
|
|
64
|
+
const claudeMd = loadClaudeMdHierarchy();
|
|
65
|
+
const claudeMdPrompt = claudeMdToPrompt(claudeMd);
|
|
66
|
+
if (claudeMdPrompt)
|
|
67
|
+
parts.push(claudeMdPrompt);
|
|
62
68
|
// Auto-memory: load saved learnings into context
|
|
63
69
|
const memories = loadMemories();
|
|
64
70
|
const memoryPrompt = memoriesToPrompt(memories);
|
package/dist/harness/config.d.ts
CHANGED
|
@@ -98,6 +98,17 @@ export type OhConfig = {
|
|
|
98
98
|
rateLimit?: number;
|
|
99
99
|
allowedTools?: string[];
|
|
100
100
|
};
|
|
101
|
+
/**
|
|
102
|
+
* Environment variables injected into child processes spawned by the harness —
|
|
103
|
+
* Bash/Monitor/PowerShell tool executions and MCP server subprocesses. Useful
|
|
104
|
+
* for passing API keys to MCP servers without embedding them in the server's
|
|
105
|
+
* `env` field (which is per-server) or requiring the user to export them in
|
|
106
|
+
* their shell. Claude Code convention: same shape as `settings.json.env`.
|
|
107
|
+
*
|
|
108
|
+
* Implementation: read by `safeEnv()` in `src/utils/safe-env.ts` — every
|
|
109
|
+
* call-site that already uses `safeEnv()` picks this up automatically.
|
|
110
|
+
*/
|
|
111
|
+
env?: Record<string, string>;
|
|
101
112
|
};
|
|
102
113
|
/** Clear cached config (call after writes or to force re-read) */
|
|
103
114
|
export declare function invalidateConfigCache(): void;
|
package/dist/harness/hooks.d.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* - http: POST JSON to URL, expect { allowed: true/false }
|
|
10
10
|
* - prompt: LLM yes/no check via provider.complete()
|
|
11
11
|
*/
|
|
12
|
+
import type { HookDef } from "./config.js";
|
|
12
13
|
export type HookEvent = "sessionStart" | "sessionEnd" | "preToolUse" | "postToolUse" | "fileChanged" | "cwdChanged" | "subagentStart" | "subagentStop" | "preCompact" | "postCompact" | "configChange" | "notification";
|
|
13
14
|
export type HookContext = {
|
|
14
15
|
toolName?: string;
|
|
@@ -32,6 +33,19 @@ export type HookContext = {
|
|
|
32
33
|
};
|
|
33
34
|
/** Clear hook cache (call after config changes) */
|
|
34
35
|
export declare function invalidateHookCache(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Evaluate a hook matcher against the current tool name.
|
|
38
|
+
*
|
|
39
|
+
* Supported forms (Claude Code compatible):
|
|
40
|
+
* - No matcher → always matches.
|
|
41
|
+
* - `/pattern/flags` → treated as a regex. Flags optional.
|
|
42
|
+
* - `mcp__server__tool` → literal match is a substring check (works for the
|
|
43
|
+
* standard `mcp__<server>__<tool>` naming convention).
|
|
44
|
+
* - `prefix*` or glob-ish → simple wildcard translated to regex.
|
|
45
|
+
* - Anything else → case-sensitive substring (legacy behavior — back-compat).
|
|
46
|
+
*/
|
|
47
|
+
/** @internal Exposed for testing. */
|
|
48
|
+
export declare function matchesHook(def: HookDef, ctx: HookContext): boolean;
|
|
35
49
|
/**
|
|
36
50
|
* Emit a hook event. For preToolUse, returns false if any hook blocks the call.
|
|
37
51
|
*
|
package/dist/harness/hooks.js
CHANGED
|
@@ -58,11 +58,54 @@ function buildEnv(event, ctx) {
|
|
|
58
58
|
env.OH_MESSAGE = ctx.message;
|
|
59
59
|
return env;
|
|
60
60
|
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
/**
|
|
62
|
+
* Evaluate a hook matcher against the current tool name.
|
|
63
|
+
*
|
|
64
|
+
* Supported forms (Claude Code compatible):
|
|
65
|
+
* - No matcher → always matches.
|
|
66
|
+
* - `/pattern/flags` → treated as a regex. Flags optional.
|
|
67
|
+
* - `mcp__server__tool` → literal match is a substring check (works for the
|
|
68
|
+
* standard `mcp__<server>__<tool>` naming convention).
|
|
69
|
+
* - `prefix*` or glob-ish → simple wildcard translated to regex.
|
|
70
|
+
* - Anything else → case-sensitive substring (legacy behavior — back-compat).
|
|
71
|
+
*/
|
|
72
|
+
/** @internal Exposed for testing. */
|
|
73
|
+
export function matchesHook(def, ctx) {
|
|
74
|
+
if (!def.match)
|
|
75
|
+
return true;
|
|
76
|
+
if (!ctx.toolName)
|
|
77
|
+
return true;
|
|
78
|
+
const match = def.match;
|
|
79
|
+
// /regex/flags form
|
|
80
|
+
if (match.length > 2 && match.startsWith("/")) {
|
|
81
|
+
const lastSlash = match.lastIndexOf("/");
|
|
82
|
+
if (lastSlash > 0) {
|
|
83
|
+
try {
|
|
84
|
+
const pattern = match.slice(1, lastSlash);
|
|
85
|
+
const flags = match.slice(lastSlash + 1);
|
|
86
|
+
return new RegExp(pattern, flags).test(ctx.toolName);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
}
|
|
65
|
-
|
|
93
|
+
// Simple glob: asterisks translated to `.*`, anchored. Only activates if the
|
|
94
|
+
// match contains an asterisk — otherwise treat as substring for back-compat.
|
|
95
|
+
if (match.includes("*")) {
|
|
96
|
+
const escaped = match
|
|
97
|
+
.split("*")
|
|
98
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
99
|
+
.join(".*");
|
|
100
|
+
try {
|
|
101
|
+
return new RegExp(`^${escaped}$`).test(ctx.toolName);
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Legacy substring match
|
|
108
|
+
return ctx.toolName.includes(match);
|
|
66
109
|
}
|
|
67
110
|
// ── Hook Executors ──
|
|
68
111
|
/** Run a command hook. Returns exit code (0 = success/allowed). */
|
|
@@ -36,8 +36,76 @@ export type InstalledPlugin = {
|
|
|
36
36
|
marketplace: string;
|
|
37
37
|
installedAt: number;
|
|
38
38
|
cachePath: string;
|
|
39
|
+
/** Optional fields populated from `.claude-plugin/plugin.json` if present */
|
|
40
|
+
description?: string;
|
|
41
|
+
author?: string;
|
|
42
|
+
license?: string;
|
|
43
|
+
homepage?: string;
|
|
44
|
+
keywords?: string[];
|
|
45
|
+
};
|
|
46
|
+
/** Claude Code plugin manifest (`.claude-plugin/plugin.json`).
|
|
47
|
+
* Required: name, description. All other fields optional.
|
|
48
|
+
*/
|
|
49
|
+
export type CcPluginManifest = {
|
|
50
|
+
name: string;
|
|
51
|
+
description: string;
|
|
52
|
+
version?: string;
|
|
53
|
+
author?: {
|
|
54
|
+
name?: string;
|
|
55
|
+
email?: string;
|
|
56
|
+
} | string;
|
|
57
|
+
homepage?: string;
|
|
58
|
+
repository?: string;
|
|
59
|
+
license?: string;
|
|
60
|
+
keywords?: string[];
|
|
61
|
+
};
|
|
62
|
+
/** Parse a `.claude-plugin/plugin.json` file at the given plugin root, or null if missing/invalid. */
|
|
63
|
+
export declare function parseCcPluginManifest(pluginRoot: string): CcPluginManifest | null;
|
|
64
|
+
/** Claude Code marketplace.json format — superset of OH's marketplace.json with source-typed entries. */
|
|
65
|
+
export type CcMarketplacePluginSource = string | {
|
|
66
|
+
source: "github";
|
|
67
|
+
repo: string;
|
|
68
|
+
ref?: string;
|
|
69
|
+
} | {
|
|
70
|
+
source: "url";
|
|
71
|
+
url: string;
|
|
72
|
+
} | {
|
|
73
|
+
source: "npm";
|
|
74
|
+
package: string;
|
|
75
|
+
version?: string;
|
|
76
|
+
};
|
|
77
|
+
export type CcMarketplaceEntry = {
|
|
78
|
+
name: string;
|
|
79
|
+
description?: string;
|
|
80
|
+
version?: string;
|
|
81
|
+
author?: {
|
|
82
|
+
name?: string;
|
|
83
|
+
email?: string;
|
|
84
|
+
} | string;
|
|
85
|
+
source: CcMarketplacePluginSource;
|
|
39
86
|
};
|
|
40
|
-
|
|
87
|
+
export type CcMarketplace = {
|
|
88
|
+
name: string;
|
|
89
|
+
description?: string;
|
|
90
|
+
owner?: {
|
|
91
|
+
name?: string;
|
|
92
|
+
email?: string;
|
|
93
|
+
};
|
|
94
|
+
plugins: CcMarketplaceEntry[];
|
|
95
|
+
};
|
|
96
|
+
/** Convert a CcMarketplace to OH's internal Marketplace type (lossy: dropped fields like `owner` are not stored). */
|
|
97
|
+
export declare function ccMarketplaceToOh(cc: CcMarketplace): Marketplace;
|
|
98
|
+
/** Parse marketplace JSON text. Tries CC format (.claude-plugin/marketplace.json shape) first, falls back to OH-native. */
|
|
99
|
+
export declare function parseMarketplaceJson(text: string): Marketplace | null;
|
|
100
|
+
/** Discover plugin-shipped MCP servers from `cachePath/.mcp.json`. Returns raw object for the runtime to merge. */
|
|
101
|
+
export declare function getPluginMcpServers(cachePath: string): Record<string, unknown> | null;
|
|
102
|
+
/** Discover plugin-shipped hooks from `cachePath/hooks/hooks.json`. Returns raw config for the runtime to register. */
|
|
103
|
+
export declare function getPluginHooks(cachePath: string): Record<string, unknown> | null;
|
|
104
|
+
/** Discover plugin-shipped LSP servers from `cachePath/.lsp.json`. Returns raw config for the runtime to register. */
|
|
105
|
+
export declare function getPluginLspServers(cachePath: string): Record<string, unknown> | null;
|
|
106
|
+
/** Add a marketplace from a URL, GitHub repo, or local path.
|
|
107
|
+
* Probes for both OH-native `marketplace.json` and Claude Code `.claude-plugin/marketplace.json`.
|
|
108
|
+
*/
|
|
41
109
|
export declare function addMarketplace(nameOrUrl: string): Marketplace | null;
|
|
42
110
|
/** Remove a marketplace */
|
|
43
111
|
export declare function removeMarketplace(name: string): boolean;
|
|
@@ -51,7 +119,14 @@ export declare function searchMarketplace(query: string): Array<MarketplaceEntry
|
|
|
51
119
|
export declare function installPlugin(pluginName: string, marketplaceName?: string): InstalledPlugin | null;
|
|
52
120
|
/** Uninstall a plugin */
|
|
53
121
|
export declare function uninstallPlugin(name: string): boolean;
|
|
54
|
-
/** Get all installed plugins
|
|
122
|
+
/** Get all installed plugins.
|
|
123
|
+
* Sources merged in priority order:
|
|
124
|
+
* 1. installed.json (plugins installed via /plugin install or addMarketplace flow)
|
|
125
|
+
* 2. CC-style plugins discovered in PLUGIN_CACHE_DIR via .claude-plugin/plugin.json
|
|
126
|
+
* (covers plugins manually dropped in the cache, or installed by parallel tooling)
|
|
127
|
+
* Plugins from #1 are enriched with manifest data if their cachePath has one.
|
|
128
|
+
* De-duplication is by cachePath.
|
|
129
|
+
*/
|
|
55
130
|
export declare function getInstalledPlugins(): InstalledPlugin[];
|
|
56
131
|
/** Format marketplace entries for display */
|
|
57
132
|
export declare function formatMarketplaceSearch(results: Array<MarketplaceEntry & {
|
|
@@ -7,59 +7,196 @@
|
|
|
7
7
|
* Inspired by Claude Code's marketplace model.
|
|
8
8
|
*/
|
|
9
9
|
import { execSync } from "node:child_process";
|
|
10
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { homedir } from "node:os";
|
|
12
12
|
import { basename, join } from "node:path";
|
|
13
13
|
const MARKETPLACE_DIR = join(homedir(), ".oh", "marketplaces");
|
|
14
14
|
const PLUGIN_CACHE_DIR = join(homedir(), ".oh", "plugins", "cache");
|
|
15
15
|
const INSTALLED_PLUGINS_FILE = join(homedir(), ".oh", "plugins", "installed.json");
|
|
16
|
+
// Claude Code plugin manifest path relative to plugin root
|
|
17
|
+
const CC_MANIFEST_PATH = join(".claude-plugin", "plugin.json");
|
|
18
|
+
/** Parse a `.claude-plugin/plugin.json` file at the given plugin root, or null if missing/invalid. */
|
|
19
|
+
export function parseCcPluginManifest(pluginRoot) {
|
|
20
|
+
const path = join(pluginRoot, CC_MANIFEST_PATH);
|
|
21
|
+
if (!existsSync(path))
|
|
22
|
+
return null;
|
|
23
|
+
try {
|
|
24
|
+
const m = JSON.parse(readFileSync(path, "utf-8"));
|
|
25
|
+
if (typeof m?.name !== "string" || typeof m?.description !== "string")
|
|
26
|
+
return null;
|
|
27
|
+
return m;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Convert a CcMarketplace to OH's internal Marketplace type (lossy: dropped fields like `owner` are not stored). */
|
|
34
|
+
export function ccMarketplaceToOh(cc) {
|
|
35
|
+
const plugins = [];
|
|
36
|
+
for (const p of cc.plugins) {
|
|
37
|
+
const ohSource = ccSourceToOh(p.source);
|
|
38
|
+
if (!ohSource)
|
|
39
|
+
continue; // Skip relative-path sources — only resolvable inside the marketplace repo
|
|
40
|
+
plugins.push({
|
|
41
|
+
name: p.name,
|
|
42
|
+
description: p.description ?? "",
|
|
43
|
+
version: p.version ?? "0.0.0",
|
|
44
|
+
author: typeof p.author === "string" ? p.author : p.author?.name,
|
|
45
|
+
source: ohSource,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return { name: cc.name, version: 1, description: cc.description, plugins };
|
|
49
|
+
}
|
|
50
|
+
function ccSourceToOh(src) {
|
|
51
|
+
if (typeof src === "string")
|
|
52
|
+
return null; // relative paths require marketplace-repo context
|
|
53
|
+
if (src.source === "github")
|
|
54
|
+
return { type: "github", repo: src.repo };
|
|
55
|
+
if (src.source === "url")
|
|
56
|
+
return { type: "url", url: src.url };
|
|
57
|
+
if (src.source === "npm")
|
|
58
|
+
return { type: "npm", package: src.package };
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/** Parse marketplace JSON text. Tries CC format (.claude-plugin/marketplace.json shape) first, falls back to OH-native. */
|
|
62
|
+
export function parseMarketplaceJson(text) {
|
|
63
|
+
let raw;
|
|
64
|
+
try {
|
|
65
|
+
raw = JSON.parse(text);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (!raw || typeof raw !== "object")
|
|
71
|
+
return null;
|
|
72
|
+
const obj = raw;
|
|
73
|
+
if (!Array.isArray(obj.plugins))
|
|
74
|
+
return null;
|
|
75
|
+
// Detect CC format: any plugin entry has either a string `source` or `source.source` (kind tag)
|
|
76
|
+
const looksCc = obj.plugins.some((p) => {
|
|
77
|
+
if (!p || typeof p !== "object")
|
|
78
|
+
return false;
|
|
79
|
+
const s = p.source;
|
|
80
|
+
return typeof s === "string" || (s !== null && typeof s === "object" && "source" in s);
|
|
81
|
+
});
|
|
82
|
+
if (looksCc) {
|
|
83
|
+
return ccMarketplaceToOh(obj);
|
|
84
|
+
}
|
|
85
|
+
// OH-native format
|
|
86
|
+
return obj;
|
|
87
|
+
}
|
|
88
|
+
/** Discover plugin-shipped MCP servers from `cachePath/.mcp.json`. Returns raw object for the runtime to merge. */
|
|
89
|
+
export function getPluginMcpServers(cachePath) {
|
|
90
|
+
const path = join(cachePath, ".mcp.json");
|
|
91
|
+
if (!existsSync(path))
|
|
92
|
+
return null;
|
|
93
|
+
try {
|
|
94
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
95
|
+
// Claude Code shape: { "mcpServers": { name: { command, args, env, ... } } }
|
|
96
|
+
// Some plugins store the inner map directly. Accept both.
|
|
97
|
+
if (data && typeof data === "object") {
|
|
98
|
+
if ("mcpServers" in data && typeof data.mcpServers === "object")
|
|
99
|
+
return data.mcpServers;
|
|
100
|
+
return data;
|
|
101
|
+
}
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Discover plugin-shipped hooks from `cachePath/hooks/hooks.json`. Returns raw config for the runtime to register. */
|
|
109
|
+
export function getPluginHooks(cachePath) {
|
|
110
|
+
const path = join(cachePath, "hooks", "hooks.json");
|
|
111
|
+
if (!existsSync(path))
|
|
112
|
+
return null;
|
|
113
|
+
try {
|
|
114
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
115
|
+
if (data && typeof data === "object")
|
|
116
|
+
return data;
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Discover plugin-shipped LSP servers from `cachePath/.lsp.json`. Returns raw config for the runtime to register. */
|
|
124
|
+
export function getPluginLspServers(cachePath) {
|
|
125
|
+
const path = join(cachePath, ".lsp.json");
|
|
126
|
+
if (!existsSync(path))
|
|
127
|
+
return null;
|
|
128
|
+
try {
|
|
129
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
130
|
+
if (data && typeof data === "object") {
|
|
131
|
+
if ("lspServers" in data && typeof data.lspServers === "object")
|
|
132
|
+
return data.lspServers;
|
|
133
|
+
return data;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
16
141
|
// ── Marketplace Management ──
|
|
17
|
-
/** Add a marketplace from a URL, GitHub repo, or local path
|
|
142
|
+
/** Add a marketplace from a URL, GitHub repo, or local path.
|
|
143
|
+
* Probes for both OH-native `marketplace.json` and Claude Code `.claude-plugin/marketplace.json`.
|
|
144
|
+
*/
|
|
18
145
|
export function addMarketplace(nameOrUrl) {
|
|
19
146
|
mkdirSync(MARKETPLACE_DIR, { recursive: true });
|
|
20
|
-
//
|
|
21
|
-
|
|
147
|
+
// Candidate filenames to try, in priority order
|
|
148
|
+
const candidatePaths = ["marketplace.json", ".claude-plugin/marketplace.json"];
|
|
149
|
+
let data = null;
|
|
22
150
|
let marketplaceName;
|
|
23
151
|
if (nameOrUrl.startsWith("http")) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
152
|
+
marketplaceName = new URL(nameOrUrl).hostname;
|
|
153
|
+
for (const fname of candidatePaths) {
|
|
154
|
+
try {
|
|
155
|
+
const text = execSync(`curl -sfL "${nameOrUrl}/${fname}"`, { encoding: "utf-8", timeout: 10_000 });
|
|
156
|
+
if (text.trim()) {
|
|
157
|
+
data = text;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
/* try next */
|
|
163
|
+
}
|
|
31
164
|
}
|
|
32
165
|
}
|
|
33
166
|
else if (nameOrUrl.includes("/") && !nameOrUrl.startsWith(".")) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
167
|
+
marketplaceName = nameOrUrl.replace("/", "-");
|
|
168
|
+
for (const fname of candidatePaths) {
|
|
169
|
+
try {
|
|
170
|
+
const url = `https://raw.githubusercontent.com/${nameOrUrl}/main/${fname}`;
|
|
171
|
+
const text = execSync(`curl -sfL "${url}"`, { encoding: "utf-8", timeout: 10_000 });
|
|
172
|
+
if (text.trim()) {
|
|
173
|
+
data = text;
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
/* try next */
|
|
179
|
+
}
|
|
42
180
|
}
|
|
43
181
|
}
|
|
44
|
-
else
|
|
45
|
-
// Local path
|
|
46
|
-
data = readFileSync(join(nameOrUrl, "marketplace.json"), "utf-8");
|
|
182
|
+
else {
|
|
47
183
|
marketplaceName = basename(nameOrUrl);
|
|
184
|
+
for (const fname of candidatePaths) {
|
|
185
|
+
const path = join(nameOrUrl, fname);
|
|
186
|
+
if (existsSync(path)) {
|
|
187
|
+
data = readFileSync(path, "utf-8");
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
48
191
|
}
|
|
49
|
-
|
|
192
|
+
if (!data)
|
|
50
193
|
return null;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const marketplace = JSON.parse(data);
|
|
54
|
-
if (!marketplace.plugins || !Array.isArray(marketplace.plugins))
|
|
55
|
-
return null;
|
|
56
|
-
marketplace.name = marketplace.name ?? marketplaceName;
|
|
57
|
-
writeFileSync(join(MARKETPLACE_DIR, `${marketplace.name}.json`), JSON.stringify(marketplace, null, 2));
|
|
58
|
-
return marketplace;
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
194
|
+
const marketplace = parseMarketplaceJson(data);
|
|
195
|
+
if (!marketplace)
|
|
61
196
|
return null;
|
|
62
|
-
|
|
197
|
+
marketplace.name = marketplace.name ?? marketplaceName;
|
|
198
|
+
writeFileSync(join(MARKETPLACE_DIR, `${marketplace.name}.json`), JSON.stringify(marketplace, null, 2));
|
|
199
|
+
return marketplace;
|
|
63
200
|
}
|
|
64
201
|
/** Remove a marketplace */
|
|
65
202
|
export function removeMarketplace(name) {
|
|
@@ -199,17 +336,102 @@ export function uninstallPlugin(name) {
|
|
|
199
336
|
saveInstalledPluginList(remaining);
|
|
200
337
|
return true;
|
|
201
338
|
}
|
|
202
|
-
/** Get all installed plugins
|
|
339
|
+
/** Get all installed plugins.
|
|
340
|
+
* Sources merged in priority order:
|
|
341
|
+
* 1. installed.json (plugins installed via /plugin install or addMarketplace flow)
|
|
342
|
+
* 2. CC-style plugins discovered in PLUGIN_CACHE_DIR via .claude-plugin/plugin.json
|
|
343
|
+
* (covers plugins manually dropped in the cache, or installed by parallel tooling)
|
|
344
|
+
* Plugins from #1 are enriched with manifest data if their cachePath has one.
|
|
345
|
+
* De-duplication is by cachePath.
|
|
346
|
+
*/
|
|
203
347
|
export function getInstalledPlugins() {
|
|
204
|
-
|
|
205
|
-
|
|
348
|
+
const recorded = (() => {
|
|
349
|
+
if (!existsSync(INSTALLED_PLUGINS_FILE))
|
|
350
|
+
return [];
|
|
351
|
+
try {
|
|
352
|
+
return JSON.parse(readFileSync(INSTALLED_PLUGINS_FILE, "utf-8"));
|
|
353
|
+
}
|
|
354
|
+
catch {
|
|
355
|
+
return [];
|
|
356
|
+
}
|
|
357
|
+
})();
|
|
358
|
+
// Enrich recorded plugins from their CC manifest if present
|
|
359
|
+
const enriched = recorded.map((p) => mergeManifest(p, parseCcPluginManifest(p.cachePath)));
|
|
360
|
+
const seenPaths = new Set(enriched.map((p) => p.cachePath));
|
|
361
|
+
// Discover CC-style plugins in PLUGIN_CACHE_DIR not already recorded
|
|
362
|
+
const discovered = [];
|
|
363
|
+
if (existsSync(PLUGIN_CACHE_DIR)) {
|
|
364
|
+
try {
|
|
365
|
+
for (const nameEntry of readdirSync(PLUGIN_CACHE_DIR)) {
|
|
366
|
+
const nameDir = join(PLUGIN_CACHE_DIR, nameEntry);
|
|
367
|
+
if (!safeIsDirectory(nameDir))
|
|
368
|
+
continue;
|
|
369
|
+
// Plugin can sit either at <name>/ or <name>/<version>/
|
|
370
|
+
const candidates = [nameDir, ...listSubdirs(nameDir)];
|
|
371
|
+
for (const root of candidates) {
|
|
372
|
+
if (seenPaths.has(root))
|
|
373
|
+
continue;
|
|
374
|
+
const manifest = parseCcPluginManifest(root);
|
|
375
|
+
if (!manifest)
|
|
376
|
+
continue;
|
|
377
|
+
discovered.push({
|
|
378
|
+
name: manifest.name,
|
|
379
|
+
version: manifest.version ?? "0.0.0",
|
|
380
|
+
marketplace: "discovered",
|
|
381
|
+
installedAt: tryStatTime(root),
|
|
382
|
+
cachePath: root,
|
|
383
|
+
...projectManifestFields(manifest),
|
|
384
|
+
});
|
|
385
|
+
seenPaths.add(root);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* ignore */
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return [...enriched, ...discovered];
|
|
394
|
+
}
|
|
395
|
+
function safeIsDirectory(path) {
|
|
396
|
+
try {
|
|
397
|
+
return statSync(path).isDirectory();
|
|
398
|
+
}
|
|
399
|
+
catch {
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
function listSubdirs(dir) {
|
|
206
404
|
try {
|
|
207
|
-
return
|
|
405
|
+
return readdirSync(dir)
|
|
406
|
+
.map((entry) => join(dir, entry))
|
|
407
|
+
.filter((p) => safeIsDirectory(p));
|
|
208
408
|
}
|
|
209
409
|
catch {
|
|
210
410
|
return [];
|
|
211
411
|
}
|
|
212
412
|
}
|
|
413
|
+
function tryStatTime(path) {
|
|
414
|
+
try {
|
|
415
|
+
return statSync(path).mtimeMs;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return Date.now();
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function projectManifestFields(m) {
|
|
422
|
+
return {
|
|
423
|
+
description: m.description,
|
|
424
|
+
author: typeof m.author === "string" ? m.author : m.author?.name,
|
|
425
|
+
license: m.license,
|
|
426
|
+
homepage: m.homepage,
|
|
427
|
+
keywords: m.keywords,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
function mergeManifest(plugin, manifest) {
|
|
431
|
+
if (!manifest)
|
|
432
|
+
return plugin;
|
|
433
|
+
return { ...plugin, ...projectManifestFields(manifest) };
|
|
434
|
+
}
|
|
213
435
|
function saveInstalledPlugin(plugin) {
|
|
214
436
|
const installed = getInstalledPlugins();
|
|
215
437
|
// Replace existing version
|