Skip to content

Comments

fix: match installed skills by source repo, not just name#205

Open
mwmdev wants to merge 2 commits intospacedriveapp:mainfrom
mwmdev:fix/skill-installed-status
Open

fix: match installed skills by source repo, not just name#205
mwmdev wants to merge 2 commits intospacedriveapp:mainfrom
mwmdev:fix/skill-installed-status

Conversation

@mwmdev
Copy link

@mwmdev mwmdev commented Feb 24, 2026

Summary

  • Fixes Bug: After installing a skill, ALL skills with that name get marked as "installed" #190: after installing a skill from the registry, all skills with the same name were marked as installed regardless of source repository
  • Stores source_repo (e.g. anthropics/skills) in SKILL.md frontmatter during GitHub install
  • Surfaces source_repo through the API and uses source_repo/name as a composite key for installed-status checks in the frontend
  • Falls back to name-only matching for skills installed before this fix

Test plan

  • cargo test --lib skills — all 14 tests pass (including 5 new tests for inject_source_repo)
  • Install a skill from the registry, verify SKILL.md gets source_repo in frontmatter
  • In the UI, search for a skill name with multiple results — only the installed one shows the checkmark

Note

Changes overview: Adds source_repo tracking throughout the skill system. Backend now captures GitHub org/repo during installation via inject_source_repo() helper that safely modifies SKILL.md frontmatter (handles missing/malformed frontmatter gracefully). Frontend UI updated to build composite keys (source_repo/name) for installed-skill lookups, with fallback to name-only matching for backward compatibility. API surfaces the new optional source_repo field. All types (Skill, SkillInfo, SkillSet) updated to carry this metadata.

Written by Tembo for commit 826f255. This will update automatically on new commits.

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 826f255 and edc37b4.

📒 Files selected for processing (1)
  • interface/src/api/client.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • interface/src/api/client.ts

Walkthrough

Adds optional source_repo to skill data across backend, API, installer, and frontend; installer injects source_repo into SKILL.md during GitHub installs; frontend uses composite keys (source_repo/name) to determine installed status, preventing name-collision false positives.

Changes

Cohort / File(s) Summary
API Interface
interface/src/api/client.ts
Added optional source_repo?: string to public SkillInfo shape.
Frontend: installed detection
interface/src/routes/AgentSkills.tsx
Builds installedKeys using source_repo/name when available; checks registry skills against compositeKey or name-only key instead of name-only match.
Backend: API layer
src/api/skills.rs
Added source_repo: Option<String> to SkillInfo and populate it from internal skill data when listing skills.
Core skill model
src/skills.rs
Added source_repo: Option<String> to Skill and SkillInfo; parse frontmatter for source_repo; propagate through SkillSet::list and tests.
Installer & provenance
src/skills/installer.rs
Threaded source_repo into extract_and_install; compute owner/repo for GitHub installs; inject or update source_repo in SKILL.md frontmatter via new inject_source_repo (with tests); error writing frontmatter logged as warnings.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: fixing installed skill matching to use source repo in addition to name, which directly addresses the root cause of the linked issue.
Description check ✅ Passed The description provides clear context about the fix for issue #190, explains the implementation approach across frontend and backend, and includes test plan details.
Linked Issues check ✅ Passed The PR fully addresses issue #190 by implementing source_repo tracking throughout the skill system (backend storage via inject_source_repo, API exposure, frontend composite key matching with fallback).
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing installed skill matching by source repo. No unrelated modifications detected in the file summaries.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.


// Extract and install
let installed = extract_and_install(&zip_path, target_dir, skill_path.as_deref()).await?;
let source_repo = format!("{owner}/{repo}");
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since source_repo ends up embedded into YAML frontmatter, it might be worth ensuring it can’t contain newlines (e.g. if spec is weird) so we don’t accidentally corrupt SKILL.md.

Suggested change
let source_repo = format!("{owner}/{repo}");
let mut source_repo = format!("{owner}/{repo}");
source_repo.retain(|ch| ch != '\n' && ch != '\r');

}

let after_opening = &trimmed[3..];
if let Some(end_pos) = after_opening.find("\n---") {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

find("\n---") can match a literal --- line inside a multiline YAML value. Matching \n---\n first (and falling back) makes the split a bit safer.

Suggested change
if let Some(end_pos) = after_opening.find("\n---") {
let after_opening = &trimmed[3..];
let Some((end_pos, delimiter_len)) = after_opening
.find("\n---\n")
.map(|pos| (pos, 5))
.or_else(|| after_opening.find("\n---").map(|pos| (pos, 4)))
else {
// Malformed frontmatter, prepend
return format!("---\n{line}\n---\n{content}");
};
let fm_block = &after_opening[..end_pos];
let body = &after_opening[end_pos + delimiter_len..];

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/skills/installer.rs (1)

36-41: ⚠️ Potential issue | 🟠 Major

No timeout on the GitHub archive download.

reqwest::Client::new() uses no timeout; a stalled or slow response from GitHub will hang the install task indefinitely. The registry proxy in src/api/skills.rs already uses .timeout(Duration::from_secs(10)) as a precedent.

🔧 Proposed fix
-    let client = reqwest::Client::new();
-    let response = client
-        .get(&download_url)
-        .send()
+    let client = reqwest::Client::builder()
+        .timeout(std::time::Duration::from_secs(60))
+        .build()
+        .context("failed to build HTTP client")?;
+    let response = client
+        .get(&download_url)
+        .send()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/skills/installer.rs` around lines 36 - 41, The download currently uses
reqwest::Client::new() with no timeout which can hang; replace the client
creation with a timed client (use
reqwest::Client::builder().timeout(Duration::from_secs(10)).build()) and use
that client for the existing client.get(&download_url).send().await call, adding
a std::time::Duration import if missing so the GitHub archive download in
installer.rs times out like the registry proxy does.
🧹 Nitpick comments (2)
interface/src/routes/AgentSkills.tsx (2)

234-240: Core composite-key logic is correct; document the known limitation of the name-only fallback.

The key shapes match correctly:

  • New installs: installedKeys entry = "${source_repo}/${name}" (e.g. "anthropics/skills/weather"); compositeKey = "${skill.source}/${skill.name}" → same shape → correct match.
  • Different source, same name: keys differ → no false positive → fixes #190 ✓.
  • Legacy installs (no source_repo): installedKeys entry = "weather" → the fallback installedKeys.has(skill.name.toLowerCase()) marks all registry skills with that name as installed, which is the original bug behaviour.

This is intentional per the PR description, but it is worth a brief inline comment so future contributors understand why the fallback exists and the trade-off it carries:

📝 Suggested documentation comment
+	// installedKeys uses source_repo/name when available (new installs) for
+	// per-repo precision. Skills installed before this change have no source_repo
+	// and fall back to name-only matching, which re-exhibits the original false-
+	// positive behaviour until those skills are re-installed.
 	const installedKeys = new Set(
 		installedSkills.map((s) =>
 			s.source_repo
 				? `${s.source_repo}/${s.name}`.toLowerCase()
 				: s.name.toLowerCase(),
 		),
 	);

Also applies to: 371-374

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/routes/AgentSkills.tsx` around lines 234 - 240, Add a brief
inline comment next to the installedKeys construction and the fallback
membership check explaining that legacy installs (installedSkills entries
without source_repo) are stored as name-only (e.g., "weather"), and the fallback
installedKeys.has(skill.name.toLowerCase()) will therefore mark any registry
skill with the same name as installed; state this is an intentional trade-off to
preserve legacy behavior and reference the compositeKey
(`${skill.source}/${skill.name}`) versus name-only key formats so future
contributors understand the limitation and why the fallback exists.

506-517: key={skill.name} on InstalledSkill is safe today but fragile.

SkillSet already enforces one-skill-per-lowercase-name, so duplicate keys cannot occur now. However, if that invariant ever relaxes (e.g. a future multi-source install mode), React will silently render only the last matching child.

Consider key={skill.source_repo ? \${skill.source_repo}/${skill.name}` : skill.name}` to make the key stable and unique under any future extension.

✏️ Proposed change
-								key={skill.name}
+								key={skill.source_repo ? `${skill.source_repo}/${skill.name}` : skill.name}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@interface/src/routes/AgentSkills.tsx` around lines 506 - 517, The current
InstalledSkill list uses key={skill.name} which relies on a fragile uniqueness
invariant; change the key generation in the installedSkills.map to a stable
unique composite (e.g., include skill.source_repo when present) so keys remain
unique if multiple skills share a name. Update the InstalledSkill mapping to
compute the key as something like `${skill.source_repo}/${skill.name}` when
skill.source_repo exists, otherwise fallback to skill.name, ensuring
InstalledSkill receives that composite key and preventing React child key
collisions in future multi-source installs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/skills/installer.rs`:
- Around line 184-198: The read of SKILL.md currently swallows errors with `if
let Ok(content) = fs::read_to_string(&skill_md).await`, so add error
handling/logging for the read path: replace the `if let Ok(...)` with a match or
`if let Err(e)` branch so that when `fs::read_to_string(&skill_md).await` fails
you emit a `tracing::warn!` (including `skill = %skill_name`, `%e`, and a clear
message like "failed to read SKILL.md") before returning/continuing; keep the
existing logic that calls `inject_source_repo(&content, repo)` and logs write
failures for `fs::write(&skill_md, patched).await`.

---

Outside diff comments:
In `@src/skills/installer.rs`:
- Around line 36-41: The download currently uses reqwest::Client::new() with no
timeout which can hang; replace the client creation with a timed client (use
reqwest::Client::builder().timeout(Duration::from_secs(10)).build()) and use
that client for the existing client.get(&download_url).send().await call, adding
a std::time::Duration import if missing so the GitHub archive download in
installer.rs times out like the registry proxy does.

---

Nitpick comments:
In `@interface/src/routes/AgentSkills.tsx`:
- Around line 234-240: Add a brief inline comment next to the installedKeys
construction and the fallback membership check explaining that legacy installs
(installedSkills entries without source_repo) are stored as name-only (e.g.,
"weather"), and the fallback installedKeys.has(skill.name.toLowerCase()) will
therefore mark any registry skill with the same name as installed; state this is
an intentional trade-off to preserve legacy behavior and reference the
compositeKey (`${skill.source}/${skill.name}`) versus name-only key formats so
future contributors understand the limitation and why the fallback exists.
- Around line 506-517: The current InstalledSkill list uses key={skill.name}
which relies on a fragile uniqueness invariant; change the key generation in the
installedSkills.map to a stable unique composite (e.g., include
skill.source_repo when present) so keys remain unique if multiple skills share a
name. Update the InstalledSkill mapping to compute the key as something like
`${skill.source_repo}/${skill.name}` when skill.source_repo exists, otherwise
fallback to skill.name, ensuring InstalledSkill receives that composite key and
preventing React child key collisions in future multi-source installs.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between af095f3 and 826f255.

📒 Files selected for processing (5)
  • interface/src/api/client.ts
  • interface/src/routes/AgentSkills.tsx
  • src/api/skills.rs
  • src/skills.rs
  • src/skills/installer.rs

Comment on lines +184 to +198
if let Some(repo) = source_repo {
let skill_md = target_skill_dir.join("SKILL.md");
if skill_md.exists() {
if let Ok(content) = fs::read_to_string(&skill_md).await {
let patched = inject_source_repo(&content, repo);
if let Err(error) = fs::write(&skill_md, patched).await {
tracing::warn!(
skill = %skill_name,
%error,
"failed to write source_repo to SKILL.md"
);
}
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Silent discard of read_to_string error violates the no-silent-error guideline.

The if let Ok(content) = ... silently swallows any IO error when reading SKILL.md, while the subsequent write error is properly logged. Both paths should emit a warning.

🛡️ Proposed fix
-                if let Ok(content) = fs::read_to_string(&skill_md).await {
-                    let patched = inject_source_repo(&content, repo);
-                    if let Err(error) = fs::write(&skill_md, patched).await {
-                        tracing::warn!(
-                            skill = %skill_name,
-                            %error,
-                            "failed to write source_repo to SKILL.md"
-                        );
-                    }
-                }
+                match fs::read_to_string(&skill_md).await {
+                    Ok(content) => {
+                        let patched = inject_source_repo(&content, repo);
+                        if let Err(error) = fs::write(&skill_md, patched).await {
+                            tracing::warn!(
+                                skill = %skill_name,
+                                %error,
+                                "failed to write source_repo to SKILL.md"
+                            );
+                        }
+                    }
+                    Err(error) => {
+                        tracing::warn!(
+                            skill = %skill_name,
+                            %error,
+                            "failed to read SKILL.md for source_repo injection"
+                        );
+                    }
+                }

As per coding guidelines: "Don't silently discard errors; no let _ on Results. Handle, log, or propagate errors."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/skills/installer.rs` around lines 184 - 198, The read of SKILL.md
currently swallows errors with `if let Ok(content) =
fs::read_to_string(&skill_md).await`, so add error handling/logging for the read
path: replace the `if let Ok(...)` with a match or `if let Err(e)` branch so
that when `fs::read_to_string(&skill_md).await` fails you emit a
`tracing::warn!` (including `skill = %skill_name`, `%e`, and a clear message
like "failed to read SKILL.md") before returning/continuing; keep the existing
logic that calls `inject_source_repo(&content, repo)` and logs write failures
for `fs::write(&skill_md, patched).await`.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: After installing a skill, ALL skills with that name get marked as "installed"

2 participants