Hacktoberfest 2026: le issue che i maintainer hanno segnato per ottobre, aperte e adatte ai principianti. Sfoglia le issue Hacktoberfest

Skills catalog shows Install for skills that are already installed

Aperta Adatta ai principianti
#793 0 commenti 0 reazioni 0 assegnatari Vedi su GitHub

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

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 frontmatter name; a catalog row by the folder installSkill would create. Case-insensitive, matching the dedupe in listLocalSkills.
  • SkillsTab.tsx: compute the installed keys from local with useMemo; a matching row renders the existing quiet, disabled button with the existing skillsTab.installed label. No new strings or tokens. Rows with an in-session action keep today's behavior.
  • test/skill-catalog-match.test.cjs: 5 node:test cases, written like test/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

Come iniziare

  1. Leggi tutta la issue e poi la guida ai contributi del progetto.
  2. Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
  3. Fai un fork del repository e lavora su un branch.
  4. Apri una pull request che faccia riferimento al numero della issue.

Altre issue di HarnessMD/munder-difflin

Tutte le issue di HarnessMD/munder-difflin

Issue simili

Altre issue su TypeScript

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.