Skip to content

Commit ca39495

Browse files
RealZSTclaude
andauthored
feat(kits): Toolkit Bundles — pack, install, sync extension bundles (#68)
Adds the Toolkit Bundles (Kits) feature: pack a project's skills/MCPs/config files into a portable .hk-kit.zip and install/sync/export it across projects and agents. Closes #51. - Backend: SQLite v5–v8 migrations; kits service (pack, install plan, sync/unsync, conflict preview); install_meta propagation so Kit-installed extensions merge with their marketplace origin in the Extensions list; unsync directly drops project-scoped empty rows instead of waiting for scanner self-heal. - Frontend: Kits page (folder grid + colored paper stack), Kit editor dialog (TanStack Table render path matching Extensions), install/preview/conflict dialogs, edit propagation hint, header search + clear-filters mirroring Audit. - i18n: Full Chinese sweep (Kit → 工具集, agent → 智能体, path placeholders, search/filter copy). - Perf: Candidate prefetch deferred to requestIdleCallback + inflight-dedupe + module-level group cache pre-warm. - UI polish: Folder-card paper colors tuned for Tiesen light/dark; warning chips use --warning token (chart-5 was unreadable in dark); selected folder card border-only highlight. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c7460e7 commit ca39495

91 files changed

Lines changed: 11736 additions & 35 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Cargo.lock

Lines changed: 74 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,5 @@ serde_json = "1"
1717
anyhow = "1"
1818
chrono = { version = "0.4", features = ["serde"] }
1919
uuid = { version = "1", features = ["v4", "serde"] }
20+
zip = { version = "2", default-features = false, features = ["deflate"] }
21+
sha2 = "0.10"

crates/hk-core/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ notify = "7"
2424
tempfile = "3"
2525
semver = "1"
2626
fs2 = "0.4"
27+
zip = { workspace = true }
28+
sha2 = { workspace = true }
29+
30+
[dev-dependencies]
31+
serial_test = "3"
2732

2833
# When building for musl, compile OpenSSL from source instead of linking
2934
# to system libssl (which doesn't exist in musl environments).

crates/hk-core/src/adapter/codex.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ impl AgentAdapter for CodexAdapter {
178178
vec![".codex/agents/*.toml".into()]
179179
}
180180

181+
fn project_mcp_config_relpath(&self) -> Option<String> {
182+
// Project-level Codex MCP servers live in the same project config
183+
// file as everything else: `<repo>/.codex/config.toml`, under
184+
// `[mcp_servers.<name>]` sections (same shape as the global file at
185+
// `~/.codex/config.toml`). Source:
186+
// https://developers.openai.com/codex/mcp
187+
Some(".codex/config.toml".into())
188+
}
189+
181190
fn project_hook_config_relpath(&self) -> Option<String> {
182191
// Project-level Codex hooks live at `<repo>/.codex/hooks.json`. Codex
183192
// also supports inline `[hooks]` tables in `<repo>/.codex/config.toml`

crates/hk-core/src/adapter/copilot.rs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,13 @@ impl AgentAdapter for CopilotAdapter {
125125
// Source: https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
126126
vec![".github/skills".into()]
127127
}
128+
fn project_skill_read_dirs(&self) -> Vec<String> {
129+
// Read-only aliases — Copilot picks these up if present, but HK writes
130+
// to the canonical `.github/skills`. Surfaces in shared-dir warnings
131+
// when a sibling agent (Claude, Codex/Antigravity) installs to one of
132+
// these and the user later removes from that agent.
133+
vec![".claude/skills".into(), ".agents/skills".into()]
134+
}
128135
fn mcp_config_path(&self) -> PathBuf {
129136
self.vscode_user_dir().join("mcp.json")
130137
}
@@ -188,11 +195,20 @@ impl AgentAdapter for CopilotAdapter {
188195

189196
fn project_settings_patterns(&self) -> Vec<String> {
190197
vec![
191-
"copilot/mcp-config.json".into(),
198+
// https://code.visualstudio.com/docs/copilot/customization/mcp-servers
199+
".vscode/mcp.json".into(),
192200
".github/hooks/*.json".into(),
193201
]
194202
}
195203

204+
fn project_mcp_config_relpath(&self) -> Option<String> {
205+
// GitHub Copilot in VS Code reads workspace-local MCP servers from
206+
// `.vscode/mcp.json`. JSON, top-level `servers` key (same shape as
207+
// the user-level file at `<VS Code profile>/mcp.json`). Source:
208+
// https://code.visualstudio.com/docs/copilot/customization/mcp-servers
209+
Some(".vscode/mcp.json".into())
210+
}
211+
196212
fn project_subagent_patterns(&self) -> Vec<String> {
197213
vec![".github/agents/*.agent.md".into()]
198214
}

crates/hk-core/src/adapter/gemini.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ impl AgentAdapter for GeminiAdapter {
9898
// Source: https://geminicli.com/docs/cli/skills/
9999
vec![".gemini/skills".into()]
100100
}
101+
fn project_skill_read_dirs(&self) -> Vec<String> {
102+
// Gemini reads .agents/skills too — declared separately so Kit-remove
103+
// warnings flag cross-agent fallout.
104+
vec![".agents/skills".into()]
105+
}
101106
fn mcp_config_path(&self) -> PathBuf {
102107
self.base_dir().join("settings.json")
103108
}
@@ -145,6 +150,13 @@ impl AgentAdapter for GeminiAdapter {
145150
vec![".gemini/settings.json".into()]
146151
}
147152

153+
fn project_mcp_config_relpath(&self) -> Option<String> {
154+
// Project-level Gemini MCP servers live under the `mcpServers` key in
155+
// the same project settings file, `<repo>/.gemini/settings.json`
156+
// (JSON, same shape as the global file at `~/.gemini/settings.json`).
157+
Some(".gemini/settings.json".into())
158+
}
159+
148160
fn project_subagent_patterns(&self) -> Vec<String> {
149161
vec![".gemini/agents/*.md".into()]
150162
}

crates/hk-core/src/adapter/mod.rs

Lines changed: 102 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,21 +33,30 @@ pub(crate) fn files_with_ext<'a>(
3333
.filter(move |path| path.extension().is_some_and(|e| e == ext))
3434
}
3535

36-
/// Represents an MCP server entry parsed from an agent's config
37-
#[derive(Debug, Clone)]
36+
/// Represents an MCP server entry parsed from an agent's config.
37+
///
38+
/// Serde representation is the canonical Kit blob: `{command, args, env}`.
39+
/// `name` is carried by the caller's context (asset name in the manifest);
40+
/// `enabled` is HarnessKit-internal and defaults to `true` on the install
41+
/// side (only OpenCode's source schema has a per-entry agent-native
42+
/// `enabled` — every other adapter always sets `true`).
43+
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
3844
pub struct McpServerEntry {
45+
#[serde(skip)]
3946
pub name: String,
4047
pub command: String,
48+
#[serde(default)]
4149
pub args: Vec<String>,
50+
#[serde(default)]
4251
pub env: std::collections::HashMap<String, String>,
43-
/// Whether the agent itself considers this entry active. Currently only
44-
/// OpenCode's schema has a per-entry `"enabled"` boolean; every other
45-
/// adapter always sets this to `true` because their formats have no
46-
/// agent-native disable concept. HarnessKit's own user-toggled disable
47-
/// flow tracks state separately in SQLite — this field is orthogonal.
52+
#[serde(skip, default = "default_enabled")]
4853
pub enabled: bool,
4954
}
5055

56+
fn default_enabled() -> bool {
57+
true
58+
}
59+
5160
/// Represents a hook entry parsed from an agent's config
5261
#[derive(Debug, Clone)]
5362
pub struct HookEntry {
@@ -210,6 +219,19 @@ pub trait AgentAdapter: Send + Sync {
210219
vec![]
211220
}
212221

222+
/// Canonical relative path used when writing a project-level Rules file
223+
/// from a Kit. Default: the unique non-glob, non-trailing-slash entry
224+
/// from `project_rules_patterns()`, else `None`. Adapters with multiple
225+
/// legitimate paths should override.
226+
fn project_rules_target_relpath(&self) -> Option<String> {
227+
pick_unique_concrete(self.project_rules_patterns())
228+
}
229+
230+
/// Same as `project_rules_target_relpath` but for Memory.
231+
fn project_memory_target_relpath(&self) -> Option<String> {
232+
pick_unique_concrete(self.project_memory_patterns())
233+
}
234+
213235
/// Relative paths/globs for settings within a project dir
214236
fn project_settings_patterns(&self) -> Vec<String> {
215237
vec![]
@@ -259,6 +281,16 @@ pub trait AgentAdapter: Send + Sync {
259281
vec![]
260282
}
261283

284+
/// Additional skill-dir aliases the agent READS from but doesn't write to
285+
/// — e.g. Copilot canonical is `.github/skills` but it also picks up
286+
/// `.claude/skills` and `.agents/skills` if present. Declaring these lets
287+
/// callers (e.g. the Kit-remove shared-dir warning) know that a sibling
288+
/// agent's install at one of these paths is visible to this agent too.
289+
/// Returning `vec![]` (the default) means "only the canonical dir".
290+
fn project_skill_read_dirs(&self) -> Vec<String> {
291+
vec![]
292+
}
293+
262294
/// Relative path of the project-level MCP config file (e.g. `.mcp.json`).
263295
fn project_mcp_config_relpath(&self) -> Option<String> {
264296
None
@@ -315,6 +347,17 @@ pub trait AgentAdapter: Send + Sync {
315347
}
316348
}
317349

350+
fn pick_unique_concrete(patterns: Vec<String>) -> Option<String> {
351+
let mut concrete = patterns
352+
.into_iter()
353+
.filter(|p| !p.contains('*') && !p.ends_with('/'));
354+
let first = concrete.next()?;
355+
if concrete.next().is_some() {
356+
return None;
357+
}
358+
Some(first)
359+
}
360+
318361
/// Returns all agent adapters in canonical display order.
319362
/// Must match AGENT_ORDER in src/lib/types.ts.
320363
pub fn all_adapters() -> Vec<Box<dyn AgentAdapter>> {
@@ -469,4 +512,56 @@ mod tests {
469512
assert_eq!(&actual, want, "{} project skill path mismatch", a.name());
470513
}
471514
}
515+
516+
#[test]
517+
fn project_rules_target_relpath_default_returns_single_non_glob() {
518+
let adapters = crate::adapter::all_adapters();
519+
for a in &adapters {
520+
let patterns = a.project_rules_patterns();
521+
let non_glob: Vec<&String> = patterns
522+
.iter()
523+
.filter(|p| !p.contains('*') && !p.ends_with('/'))
524+
.collect();
525+
match non_glob.as_slice() {
526+
[only] => {
527+
assert_eq!(
528+
a.project_rules_target_relpath().as_deref(),
529+
Some(only.as_str()),
530+
"{}: target relpath default should pick the unique non-glob",
531+
a.name()
532+
);
533+
}
534+
_ => {
535+
// Adapters with 0 or many candidates may legitimately return None
536+
// unless they override; just verify the contract holds.
537+
let _ = a.project_rules_target_relpath();
538+
}
539+
}
540+
}
541+
}
542+
543+
#[test]
544+
fn project_memory_target_relpath_default_returns_single_non_glob() {
545+
let adapters = crate::adapter::all_adapters();
546+
for a in &adapters {
547+
let patterns = a.project_memory_patterns();
548+
let non_glob: Vec<&String> = patterns
549+
.iter()
550+
.filter(|p| !p.contains('*') && !p.ends_with('/'))
551+
.collect();
552+
match non_glob.as_slice() {
553+
[only] => {
554+
assert_eq!(
555+
a.project_memory_target_relpath().as_deref(),
556+
Some(only.as_str()),
557+
"{}: target relpath default should pick the unique non-glob",
558+
a.name()
559+
);
560+
}
561+
_ => {
562+
let _ = a.project_memory_target_relpath();
563+
}
564+
}
565+
}
566+
}
472567
}

0 commit comments

Comments
 (0)