Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ HarnessKit manages **all five extension types** from a unified interface — **S
| **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **OpenClaw** | ✓ | ✓ | — | — | ✓ |
| **PenguinHarness** | ✓ | — | — | — | — |
| **Pi** | ✓ | ✓ | ✓ | — | ✓ |

<small><i>* "—" means HarnessKit does not manage this extension type for the agent yet.</i></small>

Expand All @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef

### 🤖 Agent Configs, Memory & Rules

HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **16 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, and **PenguinHarness**.
HarnessKit manages every agent's **Configs**, **Memory**, **Rules**, **Subagents**, and **Ignore** files from one place. Currently supporting **17 agents**: **Claude Code**, **Codex**, **Gemini CLI**, **Cursor**, **Antigravity**, **Copilot**, **Devin Desktop**, **OpenCode**, **Hermes**, **Kiro**, **Oh My Pi**, **DeepSeek Harness**, **Grok Build**, **Qoder CN**, **OpenClaw**, **PenguinHarness**, and **Pi**.

- **Config file tracking** — Automatically discovers every agent's config files — both global and per-project. Add your project directories or custom paths and HarnessKit picks them up alongside the global ones.
- **Per-agent dashboard** — Each agent gets its own page with all files organized by category, showing scope, path, file size, and a summary of installed extensions. Expand any file to preview its content right in the app.
Expand Down
3 changes: 2 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ HarnessKit 通过统一界面管理 **全部五种扩展类型** —— **Skill*
| **Qoder CN** | ✓ | ✓ | ✓ | ✓ | ✓ |
| **OpenClaw** | ✓ | ✓ | — | — | ✓ |
| **PenguinHarness** | ✓ | — | — | — | — |
| **Pi** | ✓ | ✓ | ✓ | — | ✓ |

<small><i>* "—" 表示 HarnessKit 目前还不管理该 Agent 的这类扩展。</i></small>

Expand All @@ -92,7 +93,7 @@ https://github.com/user-attachments/assets/a2a74fd1-f3f2-4525-9d64-ba00378d6eef

### 🤖 Agent 配置、记忆与规则

HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **16 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw** 与 **PenguinHarness**。
HarnessKit 统一管理每个 Agent 的 **配置**、**记忆**、**规则**、**子 Agent** 与 **忽略**(Ignore)文件。目前支持 **17 个 Agent**:**Claude Code**、**Codex**、**Gemini CLI**、**Cursor**、**Antigravity**、**Copilot**、**Devin Desktop**、**OpenCode**、**Hermes**、**Kiro**、**Oh My Pi**、**DeepSeek Harness**、**Grok Build**、**Qoder CN**、**OpenClaw**、**PenguinHarness** 与 **Pi**。

- **配置文件跟踪** —— 自动发现每个 Agent 的全局与项目级配置文件。添加项目目录或自定义路径后,HarnessKit 会将它们与全局配置一同纳入管理。
- **Agent 专属面板** —— 每个 Agent 拥有独立页面,文件按类别组织,列出范围、路径、文件大小以及已安装扩展的概览。展开任意文件即可在应用内预览。
Expand Down
101 changes: 88 additions & 13 deletions crates/hk-core/src/adapter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ pub mod omp;
pub mod openclaw;
pub mod opencode;
pub mod penguin;
pub mod pi;
pub mod qoder_cn;
pub mod windsurf;

Expand Down Expand Up @@ -237,6 +238,72 @@ pub(crate) fn json_string_vec(val: &serde_json::Value, key: &str) -> Vec<String>
.unwrap_or_default()
}

/// Local TypeScript/JavaScript extension modules in `dir`, as omp and Pi
/// load them: a direct `*.ts`/`*.js` file, or a `<name>/index.{ts,js}`
/// directory with TypeScript preferred. The loaders glob only those two
/// extensions, so `.mjs`/`.cjs` and any other file are never listed.
/// package.json-manifest extensions are not modeled.
///
/// A `.disabled` suffix is HarnessKit's in-place disable rename; the loader
/// glob no longer matches it. The name is taken with the suffix stripped so
/// it stays stable across the toggle. For a directory extension the entry's
/// path is the index file, which the toggle renames, keeping the directory
/// (and so the name) stable; active index files are probed first so an
/// `index.ts.disabled` next to a live `index.js` reports what the agent
/// actually loads.
pub(crate) fn read_ts_extensions(dir: &Path) -> Vec<PluginEntry> {
fn entry(name: String, path: PathBuf, enabled: bool) -> PluginEntry {
PluginEntry {
name,
source: "local".into(),
enabled,
path: Some(path),
source_url: None,
uri: None,
installed_at: None,
updated_at: None,
base_layers: vec![],
pack: None,
}
}
let Ok(files) = std::fs::read_dir(dir) else {
return vec![];
};
let mut entries = Vec::new();
for path in files.flatten().map(|f| f.path()) {
let Some(file_name) = path.file_name().map(|n| n.to_string_lossy().to_string()) else {
continue;
};
if path.is_file() {
let enabled = !file_name.ends_with(".disabled");
let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name);
let base = Path::new(base);
if !matches!(base.extension().and_then(|e| e.to_str()), Some("ts" | "js")) {
continue;
}
let Some(name) = base.file_stem().map(|s| s.to_string_lossy().to_string()) else {
continue;
};
entries.push(entry(name, path, enabled));
} else if path.is_dir() {
let candidates = [
("index.ts", true),
("index.js", true),
("index.ts.disabled", false),
("index.js.disabled", false),
];
if let Some((index, enabled)) = candidates
.iter()
.map(|(f, e)| (path.join(f), *e))
.find(|(p, _)| p.is_file())
{
entries.push(entry(file_name, index, enabled));
}
}
}
entries
}

/// Represents a hook entry parsed from an agent's config
#[derive(Debug, Clone)]
pub struct HookEntry {
Expand Down Expand Up @@ -445,17 +512,21 @@ pub enum McpFormat {
/// How an agent's config spells a remote (HTTP/SSE) MCP entry.
///
/// This is the single source of truth for "which transports can this agent
/// receive": the deployer's JSON writer dispatches on the four JSON-family
/// receive": the deployer's JSON writer dispatches on the JSON-family
/// variants, and `AgentCapabilities::from_adapter` derives UI install-gating
/// from it (`Toml` and `DshTransport` are the HTTP-only variants — Codex
/// and dsh have no SSE support; `GrokToml` accepts both;
/// `Unsupported` receives no remote entries at all).
/// from it (`Toml`, `DshTransport` and `PlainUrlHttpOnly` are the HTTP-only
/// variants — Codex, dsh and Pi have no SSE support; `GrokToml` accepts
/// both; `Unsupported` receives no remote entries at all).
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum RemoteMcpSchema {
/// `{type: "http"|"sse", url, headers}` — Claude, Copilot, omp.
TypeAndUrl,
/// `{url, headers}`, protocol auto-detected by the agent — Cursor, Kiro.
PlainUrl,
/// `{url, headers}`, Streamable HTTP only — Pi. The file shape matches
/// Cursor/Kiro's PlainUrl, but Pi rejects the legacy SSE transport
/// (docs/mcp.md), so remote entries are HTTP-only.
PlainUrlHttpOnly,
/// `{httpUrl}` for Streamable HTTP, `{url}` for SSE, `headers` for both — Gemini.
GeminiSplit,
/// `{serverUrl, headers}`, protocol auto-detected — Windsurf, Antigravity.
Expand Down Expand Up @@ -828,16 +899,17 @@ impl crate::models::AgentCapabilities {
.is_some(),
global_hook_install: a.supports_global_hook_install(),
vendor_baseline_packs: a.vendor_baseline_packs(),
// Codex (`Toml`) and dsh (`DshTransport`) speak Streamable HTTP
// but not SSE; GrokToml and every other non-Unsupported schema
// take both.
// Codex (`Toml`), dsh (`DshTransport`) and Pi (`PlainUrlHttpOnly`)
// speak Streamable HTTP but not SSE; GrokToml and every other
// non-Unsupported schema take both.
mcp_remote: crate::models::RemoteTransportFlags {
http: remote_schema != RemoteMcpSchema::Unsupported,
sse: !matches!(
remote_schema,
RemoteMcpSchema::Unsupported
| RemoteMcpSchema::Toml
| RemoteMcpSchema::DshTransport
| RemoteMcpSchema::PlainUrlHttpOnly
),
},
}
Expand Down Expand Up @@ -875,6 +947,7 @@ pub fn all_adapters() -> Vec<Box<dyn AgentAdapter>> {
Box::new(qoder_cn::QoderCnAdapter::new()),
Box::new(openclaw::OpenClawAdapter::new()),
Box::new(penguin::PenguinAdapter::new()),
Box::new(pi::PiAdapter::new()),
]
}

Expand Down Expand Up @@ -921,7 +994,7 @@ mod tests {
for a in all_adapters() {
let caps = crate::models::AgentCapabilities::from_adapter(a.as_ref());
match a.name() {
"codex" | "dsh" => {
"codex" | "dsh" | "pi" => {
assert!(caps.mcp_remote.http);
assert!(!caps.mcp_remote.sse);
}
Expand Down Expand Up @@ -958,9 +1031,9 @@ mod tests {
}

#[test]
fn test_all_adapters_returns_sixteen() {
fn test_all_adapters_returns_seventeen() {
let adapters = all_adapters();
assert_eq!(adapters.len(), 16);
assert_eq!(adapters.len(), 17);
let names: Vec<&str> = adapters.iter().map(|a| a.name()).collect();
assert_eq!(
names,
Expand All @@ -981,6 +1054,7 @@ mod tests {
"qoder-cn",
"openclaw",
"penguin",
"pi",
]
);
}
Expand All @@ -1004,8 +1078,7 @@ mod tests {
// hurting cross-machine portability.
for name in [
"claude", "codex", "gemini", "cursor", "copilot", "opencode", "hermes", "kiro", "omp",
"dsh", "grok",
"penguin",
"dsh", "grok", "penguin", "pi",
] {
assert!(
!by_name[name].needs_path_injection(),
Expand All @@ -1022,7 +1095,7 @@ mod tests {
for a in &adapters {
let expected = matches!(
a.name(),
"hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw"
"hermes" | "kiro" | "omp" | "dsh" | "grok" | "openclaw" | "pi"
);
assert_eq!(
a.supports_native_mcp_toggle(),
Expand Down Expand Up @@ -1061,6 +1134,7 @@ mod tests {
("qoder-cn", true, false, true, true, true), // project MCP merge pending
("openclaw", false, false, false, false, true), // global-only: gateway state dir; hooks are JS handlers
("penguin", false, false, false, false, true), // read-only discovery; no hook format, like dsh
("pi", true, true, false, false, true), // TS/JS extensions; MCP has native per-entry enabled; no shell hooks
];

let adapters = all_adapters();
Expand Down Expand Up @@ -1204,6 +1278,7 @@ mod tests {
("dsh", ".dsh/skills"),
("grok", ".grok/skills"),
("qoder-cn", ".agents/skills"), // Universal standard alias, confirmed in app.asar
("pi", ".pi/skills"),
// hermes is global-only — no project skill dir (hermes-agent#4667).
]
.into_iter()
Expand Down
128 changes: 27 additions & 101 deletions crates/hk-core/src/adapter/omp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,42 +53,6 @@ impl OmpAdapter {
serde_json::from_str(&content).ok()
}

fn plugin_name(path: &Path) -> String {
// Strip `.disabled` first so a toggled-off `orca-spin.ts.disabled`
// shares the name of its enabled sibling `orca-spin.ts` — the name
// must be stable across enable/disable or HK's toggle model breaks.
// Same approach as the opencode adapter.
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name);
Path::new(base)
.file_stem()
.map(|stem| stem.to_string_lossy().to_string())
.unwrap_or_else(|| base.to_string())
}

/// omp extensions are .ts/.js modules exporting a default factory. The
/// loader globs only `*.{ts,js}` (discovery/helpers.ts, extension-loading.md)
/// — no .mjs/.cjs — so matching more here would list files omp never loads.
///
/// A `.disabled` suffix is HK's own in-place disable rename (same model as
/// opencode plugins). omp's native disable mechanism is the
/// `disabledExtensions` id list in config.yml, which HK doesn't write; the
/// rename works because the loader's glob won't match a `.disabled` file.
fn is_extension_file(path: &Path) -> bool {
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_default();
let base = file_name.strip_suffix(".disabled").unwrap_or(&file_name);
matches!(
Path::new(base).extension().and_then(|ext| ext.to_str()),
Some("ts" | "js")
)
}

/// Read a string-array field (e.g. `disabledServers`) from the *user*
/// mcp.json. Both the denylist and the force-enable allowlist live only in
/// the user file but apply to servers from every source, including project
Expand Down Expand Up @@ -225,71 +189,19 @@ impl AgentAdapter for OmpAdapter {
}

fn read_plugins(&self) -> Vec<PluginEntry> {
let mut entries = Vec::new();
for plugin_dir in self.plugin_dirs() {
let Ok(files) = std::fs::read_dir(plugin_dir) else {
continue;
};
for file in files.flatten() {
let path = file.path();
if path.is_file() {
if !Self::is_extension_file(&path) {
continue;
}
let enabled = path.extension().is_none_or(|ext| ext != "disabled");
entries.push(PluginEntry {
name: Self::plugin_name(&path),
source: "local".into(),
enabled,
path: Some(path),
source_url: None,
uri: None,
installed_at: None,
updated_at: None,
base_layers: vec![],
pack: None,
});
} else if path.is_dir() {
// Directory-form extension: <name>/index.{ts,js}, TypeScript
// preferred (extension-loading.md resolution order). Active
// entry files first so a `index.ts.disabled` next to a live
// `index.js` reports what omp actually loads. The entry's
// path is the index file — HK's toggle renames that file,
// keeping the directory (and thus the plugin name) stable.
// package.json-manifest extensions (`omp.extensions`) are
// not modeled.
let Some(dir_name) = path.file_name().map(|n| n.to_string_lossy().to_string())
else {
continue;
};
let candidates = [
("index.ts", true),
("index.js", true),
("index.ts.disabled", false),
("index.js.disabled", false),
];
if let Some((entry_file, enabled)) = candidates
.iter()
.map(|(f, e)| (path.join(f), *e))
.find(|(p, _)| p.is_file())
{
entries.push(PluginEntry {
name: dir_name,
source: "local".into(),
enabled,
path: Some(entry_file),
source_url: None,
uri: None,
installed_at: None,
updated_at: None,
base_layers: vec![],
pack: None,
});
}
}
}
}
entries
// .ts/.js modules exporting a default factory; the loader globs only
// `*.{ts,js}` (discovery/helpers.ts, extension-loading.md). omp's
// native disable is the `disabledExtensions` id list in config.yml,
// which HK doesn't write; the `.disabled` rename works because the
// glob won't match it.
self.plugin_dirs()
.iter()
.flat_map(|dir| super::read_ts_extensions(dir))
.collect()
}

fn read_plugins_from(&self, dir: &Path) -> Vec<PluginEntry> {
super::read_ts_extensions(dir)
}

fn global_rules_files(&self) -> Vec<PathBuf> {
Expand Down Expand Up @@ -516,6 +428,20 @@ mod tests {
assert!(!by_name["orca-dock"].enabled);
}

#[test]
fn read_plugins_from_reads_project_extensions() {
let tmp = tempfile::tempdir().unwrap();
let adapter = OmpAdapter::with_home(tmp.path().to_path_buf());
let ext_dir = tmp.path().join("repo/.omp/extensions");
std::fs::create_dir_all(&ext_dir).unwrap();
std::fs::write(ext_dir.join("repo-tool.ts"), "// ext\n").unwrap();

let plugins = adapter.read_plugins_from(&ext_dir);
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0].name, "repo-tool");
assert!(adapter.read_plugins().is_empty());
}

#[test]
fn paths_match_omp_native_conventions() {
let adapter = OmpAdapter::with_home(PathBuf::from("/h"));
Expand Down
Loading
Loading