Skip to content

Commit b8e7e64

Browse files
author
Haider
committed
fix(tui): address OpenCodeReview findings on skills-install classifier
Two medium findings from the automated review pass on this PR: 1. `installSkillDirect` used the raw `normalized` input when building a clone URL, but `classifyInstallSource` tolerates a trailing `.git` suffix / whitespace / trailing dots. An input like `owner/repo.git` passed the classifier's `owner-repo` check and then produced `https://github.com/owner/repo.git.git`. Extracted the trim/strip logic into `normalizeInstallSource` and use it in both the classifier and the URL builder so the two can't disagree on shape. 2. The `options` `createMemo` read the `filter()` signal, so every keystroke re-executed the full `list.map` — including `detectToolReferences` (regex parse per skill). Split into a `baseOptions` memo that depends only on `skills()` and a derived `options` memo that composes the synthetic Install row on top. Now a filter change only re-checks `classifyInstallSource(q)` and prepends one item; `detectToolReferences` only re-runs when the underlying skills list changes. Also adds a `normalizeInstallSource` unit-test suite (4 cases pinning the `.git` / trailing-dot / whitespace behaviour) alongside the existing classifier tests. Verified: `bun run typecheck` clean; classifier suite 14/14 (54 expects); fork-feature-guards 18/18 (61 expects).
1 parent 716735c commit b8e7e64

2 files changed

Lines changed: 54 additions & 5 deletions

File tree

packages/opencode/src/plugin/tui/altimate/skill-ops.tsx

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,17 @@ const id = "altimate:skill-ops"
4343
// surface the synthetic Install row when Enter will actually try to install.
4444
export type InstallSourceKind = "github-url" | "owner-repo" | "absolute-path"
4545
const OWNER_REPO_REGEX = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/
46+
47+
// Strip the surface variation the classifier tolerates — whitespace, trailing dots,
48+
// and a `.git` suffix — before comparing. Exported so callers that consume the
49+
// classified string (e.g. installSkillDirect building a clone URL) can use the exact
50+
// same shape as the classifier, avoiding double-suffix bugs like `owner/repo.git.git`.
51+
export function normalizeInstallSource(source: string): string {
52+
return source.trim().replace(/\.+$/, "").replace(/\.git$/, "")
53+
}
54+
4655
export function classifyInstallSource(source: string): InstallSourceKind | null {
47-
const trimmed = source.trim().replace(/\.+$/, "").replace(/\.git$/, "")
56+
const trimmed = normalizeInstallSource(source)
4857
if (trimmed.length < 3) return null
4958
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "github-url"
5059
if (OWNER_REPO_REGEX.test(trimmed)) return "owner-repo"
@@ -176,7 +185,12 @@ async function installSkillDirect(
176185
// reach the installer without going through the UI's install-preview.
177186
const kind = classifyInstallSource(normalized)
178187
if (kind === "github-url" || kind === "owner-repo") {
179-
const url = normalized.startsWith("http") ? normalized : `https://github.com/${normalized}.git`
188+
// Build the clone URL from the *normalized* source so a `.git` suffix or trailing
189+
// dot in the typed query doesn't produce `owner/repo.git.git` (bug reported by
190+
// OpenCodeReview): the classifier tolerates those shapes but the raw `normalized`
191+
// still carries them until stripped.
192+
const cleaned = normalizeInstallSource(normalized)
193+
const url = cleaned.startsWith("http") ? cleaned : `https://github.com/${cleaned}.git`
180194
const label = url.replace(/https?:\/\/github\.com\//, "").replace(/\.git$/, "")
181195
onProgress?.(`Cloning ${label}...`)
182196
const cache = cacheDir()
@@ -589,10 +603,14 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
589603
currentFilter = filter
590604
// altimate_change end
591605

592-
const options = createMemo<TuiDialogSelectOption<string>[]>(() => {
606+
// Base list — depends only on `skills()`. Kept separate from the `options` memo below
607+
// so that per-keystroke filter changes don't re-run `detectToolReferences` (regex parse
608+
// per skill) across the whole list. On projects with many installed skills the previous
609+
// fused memo produced noticeable typing lag.
610+
const baseOptions = createMemo<TuiDialogSelectOption<string>[]>(() => {
593611
const list = skills() ?? []
594612
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
595-
const items = list.map((skill) => {
613+
return list.map((skill) => {
596614
const tools = detectToolReferences(skill.content)
597615
const category = SKILL_CATEGORIES[skill.name] ?? "Other"
598616
const desc = skill.description?.replace(/\s+/g, " ").trim()
@@ -605,6 +623,10 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
605623
category,
606624
} satisfies TuiDialogSelectOption<string>
607625
})
626+
})
627+
628+
const options = createMemo<TuiDialogSelectOption<string>[]>(() => {
629+
const items = baseOptions()
608630
// altimate_change start — when the filter looks like a shape installSkillDirect will
609631
// accept (github URL, `owner/repo` shorthand, or absolute path), prepend a synthetic
610632
// "Install <query>" top option. Selecting it (Enter) routes to installSkillDirect.

packages/opencode/test/altimate/skill-install-classifier.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, test } from "bun:test"
2-
import { classifyInstallSource } from "@/plugin/tui/altimate/skill-ops"
2+
import { classifyInstallSource, normalizeInstallSource } from "@/plugin/tui/altimate/skill-ops"
33

44
// classifyInstallSource is the shared classifier for the DialogSkillList's "Install <query>"
55
// affordance and installSkillDirect. Keeping the two in lockstep matters: an earlier version
@@ -63,3 +63,30 @@ describe("classifyInstallSource", () => {
6363
expect(classifyInstallSource("\thttps://github.com/x/y\n")).toBe("github-url")
6464
})
6565
})
66+
67+
// normalizeInstallSource is the shared trim/strip helper that installSkillDirect uses
68+
// before building a clone URL from an `owner/repo` shorthand. Without it, an input like
69+
// `owner/repo.git` (accepted by the classifier because it strips `.git`) would produce
70+
// `https://github.com/owner/repo.git.git` — real bug flagged in review.
71+
describe("normalizeInstallSource", () => {
72+
test("strips a trailing `.git` suffix", () => {
73+
expect(normalizeInstallSource("owner/repo.git")).toBe("owner/repo")
74+
expect(normalizeInstallSource("https://github.com/x/y.git")).toBe("https://github.com/x/y")
75+
})
76+
77+
test("strips trailing dots", () => {
78+
expect(normalizeInstallSource("owner/repo.")).toBe("owner/repo")
79+
expect(normalizeInstallSource("owner/repo...")).toBe("owner/repo")
80+
})
81+
82+
test("trims surrounding whitespace", () => {
83+
expect(normalizeInstallSource(" owner/repo ")).toBe("owner/repo")
84+
expect(normalizeInstallSource("\n\towner/repo\r\n")).toBe("owner/repo")
85+
})
86+
87+
test("returns the source unchanged when nothing to strip", () => {
88+
expect(normalizeInstallSource("owner/repo")).toBe("owner/repo")
89+
expect(normalizeInstallSource("/tmp/skills")).toBe("/tmp/skills")
90+
expect(normalizeInstallSource("")).toBe("")
91+
})
92+
})

0 commit comments

Comments
 (0)