@swarmclawai/swarmclaw 1.5.52 → 1.5.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -399,6 +399,18 @@ Operational docs: https://swarmclaw.ai/docs/observability
399
399
 
400
400
  ## Releases
401
401
 
402
+ ### v1.5.54 Highlights
403
+
404
+ - **Mission templates library**: the `/missions` page now opens with a curated gallery of starter missions. Each template pre-wires a goal, success criteria, USD / token / turn / wallclock budgets, and a report cadence, so non-technical users can install a working autonomous run in one click. Initial lineup: Daily News Digest, Inbox Triage, Competitor Watch, Weekly Research Report, Social Listener, and Customer Support Triage. Setup notes flag any connector or permission prerequisites before installation. Power-user overrides (budget caps, success criteria, report cadence) live behind a collapsed **Advanced Settings** panel so the default install flow stays one click.
405
+ - **New API routes `GET /api/missions/templates` and `POST /api/missions/templates/:id/instantiate`** with matching CLI commands `swarmclaw missions templates` and `swarmclaw missions instantiate`. Installed missions persist a `templateId` so the origin is traceable for future template-update flows; legacy missions normalize to `templateId: null` on load, no data migration required.
406
+ - **Fix: user-selected provider and model now survive the chat execution pipeline** ([#51](https://github.com/swarmclawai/swarmclaw/pull/51), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). Switching provider or model via the inspector panel mid-session was being reverted on every turn because the agent's configured route was unconditionally reapplied in three places. `syncSessionFromAgent` now only syncs credentials / endpoint / fallbacks when the session's provider still matches the route provider, `prepareChatTurn` preserves the user's chosen model after applying the route, and `updateChatSession` auto-resolves a stored credential for the new provider (and clears the stale `apiEndpoint`) when provider changes without an explicit `credentialId`. Restores reliable switching between Copilot CLI, Codex CLI, Groq, and OpenAI-compatible providers.
407
+
408
+ > **Note:** v1.5.53 release notes described the mission templates library, but the feature commit landed after the v1.5.53 tag was cut. v1.5.54 is the release that actually ships it.
409
+
410
+ ### v1.5.53 Highlights
411
+
412
+ - **Fix: switching a session's model now sticks in the UI** ([#50](https://github.com/swarmclawai/swarmclaw/pull/50), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The **Switch Model** panel in the agent inspector was reading from `agent.provider` / `agent.model` (the agent's defaults) instead of `session.provider` / `session.model`, so after saving a model switch the collapsed pill still showed the agent default, the combobox reset to the default when reopened, and `selectedProvider` reverted on every save. `ModelSwitcherInline` now uses `session.provider || agent.provider` and `session.model || agent.model` as the source of truth, and its `useEffect` syncs to `session.provider` changes so a successful save updates the panel immediately.
413
+
402
414
  ### v1.5.52 Highlights
403
415
 
404
416
  - **Session X-Ray now surfaces the backend execution log** ([#48](https://github.com/swarmclawai/swarmclaw/pull/48), thanks to [@borislavnnikolov](https://github.com/borislavnnikolov)). The debug panel fetches entries from the SQLite execution log on open and merges them with in-memory message events, sorted by time. Expandable entries show provider, model, stream errors, duration, and token counts — the info that was previously invisible when Ollama or other local-model runs failed silently. A new **Tools** filter tab, an `exec` badge for log-sourced entries, an entry count in the stats bar, and a Refresh button round it out. New API route `GET /api/chats/:id/execution-log` with `limit`, `since`, and `category` query params, registered in the CLI manifest as `swarmclaw chats execution-log`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@swarmclawai/swarmclaw",
3
- "version": "1.5.52",
3
+ "version": "1.5.54",
4
4
  "description": "Build and run autonomous AI agents with OpenClaw, Hermes, multiple model providers, orchestration, delegation, memory, skills, schedules, and chat connectors.",
5
5
  "main": "electron-dist/main.js",
6
6
  "license": "MIT",
@@ -0,0 +1,64 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { z } from 'zod'
3
+ import { safeParseBody } from '@/lib/server/safe-parse-body'
4
+ import { formatZodError } from '@/lib/validation/schemas'
5
+ import { notFound } from '@/lib/server/collection-helpers'
6
+ import { createMissionFromTemplate } from '@/lib/server/missions/mission-service'
7
+ import { patchSession } from '@/lib/server/sessions/session-repository'
8
+
9
+ export const dynamic = 'force-dynamic'
10
+
11
+ const BudgetOverrideSchema = z.object({
12
+ maxUsd: z.number().positive().nullable().optional(),
13
+ maxTokens: z.number().positive().int().nullable().optional(),
14
+ maxToolCalls: z.number().positive().int().nullable().optional(),
15
+ maxWallclockSec: z.number().positive().int().nullable().optional(),
16
+ maxTurns: z.number().positive().int().nullable().optional(),
17
+ warnAtFractions: z.array(z.number().positive().lt(1)).max(10).optional(),
18
+ }).partial()
19
+
20
+ const ReportScheduleSchema = z.object({
21
+ intervalSec: z.number().int().min(30),
22
+ format: z.enum(['markdown', 'slack', 'discord', 'email', 'audio']),
23
+ enabled: z.boolean().default(true),
24
+ lastReportAt: z.number().nullable().optional(),
25
+ }).strict()
26
+
27
+ const InstantiateSchema = z.object({
28
+ rootSessionId: z.string().min(1, 'rootSessionId is required'),
29
+ overrides: z.object({
30
+ title: z.string().min(1).max(200).optional(),
31
+ goal: z.string().min(1).max(4000).optional(),
32
+ successCriteria: z.array(z.string().min(1)).max(32).optional(),
33
+ budget: BudgetOverrideSchema.optional(),
34
+ reportSchedule: ReportScheduleSchema.nullable().optional(),
35
+ agentIds: z.array(z.string().min(1)).max(32).optional(),
36
+ reportConnectorIds: z.array(z.string().min(1)).max(8).optional(),
37
+ }).optional(),
38
+ }).strict()
39
+
40
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
41
+ const { id } = await params
42
+ const { data: body, error } = await safeParseBody<Record<string, unknown>>(req)
43
+ if (error) return error
44
+ const parsed = InstantiateSchema.safeParse(body)
45
+ if (!parsed.success) return NextResponse.json(formatZodError(parsed.error), { status: 400 })
46
+
47
+ const result = createMissionFromTemplate({
48
+ templateId: id,
49
+ rootSessionId: parsed.data.rootSessionId,
50
+ overrides: parsed.data.overrides,
51
+ })
52
+ if (!result) return notFound()
53
+
54
+ try {
55
+ patchSession(result.mission.rootSessionId, (current) => {
56
+ if (!current) return null
57
+ return { ...current, missionId: result.mission.id }
58
+ })
59
+ } catch {
60
+ // Session may not exist yet; budget hook falls back to service map.
61
+ }
62
+
63
+ return NextResponse.json({ mission: result.mission, template: result.template })
64
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from 'next/server'
2
+ import { listMissionTemplates } from '@/lib/server/missions/mission-templates'
3
+
4
+ export const dynamic = 'force-dynamic'
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(listMissionTemplates())
8
+ }
@@ -5,7 +5,12 @@ import { api } from '@/lib/app/api-client'
5
5
  import { MainContent } from '@/components/layout/main-content'
6
6
  import { HintTip } from '@/components/shared/hint-tip'
7
7
  import { inputClass } from '@/components/shared/form-styles'
8
- import type { Mission, MissionReport, MissionEvent, Session } from '@/types'
8
+ import { MissionTemplateGallery } from '@/components/missions/mission-template-gallery'
9
+ import {
10
+ MissionTemplateInstallDialog,
11
+ type InstantiateInput,
12
+ } from '@/components/missions/mission-template-install-dialog'
13
+ import type { Mission, MissionReport, MissionEvent, MissionTemplate, Session } from '@/types'
9
14
  import { toast } from 'sonner'
10
15
 
11
16
  const POLL_MS = 4_000
@@ -489,6 +494,9 @@ export default function MissionsPage() {
489
494
  const [createOpen, setCreateOpen] = useState(false)
490
495
  const [busy, setBusy] = useState(false)
491
496
  const [loaded, setLoaded] = useState(false)
497
+ const [templates, setTemplates] = useState<MissionTemplate[]>([])
498
+ const [galleryOpen, setGalleryOpen] = useState(false)
499
+ const [installTemplate, setInstallTemplate] = useState<MissionTemplate | null>(null)
492
500
 
493
501
  const selected = useMemo(() => missions.find((m) => m.id === selectedId) ?? null, [missions, selectedId])
494
502
 
@@ -534,7 +542,15 @@ export default function MissionsPage() {
534
542
  api<Session[]>('GET', '/chats').then((s) => {
535
543
  setSessions(Array.isArray(s) ? s : Object.values(s))
536
544
  }).catch(() => setSessions([]))
537
- }, [createOpen])
545
+ }, [createOpen, galleryOpen, installTemplate])
546
+
547
+ useEffect(() => {
548
+ let cancelled = false
549
+ api<MissionTemplate[]>('GET', '/missions/templates')
550
+ .then((list) => { if (!cancelled) setTemplates(Array.isArray(list) ? list : []) })
551
+ .catch(() => { if (!cancelled) setTemplates([]) })
552
+ return () => { cancelled = true }
553
+ }, [])
538
554
 
539
555
  const handleAction = useCallback(async (action: string, reason?: string) => {
540
556
  if (!selectedId) return
@@ -571,28 +587,60 @@ export default function MissionsPage() {
571
587
  toast.success(`Mission "${created.title}" created`)
572
588
  }, [refreshList])
573
589
 
590
+ const handleInstallTemplate = useCallback(async (template: MissionTemplate, input: InstantiateInput) => {
591
+ const result = await api<{ mission: Mission }>(
592
+ 'POST',
593
+ `/missions/templates/${encodeURIComponent(template.id)}/instantiate`,
594
+ input,
595
+ )
596
+ await refreshList()
597
+ setSelectedId(result.mission.id)
598
+ setGalleryOpen(false)
599
+ toast.success(`Mission "${result.mission.title}" installed`)
600
+ }, [refreshList])
601
+
574
602
  return (
575
603
  <MainContent>
576
604
  <div className="flex-1 flex min-h-0">
577
605
  <div className="w-[340px] shrink-0 border-r border-white/[0.06] flex flex-col min-h-0">
578
- <div className="p-3 border-b border-white/[0.06] flex items-center justify-between">
579
- <div>
580
- <div className="text-[13px] font-600">Missions</div>
581
- <div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
606
+ <div className="p-3 border-b border-white/[0.06]">
607
+ <div className="flex items-center justify-between mb-2">
608
+ <div>
609
+ <div className="text-[13px] font-600">Missions</div>
610
+ <div className="text-[10px] text-text-3">Autonomous goal-driven runs</div>
611
+ </div>
612
+ <button
613
+ onClick={() => setCreateOpen(true)}
614
+ className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
615
+ >
616
+ + New
617
+ </button>
582
618
  </div>
583
- <button
584
- onClick={() => setCreateOpen(true)}
585
- className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15"
586
- >
587
- + New
588
- </button>
619
+ {templates.length > 0 && (
620
+ <button
621
+ onClick={() => setGalleryOpen(true)}
622
+ className="w-full text-left text-[11px] font-600 px-2.5 py-1.5 rounded border border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text"
623
+ >
624
+ Browse {templates.length} starter templates →
625
+ </button>
626
+ )}
589
627
  </div>
590
628
  <div className="flex-1 overflow-y-auto p-2 flex flex-col gap-1.5">
591
629
  {!loaded ? (
592
630
  <div className="text-[11px] text-text-3 p-3">Loading...</div>
593
631
  ) : missions.length === 0 ? (
594
- <div className="text-[11px] text-text-3 p-3">
595
- No missions yet. Click <span className="text-emerald-300 font-600">+ New</span> to start an autonomous run.
632
+ <div className="flex flex-col gap-2 p-3">
633
+ <div className="text-[11px] text-text-3">
634
+ No missions yet. Start from a template or create one from scratch.
635
+ </div>
636
+ {templates.length > 0 && (
637
+ <button
638
+ onClick={() => setGalleryOpen(true)}
639
+ className="text-[11px] font-600 px-2.5 py-1 rounded border border-emerald-500/30 bg-emerald-500/10 text-emerald-300 hover:bg-emerald-500/15 self-start"
640
+ >
641
+ Open template gallery
642
+ </button>
643
+ )}
596
644
  </div>
597
645
  ) : (
598
646
  missions.map((m) => (
@@ -616,6 +664,13 @@ export default function MissionsPage() {
616
664
  onAction={handleAction}
617
665
  onForceReport={handleForceReport}
618
666
  />
667
+ ) : loaded && missions.length === 0 && templates.length > 0 ? (
668
+ <div className="p-6">
669
+ <MissionTemplateGallery
670
+ templates={templates}
671
+ onInstall={(t) => setInstallTemplate(t)}
672
+ />
673
+ </div>
619
674
  ) : (
620
675
  <div className="flex items-center justify-center h-full text-text-3 text-[12px]">
621
676
  {loaded && missions.length === 0 ? 'Create a mission to get started.' : 'Select a mission'}
@@ -630,6 +685,34 @@ export default function MissionsPage() {
630
685
  onClose={() => setCreateOpen(false)}
631
686
  onCreate={handleCreate}
632
687
  />
688
+
689
+ {galleryOpen && (
690
+ <div
691
+ className="fixed inset-0 z-40 flex items-center justify-center bg-black/60 p-4"
692
+ onClick={() => setGalleryOpen(false)}
693
+ >
694
+ <div
695
+ className="w-full max-w-4xl max-h-[90vh] overflow-y-auto rounded-[14px] border border-white/[0.08] bg-bg shadow-[0_24px_64px_rgba(0,0,0,0.6)] p-6"
696
+ onClick={(e) => e.stopPropagation()}
697
+ >
698
+ <div className="flex items-center justify-between mb-4">
699
+ <div className="text-[15px] font-700 text-text">Mission templates</div>
700
+ <button onClick={() => setGalleryOpen(false)} className="text-text-3 text-[12px] hover:text-text">Close</button>
701
+ </div>
702
+ <MissionTemplateGallery
703
+ templates={templates}
704
+ onInstall={(t) => setInstallTemplate(t)}
705
+ />
706
+ </div>
707
+ </div>
708
+ )}
709
+
710
+ <MissionTemplateInstallDialog
711
+ template={installTemplate}
712
+ sessions={sessions}
713
+ onClose={() => setInstallTemplate(null)}
714
+ onInstall={handleInstallTemplate}
715
+ />
633
716
  </MainContent>
634
717
  )
635
718
  }
package/src/cli/index.js CHANGED
@@ -817,6 +817,8 @@ const COMMAND_GROUPS = [
817
817
  cmd('reports', 'GET', '/missions/:id/reports', 'List mission reports'),
818
818
  cmd('report-now', 'POST', '/missions/:id/reports', 'Force-generate a mission report now'),
819
819
  cmd('events', 'GET', '/missions/:id/events', 'List mission events (use --query sinceAt=..., --query untilAt=...)'),
820
+ cmd('templates', 'GET', '/missions/templates', 'List built-in mission templates'),
821
+ cmd('instantiate', 'POST', '/missions/templates/:id/instantiate', 'Create a mission from a template', { expectsJsonBody: true }),
820
822
  ],
821
823
  },
822
824
  {
package/src/cli/spec.js CHANGED
@@ -571,6 +571,8 @@ const COMMAND_GROUPS = {
571
571
  reports: { description: 'List mission reports', method: 'GET', path: '/missions/:id/reports', params: ['id'] },
572
572
  'report-now': { description: 'Force-generate a mission report now', method: 'POST', path: '/missions/:id/reports', params: ['id'] },
573
573
  events: { description: 'List mission events', method: 'GET', path: '/missions/:id/events', params: ['id'] },
574
+ templates: { description: 'List built-in mission templates', method: 'GET', path: '/missions/templates' },
575
+ instantiate: { description: 'Create a mission from a template', method: 'POST', path: '/missions/templates/:id/instantiate', params: ['id'], body: true },
574
576
  },
575
577
  },
576
578
  }
@@ -73,22 +73,24 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
73
73
  const refreshSession = useAppStore((s) => s.refreshSession)
74
74
  const streaming = useChatStore((s) => s.streaming)
75
75
  const [expanded, setExpanded] = useState(false)
76
- const [selectedProvider, setSelectedProvider] = useState(agent.provider)
76
+ const [selectedProvider, setSelectedProvider] = useState(session.provider || agent.provider)
77
77
  const [saving, setSaving] = useState(false)
78
78
 
79
79
  useEffect(() => {
80
80
  void loadProviders()
81
81
  void loadProviderConfigs()
82
82
  }, [loadProviderConfigs, loadProviders])
83
- useEffect(() => { setSelectedProvider(agent.provider) }, [agent.provider])
83
+ // Sync selectedProvider when the session's provider changes (e.g. after a successful save)
84
+ useEffect(() => { setSelectedProvider(session.provider || agent.provider) }, [session.provider, agent.provider])
84
85
 
85
86
  const agentSelectableProviders = useMemo(
86
87
  () => buildAgentSelectableProviders(providers, providerConfigs),
87
88
  [providerConfigs, providers],
88
89
  )
89
90
  const currentProviderInfo = agentSelectableProviders.find((p) => p.id === selectedProvider)
90
- const activeAgentProvider = agentSelectableProviders.find((p) => p.id === agent.provider)
91
- const providerLabel = PROVIDER_LABELS[agent.provider] || activeAgentProvider?.name || agent.provider.replace(/-/g, ' ')
91
+ const activeSessionProvider = agentSelectableProviders.find((p) => p.id === (session.provider || agent.provider))
92
+ const effectiveProvider = session.provider || agent.provider
93
+ const providerLabel = PROVIDER_LABELS[effectiveProvider] || activeSessionProvider?.name || effectiveProvider.replace(/-/g, ' ')
92
94
 
93
95
  const handleModelChange = async (model: string) => {
94
96
  if (saving) return
@@ -117,7 +119,7 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
117
119
  {providerLabel}
118
120
  </span>
119
121
  <span className="inline-flex max-w-[180px] items-center rounded-[8px] border border-white/[0.06] bg-white/[0.03] px-2 py-1 text-[10px] font-mono text-text-3/70 truncate group-hover:border-white/[0.1] group-hover:text-text-2 transition-colors">
120
- {agent.model || 'Default model'}
122
+ {session.model || agent.model || 'Default model'}
121
123
  </span>
122
124
  <svg width="8" height="8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" className="text-text-3/30 group-hover:text-text-3/60 transition-colors ml-auto shrink-0">
123
125
  <polyline points="6 9 12 15 18 9" />
@@ -157,7 +159,7 @@ function ModelSwitcherInline({ session, agent }: { session: Session; agent: Agen
157
159
  {currentProviderInfo && (
158
160
  <ModelCombobox
159
161
  providerId={currentProviderInfo.id}
160
- value={agent.model || currentProviderInfo.models[0] || ''}
162
+ value={session.model || agent.model || currentProviderInfo.models[0] || ''}
161
163
  onChange={(m) => void handleModelChange(m)}
162
164
  models={currentProviderInfo.models}
163
165
  defaultModels={currentProviderInfo.defaultModels}
@@ -0,0 +1,113 @@
1
+ 'use client'
2
+
3
+ import { useMemo, useState } from 'react'
4
+ import type { MissionTemplate, MissionTemplateCategory } from '@/types'
5
+
6
+ const CATEGORY_LABELS: Record<MissionTemplateCategory, string> = {
7
+ research: 'Research',
8
+ communication: 'Communication',
9
+ monitoring: 'Monitoring',
10
+ productivity: 'Productivity',
11
+ support: 'Support',
12
+ }
13
+
14
+ interface Props {
15
+ templates: MissionTemplate[]
16
+ onInstall: (template: MissionTemplate) => void
17
+ }
18
+
19
+ export function MissionTemplateGallery({ templates, onInstall }: Props) {
20
+ const [category, setCategory] = useState<'all' | MissionTemplateCategory>('all')
21
+
22
+ const categories = useMemo(() => {
23
+ const seen = new Set<MissionTemplateCategory>()
24
+ for (const template of templates) seen.add(template.category)
25
+ return Array.from(seen)
26
+ }, [templates])
27
+
28
+ const filtered = useMemo(() => {
29
+ if (category === 'all') return templates
30
+ return templates.filter((t) => t.category === category)
31
+ }, [templates, category])
32
+
33
+ return (
34
+ <div className="flex flex-col gap-4">
35
+ <div>
36
+ <h2 className="text-[15px] font-700 text-text">Start from a template</h2>
37
+ <p className="text-[12px] text-text-3 mt-1">
38
+ Pre-wired goals, budgets, and report schedules. Tweak anything before you install.
39
+ </p>
40
+ </div>
41
+
42
+ <div className="flex flex-wrap gap-1.5">
43
+ <FilterChip active={category === 'all'} onClick={() => setCategory('all')} label="All" />
44
+ {categories.map((c) => (
45
+ <FilterChip
46
+ key={c}
47
+ active={category === c}
48
+ onClick={() => setCategory(c)}
49
+ label={CATEGORY_LABELS[c]}
50
+ />
51
+ ))}
52
+ </div>
53
+
54
+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
55
+ {filtered.map((template) => (
56
+ <TemplateCard key={template.id} template={template} onInstall={() => onInstall(template)} />
57
+ ))}
58
+ </div>
59
+ </div>
60
+ )
61
+ }
62
+
63
+ function FilterChip({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) {
64
+ return (
65
+ <button
66
+ type="button"
67
+ onClick={onClick}
68
+ className={`text-[11px] font-600 px-2.5 py-1 rounded-full border transition-colors ${
69
+ active
70
+ ? 'border-emerald-500/40 bg-emerald-500/15 text-emerald-200'
71
+ : 'border-white/[0.08] bg-white/[0.02] text-text-3 hover:border-white/[0.16] hover:text-text'
72
+ }`}
73
+ >
74
+ {label}
75
+ </button>
76
+ )
77
+ }
78
+
79
+ function TemplateCard({ template, onInstall }: { template: MissionTemplate; onInstall: () => void }) {
80
+ return (
81
+ <button
82
+ type="button"
83
+ onClick={onInstall}
84
+ className="text-left group flex flex-col gap-3 rounded-[14px] border border-white/[0.06] bg-white/[0.02] px-4 py-4 transition-all hover:border-white/[0.16] hover:bg-white/[0.04]"
85
+ >
86
+ <div className="flex items-start gap-3">
87
+ <span className="text-[22px] leading-none" aria-hidden>{template.icon}</span>
88
+ <div className="flex-1 min-w-0">
89
+ <div className="text-[13px] font-700 text-text">{template.name}</div>
90
+ <div className="text-[10px] uppercase tracking-wide text-text-3/70 mt-0.5">
91
+ {CATEGORY_LABELS[template.category]}
92
+ </div>
93
+ </div>
94
+ </div>
95
+ <div className="text-[12px] text-text-3 leading-[1.55] line-clamp-3">{template.description}</div>
96
+ <div className="flex items-center justify-between mt-auto">
97
+ <div className="flex flex-wrap gap-1">
98
+ {template.tags.slice(0, 3).map((tag) => (
99
+ <span
100
+ key={tag}
101
+ className="text-[10px] text-text-3/70 px-1.5 py-0.5 rounded border border-white/[0.06] bg-white/[0.02]"
102
+ >
103
+ {tag}
104
+ </span>
105
+ ))}
106
+ </div>
107
+ <span className="text-[11px] font-600 text-emerald-300 group-hover:text-emerald-200">
108
+ Install →
109
+ </span>
110
+ </div>
111
+ </button>
112
+ )
113
+ }