Skip to content

Commit 19e4fbf

Browse files
sahrizviHaider
andauthored
feat(tui): install skills via typed query in the /skills list (#1005)
* feat(tui): install skills via typed query in the /skills list The `/skills` list didn't offer a discoverable way to install from a GitHub repo / URL / absolute path — the only entry point was `ctrl+i`, whose wire byte (0x09) collides with Tab on default terminals, so many users couldn't trigger it. Surface a synthetic "Install <query>" row at the top of the list when the filter matches an installable shape, so pressing Enter opens the install dialog prefilled with the typed text. Backed by a shared `classifyInstallSource` classifier used by both the list preview and `installSkillDirect` — an earlier `q.includes("/")` check drifted from the installer and offered the row for skill-search queries like `dbt/snowflake` that the installer then rejected as "Path not found". - Add module-scope `classifyInstallSource` (recognises github URLs, clean `owner/repo` shorthand, POSIX absolute paths, Windows drive-letter paths — rejects short strings, sub-paths, `~`, relatives). - Route the sentinel through `onSelect` to `showInstall`, forward the captured filter text as `initialValue` on `DialogSkillInstall` and `DialogSkillCreate`. - Filter the sentinel out of `onMove` so highlighting the synthetic row doesn't set `currentSkill` to the sentinel string (would trip `ctrl+a`'s action picker into a degenerate lookup miss). - Harden `showActions` to bail on the sentinel or a lookup miss as belt-and-braces. - Build options via a spread instead of `Array.prototype.unshift` so the memo stays pure (Solid dev-mode double-eval would otherwise double-prepend). - Slim the fork-feature-guards test to a minimal presence check (sentinel, classifier symbol, sentinel-route in `onSelect`, prefill plumbing) — behavioural coverage of the classifier moves to a new `test/altimate/skill-install-classifier.test.ts` unit suite. Verified: `bun run typecheck` clean; new classifier suite 10/10; fork-feature-guards 18/18; `test/session` 731/731. * 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). * fix(tui): route owner/repo shorthand by classifier kind, not http-prefix probe Second review pass on this PR — one live-reproduced bug + two auxiliary finds: 1. `installSkillDirect` branched clone-URL construction on `cleaned.startsWith("http")`, which misfired for owners whose name literally begins with `http` (`httpie/httpie`, `http-party/http-server`, `httpwg/http-extensions`): they classify as `owner-repo` yet passed the prefix probe, so the installer tried to `git clone httpie/httpie` and failed. Fix: branch on the already- computed `kind` from `classifyInstallSource`. As a bonus, the `github-url` branch now preserves the trailing `.git` the user typed (matters for self-hosted git servers that require it), while the `owner-repo` branch continues to normalize. 2. Merge-drop guard in `fork-feature-guards.test.ts` didn't cover the memo block that actually creates the synthetic Install row — deleting the block still passed all guards (verified by mutation). Add a proximity-anchored `classifyInstallSource(q) → value: INSTALL_ACTION_VALUE` match; the 300-char bound is deliberately tight so a real deletion collapses it to zero and fails loudly. 3. Classifier comments and one test title claimed `dbt/snowflake` was rejected, but the classifier returns `owner-repo` for any clean two-segment string — intentional, since skill names can't contain a slash. Correct the docstrings and pin the actual behaviour with explicit `expect(...dbt/snowflake).toBe("owner-repo")` and `expect(...httpie/httpie).toBe("owner-repo")` assertions. Not addressed here: the pre-existing `/skills` slash-command collision between the upstream `prompt.skills` and this plugin's `altimate.skill.list`. Already on `main`, already tracked as a comment on the Jira ticket, out of scope for this PR. Verified: `bun run typecheck` clean; classifier + fork-guard suites 34/34 (100 expects); `test/session` 731/731 (1636 expects). --------- Co-authored-by: Haider <haider@altimate.ai>
1 parent 8c9a9a8 commit 19e4fbf

3 files changed

Lines changed: 279 additions & 23 deletions

File tree

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

Lines changed: 139 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,44 @@ import fs from "fs/promises"
3434

3535
const id = "altimate:skill-ops"
3636

37+
// altimate_change start — single classifier shared by installSkillDirect and the skill-list
38+
// dialog's "Install <query>" affordance. Extracted so the UI's install-preview and the
39+
// installer can't drift (an earlier `looksInstallable` that only checked `q.includes("/")`
40+
// surfaced the Install option for shapes the installer then rejected as "Path not found",
41+
// e.g. `owner/repo/subpath`). A clean two-segment `owner/repo` is intentionally treated
42+
// as GitHub shorthand — indistinguishable from a two-token search without a network call,
43+
// and skill names cannot contain a slash so no real search result is hijacked. Returns
44+
// null for shapes the installer wouldn't accept without ambiguity — three-segment paths,
45+
// relative paths, `~`, bare identifiers — so we only surface the synthetic Install row
46+
// when Enter will actually try to install.
47+
export type InstallSourceKind = "github-url" | "owner-repo" | "absolute-path"
48+
const OWNER_REPO_REGEX = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/
49+
50+
// Strip the surface variation the classifier tolerates — whitespace, trailing dots,
51+
// and a `.git` suffix — before comparing. Exported so callers that consume the
52+
// classified string (e.g. installSkillDirect building a clone URL) can use the exact
53+
// same shape as the classifier, avoiding double-suffix bugs like `owner/repo.git.git`.
54+
export function normalizeInstallSource(source: string): string {
55+
return source.trim().replace(/\.+$/, "").replace(/\.git$/, "")
56+
}
57+
58+
export function classifyInstallSource(source: string): InstallSourceKind | null {
59+
const trimmed = normalizeInstallSource(source)
60+
if (trimmed.length < 3) return null
61+
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) return "github-url"
62+
if (OWNER_REPO_REGEX.test(trimmed)) return "owner-repo"
63+
if (trimmed.startsWith("/")) return "absolute-path"
64+
if (/^[a-zA-Z]:[\\/]/.test(trimmed)) return "absolute-path"
65+
return null
66+
}
67+
68+
// Sentinel value for the synthetic top-of-list "Install <query>" option. Namespaced so it
69+
// can't collide with any skill name (skill names match `^[a-z][a-z0-9]*(-[a-z0-9]+)*$`).
70+
// Module-scope so it's stable across renders and reachable by every function that needs
71+
// to check whether an item.value is the sentinel (onSelect, onMove, showActions).
72+
const INSTALL_ACTION_VALUE = "__altimate:skill:install-from-query__"
73+
// altimate_change end
74+
3775
// Categorize skills by domain for cleaner grouping in the list.
3876
const SKILL_CATEGORIES: Record<string, string> = {
3977
"dbt-develop": "dbt",
@@ -143,12 +181,27 @@ async function installSkillDirect(
143181
normalized = `https://github.com/${ghWebMatch[1]}.git`
144182
}
145183

146-
if (
147-
normalized.startsWith("http://") ||
148-
normalized.startsWith("https://") ||
149-
normalized.match(/^[a-zA-Z0-9_-]+\/[a-zA-Z0-9._-]+$/)
150-
) {
151-
const url = normalized.startsWith("http") ? normalized : `https://github.com/${normalized}.git`
184+
// Classify the source. Anything the classifier recognises (github-url / owner-repo /
185+
// absolute-path) takes the corresponding branch. Anything it doesn't (relative path,
186+
// `~`, bare identifier) falls through to the path branch below and is resolved against
187+
// cwd — that's the historical behaviour, preserved as a fallback for callers that
188+
// reach the installer without going through the UI's install-preview.
189+
const kind = classifyInstallSource(normalized)
190+
if (kind === "github-url" || kind === "owner-repo") {
191+
// Branch on the already-computed `kind`, not on a `startsWith("http")` probe of the
192+
// normalized string. Two bugs are avoided that way:
193+
// 1. An owner whose name literally starts with `http` (e.g. `httpie/httpie`,
194+
// `http-party/http-server`) classifies as `owner-repo` but its normalized form
195+
// also starts with `http` — a raw prefix test would treat it as an "already a
196+
// URL" and try to `git clone httpie/httpie`, which fails.
197+
// 2. `normalizeInstallSource` strips a trailing `.git`; for owner-repo we want that
198+
// (so `owner/repo.git` doesn't produce `owner/repo.git.git`), but for an explicit
199+
// `github-url` we want to preserve whatever the user typed — self-hosted git
200+
// servers require the exact suffix form.
201+
const url =
202+
kind === "github-url"
203+
? normalized.trim().replace(/\.+$/, "")
204+
: `https://github.com/${normalizeInstallSource(normalized)}.git`
152205
const label = url.replace(/https?:\/\/github\.com\//, "").replace(/\.git$/, "")
153206
onProgress?.(`Cloning ${label}...`)
154207
const cache = cacheDir()
@@ -295,14 +348,15 @@ async function reloadAndVerify(api: TuiPluginApi, expectedNames: string[]): Prom
295348

296349
// ── Sub-dialogs ─────────────────────────────────────────────────────────────────────────────────
297350

298-
function DialogSkillCreate(props: { api: TuiPluginApi }) {
351+
function DialogSkillCreate(props: { api: TuiPluginApi; initialValue?: string }) {
299352
const { api } = props
300353
const theme = () => api.theme.current
301354
const [busy, setBusy] = createSignal(false)
302355
return (
303356
<api.ui.DialogPrompt
304357
title="Create Skill"
305358
placeholder="my-tool"
359+
value={props.initialValue}
306360
busy={busy()}
307361
busyText="Creating skill..."
308362
description={() => (
@@ -350,7 +404,7 @@ function DialogSkillCreate(props: { api: TuiPluginApi }) {
350404
)
351405
}
352406

353-
function DialogSkillInstall(props: { api: TuiPluginApi }) {
407+
function DialogSkillInstall(props: { api: TuiPluginApi; initialValue?: string }) {
354408
const { api } = props
355409
const theme = () => api.theme.current
356410
const [busy, setBusy] = createSignal(false)
@@ -359,6 +413,7 @@ function DialogSkillInstall(props: { api: TuiPluginApi }) {
359413
<api.ui.DialogPrompt
360414
title="Install Skill (owner/repo, URL, or path)"
361415
placeholder="anthropics/skills"
416+
value={props.initialValue}
362417
busy={busy()}
363418
busyText="Installing skill..."
364419
description={() => (
@@ -552,7 +607,18 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
552607
// Expose the lookup to the keymap-layer commands (ctrl+a/test/etc).
553608
registerLookup(api, skillMap)
554609

555-
const options = createMemo<TuiDialogSelectOption<string>[]>(() => {
610+
// altimate_change start — capture the current filter text so an install/create
611+
// action triggered from inside the list can prefill the sub-dialog with what the user typed
612+
// (e.g. a GitHub URL typed into the search box), instead of dropping it on the floor.
613+
const [filter, setFilter] = createSignal("")
614+
currentFilter = filter
615+
// altimate_change end
616+
617+
// Base list — depends only on `skills()`. Kept separate from the `options` memo below
618+
// so that per-keystroke filter changes don't re-run `detectToolReferences` (regex parse
619+
// per skill) across the whole list. On projects with many installed skills the previous
620+
// fused memo produced noticeable typing lag.
621+
const baseOptions = createMemo<TuiDialogSelectOption<string>[]>(() => {
556622
const list = skills() ?? []
557623
const maxWidth = Math.max(0, ...list.map((s) => s.name.length))
558624
return list.map((skill) => {
@@ -570,13 +636,49 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
570636
})
571637
})
572638

639+
const options = createMemo<TuiDialogSelectOption<string>[]>(() => {
640+
const items = baseOptions()
641+
// altimate_change start — when the filter looks like a shape installSkillDirect will
642+
// accept (github URL, `owner/repo` shorthand, or absolute path), prepend a synthetic
643+
// "Install <query>" top option. Selecting it (Enter) routes to installSkillDirect.
644+
// Non-mutating build (spread instead of unshift) so this stays a pure memo — Solid
645+
// dev-mode double-eval would otherwise double-prepend.
646+
// ctrl+i on the wire is byte 0x09 = Tab, so we can't offer a reliable ctrl+i binding
647+
// on default terminals; Enter on this synthetic row is the discoverable substitute.
648+
const q = filter().trim()
649+
if (classifyInstallSource(q) !== null) {
650+
const installOption: TuiDialogSelectOption<string> = {
651+
title: `Install ${q}`,
652+
description: "Press Enter to install from this GitHub repo, URL, or path",
653+
footer: undefined,
654+
value: INSTALL_ACTION_VALUE,
655+
category: "Install",
656+
}
657+
return [installOption, ...items]
658+
}
659+
// altimate_change end
660+
return items
661+
})
662+
573663
return (
574664
<api.ui.DialogSelect
575-
title="Skills (ctrl+a actions · ctrl+n new · ctrl+i install)"
576-
placeholder="Search skills..."
665+
title="Skills"
666+
placeholder="Search skills, or type a repo/URL and press Enter to install..."
577667
options={options()}
578-
onMove={(item) => props.onCurrent(item.value)}
668+
onFilter={(q) => setFilter(q)}
669+
// altimate_change start — filter the sentinel out of onCurrent so highlighting
670+
// the synthetic Install row doesn't set `currentSkill` to the sentinel string
671+
// (which would trip ctrl+a's showActions into opening a degenerate action picker
672+
// on a non-existent skill named INSTALL_ACTION_VALUE).
673+
onMove={(item) => props.onCurrent(item.value === INSTALL_ACTION_VALUE ? undefined : item.value)}
674+
// altimate_change end
579675
onSelect={(item) => {
676+
// altimate_change start — synthetic install option routes to installer.
677+
if (item.value === INSTALL_ACTION_VALUE) {
678+
showInstall(api, filter().trim() || undefined)
679+
return
680+
}
681+
// altimate_change end
580682
props.onCurrent(item.value)
581683
// Selecting a skill opens its action picker (the pre-merge default action was the picker).
582684
openActionPicker(api, skillMap().get(item.value), item.value, () => showList(api))
@@ -589,34 +691,48 @@ function DialogSkillList(props: { api: TuiPluginApi; onCurrent: (skill: string |
589691
// Both are module-level so the layer (registered once at plugin init) can reach the live values.
590692
let currentSkill: string | undefined
591693
let currentLookup: () => Map<string, SkillInfo> = () => new Map()
694+
// altimate_change start — expose the current list dialog's filter text so the outer
695+
// keymap-layer install/create commands (ctrl+i/ctrl+n) can prefill the sub-dialog with whatever
696+
// the user has typed into the search box. Reset to a no-op after the list closes so a fresh
697+
// keybind press from an unrelated context doesn't leak stale text.
698+
let currentFilter: () => string = () => ""
699+
// altimate_change end
592700

593701
function registerLookup(_api: TuiPluginApi, lookup: () => Map<string, SkillInfo>) {
594702
currentLookup = lookup
595703
}
596704

597705
function showList(api: TuiPluginApi) {
598-
api.ui.dialog.replace(() => (
599-
<DialogSkillList api={api} onCurrent={(skill) => (currentSkill = skill)} />
600-
))
706+
api.ui.dialog.replace(
707+
() => <DialogSkillList api={api} onCurrent={(skill) => (currentSkill = skill)} />,
708+
() => {
709+
currentFilter = () => ""
710+
},
711+
)
601712
}
602713

603-
function showCreate(api: TuiPluginApi) {
714+
function showCreate(api: TuiPluginApi, initialValue?: string) {
604715
api.ui.dialog.replace(
605-
() => <DialogSkillCreate api={api} />,
716+
() => <DialogSkillCreate api={api} initialValue={initialValue} />,
606717
() => setTimeout(() => showList(api), 0),
607718
)
608719
}
609720

610-
function showInstall(api: TuiPluginApi) {
721+
function showInstall(api: TuiPluginApi, initialValue?: string) {
611722
api.ui.dialog.replace(
612-
() => <DialogSkillInstall api={api} />,
723+
() => <DialogSkillInstall api={api} initialValue={initialValue} />,
613724
() => setTimeout(() => showList(api), 0),
614725
)
615726
}
616727

617728
function showActions(api: TuiPluginApi) {
618-
if (!currentSkill) return
619-
openActionPicker(api, currentLookup().get(currentSkill), currentSkill, () => showList(api))
729+
// Belt-and-braces against the synthetic Install row leaking into `currentSkill` via a
730+
// future refactor that forgets the DialogSkillList `onMove` filter — refuse to open a
731+
// per-skill action picker unless we can resolve a real skill entry from the lookup.
732+
if (!currentSkill || currentSkill === INSTALL_ACTION_VALUE) return
733+
const info = currentLookup().get(currentSkill)
734+
if (!info) return
735+
openActionPicker(api, info, currentSkill, () => showList(api))
620736
}
621737

622738
const tui: TuiPlugin = async (api) => {
@@ -649,7 +765,7 @@ const tui: TuiPlugin = async (api) => {
649765
category: "Altimate",
650766
namespace: "palette",
651767
run() {
652-
showCreate(api)
768+
showCreate(api, currentFilter().trim() || undefined)
653769
},
654770
},
655771
{
@@ -659,7 +775,7 @@ const tui: TuiPlugin = async (api) => {
659775
category: "Altimate",
660776
namespace: "palette",
661777
run() {
662-
showInstall(api)
778+
showInstall(api, currentFilter().trim() || undefined)
663779
},
664780
},
665781
],
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { classifyInstallSource, normalizeInstallSource } from "@/plugin/tui/altimate/skill-ops"
3+
4+
// classifyInstallSource is the shared classifier for the DialogSkillList's "Install <query>"
5+
// affordance and installSkillDirect. Keeping the two in lockstep matters: an earlier version
6+
// of the list used `q.includes("/") && q.length >= 3`, which offered the Install option for
7+
// three-segment paths and other shapes the installer then rejected as "Path not found".
8+
// A clean two-segment `owner/repo` is intentionally accepted as GitHub shorthand — skill
9+
// names can't contain a slash so no search result is hijacked. These tests pin the
10+
// classifier's edge cases so the UI's install-preview and the installer can't drift.
11+
describe("classifyInstallSource", () => {
12+
test("recognises github owner/repo shorthand", () => {
13+
expect(classifyInstallSource("anthropics/skills")).toBe("owner-repo")
14+
expect(classifyInstallSource("owner/repo.git")).toBe("owner-repo")
15+
expect(classifyInstallSource("Owner_1/repo-2.name")).toBe("owner-repo")
16+
})
17+
18+
test("recognises http(s) URLs", () => {
19+
expect(classifyInstallSource("https://github.com/anthropics/skills.git")).toBe("github-url")
20+
expect(classifyInstallSource("http://example.com/thing")).toBe("github-url")
21+
})
22+
23+
test("recognises POSIX absolute paths", () => {
24+
expect(classifyInstallSource("/tmp/my-skill")).toBe("absolute-path")
25+
expect(classifyInstallSource("/home/user/skills/foo")).toBe("absolute-path")
26+
})
27+
28+
test("recognises Windows drive-letter paths", () => {
29+
expect(classifyInstallSource("C:\\Users\\me\\skills")).toBe("absolute-path")
30+
expect(classifyInstallSource("D:/skills/foo")).toBe("absolute-path")
31+
})
32+
33+
test("rejects short strings that the installer would refuse", () => {
34+
expect(classifyInstallSource("a")).toBeNull()
35+
expect(classifyInstallSource("ab")).toBeNull()
36+
expect(classifyInstallSource("")).toBeNull()
37+
})
38+
39+
test("rejects three-segment paths (sub-paths inside a repo)", () => {
40+
// Historically `q.includes("/")` misfired on these — the list surfaced an Install
41+
// option that installSkillDirect then refused with "Path not found".
42+
expect(classifyInstallSource("owner/repo/subpath")).toBeNull()
43+
expect(classifyInstallSource("dbt/snowflake/thing")).toBeNull()
44+
})
45+
46+
test("clean two-segment strings are treated as owner/repo shorthand", () => {
47+
// Intentional feature, not a bug: skill names can't contain a slash (see the
48+
// `^[a-z][a-z0-9]*(-[a-z0-9]+)*$` grammar enforced by createSkillDirect), so any
49+
// two-segment string typed into the filter cannot collide with a real skill name
50+
// and is unambiguously a GitHub `owner/repo` shorthand.
51+
expect(classifyInstallSource("dbt/snowflake")).toBe("owner-repo")
52+
expect(classifyInstallSource("anthropics/skills")).toBe("owner-repo")
53+
})
54+
55+
test("owners whose name starts with `http` still classify as owner-repo, not URL", () => {
56+
// Regression pin for a real bug where installSkillDirect used
57+
// `cleaned.startsWith("http")` to route between "already a URL" and "build a
58+
// github.com URL": that misrouted owner names literally beginning with `http`
59+
// (e.g. `httpie/httpie`, `http-party/http-server`) as pre-formed URLs, then failed
60+
// to clone. The fix branches on the classifier's `kind` instead of a prefix probe;
61+
// this test locks in the classifier's side of the contract.
62+
expect(classifyInstallSource("httpie/httpie")).toBe("owner-repo")
63+
expect(classifyInstallSource("http-party/http-server")).toBe("owner-repo")
64+
expect(classifyInstallSource("httpwg/http-extensions")).toBe("owner-repo")
65+
})
66+
67+
test("rejects `~`-prefixed and relative paths — ambiguous with skill names", () => {
68+
expect(classifyInstallSource("~/skills/foo")).toBeNull()
69+
expect(classifyInstallSource("./local")).toBeNull()
70+
expect(classifyInstallSource("../thing")).toBeNull()
71+
})
72+
73+
test("rejects bare identifiers with no slash and no path prefix", () => {
74+
expect(classifyInstallSource("skill-name")).toBeNull()
75+
expect(classifyInstallSource("dbt-snowflake")).toBeNull()
76+
})
77+
78+
test("strips trailing dots and `.git` before classifying", () => {
79+
expect(classifyInstallSource("owner/repo.git")).toBe("owner-repo")
80+
expect(classifyInstallSource("https://github.com/x/y.git")).toBe("github-url")
81+
expect(classifyInstallSource("owner/repo.")).toBe("owner-repo")
82+
})
83+
84+
test("trims surrounding whitespace before classifying", () => {
85+
expect(classifyInstallSource(" owner/repo ")).toBe("owner-repo")
86+
expect(classifyInstallSource("\thttps://github.com/x/y\n")).toBe("github-url")
87+
})
88+
})
89+
90+
// normalizeInstallSource is the shared trim/strip helper that installSkillDirect uses
91+
// before building a clone URL from an `owner/repo` shorthand. Without it, an input like
92+
// `owner/repo.git` (accepted by the classifier because it strips `.git`) would produce
93+
// `https://github.com/owner/repo.git.git` — real bug flagged in review.
94+
describe("normalizeInstallSource", () => {
95+
test("strips a trailing `.git` suffix", () => {
96+
expect(normalizeInstallSource("owner/repo.git")).toBe("owner/repo")
97+
expect(normalizeInstallSource("https://github.com/x/y.git")).toBe("https://github.com/x/y")
98+
})
99+
100+
test("strips trailing dots", () => {
101+
expect(normalizeInstallSource("owner/repo.")).toBe("owner/repo")
102+
expect(normalizeInstallSource("owner/repo...")).toBe("owner/repo")
103+
})
104+
105+
test("trims surrounding whitespace", () => {
106+
expect(normalizeInstallSource(" owner/repo ")).toBe("owner/repo")
107+
expect(normalizeInstallSource("\n\towner/repo\r\n")).toBe("owner/repo")
108+
})
109+
110+
test("returns the source unchanged when nothing to strip", () => {
111+
expect(normalizeInstallSource("owner/repo")).toBe("owner/repo")
112+
expect(normalizeInstallSource("/tmp/skills")).toBe("/tmp/skills")
113+
expect(normalizeInstallSource("")).toBe("")
114+
})
115+
})

0 commit comments

Comments
 (0)