Skills catalog shows Install for skills that are already installed
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 2/5
- Tempo stimato
- 1-3 ore
- Idoneità per principianti
- 76/100
- Tipo di issue
- Bug
- Chiarezza
- Specificata chiaramente
- Stato di attività
- Attiva
- Stack tecnologico
- electron, react, typescript
- Ambito
- developer-experience, frontend
Direzione di ricerca
Inizia da src/renderer/src/components/SkillsTab.tsx e dalle skill locali esistenti restituite da listLocalSkills; verifica come le righe del catalogo scelgono il pulsante di installazione e come installSkill ricava la cartella di destinazione in src/main/skills.ts. Aggiungi la corrispondenza con i nomi delle cartelle installate e i nomi dichiarati usando il file proposto src/shared/skillCatalogMatch.ts, poi esegui test/skill-catalog-match.test.cjs. Il lavoro è completo quando le skill del catalogo già installate mostrano il pulsante Installed disabilitato e le skill non correlate restano installabili.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
What happens
In Skills → Browse, every catalog skill shows Install, even when the same skill is already installed and listed under Installed. Clicking it on an installed skill fails with Already installed at ~/.claude/skills/<name>. On my machine 20 of the 252 catalog skills are already installed this way (for example docx, pdf, mcp-builder, frontend-design, color-expert, humanizer); several were installed with the Vercel Skills CLI as symlinks into ~/.claude/skills.
Seen in the 0.5.7 macOS build; the same logic is in main (4bd42ed).
Why
The catalog button only reflects clicks made in the current session (action[s.url]?.done in src/renderer/src/components/SkillsTab.tsx). Nothing compares a catalog row with the skills listLocalSkills already returns, although installSkill in src/main/skills.ts already knows which folder a row would install into (safeSkillDirName of the tree-URL path, or the catalog name for a repo-root URL) and refuses when it exists.
Proposed fix
src/shared/skillCatalogMatch.ts(new, dependency-free): an installed skill is known by its folder name and its frontmattername; a catalog row by the folderinstallSkillwould create. Case-insensitive, matching the dedupe inlistLocalSkills.SkillsTab.tsx: compute the installed keys fromlocalwithuseMemo; a matching row renders the existing quiet, disabled button with the existingskillsTab.installedlabel. No new strings or tokens. Rows with an in-session action keep today's behavior.test/skill-catalog-match.test.cjs: 5node:testcases, written liketest/agent-provider.test.cjs, covering tree and repo-root URLs, case-insensitive and declared-name matches, and a same-named skill from another folder that must stay installable.
Tested: the new test passes (5/5) and the new module passes tsc --strict. Run against the live catalog and my installed skills, it marks exactly the 20 installed rows and nothing else. I could not run the full npm run typecheck or produce the before/after screenshots the PR evidence check requires, so I'm filing this as an issue with the patch rather than a PR. Happy to turn it into a PR if that helps.
Patch (git diff against 4bd42ed)
diff --git a/src/renderer/src/components/SkillsTab.tsx b/src/renderer/src/components/SkillsTab.tsx
index 606ea8f..6cc9134 100644
--- a/src/renderer/src/components/SkillsTab.tsx
+++ b/src/renderer/src/components/SkillsTab.tsx
@@ -13,6 +13,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PixelButton } from './PixelButton';
import type { LocalSkill, CatalogSkill } from '../../../preload';
+import { installedSkillKeys, isCatalogSkillInstalled } from '../../../shared/skillCatalogMatch';
type Mode = 'installed' | 'browse';
@@ -115,6 +116,10 @@ export function SkillsTab({ agentCwd }: { agentCwd?: string }) {
s.name.toLowerCase().includes(q) || s.description.toLowerCase().includes(q)).length;
}, [catalog, owner, category, q]);
+ // Catalog rows already present on disk say so, instead of offering an Install
+ // that installSkill would refuse with "Already installed at …".
+ const installedKeys = useMemo(() => installedSkillKeys(local ?? []), [local]);
+
const install = async (c: CatalogSkill) => {
setAction((a) => ({ ...a, [c.url]: { busy: true } }));
try {
@@ -320,13 +325,20 @@ export function SkillsTab({ agentCwd }: { agentCwd?: string }) {
{s.description}
</div>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
- <button
- onClick={() => void install(s)}
- disabled={!!action[s.url]?.busy || !!action[s.url]?.done}
- style={actionBtn(action[s.url]?.done ? 'quiet' : 'primary')}
- >
- {action[s.url]?.busy ? t('skillsTab.installing') : action[s.url]?.done ?? t('skillsTab.install')}
- </button>
+ {(() => {
+ const installed = !action[s.url] && isCatalogSkillInstalled(s, installedKeys);
+ return (
+ <button
+ onClick={() => void install(s)}
+ disabled={installed || !!action[s.url]?.busy || !!action[s.url]?.done}
+ style={actionBtn(installed || action[s.url]?.done ? 'quiet' : 'primary')}
+ >
+ {installed
+ ? t('skillsTab.installed')
+ : action[s.url]?.busy ? t('skillsTab.installing') : action[s.url]?.done ?? t('skillsTab.install')}
+ </button>
+ );
+ })()}
<button
onClick={() => void window.cth.openExternal(s.url)}
style={actionBtn('quiet')}
diff --git a/src/shared/skillCatalogMatch.ts b/src/shared/skillCatalogMatch.ts
new file mode 100644
index 0000000..48854c7
--- /dev/null
+++ b/src/shared/skillCatalogMatch.ts
@@ -0,0 +1,51 @@
+/**
+ * Whether a catalog skill is already installed, so the Skills tab can say
+ * "Installed" instead of offering an Install that would only refuse with
+ * "Already installed at …".
+ *
+ * installSkill names the folder after the last segment of a GitHub tree URL
+ * (…/skills/docx → docx) or, for a repo-root URL, after the catalog name. An
+ * installed skill is known by its folder name and by the `name` in its SKILL.md
+ * frontmatter, so a skill installed some other way still counts.
+ * Matching is case-insensitive, like the dedupe in listLocalSkills.
+ */
+
+export interface InstalledSkillRef {
+ name: string;
+ path: string;
+}
+
+export interface CatalogSkillRef {
+ name: string;
+ url: string;
+}
+
+function lastSegment(p: string): string {
+ return p.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? '';
+}
+
+/** Keys an installed skill answers to: its folder name and its declared name. */
+export function installedSkillKeys(local: readonly InstalledSkillRef[]): Set<string> {
+ const keys = new Set<string>();
+ for (const s of local) {
+ for (const k of [lastSegment(s.path), s.name]) {
+ const key = k.trim().toLowerCase();
+ if (key) keys.add(key);
+ }
+ }
+ return keys;
+}
+
+/** The folder a catalog row installs into: a tree URL's last segment, otherwise
+ * its name — the same choice installSkill makes. */
+export function catalogSkillKey(c: CatalogSkillRef): string {
+ const tree = /^https:\/\/github\.com\/[^/]+\/[^/]+\/tree\/[^/]+\/(.+)$/.exec(
+ c.url.trim().replace(/[#?].*$/, '').replace(/\/+$/, '')
+ );
+ return (tree ? lastSegment(tree[1]) : c.name).trim().toLowerCase();
+}
+
+export function isCatalogSkillInstalled(c: CatalogSkillRef, installed: ReadonlySet<string>): boolean {
+ const key = catalogSkillKey(c);
+ return key !== '' && installed.has(key);
+}
diff --git a/test/skill-catalog-match.test.cjs b/test/skill-catalog-match.test.cjs
new file mode 100644
index 0000000..f1a781c
--- /dev/null
+++ b/test/skill-catalog-match.test.cjs
@@ -0,0 +1,57 @@
+'use strict';
+/**
+ * Catalog "already installed" matching (src/shared/skillCatalogMatch.ts).
+ * Self-contained like test/agent-provider.test.cjs: transpile the TypeScript
+ * module with the bundled compiler into a temp dir and require it.
+ */
+
+const assert = require('node:assert');
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const test = require('node:test');
+const ts = require('typescript');
+
+const src = fs.readFileSync(path.join(__dirname, '..', 'src', 'shared', 'skillCatalogMatch.ts'), 'utf8');
+const out = fs.mkdtempSync(path.join(os.tmpdir(), 'skillmatch-'));
+fs.writeFileSync(
+ path.join(out, 'skillCatalogMatch.js'),
+ ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 } }).outputText
+);
+const { installedSkillKeys, catalogSkillKey, isCatalogSkillInstalled } = require(path.join(out, 'skillCatalogMatch.js'));
+
+const local = [
+ { name: 'docx', path: '/Users/me/.claude/skills/docx' },
+ // A symlinked shared skill: folder and declared name agree.
+ { name: 'mcp-builder', path: '/Users/me/.claude/skills/mcp-builder' },
+ // Declared name differs from its folder.
+ { name: 'Color Expert', path: '/Users/me/.claude/skills/color-expert/' }
+];
+const keys = installedSkillKeys(local);
+
+test('a tree URL installs under its last path segment', () => {
+ assert.strictEqual(catalogSkillKey({ name: 'MCP Server', url: 'https://github.com/anthropics/skills/tree/main/skills/mcp-builder' }), 'mcp-builder');
+ assert.strictEqual(catalogSkillKey({ name: 'docx', url: 'https://github.com/anthropics/skills/tree/main/skills/docx/' }), 'docx');
+});
+
+test('a repo-root URL installs under the catalog name', () => {
+ assert.strictEqual(catalogSkillKey({ name: 'color-expert', url: 'https://github.com/meodai/skill.color-expert' }), 'color-expert');
+});
+
+test('installed skills match by folder name or declared name, case-insensitively', () => {
+ assert.ok(isCatalogSkillInstalled({ name: 'MCP Server', url: 'https://github.com/anthropics/skills/tree/main/skills/mcp-builder' }, keys));
+ assert.ok(isCatalogSkillInstalled({ name: 'DOCX', url: 'https://github.com/anthropics/skills/tree/main/skills/DOCX' }, keys));
+ assert.ok(isCatalogSkillInstalled({ name: 'color-expert', url: 'https://github.com/meodai/skill.color-expert' }, keys));
+ assert.ok(isCatalogSkillInstalled({ name: 'color expert', url: 'https://github.com/someone/color' }, keys));
+});
+
+test('a tree URL is matched by its folder, not its display name', () => {
+ // Same display name as an installed skill, but it would install into a different folder.
+ assert.ok(!isCatalogSkillInstalled({ name: 'docx', url: 'https://github.com/other/repo/tree/main/word-tools' }, keys));
+});
+
+test('skills that are not installed stay installable', () => {
+ assert.ok(!isCatalogSkillInstalled({ name: 'xlsx', url: 'https://github.com/anthropics/skills/tree/main/skills/xlsx' }, keys));
+ assert.ok(!isCatalogSkillInstalled({ name: '', url: 'https://github.com/a/b' }, keys));
+ assert.strictEqual(installedSkillKeys([]).size, 0);
+});
🤖 Generated with Claude Code
- Lingua principale
- TypeScript
- Stelle
- 8.5k
- Fork
- 1.2k
- Merge medio
- 12h 44m
- PR unite (30g)
- 65
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Ha un modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di HarnessMD/munder-difflin
-
Monitor dispatch input is too small to send messagesForse già presa @rajpreetcodes l’ha presa 17 giorni fa. Apertabug os:macos
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
HarnessMD/munder-difflin#720 ·
I maintainer di solito rispondono entro 1 giorno
-
JetBrains Junie CLIApertaenhancement
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 85/100
HarnessMD/munder-difflin#510 ·
I maintainer di solito rispondono entro 1 giorno
-
Ask Me counter counts down as questions are answered (5/5 to 1/5), which reads as questions disappearingForse di nuovo libera @xiechimon l’ha presa 44 giorni fa e non c’è nessuna pull request aperta. Apertabug good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
HarnessMD/munder-difflin#267 · 2 commenti ·
I maintainer di solito rispondono entro 1 giorno
-
bug os:windows
Difficoltà 4/5 3-5 giorni Idoneità per principianti 42/100
HarnessMD/munder-difflin#800 ·
I maintainer di solito rispondono entro 1 giorno
-
Improved Task ManagamentApertaenhancement
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
HarnessMD/munder-difflin#798 ·
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di HarnessMD/munder-difflin
Issue simili
-
area:desktop area:ui bug platform:macos
Difficoltà 2/5 1-3 ore Idoneità per principianti 67/100
anthropics/claude-code#106183 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 1-3 ore Idoneità per principianti 76/100
I maintainer di solito rispondono entro 1 giorno
-
feature:App-Tour feature:Chat ready Web
Difficoltà 2/5 1-3 ore Idoneità per principianti 62/100
digitalfabrik/integreat-app#4523 · 2 reazioni ·
I maintainer di solito rispondono entro 3 giorni
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 78/100
I maintainer di solito rispondono entro 1 giorno
-
Required Lexical field accepts two empty paragraphsForse già presa @lazerg l’ha presa oggi. ApertaBug
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
payloadcms/payload#19222 ·
I maintainer di solito rispondono entro 1 giorno