Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions app/components/PackageSkillsCard.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<script setup lang="ts">
import type { SkillListItem } from '#shared/types'

defineProps<{
skills: SkillListItem[]
packageName: string
version?: string
}>()

const skillsModal = useModal('skills-modal')
</script>

<template>
<section v-if="skills.length" id="skills" class="scroll-mt-20">
<h2 class="text-xs text-fg-subtle uppercase tracking-wider mb-3">
{{ $t('package.skills.title') }}
</h2>
<button
type="button"
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"
@click="skillsModal.open()"
>
<span class="i-custom:agent-skills w-4 h-4 shrink-0 text-fg-muted" aria-hidden="true" />
<span class="text-fg-muted">{{
$t('package.skills.skills_available', { count: skills.length }, skills.length)
}}</span>
</button>
</section>
</template>
236 changes: 236 additions & 0 deletions app/components/PackageSkillsModal.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,236 @@
<script setup lang="ts">
import type { SkillListItem } from '#shared/types'

const props = defineProps<{
skills: SkillListItem[]
packageName: string
version?: string
}>()

function getSkillSourceUrl(skill: SkillListItem): string {
const base = `/code/${props.packageName}`
const versionPath = props.version ? `/v/${props.version}` : ''
return `${base}${versionPath}/skills/${skill.dirName}/SKILL.md`
}

const expandedSkills = ref<Set<string>>(new Set())

function toggleSkill(dirName: string) {
if (expandedSkills.value.has(dirName)) {
expandedSkills.value.delete(dirName)
} else {
expandedSkills.value.add(dirName)
}
expandedSkills.value = new Set(expandedSkills.value)
}

type InstallMethod = 'skills-npm' | 'skills-cli'
const selectedMethod = ref<InstallMethod>('skills-npm')

const baseUrl = computed(() =>
typeof window !== 'undefined' ? window.location.origin : 'https://npmx.dev',
)

const installCommand = computed(() => {
if (!props.skills.length) return null
return `npx skills add ${baseUrl.value}/${props.packageName}`
})

const { copied, copy } = useClipboard({ copiedDuring: 2000 })
const copyCommand = () => installCommand.value && copy(installCommand.value)

function getWarningTooltip(skill: SkillListItem): string | undefined {
if (!skill.warnings?.length) return undefined
return skill.warnings.map(w => w.message).join(', ')
}
</script>

<template>
<Modal :modal-title="$t('package.skills.title')" id="skills-modal" class="sm:max-w-2xl">
<!-- Install header with tabs -->
<div class="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 class="text-xs text-fg-subtle uppercase tracking-wider">
{{ $t('package.skills.install') }}
</h3>
<div
class="flex items-center gap-1 p-0.5 bg-bg-subtle border border-border-subtle rounded-md"
role="tablist"
:aria-label="$t('package.skills.installation_method')"
>
<button
role="tab"
:aria-selected="selectedMethod === 'skills-npm'"
:tabindex="selectedMethod === 'skills-npm' ? 0 : -1"
type="button"
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"
:class="
selectedMethod === 'skills-npm'
? 'bg-bg border-border shadow-sm text-fg'
: 'border-transparent text-fg-subtle hover:text-fg'
"
@click="selectedMethod = 'skills-npm'"
>
skills-npm
</button>
<button
role="tab"
:aria-selected="selectedMethod === 'skills-cli'"
:tabindex="selectedMethod === 'skills-cli' ? 0 : -1"
type="button"
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"
:class="
selectedMethod === 'skills-cli'
? 'bg-bg border-border shadow-sm text-fg'
: 'border-transparent text-fg-subtle hover:text-fg'
"
@click="selectedMethod = 'skills-cli'"
>
skills CLI
</button>
</div>
</div>

<!-- skills-npm: compatible -->
<div
v-if="selectedMethod === 'skills-npm'"
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"
>
<i18n-t keypath="package.skills.compatible_with" tag="span" class="text-sm text-fg-muted">
<template #tool>
<code class="font-mono text-fg">skills-npm</code>
</template>
</i18n-t>
<a
href="/skills-npm"
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors shrink-0"
>
{{ $t('package.skills.learn_more') }}
<span class="i-carbon:arrow-right w-3 h-3" />
</a>
</div>

<!-- skills CLI: terminal command -->
<div
v-else-if="installCommand"
class="bg-bg-subtle border border-border rounded-lg overflow-hidden mb-5"
>
<div class="flex gap-1.5 px-3 pt-2 sm:px-4 sm:pt-3">
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
<span class="w-2.5 h-2.5 rounded-full bg-fg-subtle" />
</div>
<div class="px-3 pt-2 pb-3 sm:px-4 sm:pt-3 sm:pb-4 overflow-x-auto">
<div class="relative group/cmd">
<code class="font-mono text-sm whitespace-nowrap">
<span class="text-fg-subtle select-none">$ </span>
<span class="text-fg">npx </span>
<span class="text-fg-muted">skills add {{ baseUrl }}/{{ packageName }}</span>
</code>
<button
type="button"
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"
:aria-label="$t('package.get_started.copy_command')"
@click.stop="copyCommand"
>
<span aria-live="polite">{{ copied ? $t('common.copied') : $t('common.copy') }}</span>
</button>
</div>
</div>
</div>

<!-- Skills list -->
<div class="flex items-baseline justify-between gap-2 mb-2">
<h3 class="text-xs text-fg-subtle uppercase tracking-wider">
{{ $t('package.skills.available_skills') }}
</h3>
<span class="text-xs text-fg-subtle/60">{{ $t('package.skills.click_to_expand') }}</span>
</div>
<ul class="space-y-0.5 list-none m-0 p-0">
<li v-for="skill in skills" :key="skill.dirName">
<button
type="button"
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"
:aria-expanded="expandedSkills.has(skill.dirName)"
@click="toggleSkill(skill.dirName)"
>
<span
class="i-carbon:chevron-right w-3 h-3 text-fg-subtle shrink-0 transition-transform duration-200"
:class="{ 'rotate-90': expandedSkills.has(skill.dirName) }"
aria-hidden="true"
/>
<span class="font-mono text-sm text-fg-muted">{{ skill.name }}</span>
<span
v-if="skill.warnings?.length"
class="i-carbon:warning w-3.5 h-3.5 text-amber-500 shrink-0"
:title="getWarningTooltip(skill)"
/>
</button>

<!-- Expandable details -->
<div
class="grid transition-[grid-template-rows] duration-200 ease-out"
:class="expandedSkills.has(skill.dirName) ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'"
>
<div class="overflow-hidden">
<div class="ps-5.5 pe-2 pb-2 pt-1 space-y-1.5">
<!-- Description -->
<p v-if="skill.description" class="text-sm text-fg-subtle">
{{ skill.description }}
</p>
<p v-else class="text-sm text-fg-subtle/50 italic">
{{ $t('package.skills.no_description') }}
</p>

<!-- File counts & warnings -->
<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span v-if="skill.fileCounts?.scripts" class="text-fg-subtle">
<span class="i-carbon:script size-3 inline-block align-[-2px] me-0.5" />{{
$t(
'package.skills.file_counts.scripts',
{ count: skill.fileCounts.scripts },
skill.fileCounts.scripts,
)
}}
</span>
<span v-if="skill.fileCounts?.references" class="text-fg-subtle">
<span class="i-carbon:document size-3 inline-block align-[-2px] me-0.5" />{{
$t(
'package.skills.file_counts.refs',
{ count: skill.fileCounts.references },
skill.fileCounts.references,
)
}}
</span>
<span v-if="skill.fileCounts?.assets" class="text-fg-subtle">
<span class="i-carbon:image size-3 inline-block align-[-2px] me-0.5" />{{
$t(
'package.skills.file_counts.assets',
{ count: skill.fileCounts.assets },
skill.fileCounts.assets,
)
}}
</span>
<template v-for="warning in skill.warnings" :key="warning.message">
<span class="text-amber-500">
<span class="i-carbon:warning size-3 inline-block align-[-2px] me-0.5" />{{
warning.message
}}
</span>
</template>
</div>

<!-- Source link -->
<NuxtLink
:to="getSkillSourceUrl(skill)"
class="inline-flex items-center gap-1 text-xs text-fg-subtle hover:text-fg transition-colors"
@click.stop
>
<span class="i-carbon:code size-3" />{{ $t('package.skills.view_source') }}
</NuxtLink>
</div>
</div>
</div>
</li>
</ul>
</Modal>
</template>
1 change: 1 addition & 0 deletions app/composables/useConnector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { PendingOperation, OperationStatus, OperationType } from '../../cli/src/types'
import { $fetch } from 'ofetch'

export interface NewOperation {
type: OperationType
Expand Down
35 changes: 34 additions & 1 deletion app/pages/[...package].vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
<script setup lang="ts">
import type { NpmVersionDist, PackumentVersion, ReadmeResponse } from '#shared/types'
import type {
NpmVersionDist,
PackumentVersion,
ReadmeResponse,
SkillsListResponse,
} from '#shared/types'
import type { JsrPackageInfo } from '#shared/types/jsr'
import { assertValidPackageName } from '#shared/utils/npm'
import { joinURL } from 'ufo'
Expand Down Expand Up @@ -66,6 +71,15 @@ const {
)
onMounted(() => fetchInstallSize())

const { data: skillsData } = useLazyFetch<SkillsListResponse>(
() => {
const base = `/skills/${packageName.value}`
const version = requestedVersion.value
return version ? `${base}/v/${version}` : base
},
{ default: () => ({ package: '', version: '', skills: [] }) },
)

const { data: packageAnalysis } = usePackageAnalysis(packageName, requestedVersion)
const { data: moduleReplacement } = useModuleReplacement(packageName)

Expand Down Expand Up @@ -836,6 +850,15 @@ function handleClick(event: MouseEvent) {
</dd>
</div>
</dl>

<!-- Skills Modal -->
<ClientOnly>
<PackageSkillsModal
:skills="skillsData?.skills ?? []"
:package-name="pkg.name"
:version="displayVersion?.version"
/>
</ClientOnly>
</header>

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

<!-- Agent Skills -->
<ClientOnly>
<PackageSkillsCard
v-if="skillsData?.skills?.length"
:skills="skillsData.skills"
:package-name="pkg.name"
:version="displayVersion?.version"
/>
</ClientOnly>

<!-- Download stats -->
<PackageWeeklyDownloadStats :packageName />

Expand Down
15 changes: 12 additions & 3 deletions app/pages/code/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,18 @@ const fileContentUrl = computed(() => {
return `/api/registry/file/${packageName.value}/v/${version.value}/${filePath.value}`
})

const { data: fileContent, status: fileStatus } = useFetch<PackageFileContentResponse>(
() => fileContentUrl.value!,
{ immediate: !!fileContentUrl.value },
const {
data: fileContent,
status: fileStatus,
execute: fetchFileContent,
} = useFetch<PackageFileContentResponse>(() => fileContentUrl.value!, { immediate: false })

watch(
fileContentUrl,
url => {
if (url) fetchFileContent()
},
{ immediate: true },
)
Comment on lines +102 to 114
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is ths fixing another issue?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, let me test if this has been fixed in main. Otherwise opening another pr now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


// Track hash manually since we update it via history API to avoid scroll
Expand Down
19 changes: 19 additions & 0 deletions i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -143,13 +143,32 @@
"install_size": "Install Size",
"vulns": "Vulns",
"updated": "Updated",
"skills": "Skills",
"view_dependency_graph": "View dependency graph",
"inspect_dependency_tree": "Inspect dependency tree",
"size_tooltip": {
"unpacked": "{size} unpacked size (this package)",
"total": "{size} total unpacked size (including all {count} dependencies for linux-x64)"
}
},
"skills": {
"title": "Agent Skills",
"skills_available": "{count} skill available | {count} skills available",
"view": "View",
"compatible_with": "Compatible with {tool}",
"install": "Install",
"installation_method": "Installation method",
"learn_more": "Learn more",
"available_skills": "Available Skills",
"click_to_expand": "Click to expand",
"no_description": "No description",
"file_counts": {
"scripts": "{count} script | {count} scripts",
"refs": "{count} ref | {count} refs",
"assets": "{count} asset | {count} assets"
},
"view_source": "View source"
},
"links": {
"repo": "repo",
"homepage": "homepage",
Expand Down
Loading
Loading