@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/harness/memory.d.ts
CHANGED
|
@@ -31,6 +31,40 @@ export type MemoryEntry = {
|
|
|
31
31
|
export declare function loadMemories(): MemoryEntry[];
|
|
32
32
|
/** Build a system prompt section from loaded memories (capped at MEMORY_PROMPT_MAX_CHARS) */
|
|
33
33
|
export declare function memoriesToPrompt(memories: MemoryEntry[]): string;
|
|
34
|
+
/** A single CLAUDE.md source with its resolved content (imports inlined). */
|
|
35
|
+
export type ClaudeMdEntry = {
|
|
36
|
+
path: string;
|
|
37
|
+
source: "project" | "project-local" | "user" | "claude-dir";
|
|
38
|
+
content: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Resolve `@path/to/file` imports inline. Relative paths resolve against
|
|
42
|
+
* `baseDir`. Absolute paths are read as-is. Each imported file can itself
|
|
43
|
+
* contain imports — recursion capped at `CLAUDE_MD_MAX_HOPS` to prevent
|
|
44
|
+
* cycles. Missing or unreadable imports are silently dropped.
|
|
45
|
+
*
|
|
46
|
+
* @internal exposed for testing
|
|
47
|
+
*/
|
|
48
|
+
export declare function resolveClaudeMdImports(content: string, baseDir: string, hopsLeft?: number): string;
|
|
49
|
+
/**
|
|
50
|
+
* Load the hierarchical CLAUDE.md set in the order Anthropic documents:
|
|
51
|
+
* 1. `./.claude/CLAUDE.md` (project, checked in)
|
|
52
|
+
* 2. `./CLAUDE.md` (project, checked in)
|
|
53
|
+
* 3. `./CLAUDE.local.md` (project, gitignored)
|
|
54
|
+
* 4. `~/.claude/CLAUDE.md` (user-global)
|
|
55
|
+
*
|
|
56
|
+
* Each file is read, `@imports` are resolved, and the results are returned in
|
|
57
|
+
* load order. Missing files are skipped. The caller can format these into the
|
|
58
|
+
* system prompt alongside `memoriesToPrompt()` — the two systems are additive.
|
|
59
|
+
*
|
|
60
|
+
* @param root optional project root (defaults to cwd)
|
|
61
|
+
*/
|
|
62
|
+
export declare function loadClaudeMdHierarchy(root?: string): ClaudeMdEntry[];
|
|
63
|
+
/**
|
|
64
|
+
* Render loaded CLAUDE.md entries as a system-prompt block. Empty when no
|
|
65
|
+
* CLAUDE.md files exist — caller should concatenate alongside `memoriesToPrompt`.
|
|
66
|
+
*/
|
|
67
|
+
export declare function claudeMdToPrompt(entries: ClaudeMdEntry[]): string;
|
|
34
68
|
/** Save a memory entry to the project memory directory */
|
|
35
69
|
export declare function saveMemory(name: string, type: MemoryType, description: string, content: string, global?: boolean): string;
|
|
36
70
|
/**
|
package/dist/harness/memory.js
CHANGED
|
@@ -13,6 +13,10 @@ import { join, resolve, sep } from "node:path";
|
|
|
13
13
|
import { createUserMessage } from "../types/message.js";
|
|
14
14
|
const PROJECT_MEMORY_DIR = join(".oh", "memory");
|
|
15
15
|
const GLOBAL_MEMORY_DIR = join(homedir(), ".oh", "memory");
|
|
16
|
+
// Maximum number of @-import hops before giving up (prevents cycles).
|
|
17
|
+
const CLAUDE_MD_MAX_HOPS = 5;
|
|
18
|
+
// Cap per loaded CLAUDE.md source to keep the system prompt bounded.
|
|
19
|
+
const CLAUDE_MD_PER_FILE_CAP = 20_000;
|
|
16
20
|
// Version counter — incremented on every save, used by query loop for live injection
|
|
17
21
|
let _memoryVersion = 0;
|
|
18
22
|
export function memoryVersion() {
|
|
@@ -79,6 +83,98 @@ export function memoriesToPrompt(memories) {
|
|
|
79
83
|
}
|
|
80
84
|
return result.trimEnd();
|
|
81
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Resolve `@path/to/file` imports inline. Relative paths resolve against
|
|
88
|
+
* `baseDir`. Absolute paths are read as-is. Each imported file can itself
|
|
89
|
+
* contain imports — recursion capped at `CLAUDE_MD_MAX_HOPS` to prevent
|
|
90
|
+
* cycles. Missing or unreadable imports are silently dropped.
|
|
91
|
+
*
|
|
92
|
+
* @internal exposed for testing
|
|
93
|
+
*/
|
|
94
|
+
export function resolveClaudeMdImports(content, baseDir, hopsLeft = CLAUDE_MD_MAX_HOPS) {
|
|
95
|
+
if (hopsLeft <= 0)
|
|
96
|
+
return content;
|
|
97
|
+
// Match `@path` on its own line or in a line-leading position. We avoid
|
|
98
|
+
// email addresses (`foo@bar.com`) by requiring whitespace/start-of-line
|
|
99
|
+
// before the `@` and a path-like token after.
|
|
100
|
+
const importRe = /(^|\s)@([^\s@]+)/g;
|
|
101
|
+
return content.replace(importRe, (match, leading, path) => {
|
|
102
|
+
// Skip obvious non-path tokens (e.g. `@user` mentions without slashes or extensions)
|
|
103
|
+
if (!path.includes("/") && !path.includes("."))
|
|
104
|
+
return match;
|
|
105
|
+
const resolved = path.startsWith("/") || path.startsWith("~") ? path.replace(/^~/, homedir()) : join(baseDir, path);
|
|
106
|
+
if (!existsSync(resolved))
|
|
107
|
+
return match;
|
|
108
|
+
try {
|
|
109
|
+
const raw = readFileSync(resolved, "utf-8");
|
|
110
|
+
const subDir = resolved.split(/[/\\]/).slice(0, -1).join("/");
|
|
111
|
+
const expanded = resolveClaudeMdImports(raw, subDir || baseDir, hopsLeft - 1);
|
|
112
|
+
return `${leading}<!-- imported from @${path} -->\n${expanded}\n<!-- end @${path} -->`;
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return match;
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** Read a single CLAUDE.md candidate path if it exists. Returns null otherwise. */
|
|
120
|
+
function readClaudeMdIfExists(path, source) {
|
|
121
|
+
if (!existsSync(path))
|
|
122
|
+
return null;
|
|
123
|
+
try {
|
|
124
|
+
const raw = readFileSync(path, "utf-8").slice(0, CLAUDE_MD_PER_FILE_CAP);
|
|
125
|
+
const baseDir = path.split(/[/\\]/).slice(0, -1).join("/") || ".";
|
|
126
|
+
return { path, source, content: resolveClaudeMdImports(raw, baseDir) };
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Load the hierarchical CLAUDE.md set in the order Anthropic documents:
|
|
134
|
+
* 1. `./.claude/CLAUDE.md` (project, checked in)
|
|
135
|
+
* 2. `./CLAUDE.md` (project, checked in)
|
|
136
|
+
* 3. `./CLAUDE.local.md` (project, gitignored)
|
|
137
|
+
* 4. `~/.claude/CLAUDE.md` (user-global)
|
|
138
|
+
*
|
|
139
|
+
* Each file is read, `@imports` are resolved, and the results are returned in
|
|
140
|
+
* load order. Missing files are skipped. The caller can format these into the
|
|
141
|
+
* system prompt alongside `memoriesToPrompt()` — the two systems are additive.
|
|
142
|
+
*
|
|
143
|
+
* @param root optional project root (defaults to cwd)
|
|
144
|
+
*/
|
|
145
|
+
export function loadClaudeMdHierarchy(root = ".") {
|
|
146
|
+
const candidates = [
|
|
147
|
+
[join(root, ".claude", "CLAUDE.md"), "claude-dir"],
|
|
148
|
+
[join(root, "CLAUDE.md"), "project"],
|
|
149
|
+
[join(root, "CLAUDE.local.md"), "project-local"],
|
|
150
|
+
[join(homedir(), ".claude", "CLAUDE.md"), "user"],
|
|
151
|
+
];
|
|
152
|
+
const entries = [];
|
|
153
|
+
const seen = new Set();
|
|
154
|
+
for (const [path, source] of candidates) {
|
|
155
|
+
if (seen.has(path))
|
|
156
|
+
continue;
|
|
157
|
+
seen.add(path);
|
|
158
|
+
const entry = readClaudeMdIfExists(path, source);
|
|
159
|
+
if (entry)
|
|
160
|
+
entries.push(entry);
|
|
161
|
+
}
|
|
162
|
+
return entries;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Render loaded CLAUDE.md entries as a system-prompt block. Empty when no
|
|
166
|
+
* CLAUDE.md files exist — caller should concatenate alongside `memoriesToPrompt`.
|
|
167
|
+
*/
|
|
168
|
+
export function claudeMdToPrompt(entries) {
|
|
169
|
+
if (entries.length === 0)
|
|
170
|
+
return "";
|
|
171
|
+
const parts = ["# Project instructions (CLAUDE.md)"];
|
|
172
|
+
for (const e of entries) {
|
|
173
|
+
parts.push(`<!-- source: ${e.source} (${e.path}) -->`);
|
|
174
|
+
parts.push(e.content.trim());
|
|
175
|
+
}
|
|
176
|
+
return parts.join("\n\n").trimEnd();
|
|
177
|
+
}
|
|
82
178
|
/** Save a memory entry to the project memory directory */
|
|
83
179
|
export function saveMemory(name, type, description, content, global = false) {
|
|
84
180
|
const dir = global ? GLOBAL_MEMORY_DIR : PROJECT_MEMORY_DIR;
|
|
@@ -16,9 +16,19 @@ export type SkillMetadata = {
|
|
|
16
16
|
trigger: string | undefined;
|
|
17
17
|
tools: string[] | undefined;
|
|
18
18
|
args: string[] | undefined;
|
|
19
|
+
/** Optional natural-language hint for when this skill applies; concatenated to description for trigger matching */
|
|
20
|
+
whenToUse: string | undefined;
|
|
21
|
+
/** SPDX license identifier (e.g. "MIT", "Apache-2.0", "CC-BY-SA-4.0"). Used by install gate. */
|
|
22
|
+
license: string | undefined;
|
|
23
|
+
/** Glob patterns scoping skill auto-surfacing to specific file paths */
|
|
24
|
+
paths: string[] | undefined;
|
|
25
|
+
/** Execution context: "default" runs in the current agent, "fork" spawns a sub-agent (Anthropic extension) */
|
|
26
|
+
context: "default" | "fork" | undefined;
|
|
27
|
+
/** When `context: fork`, the sub-agent type to spawn (must match an AgentRole id) */
|
|
28
|
+
agent: string | undefined;
|
|
19
29
|
content: string;
|
|
20
30
|
filePath: string;
|
|
21
|
-
source: "project" | "global" | "plugin";
|
|
31
|
+
source: "bundled" | "project" | "global" | "plugin";
|
|
22
32
|
/** When false, skill is hidden from system prompt until explicitly invoked */
|
|
23
33
|
invokeModel: boolean;
|
|
24
34
|
};
|
|
@@ -44,11 +54,11 @@ export type AgentTeamConfig = {
|
|
|
44
54
|
tools?: string[];
|
|
45
55
|
}>;
|
|
46
56
|
};
|
|
47
|
-
/** Discover all available skills from project + global dirs + installed plugins */
|
|
57
|
+
/** Discover all available skills from bundled + project + global dirs + installed plugins */
|
|
48
58
|
export declare function discoverSkills(): SkillMetadata[];
|
|
49
59
|
/** Find a skill by name (case-insensitive) */
|
|
50
60
|
export declare function findSkill(name: string): SkillMetadata | null;
|
|
51
|
-
/** Find skills that match a trigger condition */
|
|
61
|
+
/** Find skills that match a trigger condition (substring match against `trigger` field). */
|
|
52
62
|
export declare function findTriggeredSkills(userMessage: string): SkillMetadata[];
|
|
53
63
|
/** Find a skill similar to a candidate (for patch-vs-create decision) */
|
|
54
64
|
export declare function findSimilarSkill(candidateName: string, candidateDescription: string, skills: Array<{
|
package/dist/harness/plugins.js
CHANGED
|
@@ -12,10 +12,31 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
-
import { join, relative } from "node:path";
|
|
15
|
+
import { dirname, join, relative } from "node:path";
|
|
16
|
+
import { fileURLToPath } from "node:url";
|
|
16
17
|
const PROJECT_SKILLS_DIR = join(".oh", "skills");
|
|
17
18
|
const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
|
|
18
|
-
|
|
19
|
+
// Claude Code ecosystem mirror paths (Anthropic convention)
|
|
20
|
+
const CC_PROJECT_SKILLS_DIR = join(".claude", "skills");
|
|
21
|
+
const CC_GLOBAL_SKILLS_DIR = join(homedir(), ".claude", "skills");
|
|
22
|
+
// Bundled skills shipped with the openharness package itself.
|
|
23
|
+
// At runtime this resolves to <package-root>/data/skills/ both in dev (src/) and prod (dist/).
|
|
24
|
+
const BUNDLED_SKILLS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "data", "skills");
|
|
25
|
+
/** Parse a frontmatter list value. Accepts `[a, b]` (YAML inline) or `a b c` (space-separated, Anthropic spec). */
|
|
26
|
+
function parseListValue(raw) {
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
|
|
29
|
+
return trimmed
|
|
30
|
+
.slice(1, -1)
|
|
31
|
+
.split(",")
|
|
32
|
+
.map((t) => t.trim())
|
|
33
|
+
.filter(Boolean);
|
|
34
|
+
}
|
|
35
|
+
// Strip surrounding quotes if present
|
|
36
|
+
const unquoted = trimmed.replace(/^["']|["']$/g, "");
|
|
37
|
+
return unquoted.split(/\s+/).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
/** Parse YAML frontmatter from a skill markdown file. Accepts both OH camelCase and Anthropic kebab-case. */
|
|
19
40
|
function parseSkillFrontmatter(content) {
|
|
20
41
|
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
21
42
|
if (!match)
|
|
@@ -28,33 +49,74 @@ function parseSkillFrontmatter(content) {
|
|
|
28
49
|
const descMatch = frontmatter.match(/^description:\s*(.+)$/m);
|
|
29
50
|
if (descMatch)
|
|
30
51
|
result.description = descMatch[1].trim();
|
|
52
|
+
// trigger: OH-native field; when-to-use / whenToUse: Anthropic-style hint (also used as trigger fallback)
|
|
31
53
|
const triggerMatch = frontmatter.match(/^trigger:\s*(.+)$/m);
|
|
32
54
|
if (triggerMatch)
|
|
33
55
|
result.trigger = triggerMatch[1].trim();
|
|
34
|
-
const
|
|
35
|
-
if (
|
|
36
|
-
result.
|
|
37
|
-
//
|
|
38
|
-
const
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
56
|
+
const whenToUseMatch = frontmatter.match(/^(?:when-to-use|whenToUse):\s*(.+)$/m);
|
|
57
|
+
if (whenToUseMatch)
|
|
58
|
+
result.whenToUse = whenToUseMatch[1].trim();
|
|
59
|
+
// tools / allowedTools / allowed-tools — array OR space-separated. Merge all forms found.
|
|
60
|
+
const toolsCollected = new Set();
|
|
61
|
+
for (const re of [/^tools:\s*(.+)$/m, /^allowedTools:\s*(.+)$/m, /^allowed-tools:\s*(.+)$/m]) {
|
|
62
|
+
const m = frontmatter.match(re);
|
|
63
|
+
if (m)
|
|
64
|
+
for (const t of parseListValue(m[1]))
|
|
65
|
+
toolsCollected.add(t);
|
|
42
66
|
}
|
|
43
|
-
|
|
67
|
+
if (toolsCollected.size > 0)
|
|
68
|
+
result.tools = [...toolsCollected];
|
|
69
|
+
// args / argument-hint
|
|
70
|
+
const argsMatch = frontmatter.match(/^(?:args|argument-hint):\s*(.+)$/m);
|
|
44
71
|
if (argsMatch)
|
|
45
|
-
result.args = argsMatch[1]
|
|
72
|
+
result.args = parseListValue(argsMatch[1]);
|
|
46
73
|
// invokeModel: false OR disable-model-invocation: true → hidden from system prompt
|
|
47
74
|
if (frontmatter.match(/^invokeModel:\s*false$/m) || frontmatter.match(/^disable-model-invocation:\s*true$/m)) {
|
|
48
75
|
result.invokeModel = false;
|
|
49
76
|
}
|
|
77
|
+
// license: SPDX identifier (e.g. MIT, Apache-2.0)
|
|
78
|
+
const licenseMatch = frontmatter.match(/^license:\s*(.+)$/m);
|
|
79
|
+
if (licenseMatch)
|
|
80
|
+
result.license = licenseMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
81
|
+
// paths: glob list — scopes auto-surfacing to matching files
|
|
82
|
+
const pathsMatch = frontmatter.match(/^paths:\s*(.+)$/m);
|
|
83
|
+
if (pathsMatch)
|
|
84
|
+
result.paths = parseListValue(pathsMatch[1]);
|
|
85
|
+
// context: "default" | "fork" — when "fork", skill runs in a new sub-agent context
|
|
86
|
+
const contextMatch = frontmatter.match(/^context:\s*(.+)$/m);
|
|
87
|
+
if (contextMatch) {
|
|
88
|
+
const v = contextMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
89
|
+
if (v === "fork" || v === "default")
|
|
90
|
+
result.context = v;
|
|
91
|
+
}
|
|
92
|
+
// agent: sub-agent type name (only meaningful when context: fork)
|
|
93
|
+
const agentMatch = frontmatter.match(/^agent:\s*(.+)$/m);
|
|
94
|
+
if (agentMatch)
|
|
95
|
+
result.agent = agentMatch[1].trim().replace(/^["']|["']$/g, "");
|
|
50
96
|
return result;
|
|
51
97
|
}
|
|
52
|
-
/** Recursively collect
|
|
98
|
+
/** Recursively collect skill .md files from a directory tree.
|
|
99
|
+
* Anthropic / Claude Code convention: a directory containing `SKILL.md` is a single
|
|
100
|
+
* directory-packaged skill — only the SKILL.md surfaces; sibling .md files are
|
|
101
|
+
* companion documentation (referenced via Read at runtime). Directories without
|
|
102
|
+
* SKILL.md fall through to the legacy flat-file behavior (every .md is a skill).
|
|
103
|
+
*/
|
|
53
104
|
function walkMdFiles(dir) {
|
|
54
105
|
if (!existsSync(dir))
|
|
55
106
|
return [];
|
|
107
|
+
let entries;
|
|
108
|
+
try {
|
|
109
|
+
entries = readdirSync(dir);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
// Directory-packaged skill: only SKILL.md counts; siblings are companions.
|
|
115
|
+
if (entries.includes("SKILL.md")) {
|
|
116
|
+
return [join(dir, "SKILL.md")];
|
|
117
|
+
}
|
|
56
118
|
const results = [];
|
|
57
|
-
for (const entry of
|
|
119
|
+
for (const entry of entries) {
|
|
58
120
|
const full = join(dir, entry);
|
|
59
121
|
try {
|
|
60
122
|
if (statSync(full).isDirectory()) {
|
|
@@ -86,6 +148,11 @@ function loadSkillsFromDir(dir, source) {
|
|
|
86
148
|
trigger: meta.trigger,
|
|
87
149
|
tools: meta.tools,
|
|
88
150
|
args: meta.args,
|
|
151
|
+
whenToUse: meta.whenToUse,
|
|
152
|
+
license: meta.license,
|
|
153
|
+
paths: meta.paths,
|
|
154
|
+
context: meta.context,
|
|
155
|
+
agent: meta.agent,
|
|
89
156
|
content,
|
|
90
157
|
filePath,
|
|
91
158
|
source,
|
|
@@ -98,11 +165,17 @@ function loadSkillsFromDir(dir, source) {
|
|
|
98
165
|
})
|
|
99
166
|
.filter((s) => s !== null);
|
|
100
167
|
}
|
|
101
|
-
/** Discover all available skills from project + global dirs + installed plugins */
|
|
168
|
+
/** Discover all available skills from bundled + project + global dirs + installed plugins */
|
|
102
169
|
export function discoverSkills() {
|
|
103
170
|
const skills = [];
|
|
171
|
+
// Bundled (shipped with the openharness package)
|
|
172
|
+
skills.push(...loadSkillsFromDir(BUNDLED_SKILLS_DIR, "bundled"));
|
|
173
|
+
// OH-native paths
|
|
104
174
|
skills.push(...loadSkillsFromDir(PROJECT_SKILLS_DIR, "project"));
|
|
105
175
|
skills.push(...loadSkillsFromDir(GLOBAL_SKILLS_DIR, "global"));
|
|
176
|
+
// Claude Code ecosystem mirror paths — same source labels (project/global)
|
|
177
|
+
skills.push(...loadSkillsFromDir(CC_PROJECT_SKILLS_DIR, "project"));
|
|
178
|
+
skills.push(...loadSkillsFromDir(CC_GLOBAL_SKILLS_DIR, "global"));
|
|
106
179
|
// Load skills from installed marketplace plugins (namespaced as plugin-name:skill-name)
|
|
107
180
|
try {
|
|
108
181
|
const { getInstalledPlugins } = require("./marketplace.js");
|
|
@@ -119,14 +192,22 @@ export function discoverSkills() {
|
|
|
119
192
|
catch {
|
|
120
193
|
/* marketplace module may not be loaded yet */
|
|
121
194
|
}
|
|
122
|
-
|
|
195
|
+
// De-duplicate by name+filePath: if same skill appears in multiple paths (e.g. CC mirror), keep first.
|
|
196
|
+
const seen = new Set();
|
|
197
|
+
return skills.filter((s) => {
|
|
198
|
+
const key = `${s.name}::${s.filePath}`;
|
|
199
|
+
if (seen.has(key))
|
|
200
|
+
return false;
|
|
201
|
+
seen.add(key);
|
|
202
|
+
return true;
|
|
203
|
+
});
|
|
123
204
|
}
|
|
124
205
|
/** Find a skill by name (case-insensitive) */
|
|
125
206
|
export function findSkill(name) {
|
|
126
207
|
const skills = discoverSkills();
|
|
127
208
|
return skills.find((s) => s.name.toLowerCase() === name.toLowerCase()) ?? null;
|
|
128
209
|
}
|
|
129
|
-
/** Find skills that match a trigger condition */
|
|
210
|
+
/** Find skills that match a trigger condition (substring match against `trigger` field). */
|
|
130
211
|
export function findTriggeredSkills(userMessage) {
|
|
131
212
|
const skills = discoverSkills();
|
|
132
213
|
return skills.filter((s) => {
|
|
@@ -48,7 +48,14 @@ export declare function sessionToIndexEntry(session: Session): SessionIndexEntry
|
|
|
48
48
|
* Rebuilds the FTS5 index from session JSON files on disk.
|
|
49
49
|
*/
|
|
50
50
|
export declare function rebuildIndex(db: Database.Database, sessionsDir?: string): number;
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Get a shared DB connection (opens once, reuses thereafter).
|
|
53
|
+
*
|
|
54
|
+
* Honors the `OH_SESSION_DB_PATH` environment variable for test isolation.
|
|
55
|
+
* When the env var changes between calls, the old singleton is closed and a
|
|
56
|
+
* new one is opened at the new path — this lets tests point at a tmp dir
|
|
57
|
+
* without leaking into the user's real `~/.oh/sessions.db`.
|
|
58
|
+
*/
|
|
52
59
|
export declare function getSessionDb(): Database.Database;
|
|
53
60
|
/** Close the singleton connection (call on process exit) */
|
|
54
61
|
export declare function closeGlobalSessionDb(): void;
|
|
@@ -145,11 +145,32 @@ export function rebuildIndex(db, sessionsDir) {
|
|
|
145
145
|
}
|
|
146
146
|
// ── Singleton Connection ──
|
|
147
147
|
let _singletonDb = null;
|
|
148
|
-
|
|
148
|
+
let _singletonDbPath = null;
|
|
149
|
+
/**
|
|
150
|
+
* Get a shared DB connection (opens once, reuses thereafter).
|
|
151
|
+
*
|
|
152
|
+
* Honors the `OH_SESSION_DB_PATH` environment variable for test isolation.
|
|
153
|
+
* When the env var changes between calls, the old singleton is closed and a
|
|
154
|
+
* new one is opened at the new path — this lets tests point at a tmp dir
|
|
155
|
+
* without leaking into the user's real `~/.oh/sessions.db`.
|
|
156
|
+
*/
|
|
149
157
|
export function getSessionDb() {
|
|
150
|
-
|
|
151
|
-
|
|
158
|
+
const envPath = process.env.OH_SESSION_DB_PATH;
|
|
159
|
+
const targetPath = envPath || DEFAULT_DB_PATH;
|
|
160
|
+
if (_singletonDb && _singletonDbPath === targetPath) {
|
|
161
|
+
return _singletonDb;
|
|
162
|
+
}
|
|
163
|
+
if (_singletonDb) {
|
|
164
|
+
try {
|
|
165
|
+
_singletonDb.close();
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
/* ignore */
|
|
169
|
+
}
|
|
170
|
+
_singletonDb = null;
|
|
152
171
|
}
|
|
172
|
+
_singletonDb = openSessionDb(targetPath);
|
|
173
|
+
_singletonDbPath = targetPath;
|
|
153
174
|
return _singletonDb;
|
|
154
175
|
}
|
|
155
176
|
/** Close the singleton connection (call on process exit) */
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Skills Registry — search and install community skills from a remote registry.
|
|
3
3
|
*/
|
|
4
|
+
/** SPDX identifiers for licenses we install without explicit user acceptance. */
|
|
5
|
+
export declare const PERMISSIVE_LICENSES: Set<string>;
|
|
4
6
|
export type RegistrySkill = {
|
|
5
7
|
name: string;
|
|
6
8
|
description: string;
|
|
@@ -8,6 +10,14 @@ export type RegistrySkill = {
|
|
|
8
10
|
version: string;
|
|
9
11
|
source: string;
|
|
10
12
|
tags: string[];
|
|
13
|
+
/** SPDX license identifier (e.g. "MIT"). Required for new entries; absent for legacy. */
|
|
14
|
+
license?: string;
|
|
15
|
+
/** Attribution string to preserve when installing (e.g. "© 2025 Jesse Vincent"). */
|
|
16
|
+
attribution?: string;
|
|
17
|
+
/** Upstream homepage / repo URL. */
|
|
18
|
+
upstream?: string;
|
|
19
|
+
/** When false, skill cannot be installed via this command — only linked to upstream. Used for viral licenses (CC-BY-SA, GPL). */
|
|
20
|
+
installable?: boolean;
|
|
11
21
|
};
|
|
12
22
|
export type Registry = {
|
|
13
23
|
skills: RegistrySkill[];
|
|
@@ -16,6 +26,20 @@ export type Registry = {
|
|
|
16
26
|
export declare function fetchRegistry(url?: string): Promise<Registry>;
|
|
17
27
|
/** Search registry by query (matches name, description, tags) */
|
|
18
28
|
export declare function searchRegistry(registry: Registry, query: string): RegistrySkill[];
|
|
19
|
-
/**
|
|
20
|
-
export
|
|
29
|
+
/** Result returned by installSkill — either success with path, or refusal with reason. */
|
|
30
|
+
export type InstallResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
filePath: string;
|
|
33
|
+
} | {
|
|
34
|
+
ok: false;
|
|
35
|
+
reason: "not-installable" | "license-not-accepted";
|
|
36
|
+
message: string;
|
|
37
|
+
};
|
|
38
|
+
/** Install a skill from the registry to ~/.oh/skills/.
|
|
39
|
+
* Refuses non-permissive licenses unless `acceptLicense` matches the entry's license.
|
|
40
|
+
* Refuses entries with `installable: false` (e.g. viral-license skills that must be installed upstream).
|
|
41
|
+
*/
|
|
42
|
+
export declare function installSkill(skill: RegistrySkill, opts?: {
|
|
43
|
+
acceptLicense?: string;
|
|
44
|
+
}): Promise<InstallResult>;
|
|
21
45
|
//# sourceMappingURL=skill-registry.d.ts.map
|
|
@@ -6,6 +6,16 @@ import { homedir } from "node:os";
|
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
const DEFAULT_REGISTRY_URL = "https://raw.githubusercontent.com/zhijiewong/openharness/main/data/registry.json";
|
|
8
8
|
const GLOBAL_SKILLS_DIR = join(homedir(), ".oh", "skills");
|
|
9
|
+
/** SPDX identifiers for licenses we install without explicit user acceptance. */
|
|
10
|
+
export const PERMISSIVE_LICENSES = new Set([
|
|
11
|
+
"MIT",
|
|
12
|
+
"Apache-2.0",
|
|
13
|
+
"BSD-2-Clause",
|
|
14
|
+
"BSD-3-Clause",
|
|
15
|
+
"ISC",
|
|
16
|
+
"CC0-1.0",
|
|
17
|
+
"Unlicense",
|
|
18
|
+
]);
|
|
9
19
|
/** Fetch the registry from remote URL */
|
|
10
20
|
export async function fetchRegistry(url = DEFAULT_REGISTRY_URL) {
|
|
11
21
|
const response = await fetch(url);
|
|
@@ -20,16 +30,44 @@ export function searchRegistry(registry, query) {
|
|
|
20
30
|
s.description.toLowerCase().includes(q) ||
|
|
21
31
|
s.tags.some((t) => t.toLowerCase().includes(q)));
|
|
22
32
|
}
|
|
23
|
-
/** Install a skill from the registry to ~/.oh/skills
|
|
24
|
-
|
|
33
|
+
/** Install a skill from the registry to ~/.oh/skills/.
|
|
34
|
+
* Refuses non-permissive licenses unless `acceptLicense` matches the entry's license.
|
|
35
|
+
* Refuses entries with `installable: false` (e.g. viral-license skills that must be installed upstream).
|
|
36
|
+
*/
|
|
37
|
+
export async function installSkill(skill, opts = {}) {
|
|
38
|
+
// Gate 1: link-only entries
|
|
39
|
+
if (skill.installable === false) {
|
|
40
|
+
const upstream = skill.upstream ? ` Visit ${skill.upstream} to install under its license terms.` : "";
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
reason: "not-installable",
|
|
44
|
+
message: `Skill "${skill.name}" is link-only (license: ${skill.license ?? "unknown"}).${upstream}`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// Gate 2: license check
|
|
48
|
+
if (skill.license && !PERMISSIVE_LICENSES.has(skill.license)) {
|
|
49
|
+
if (opts.acceptLicense !== skill.license) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
reason: "license-not-accepted",
|
|
53
|
+
message: `Skill "${skill.name}" is licensed under ${skill.license}, which is not in the auto-install allowlist.\n` +
|
|
54
|
+
`To install, re-run with --accept-license=${skill.license} to acknowledge its terms.`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
25
58
|
const response = await fetch(skill.source);
|
|
26
59
|
if (!response.ok)
|
|
27
60
|
throw new Error(`Failed to download skill: ${response.status}`);
|
|
28
|
-
|
|
61
|
+
let content = await response.text();
|
|
62
|
+
// Preserve attribution by prepending an HTML comment if present and not already in the file
|
|
63
|
+
if (skill.attribution && !content.includes(skill.attribution)) {
|
|
64
|
+
const header = `<!-- Source: ${skill.upstream ?? skill.source}\n License: ${skill.license ?? "unknown"}\n ${skill.attribution} -->\n`;
|
|
65
|
+
content = header + content;
|
|
66
|
+
}
|
|
29
67
|
mkdirSync(GLOBAL_SKILLS_DIR, { recursive: true });
|
|
30
68
|
const slug = skill.name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
31
69
|
const filePath = join(GLOBAL_SKILLS_DIR, `${slug}.md`);
|
|
32
70
|
writeFileSync(filePath, content);
|
|
33
|
-
return filePath;
|
|
71
|
+
return { ok: true, filePath };
|
|
34
72
|
}
|
|
35
73
|
//# sourceMappingURL=skill-registry.js.map
|
|
@@ -13,8 +13,8 @@ declare const inputSchema: z.ZodObject<{
|
|
|
13
13
|
prompt: string;
|
|
14
14
|
model?: string | undefined;
|
|
15
15
|
description?: string | undefined;
|
|
16
|
-
isolated?: boolean | undefined;
|
|
17
16
|
isolation?: "worktree" | undefined;
|
|
17
|
+
isolated?: boolean | undefined;
|
|
18
18
|
run_in_background?: boolean | undefined;
|
|
19
19
|
subagent_type?: string | undefined;
|
|
20
20
|
allowed_tools?: string[] | undefined;
|
|
@@ -22,8 +22,8 @@ declare const inputSchema: z.ZodObject<{
|
|
|
22
22
|
prompt: string;
|
|
23
23
|
model?: string | undefined;
|
|
24
24
|
description?: string | undefined;
|
|
25
|
-
isolated?: boolean | undefined;
|
|
26
25
|
isolation?: "worktree" | undefined;
|
|
26
|
+
isolated?: boolean | undefined;
|
|
27
27
|
run_in_background?: boolean | undefined;
|
|
28
28
|
subagent_type?: string | undefined;
|
|
29
29
|
allowed_tools?: string[] | undefined;
|
|
@@ -6,8 +6,8 @@ declare const inputSchema: z.ZodObject<{
|
|
|
6
6
|
line: z.ZodOptional<z.ZodNumber>;
|
|
7
7
|
character: z.ZodOptional<z.ZodNumber>;
|
|
8
8
|
}, "strip", z.ZodTypeAny, {
|
|
9
|
-
file_path: string;
|
|
10
9
|
action: "diagnostics" | "definition" | "references" | "hover";
|
|
10
|
+
file_path: string;
|
|
11
11
|
line?: number | undefined;
|
|
12
12
|
character?: number | undefined;
|
|
13
13
|
}, {
|
|
@@ -17,30 +17,30 @@ declare const inputSchema: z.ZodObject<{
|
|
|
17
17
|
"-n": z.ZodOptional<z.ZodBoolean>;
|
|
18
18
|
}, "strip", z.ZodTypeAny, {
|
|
19
19
|
pattern: string;
|
|
20
|
-
path?: string | undefined;
|
|
21
20
|
type?: string | undefined;
|
|
21
|
+
"-i"?: boolean | undefined;
|
|
22
|
+
path?: string | undefined;
|
|
23
|
+
context?: number | undefined;
|
|
22
24
|
glob?: string | undefined;
|
|
23
25
|
offset?: number | undefined;
|
|
24
|
-
context?: number | undefined;
|
|
25
26
|
output_mode?: "content" | "files_with_matches" | "count" | undefined;
|
|
26
27
|
head_limit?: number | undefined;
|
|
27
28
|
multiline?: boolean | undefined;
|
|
28
|
-
"-i"?: boolean | undefined;
|
|
29
29
|
"-A"?: number | undefined;
|
|
30
30
|
"-B"?: number | undefined;
|
|
31
31
|
"-C"?: number | undefined;
|
|
32
32
|
"-n"?: boolean | undefined;
|
|
33
33
|
}, {
|
|
34
34
|
pattern: string;
|
|
35
|
-
path?: string | undefined;
|
|
36
35
|
type?: string | undefined;
|
|
36
|
+
"-i"?: boolean | undefined;
|
|
37
|
+
path?: string | undefined;
|
|
38
|
+
context?: number | undefined;
|
|
37
39
|
glob?: string | undefined;
|
|
38
40
|
offset?: number | undefined;
|
|
39
|
-
context?: number | undefined;
|
|
40
41
|
output_mode?: "content" | "files_with_matches" | "count" | undefined;
|
|
41
42
|
head_limit?: number | undefined;
|
|
42
43
|
multiline?: boolean | undefined;
|
|
43
|
-
"-i"?: boolean | undefined;
|
|
44
44
|
"-A"?: number | undefined;
|
|
45
45
|
"-B"?: number | undefined;
|
|
46
46
|
"-C"?: number | undefined;
|
|
@@ -9,20 +9,20 @@ declare const inputSchema: z.ZodObject<{
|
|
|
9
9
|
query: z.ZodOptional<z.ZodString>;
|
|
10
10
|
global: z.ZodOptional<z.ZodBoolean>;
|
|
11
11
|
}, "strip", z.ZodTypeAny, {
|
|
12
|
-
action: "search" | "
|
|
12
|
+
action: "search" | "save" | "list";
|
|
13
13
|
content?: string | undefined;
|
|
14
14
|
type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
|
|
15
|
-
global?: boolean | undefined;
|
|
16
|
-
name?: string | undefined;
|
|
17
15
|
description?: string | undefined;
|
|
16
|
+
name?: string | undefined;
|
|
17
|
+
global?: boolean | undefined;
|
|
18
18
|
query?: string | undefined;
|
|
19
19
|
}, {
|
|
20
|
-
action: "search" | "
|
|
20
|
+
action: "search" | "save" | "list";
|
|
21
21
|
content?: string | undefined;
|
|
22
22
|
type?: "user" | "convention" | "preference" | "project" | "debugging" | "feedback" | "reference" | undefined;
|
|
23
|
-
global?: boolean | undefined;
|
|
24
|
-
name?: string | undefined;
|
|
25
23
|
description?: string | undefined;
|
|
24
|
+
name?: string | undefined;
|
|
25
|
+
global?: boolean | undefined;
|
|
26
26
|
query?: string | undefined;
|
|
27
27
|
}>;
|
|
28
28
|
export declare const MemoryTool: Tool<typeof inputSchema>;
|
|
@@ -78,7 +78,11 @@ export const MonitorTool = {
|
|
|
78
78
|
for (const line of parts)
|
|
79
79
|
handleLine(line);
|
|
80
80
|
});
|
|
81
|
-
|
|
81
|
+
// Use "close" rather than "exit": "exit" can fire before stdout data
|
|
82
|
+
// has been fully drained on fast-exiting commands, causing CI flakiness
|
|
83
|
+
// where output appears empty. "close" is guaranteed to fire after all
|
|
84
|
+
// stdio streams have closed.
|
|
85
|
+
proc.on("close", (code) => {
|
|
82
86
|
if (!settled) {
|
|
83
87
|
settled = true;
|
|
84
88
|
clearTimeout(timer);
|