Skip to content

Commit 88b8e90

Browse files
onmaxautofix-ci[bot]danielroe
authored
feat: auto-detect package.json skills (#464)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Daniel Roe <daniel@roe.dev>
1 parent f149825 commit 88b8e90

File tree

19 files changed

+909
-7
lines changed

19 files changed

+909
-7
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
<script setup lang="ts">
2+
import type { SkillListItem } from '#shared/types'
3+
4+
defineProps<{
5+
skills: SkillListItem[]
6+
packageName: string
7+
version?: string
8+
}>()
9+
10+
const skillsModal = useModal('skills-modal')
11+
</script>
12+
13+
<template>
14+
<section v-if="skills.length" id="skills" class="scroll-mt-20">
15+
<h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
16+
{{ $t('package.skills.title') }}
17+
</h2>
18+
<button
19+
type="button"
20+
class="w-full flex items-center gap-2 px-3 py-2 text-sm font-mono bg-bg-muted border border-border rounded-md hover:border-border-hover hover:bg-bg-elevated focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-hover transition-colors duration-200"
21+
@click="skillsModal.open()"
22+
>
23+
<span class="i-custom:agent-skills w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" />
24+
<span class="text-fg-muted">{{
25+
$t('package.skills.skills_available', { count: skills.length }, skills.length)
26+
}}</span>
27+
</button>
28+
</section>
29+
</template>
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
<script setup lang="ts">
2+
import type { SkillListItem } from '#shared/types'
3+
4+
const props = defineProps<{
5+
skills: SkillListItem[]
6+
packageName: string
7+
version?: string
8+
}>()
9+
10+
function getSkillSourceUrl(skill: SkillListItem): string {
11+
const base = `/code/${props.packageName}`
12+
const versionPath = props.version ? `/v/${props.version}` : ''
13+
return `${base}${versionPath}/skills/${skill.dirName}/SKILL.md`
14+
}
15+
16+
const expandedSkills = ref<Set<string>>(new Set())
17+
18+
function toggleSkill(dirName: string) {
19+
if (expandedSkills.value.has(dirName)) {
20+
expandedSkills.value.delete(dirName)
21+
} else {
22+
expandedSkills.value.add(dirName)
23+
}
24+
expandedSkills.value = new Set(expandedSkills.value)
25+
}
26+
27+
type InstallMethod = 'skills-npm' | 'skills-cli'
28+
const selectedMethod = ref<InstallMethod>('skills-npm')
29+
30+
const baseUrl = computed(() =>
31+
typeof window !== 'undefined' ? window.location.origin : 'https://npmx.dev',
32+
)
33+
34+
const installCommand = computed(() => {
35+
if (!props.skills.length) return null
36+
return `npx skills add ${baseUrl.value}/${props.packageName}`
37+
})
38+
39+
const { copied, copy } = useClipboard({ copiedDuring: 2000 })
40+
const copyCommand = () => installCommand.value && copy(installCommand.value)
41+
42+
function getWarningTooltip(skill: SkillListItem): string | undefined {
43+
if (!skill.warnings?.length) return undefined
44+
return skill.warnings.map(w => w.message).join(', ')
45+
}
46+
</script>
47+
48+
<template>
49+
<Modal :modal-title="$t('package.skills.title')" id="skills-modal" class="sm:max-w-2xl">
50+
<!-- Install header with tabs -->
51+
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
52+
<h3 class="text-xs text-fg-subtle uppercase tracking-wider">
53+
{{ $t('package.skills.install') }}
54+
</h3>
55+
<div
56+
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md"
57+
role="tablist"
58+
:aria-label="$t('package.skills.installation_method')"
59+
>
60+
<button
61+
role="tab"
62+
:aria-selected="selectedMethod === 'skills-npm'"
63+
:tabindex="selectedMethod === 'skills-npm' ? 0 : -1"
64+
type="button"
65+
class="px-2 py-1 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
66+
:class="
67+
selectedMethod === 'skills-npm'
68+
? 'bg-bg border-border shadow-sm text-fg'
69+
: 'border-transparent text-fg-subtle hover:text-fg'
70+
"
71+
@click="selectedMethod = 'skills-npm'"
72+
>
73+
skills-npm
74+
</button>
75+
<button
76+
role="tab"
77+
:aria-selected="selectedMethod === 'skills-cli'"
78+
:tabindex="selectedMethod === 'skills-cli' ? 0 : -1"
79+
type="button"
80+
class="px-2 py-1 font-mono text-xs rounded transition-colors duration-150 border border-solid focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
81+
:class="
82+
selectedMethod === 'skills-cli'
83+
? 'bg-bg border-border shadow-sm text-fg'
84+
: 'border-transparent text-fg-subtle hover:text-fg'
85+
"
86+
@click="selectedMethod = 'skills-cli'"
87+
>
88+
skills CLI
89+
</button>
90+
</div>
91+
</div>
92+
93+
<!-- skills-npm: compatible -->
94+
<div
95+
v-if="selectedMethod === 'skills-npm'"
96+
class="flex items-center justify-between gap-2 px-3 py-2.5 sm:px-4 bg-bg-subtle border border-border rounded-lg mb-5"
97+
>
98+
<i18n-t keypath="package.skills.compatible_with" tag="span" class="text-sm text-fg-muted">
99+
<template #tool>
100+
<code class="font-mono text-fg">skills-npm</code>
101+
</template>
102+
</i18n-t>
103+
<a
104+
href="/skills-npm"
105+
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors shrink-0"
106+
>
107+
{{ $t('package.skills.learn_more') }}
108+
<span class="i-carbon:arrow-right w-3 h-3" />
109+
</a>
110+
</div>
111+
112+
<!-- skills CLI: terminal command -->
113+
<div
114+
v-else-if="installCommand"
115+
class="bg-bg-subtle border border-border rounded-lg overflow-hidden mb-5"
116+
>
117+
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
118+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
119+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
120+
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
121+
</div>
122+
<div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 overflow-x-auto">
123+
<div class="relative group/cmd">
124+
<code class="font-mono text-sm whitespace-nowrap">
125+
<span class="text-fg-subtle select-none">$ </span>
126+
<span class="text-fg">npx </span>
127+
<span class="text-fg-muted">skills add {{ baseUrl }}/{{ packageName }}</span>
128+
</code>
129+
<button
130+
type="button"
131+
class="absolute top-0 right-0 px-2 py-0.5 font-mono text-xs text-fg-muted bg-bg-subtle/80 border border-border rounded transition-colors duration-200 opacity-0 group-hover/cmd:opacity-100 hover:(text-fg border-border-hover) active:scale-95 focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
132+
:aria-label="$t('package.get_started.copy_command')"
133+
@click.stop="copyCommand"
134+
>
135+
<span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span>
136+
</button>
137+
</div>
138+
</div>
139+
</div>
140+
141+
<!-- Skills list -->
142+
<div class="flex items-baseline justify-between gap-2 mb-2">
143+
<h3 class="text-xs text-fg-subtle uppercase tracking-wider">
144+
{{ $t('package.skills.available_skills') }}
145+
</h3>
146+
<span class="text-xs text-fg-subtle/60">{{ $t('package.skills.click_to_expand') }}</span>
147+
</div>
148+
<ul class="space-y-0.5 list-none m-0 p-0">
149+
<li v-for="skill in skills" :key="skill.dirName">
150+
<button
151+
type="button"
152+
class="w-full flex items-center gap-2 py-1.5 text-start rounded transition-colors hover:bg-bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50"
153+
:aria-expanded="expandedSkills.has(skill.dirName)"
154+
@click="toggleSkill(skill.dirName)"
155+
>
156+
<span
157+
class="i-carbon:chevron-right w-3 h-3 text-fg-subtle shrink-0 transition-transform duration-200"
158+
:class="{ 'rotate-90': expandedSkills.has(skill.dirName) }"
159+
aria-hidden="true"
160+
/>
161+
<span class="font-mono text-sm text-fg-muted">{{ skill.name }}</span>
162+
<span
163+
v-if="skill.warnings?.length"
164+
class="i-carbon:warning w-3.5 h-3.5 text-amber-500 shrink-0"
165+
:title="getWarningTooltip(skill)"
166+
/>
167+
</button>
168+
169+
<!-- Expandable details -->
170+
<div
171+
class="grid transition-[grid-template-rows] duration-200 ease-out"
172+
:class="expandedSkills.has(skill.dirName) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
173+
>
174+
<div class="overflow-hidden">
175+
<div class="ps-5.5 pe-2 pb-2 pt-1 space-y-1.5">
176+
<!-- Description -->
177+
<p v-if="skill.description" class="text-sm text-fg-subtle">
178+
{{ skill.description }}
179+
</p>
180+
<p v-else class="text-sm text-fg-subtle/50 italic">
181+
{{ $t('package.skills.no_description') }}
182+
</p>
183+
184+
<!-- File counts & warnings -->
185+
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
186+
<span v-if="skill.fileCounts?.scripts" class="text-fg-subtle">
187+
<span class="i-carbon:script size-3 inline-block align-[-2px] me-0.5" />{{
188+
$t(
189+
'package.skills.file_counts.scripts',
190+
{ count: skill.fileCounts.scripts },
191+
skill.fileCounts.scripts,
192+
)
193+
}}
194+
</span>
195+
<span v-if="skill.fileCounts?.references" class="text-fg-subtle">
196+
<span class="i-carbon:document size-3 inline-block align-[-2px] me-0.5" />{{
197+
$t(
198+
'package.skills.file_counts.refs',
199+
{ count: skill.fileCounts.references },
200+
skill.fileCounts.references,
201+
)
202+
}}
203+
</span>
204+
<span v-if="skill.fileCounts?.assets" class="text-fg-subtle">
205+
<span class="i-carbon:image size-3 inline-block align-[-2px] me-0.5" />{{
206+
$t(
207+
'package.skills.file_counts.assets',
208+
{ count: skill.fileCounts.assets },
209+
skill.fileCounts.assets,
210+
)
211+
}}
212+
</span>
213+
<template v-for="warning in skill.warnings" :key="warning.message">
214+
<span class="text-amber-500">
215+
<span class="i-carbon:warning size-3 inline-block align-[-2px] me-0.5" />{{
216+
warning.message
217+
}}
218+
</span>
219+
</template>
220+
</div>
221+
222+
<!-- Source link -->
223+
<NuxtLink
224+
:to="getSkillSourceUrl(skill)"
225+
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors"
226+
@click.stop
227+
>
228+
<span class="i-carbon:code size-3" />{{ $t('package.skills.view_source') }}
229+
</NuxtLink>
230+
</div>
231+
</div>
232+
</div>
233+
</li>
234+
</ul>
235+
</Modal>
236+
</template>

app/composables/useConnector.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PendingOperation, OperationStatus, OperationType } from '../../cli/src/types'
2+
import { $fetch } from 'ofetch'
23

34
export interface NewOperation {
45
type: OperationType

app/pages/[...package].vue

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
<script setup lang="ts">
2-
import type { NpmVersionDist, PackumentVersion, ReadmeResponse } from '#shared/types'
2+
import type {
3+
NpmVersionDist,
4+
PackumentVersion,
5+
ReadmeResponse,
6+
SkillsListResponse,
7+
} from '#shared/types'
38
import type { JsrPackageInfo } from '#shared/types/jsr'
49
import { assertValidPackageName } from '#shared/utils/npm'
510
import { joinURL } from 'ufo'
@@ -66,6 +71,15 @@ const {
6671
)
6772
onMounted(() => fetchInstallSize())
6873
74+
const { data: skillsData } = useLazyFetch<SkillsListResponse>(
75+
() => {
76+
const base = `/skills/${packageName.value}`
77+
const version = requestedVersion.value
78+
return version ? `${base}/v/${version}` : base
79+
},
80+
{ default: () => ({ package: '', version: '', skills: [] }) },
81+
)
82+
6983
const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
7084
const { data: moduleReplacement } = useModuleReplacement(packageName)
7185
@@ -836,6 +850,15 @@ function handleClick(event: MouseEvent) {
836850
</dd>
837851
</div>
838852
</dl>
853+
854+
<!-- Skills Modal -->
855+
<ClientOnly>
856+
<PackageSkillsModal
857+
:skills="skillsData?.skills ?? []"
858+
:package-name="pkg.name"
859+
:version="displayVersion?.version"
860+
/>
861+
</ClientOnly>
839862
</header>
840863

841864
<!-- Binary-only packages: Show only execute command (no install) -->
@@ -977,6 +1000,16 @@ function handleClick(event: MouseEvent) {
9771000
</ul>
9781001
</section>
9791002

1003+
<!-- Agent Skills -->
1004+
<ClientOnly>
1005+
<PackageSkillsCard
1006+
v-if="skillsData?.skills?.length"
1007+
:skills="skillsData.skills"
1008+
:package-name="pkg.name"
1009+
:version="displayVersion?.version"
1010+
/>
1011+
</ClientOnly>
1012+
9801013
<!-- Download stats -->
9811014
<PackageWeeklyDownloadStats :packageName />
9821015

app/pages/code/[...path].vue

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,18 @@ const fileContentUrl = computed(() => {
9999
return `/api/registry/file/${packageName.value}/v/${version.value}/${filePath.value}`
100100
})
101101
102-
const { data: fileContent, status: fileStatus } = useFetch<PackageFileContentResponse>(
103-
() => fileContentUrl.value!,
104-
{ immediate: !!fileContentUrl.value },
102+
const {
103+
data: fileContent,
104+
status: fileStatus,
105+
execute: fetchFileContent,
106+
} = useFetch<PackageFileContentResponse>(() => fileContentUrl.value!, { immediate: false })
107+
108+
watch(
109+
fileContentUrl,
110+
url => {
111+
if (url) fetchFileContent()
112+
},
113+
{ immediate: true },
105114
)
106115
107116
// Track hash manually since we update it via history API to avoid scroll

i18n/locales/en.json

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,32 @@
143143
"install_size": "Install Size",
144144
"vulns": "Vulns",
145145
"updated": "Updated",
146+
"skills": "Skills",
146147
"view_dependency_graph": "View dependency graph",
147148
"inspect_dependency_tree": "Inspect dependency tree",
148149
"size_tooltip": {
149150
"unpacked": "{size} unpacked size (this package)",
150151
"total": "{size} total unpacked size (including all {count} dependencies for linux-x64)"
151152
}
152153
},
154+
"skills": {
155+
"title": "Agent Skills",
156+
"skills_available": "{count} skill available | {count} skills available",
157+
"view": "View",
158+
"compatible_with": "Compatible with {tool}",
159+
"install": "Install",
160+
"installation_method": "Installation method",
161+
"learn_more": "Learn more",
162+
"available_skills": "Available Skills",
163+
"click_to_expand": "Click to expand",
164+
"no_description": "No description",
165+
"file_counts": {
166+
"scripts": "{count} script | {count} scripts",
167+
"refs": "{count} ref | {count} refs",
168+
"assets": "{count} asset | {count} assets"
169+
},
170+
"view_source": "View source"
171+
},
153172
"links": {
154173
"repo": "repo",
155174
"homepage": "homepage",

0 commit comments

Comments
 (0)