@agentprojectcontext/apx 1.58.0 → 1.60.0

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/skills/index.js +9 -0
  3. package/src/core/agent/skills/inspector.js +7 -2
  4. package/src/core/agent/skills/policy.js +128 -0
  5. package/src/core/agent/super-agent.js +8 -1
  6. package/src/core/agent/tools/handlers/_asana.js +34 -0
  7. package/src/core/agent/tools/handlers/asana-create-task.js +38 -0
  8. package/src/core/agent/tools/handlers/asana-list-projects.js +19 -0
  9. package/src/core/agent/tools/handlers/asana-list-tasks.js +27 -0
  10. package/src/core/agent/tools/handlers/asana-update-task.js +32 -0
  11. package/src/core/agent/tools/handlers/list-skills.js +6 -3
  12. package/src/core/agent/tools/handlers/load-skill.js +9 -2
  13. package/src/core/agent/tools/names.js +10 -0
  14. package/src/core/agent/tools/registry.js +11 -0
  15. package/src/core/integrations/catalog.js +66 -0
  16. package/src/core/integrations/index.js +10 -0
  17. package/src/core/integrations/plugins/asana.js +231 -0
  18. package/src/core/integrations/sources.js +56 -0
  19. package/src/core/integrations/store.js +118 -0
  20. package/src/host/daemon/api/integrations.js +191 -0
  21. package/src/host/daemon/api/skills.js +301 -17
  22. package/src/host/daemon/api.js +2 -0
  23. package/src/interfaces/cli/commands/skills.js +3 -2
  24. package/src/interfaces/web/dist/assets/index-DFNV6BWh.js +761 -0
  25. package/src/interfaces/web/dist/assets/{index-DPAuXATr.js.map → index-DFNV6BWh.js.map} +1 -1
  26. package/src/interfaces/web/dist/assets/index-HU-Wt2l9.css +1 -0
  27. package/src/interfaces/web/dist/index.html +2 -2
  28. package/src/interfaces/web/src/components/integrations/AsanaPlugin.tsx +275 -0
  29. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +44 -0
  30. package/src/interfaces/web/src/components/integrations/PluginCard.tsx +61 -0
  31. package/src/interfaces/web/src/components/integrations/PluginToolsSection.tsx +39 -0
  32. package/src/interfaces/web/src/components/settings/SkillsManager.tsx +465 -0
  33. package/src/interfaces/web/src/components/settings/SkillsSettings.tsx +49 -0
  34. package/src/interfaces/web/src/i18n/en.ts +67 -0
  35. package/src/interfaces/web/src/i18n/es.ts +67 -0
  36. package/src/interfaces/web/src/lib/api/integrations.ts +106 -0
  37. package/src/interfaces/web/src/lib/api/skills.ts +79 -8
  38. package/src/interfaces/web/src/lib/api.ts +1 -0
  39. package/src/interfaces/web/src/screens/ProjectScreen.tsx +12 -4
  40. package/src/interfaces/web/src/screens/SettingsScreen.tsx +3 -3
  41. package/src/interfaces/web/src/screens/project/IntegrationsTab.tsx +146 -0
  42. package/src/interfaces/web/src/screens/project/SkillsTab.tsx +13 -0
  43. package/src/interfaces/web/dist/assets/index-Cl0WXtxF.css +0 -1
  44. package/src/interfaces/web/dist/assets/index-DPAuXATr.js +0 -705
@@ -0,0 +1,465 @@
1
+ import { useMemo, useRef, useState, type ReactNode } from "react";
2
+ import useSWR from "swr";
3
+ import {
4
+ ChevronDown, Code2, FileText, GitBranch, Lock, PencilLine,
5
+ Plus, RotateCcw, Trash2, Upload,
6
+ } from "lucide-react";
7
+ import { Button, Field, Input, Textarea, Switch, Badge, Loading, Tip, Dialog } from "../ui";
8
+ import { UiSelect } from "../UiSelect";
9
+ import {
10
+ DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
11
+ } from "../ui/dropdown-menu";
12
+ import { useToast } from "../Toast";
13
+ import { Skills, type SkillEntry } from "../../lib/api/skills";
14
+ import { Projects } from "../../lib/api/projects";
15
+ import { t } from "../../i18n";
16
+
17
+ // Claude-Desktop-style skills manager: a left list of installed skills + a right
18
+ // viewer that renders the selected SKILL.md, with per-scope on/off, delete, and
19
+ // an "Add" dropdown (create online / upload .zip / clone git repo).
20
+ //
21
+ // Scope: "default" = the super-agent baseline; a project path = that project.
22
+ // Reusable with a FIXED scope (embedded in a project screen) or a SELECTABLE
23
+ // scope (the global settings tab, which shows a scope picker).
24
+
25
+ const SUPER = "default";
26
+
27
+ function basename(p: string): string {
28
+ const parts = p.replace(/\/+$/, "").split("/");
29
+ return parts[parts.length - 1] || p;
30
+ }
31
+
32
+ function sourceBadge(source: string): { label: string; tone: "info" | "success" | "muted" } {
33
+ if (source === "builtin") return { label: t("skills_page.source_builtin"), tone: "info" };
34
+ if (source === "project") return { label: t("skills_page.source_project"), tone: "success" };
35
+ return { label: t("skills_page.source_global"), tone: "muted" };
36
+ }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Minimal Markdown renderer (no dependency) — headings, lists, code, bold.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function inline(s: string): ReactNode {
43
+ const parts: ReactNode[] = [];
44
+ const re = /(\*\*[^*]+\*\*|`[^`]+`)/g;
45
+ let last = 0, m: RegExpExecArray | null, k = 0;
46
+ while ((m = re.exec(s))) {
47
+ if (m.index > last) parts.push(s.slice(last, m.index));
48
+ const tok = m[0];
49
+ if (tok.startsWith("**")) parts.push(<strong key={k++}>{tok.slice(2, -2)}</strong>);
50
+ else parts.push(<code key={k++} className="rounded bg-muted px-1 py-0.5 text-[0.85em]">{tok.slice(1, -1)}</code>);
51
+ last = m.index + tok.length;
52
+ }
53
+ if (last < s.length) parts.push(s.slice(last));
54
+ return parts;
55
+ }
56
+
57
+ function renderMarkdown(md: string): ReactNode {
58
+ const lines = md.split("\n");
59
+ const out: ReactNode[] = [];
60
+ let i = 0, key = 0;
61
+ while (i < lines.length) {
62
+ const line = lines[i];
63
+ if (line.trim().startsWith("```")) {
64
+ const buf: string[] = [];
65
+ i++;
66
+ while (i < lines.length && !lines[i].trim().startsWith("```")) { buf.push(lines[i]); i++; }
67
+ i++;
68
+ out.push(
69
+ <pre key={key++} className="my-2 overflow-x-auto rounded-md border border-border bg-muted/50 p-3 text-xs">
70
+ <code>{buf.join("\n")}</code>
71
+ </pre>,
72
+ );
73
+ continue;
74
+ }
75
+ const h = line.match(/^(#{1,4})\s+(.*)$/);
76
+ if (h) {
77
+ const lvl = h[1].length;
78
+ const cls = lvl === 1 ? "mt-4 mb-1 text-lg font-semibold"
79
+ : lvl === 2 ? "mt-3 mb-1 text-base font-semibold"
80
+ : "mt-2 mb-0.5 text-sm font-semibold";
81
+ out.push(<div key={key++} className={cls}>{inline(h[2])}</div>);
82
+ i++; continue;
83
+ }
84
+ if (/^\s*[-*]\s+/.test(line)) {
85
+ const items: ReactNode[] = [];
86
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
87
+ items.push(<li key={items.length}>{inline(lines[i].replace(/^\s*[-*]\s+/, ""))}</li>);
88
+ i++;
89
+ }
90
+ out.push(<ul key={key++} className="my-1 list-disc space-y-0.5 pl-5 text-sm">{items}</ul>);
91
+ continue;
92
+ }
93
+ if (line.trim() === "") { i++; continue; }
94
+ const buf: string[] = [];
95
+ while (
96
+ i < lines.length && lines[i].trim() !== "" &&
97
+ !/^(#{1,4})\s/.test(lines[i]) && !/^\s*[-*]\s+/.test(lines[i]) &&
98
+ !lines[i].trim().startsWith("```")
99
+ ) { buf.push(lines[i]); i++; }
100
+ out.push(<p key={key++} className="my-1.5 text-sm leading-relaxed">{inline(buf.join(" "))}</p>);
101
+ }
102
+ return out;
103
+ }
104
+
105
+ function fileToBase64(file: File): Promise<string> {
106
+ return new Promise((resolve, reject) => {
107
+ const r = new FileReader();
108
+ r.onload = () => resolve(String(r.result).replace(/^data:.*;base64,/, ""));
109
+ r.onerror = () => reject(new Error("read failed"));
110
+ r.readAsDataURL(file);
111
+ });
112
+ }
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Main
116
+ // ---------------------------------------------------------------------------
117
+
118
+ export function SkillsManager({ scope: fixedScope, selectable = false }: { scope?: string; selectable?: boolean }) {
119
+ const toast = useToast();
120
+ const [scopeState, setScopeState] = useState<string>(fixedScope ?? SUPER);
121
+ const scope = fixedScope ?? scopeState;
122
+ const projectPath = scope === SUPER ? undefined : scope;
123
+
124
+ const [busy, setBusy] = useState(false);
125
+ const [picked, setPicked] = useState<string | null>(null);
126
+ const [view, setView] = useState<"preview" | "source">("preview");
127
+ const [createOpen, setCreateOpen] = useState(false);
128
+ const [repoOpen, setRepoOpen] = useState(false);
129
+ const fileRef = useRef<HTMLInputElement>(null);
130
+
131
+ const { data: projects } = useSWR(selectable ? "/projects" : null, () => Projects.list());
132
+ const scopeOptions = useMemo(() => [
133
+ { value: SUPER, label: t("skills_page.scope_super_agent") },
134
+ ...(projects ?? []).map((p) => ({ value: p.path, label: p.name || basename(p.path) })),
135
+ ], [projects]);
136
+
137
+ const { data, mutate, isLoading } = useSWR(["/skills", scope], () => Skills.list(projectPath));
138
+ const skills = useMemo(() => data?.skills ?? [], [data]);
139
+ const onCount = skills.filter((s) => s.enabled !== false).length;
140
+
141
+ // Derived selection: keep the picked slug if still present, else first.
142
+ const selected = picked && skills.some((s) => s.slug === picked) ? picked : (skills[0]?.slug ?? null);
143
+ const { data: detail } = useSWR(
144
+ selected ? ["/skill-detail", scope, selected] : null,
145
+ () => Skills.detail(selected!, projectPath),
146
+ );
147
+
148
+ const setEnabled = async (slug: string, enabled: boolean | null) => {
149
+ setBusy(true);
150
+ try { await Skills.setEnabled({ slug, enabled, scope }); await mutate(); }
151
+ catch (e) { toast.error(t("skills_page.toggle_failed", { msg: (e as Error).message })); }
152
+ finally { setBusy(false); }
153
+ };
154
+
155
+ const remove = async (slug: string) => {
156
+ if (!window.confirm(t("skills_page.delete_confirm", { slug }))) return;
157
+ setBusy(true);
158
+ try {
159
+ await Skills.remove(slug, projectPath);
160
+ toast.success(t("skills_page.deleted_ok", { slug }));
161
+ if (picked === slug) setPicked(null);
162
+ await mutate();
163
+ } catch (e) { toast.error(t("skills_page.delete_failed", { msg: (e as Error).message })); }
164
+ finally { setBusy(false); }
165
+ };
166
+
167
+ const afterAdd = async (slug: string, okMsg: string) => {
168
+ toast.success(okMsg);
169
+ setPicked(slug);
170
+ await mutate();
171
+ };
172
+
173
+ const createSkill = async (slug: string, description: string, body: string) => {
174
+ await Skills.create({ slug, description, body, project_path: projectPath });
175
+ await afterAdd(slug, t("skills_page.created_ok", { slug }));
176
+ };
177
+ const importRepo = async (url: string) => {
178
+ const r = await Skills.importRepo({ url, project_path: projectPath });
179
+ await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
180
+ };
181
+ const onPickZip = async (file: File | undefined) => {
182
+ if (!file) return;
183
+ setBusy(true);
184
+ try {
185
+ const data64 = await fileToBase64(file);
186
+ const r = await Skills.importZip({ data: data64, project_path: projectPath });
187
+ await afterAdd(r.slug, t("skills_page.imported_ok", { slug: r.slug }));
188
+ } catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
189
+ finally { setBusy(false); if (fileRef.current) fileRef.current.value = ""; }
190
+ };
191
+
192
+ return (
193
+ <div className="space-y-4">
194
+ {/* Header: scope + count + Add */}
195
+ <div className="flex flex-wrap items-center justify-between gap-3">
196
+ <div className="flex flex-wrap items-center gap-3">
197
+ {selectable ? (
198
+ <div className="w-64">
199
+ <UiSelect value={scope} onChange={(v) => { setScopeState(v); setPicked(null); }}
200
+ options={scopeOptions} placeholder={t("skills_page.scope_ph")} />
201
+ </div>
202
+ ) : null}
203
+ <Badge tone="muted">{t("skills_page.count_label", { n: skills.length, on: onCount })}</Badge>
204
+ </div>
205
+
206
+ <DropdownMenu>
207
+ <DropdownMenuTrigger
208
+ disabled={busy}
209
+ className="inline-flex h-9 items-center gap-1.5 rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
210
+ >
211
+ <Plus size={15} /> {t("skills_page.add_menu")} <ChevronDown size={14} />
212
+ </DropdownMenuTrigger>
213
+ <DropdownMenuContent align="end" sideOffset={6} className="w-72">
214
+ <DropdownMenuItem onClick={() => setCreateOpen(true)}>
215
+ <PencilLine size={15} className="text-muted-fg" />
216
+ <AddItemLabel title={t("skills_page.add_online")} hint={t("skills_page.add_online_hint")} />
217
+ </DropdownMenuItem>
218
+ <DropdownMenuItem onClick={() => fileRef.current?.click()}>
219
+ <Upload size={15} className="text-muted-fg" />
220
+ <AddItemLabel title={t("skills_page.add_zip")} hint={t("skills_page.add_zip_hint")} />
221
+ </DropdownMenuItem>
222
+ <DropdownMenuItem onClick={() => setRepoOpen(true)}>
223
+ <GitBranch size={15} className="text-muted-fg" />
224
+ <AddItemLabel title={t("skills_page.add_repo")} hint={t("skills_page.add_repo_hint")} />
225
+ </DropdownMenuItem>
226
+ </DropdownMenuContent>
227
+ </DropdownMenu>
228
+ <input ref={fileRef} type="file" accept=".zip" className="hidden"
229
+ onChange={(e) => onPickZip(e.target.files?.[0])} />
230
+ </div>
231
+
232
+ {/* Body: list + viewer */}
233
+ {isLoading || !data ? (
234
+ <Loading />
235
+ ) : (
236
+ <div className="grid min-h-[62vh] gap-4 lg:grid-cols-[20rem_1fr]">
237
+ {/* List */}
238
+ <div className="overflow-hidden rounded-xl border border-border bg-card">
239
+ <ul className="max-h-[62vh] divide-y divide-border overflow-y-auto">
240
+ {skills.length === 0 ? (
241
+ <li className="px-3 py-4 text-sm text-muted-fg">{t("skills_page.empty")}</li>
242
+ ) : skills.map((s) => (
243
+ <SkillRow key={s.slug} skill={s} active={s.slug === selected} busy={busy}
244
+ onSelect={() => setPicked(s.slug)}
245
+ onToggle={(v) => setEnabled(s.slug, v)} />
246
+ ))}
247
+ </ul>
248
+ </div>
249
+
250
+ {/* Viewer */}
251
+ <div className="min-w-0 overflow-hidden rounded-xl border border-border bg-card">
252
+ {!selected ? (
253
+ <div className="grid h-full place-items-center p-8 text-sm text-muted-fg">
254
+ {t("skills_page.select_a_skill")}
255
+ </div>
256
+ ) : !detail ? (
257
+ <div className="p-6"><Loading /></div>
258
+ ) : (
259
+ <div className="flex h-full max-h-[62vh] flex-col">
260
+ {/* Viewer header */}
261
+ <div className="flex items-start justify-between gap-3 border-b border-border px-5 py-4">
262
+ <div className="min-w-0">
263
+ <div className="flex flex-wrap items-center gap-2">
264
+ <code className="text-sm font-semibold">{detail.slug}</code>
265
+ {(() => { const b = sourceBadge(detail.source); return <Badge tone={b.tone}>{b.label}</Badge>; })()}
266
+ {detail.private && (
267
+ <span className="inline-flex items-center gap-1 text-xs text-muted-fg">
268
+ <Lock size={11} /> {t("skills_page.private_badge")}
269
+ </span>
270
+ )}
271
+ </div>
272
+ {detail.description && (
273
+ <p className="mt-1 text-sm text-muted-fg">{detail.description}</p>
274
+ )}
275
+ <div className="mt-2 flex flex-wrap gap-x-6 gap-y-1 text-xs text-muted-fg">
276
+ <span>{t("skills_page.added_by")}: <span className="text-foreground">
277
+ {detail.private ? t("skills_page.by_apx") : t("skills_page.by_you")}</span></span>
278
+ <span>{t("skills_page.activator")}: <span className="text-foreground">
279
+ {t("skills_page.activator_value")}</span></span>
280
+ </div>
281
+ </div>
282
+ <div className="flex shrink-0 items-center gap-2">
283
+ {(detail.source === "global" || detail.source === "project") && (
284
+ <Tip content={t("skills_page.delete_btn")}>
285
+ <Button variant="ghost" size="sm" disabled={busy}
286
+ onClick={() => remove(detail.slug)} aria-label={t("skills_page.delete_btn")}>
287
+ <Trash2 size={14} />
288
+ </Button>
289
+ </Tip>
290
+ )}
291
+ <Switch checked={detail.private ? true : detail.enabled}
292
+ disabled={busy || detail.private}
293
+ onChange={(v) => setEnabled(detail.slug, v)}
294
+ label={(detail.private ? true : detail.enabled) ? t("skills_page.on") : t("skills_page.off")} />
295
+ </div>
296
+ </div>
297
+
298
+ {/* View toggle */}
299
+ <div className="flex items-center gap-1 border-b border-border px-4 py-2">
300
+ <ViewTab active={view === "preview"} onClick={() => setView("preview")}
301
+ icon={FileText} label={t("skills_page.tab_preview")} />
302
+ <ViewTab active={view === "source"} onClick={() => setView("source")}
303
+ icon={Code2} label={t("skills_page.tab_source")} />
304
+ </div>
305
+
306
+ {/* Body */}
307
+ <div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
308
+ {view === "preview" ? (
309
+ <div className="prose-none">{renderMarkdown(detail.body || "")}</div>
310
+ ) : (
311
+ <pre className="overflow-x-auto whitespace-pre-wrap break-words text-xs leading-relaxed text-muted-fg">
312
+ {detail.body}
313
+ </pre>
314
+ )}
315
+ </div>
316
+ </div>
317
+ )}
318
+ </div>
319
+ </div>
320
+ )}
321
+
322
+ {/* Dialogs */}
323
+ <CreateDialog open={createOpen} onClose={() => setCreateOpen(false)}
324
+ onCreate={createSkill} />
325
+ <RepoDialog open={repoOpen} onClose={() => setRepoOpen(false)}
326
+ onImport={importRepo} />
327
+ </div>
328
+ );
329
+ }
330
+
331
+ // ---------------------------------------------------------------------------
332
+ // Sub-components
333
+ // ---------------------------------------------------------------------------
334
+
335
+ function AddItemLabel({ title, hint }: { title: string; hint: string }) {
336
+ return (
337
+ <span className="flex min-w-0 flex-col leading-tight">
338
+ <span className="font-medium">{title}</span>
339
+ <span className="text-[11px] text-muted-fg">{hint}</span>
340
+ </span>
341
+ );
342
+ }
343
+
344
+ function ViewTab({ active, onClick, icon: Icon, label }: {
345
+ active: boolean; onClick: () => void; icon: React.ElementType; label: string;
346
+ }) {
347
+ return (
348
+ <button type="button" onClick={onClick}
349
+ className={`inline-flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs font-medium transition ${
350
+ active ? "bg-accent text-foreground" : "text-muted-fg hover:text-foreground"}`}>
351
+ <Icon size={13} /> {label}
352
+ </button>
353
+ );
354
+ }
355
+
356
+ function SkillRow({ skill, active, busy, onSelect, onToggle }: {
357
+ skill: SkillEntry; active: boolean; busy: boolean;
358
+ onSelect: () => void; onToggle: (v: boolean) => void;
359
+ }) {
360
+ const b = sourceBadge(skill.source);
361
+ const enabled = skill.private ? true : skill.enabled !== false;
362
+ return (
363
+ <li>
364
+ <div className={`flex items-center gap-2 px-3 py-2.5 ${active ? "bg-accent/50" : "hover:bg-accent/25"}`}>
365
+ <button type="button" onClick={onSelect} className="min-w-0 flex-1 text-left">
366
+ <div className="flex items-center gap-1.5">
367
+ <code className="truncate text-[13px] font-medium">{skill.slug}</code>
368
+ {skill.private && <Lock size={10} className="shrink-0 text-muted-fg" />}
369
+ </div>
370
+ <div className="mt-0.5 flex items-center gap-1.5">
371
+ <Badge tone={b.tone}>{b.label}</Badge>
372
+ {skill.overridden && <Badge tone="warning">{t("skills_page.overridden_badge")}</Badge>}
373
+ </div>
374
+ </button>
375
+ <Switch checked={enabled} disabled={busy || skill.private} onChange={onToggle} />
376
+ </div>
377
+ </li>
378
+ );
379
+ }
380
+
381
+ function CreateDialog({ open, onClose, onCreate }: {
382
+ open: boolean; onClose: () => void;
383
+ onCreate: (slug: string, description: string, body: string) => Promise<void>;
384
+ }) {
385
+ const toast = useToast();
386
+ const [slug, setSlug] = useState("");
387
+ const [description, setDescription] = useState("");
388
+ const [body, setBody] = useState("");
389
+ const [busy, setBusy] = useState(false);
390
+ const valid = /^[a-z0-9][a-z0-9-]*$/.test(slug);
391
+
392
+ const reset = () => { setSlug(""); setDescription(""); setBody(""); };
393
+ const submit = async () => {
394
+ if (!valid) return;
395
+ setBusy(true);
396
+ try { await onCreate(slug, description, body); reset(); onClose(); }
397
+ catch (e) { toast.error(t("skills_page.create_failed", { msg: (e as Error).message })); }
398
+ finally { setBusy(false); }
399
+ };
400
+
401
+ return (
402
+ <Dialog open={open} onClose={onClose} title={t("skills_page.create_dialog_title")}
403
+ description={t("skills_page.add_desc")} size="lg"
404
+ footer={
405
+ <>
406
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
407
+ <Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
408
+ <Plus size={14} /> {t("skills_page.add_btn")}
409
+ </Button>
410
+ </>
411
+ }>
412
+ <div className="space-y-4">
413
+ <div className="grid gap-4 sm:grid-cols-2">
414
+ <Field label={t("skills_page.add_slug_label")}>
415
+ <Input value={slug} placeholder={t("skills_page.add_slug_ph")} disabled={busy}
416
+ onChange={(e) => setSlug(e.target.value.toLowerCase())} />
417
+ </Field>
418
+ <Field label={t("skills_page.add_desc_label")}>
419
+ <Input value={description} placeholder={t("skills_page.add_desc_ph")} disabled={busy}
420
+ onChange={(e) => setDescription(e.target.value)} />
421
+ </Field>
422
+ </div>
423
+ <Field label={t("skills_page.add_body_label")}>
424
+ <Textarea value={body} placeholder={t("skills_page.add_body_ph")} disabled={busy} rows={10}
425
+ onChange={(e) => setBody(e.target.value)} />
426
+ </Field>
427
+ </div>
428
+ </Dialog>
429
+ );
430
+ }
431
+
432
+ function RepoDialog({ open, onClose, onImport }: {
433
+ open: boolean; onClose: () => void; onImport: (url: string) => Promise<void>;
434
+ }) {
435
+ const toast = useToast();
436
+ const [url, setUrl] = useState("");
437
+ const [busy, setBusy] = useState(false);
438
+ const valid = /^(https?:\/\/|git@|ssh:\/\/|git:\/\/)\S+$/.test(url.trim());
439
+
440
+ const submit = async () => {
441
+ if (!valid) return;
442
+ setBusy(true);
443
+ try { await onImport(url.trim()); setUrl(""); onClose(); }
444
+ catch (e) { toast.error(t("skills_page.import_failed", { msg: (e as Error).message })); }
445
+ finally { setBusy(false); }
446
+ };
447
+
448
+ return (
449
+ <Dialog open={open} onClose={onClose} title={t("skills_page.repo_dialog_title")} size="md"
450
+ footer={
451
+ <>
452
+ <Button variant="ghost" onClick={onClose} disabled={busy}>{t("skills_page.cancel")}</Button>
453
+ <Button variant="primary" onClick={submit} disabled={busy || !valid} loading={busy}>
454
+ <GitBranch size={14} /> {t("skills_page.import_btn")}
455
+ </Button>
456
+ </>
457
+ }>
458
+ <Field label={t("skills_page.repo_url_label")} hint={t("skills_page.repo_url_hint")}>
459
+ <Input value={url} placeholder={t("skills_page.repo_url_ph")} disabled={busy}
460
+ onChange={(e) => setUrl(e.target.value)}
461
+ onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
462
+ </Field>
463
+ </Dialog>
464
+ );
465
+ }
@@ -0,0 +1,49 @@
1
+ import { useSearchParams } from "react-router-dom";
2
+ import { Sparkles, SlidersHorizontal } from "lucide-react";
3
+ import { SkillsManager } from "./SkillsManager";
4
+ import { SkillsInspectorPanel } from "./SkillsInspectorPanel";
5
+ import { t } from "../../i18n";
6
+
7
+ // One settings entry ("Skills") with two inner tabs, deep-linkable via ?tab=:
8
+ // ?tab=manager (default) → the scope-aware skills manager
9
+ // ?tab=rag → the Skill Inspector (per-turn RAG) config
10
+ type Tab = "manager" | "rag";
11
+
12
+ export function SkillsSettings() {
13
+ const [params, setParams] = useSearchParams();
14
+ const tab: Tab = params.get("tab") === "rag" ? "rag" : "manager";
15
+
16
+ const setTab = (v: Tab) => {
17
+ const next = new URLSearchParams(params);
18
+ next.set("tab", v);
19
+ setParams(next, { replace: true });
20
+ };
21
+
22
+ return (
23
+ <div className="space-y-5">
24
+ <div className="flex items-center gap-1 border-b border-border">
25
+ <SubTab active={tab === "manager"} onClick={() => setTab("manager")}
26
+ icon={Sparkles} label={t("skills_page.manager_tab")} />
27
+ <SubTab active={tab === "rag"} onClick={() => setTab("rag")}
28
+ icon={SlidersHorizontal} label={t("skills_page.rag_tab")} />
29
+ </div>
30
+
31
+ {tab === "manager" ? <SkillsManager selectable /> : <SkillsInspectorPanel />}
32
+ </div>
33
+ );
34
+ }
35
+
36
+ function SubTab({ active, onClick, icon: Icon, label }: {
37
+ active: boolean; onClick: () => void; icon: React.ElementType; label: string;
38
+ }) {
39
+ return (
40
+ <button type="button" onClick={onClick}
41
+ className={`-mb-px inline-flex items-center gap-1.5 border-b-2 px-3 py-2 text-sm font-medium transition ${
42
+ active
43
+ ? "border-foreground text-foreground"
44
+ : "border-transparent text-muted-fg hover:text-foreground"
45
+ }`}>
46
+ <Icon size={15} /> {label}
47
+ </button>
48
+ );
49
+ }
@@ -1685,6 +1685,73 @@ export const en = {
1685
1685
  cfg_apx_storage_id: "APX storage id",
1686
1686
  },
1687
1687
 
1688
+ skills_page: {
1689
+ title: "Skills",
1690
+ desc: "Turn skills on or off per agent. Pick a scope: the super-agent (global) or a specific project.",
1691
+ list_title: "Installed skills",
1692
+ list_desc: "APX's private skills are always active and can't be changed.",
1693
+ scope_label: "Scope",
1694
+ scope_super_agent: "Super-agent (global)",
1695
+ scope_hint: "The super-agent uses the global scope. Each project can override skills independently.",
1696
+ count_label: "{n} skills · {on} on",
1697
+ empty: "No skills yet. Create one below or install via the CLI.",
1698
+ source_builtin: "APX",
1699
+ source_global: "Global",
1700
+ source_project: "Project",
1701
+ private_badge: "Private",
1702
+ private_hint: "Built-in APX skill — always active, can't be disabled or deleted.",
1703
+ overridden_badge: "Override",
1704
+ inherited_hint: "Inherited from global",
1705
+ reset_to_global: "Reset to global",
1706
+ on: "on",
1707
+ off: "off",
1708
+ toggle_failed: "Could not change state: {msg}",
1709
+ add_title: "Add a skill",
1710
+ add_desc: "Creates a user skill at ~/.apx/skills/<slug>/SKILL.md. Available across all scopes.",
1711
+ add_slug_label: "Slug",
1712
+ add_slug_ph: "my-skill",
1713
+ add_desc_label: "Description",
1714
+ add_desc_ph: "One line describing when to use it",
1715
+ add_body_label: "Body (Markdown)",
1716
+ add_body_ph: "# My skill\n\nInstructions for the agent…",
1717
+ add_btn: "Create skill",
1718
+ created_ok: "Skill \"{slug}\" created.",
1719
+ create_failed: "Could not create: {msg}",
1720
+ delete_btn: "Delete",
1721
+ delete_confirm: "Delete skill \"{slug}\"? This can't be undone.",
1722
+ deleted_ok: "Skill \"{slug}\" deleted.",
1723
+ delete_failed: "Could not delete: {msg}",
1724
+ inspector_section_title: "Skill Inspector (per-turn RAG)",
1725
+ inspector_section_desc: "Advanced: local RAG that injects only the skills a message needs.",
1726
+ scope_ph: "— choose scope —",
1727
+ select_a_skill: "Pick a skill from the list to see its content.",
1728
+ added_by: "Added by",
1729
+ activator: "Activator",
1730
+ by_apx: "APX (built-in)",
1731
+ by_you: "You",
1732
+ activator_value: "Semantic match (RAG)",
1733
+ tab_preview: "Preview",
1734
+ tab_source: "Source",
1735
+ add_menu: "Add",
1736
+ add_online: "Create with editor",
1737
+ add_online_hint: "Write slug + description + content",
1738
+ add_zip: "Upload .zip",
1739
+ add_zip_hint: "Import a packaged skill",
1740
+ add_repo: "From git repo",
1741
+ add_repo_hint: "Clone from a URL",
1742
+ create_dialog_title: "Create skill",
1743
+ repo_dialog_title: "Import from git repo",
1744
+ repo_url_label: "Repo URL",
1745
+ repo_url_ph: "https://github.com/user/my-skill.git",
1746
+ repo_url_hint: "The repo (or its subfolder) must contain a SKILL.md.",
1747
+ import_btn: "Import",
1748
+ imported_ok: "Skill \"{slug}\" imported.",
1749
+ import_failed: "Could not import: {msg}",
1750
+ cancel: "Cancel",
1751
+ manager_tab: "Skills",
1752
+ rag_tab: "Config (RAG)",
1753
+ },
1754
+
1688
1755
  shared_ui: {
1689
1756
  skill_inspector_title: "Skill Inspector ({embedder}) chose these skills for this turn",
1690
1757
  tools_count: "{n} tools",
@@ -1683,6 +1683,73 @@ export const es = {
1683
1683
  cfg_apx_storage_id: "ID de storage APX",
1684
1684
  },
1685
1685
 
1686
+ skills_page: {
1687
+ title: "Skills",
1688
+ desc: "Activá o desactivá qué skills carga cada agente. Elegí el scope: el super-agent (global) o un proyecto puntual.",
1689
+ list_title: "Skills instaladas",
1690
+ list_desc: "Las privadas de APX están siempre activas y no se pueden tocar.",
1691
+ scope_label: "Scope",
1692
+ scope_super_agent: "Super-agent (global)",
1693
+ scope_hint: "El super-agent usa el scope global. Cada proyecto puede sobrescribir skills de forma independiente.",
1694
+ count_label: "{n} skills · {on} activas",
1695
+ empty: "No hay skills. Creá una abajo o instalá con la CLI.",
1696
+ source_builtin: "APX",
1697
+ source_global: "Global",
1698
+ source_project: "Proyecto",
1699
+ private_badge: "Privada",
1700
+ private_hint: "Skill interna de APX — siempre activa, no se puede desactivar ni borrar.",
1701
+ overridden_badge: "Override",
1702
+ inherited_hint: "Heredada del global",
1703
+ reset_to_global: "Volver al global",
1704
+ on: "activa",
1705
+ off: "inactiva",
1706
+ toggle_failed: "No se pudo cambiar el estado: {msg}",
1707
+ add_title: "Agregar skill",
1708
+ add_desc: "Crea una skill de usuario en ~/.apx/skills/<slug>/SKILL.md. Queda disponible para todos los scopes.",
1709
+ add_slug_label: "Slug",
1710
+ add_slug_ph: "mi-skill",
1711
+ add_desc_label: "Descripción",
1712
+ add_desc_ph: "Una línea que explique cuándo usarla",
1713
+ add_body_label: "Cuerpo (Markdown)",
1714
+ add_body_ph: "# Mi skill\n\nInstrucciones para el agente…",
1715
+ add_btn: "Crear skill",
1716
+ created_ok: "Skill \"{slug}\" creada.",
1717
+ create_failed: "No se pudo crear: {msg}",
1718
+ delete_btn: "Borrar",
1719
+ delete_confirm: "¿Borrar la skill \"{slug}\"? No se puede deshacer.",
1720
+ deleted_ok: "Skill \"{slug}\" borrada.",
1721
+ delete_failed: "No se pudo borrar: {msg}",
1722
+ inspector_section_title: "Skill Inspector (RAG por turno)",
1723
+ inspector_section_desc: "Config avanzada: RAG local que inyecta solo las skills que el mensaje necesita.",
1724
+ scope_ph: "— elegir scope —",
1725
+ select_a_skill: "Elegí una skill de la lista para ver su contenido.",
1726
+ added_by: "Agregado por",
1727
+ activator: "Activador",
1728
+ by_apx: "APX (built-in)",
1729
+ by_you: "Vos",
1730
+ activator_value: "Coincidencia semántica (RAG)",
1731
+ tab_preview: "Vista",
1732
+ tab_source: "Fuente",
1733
+ add_menu: "Agregar",
1734
+ add_online: "Crear con el editor",
1735
+ add_online_hint: "Escribí slug + descripción + contenido",
1736
+ add_zip: "Subir .zip",
1737
+ add_zip_hint: "Importar una skill empaquetada",
1738
+ add_repo: "Desde repo git",
1739
+ add_repo_hint: "Clonar desde una URL",
1740
+ create_dialog_title: "Crear skill",
1741
+ repo_dialog_title: "Importar desde repo git",
1742
+ repo_url_label: "URL del repo",
1743
+ repo_url_ph: "https://github.com/usuario/mi-skill.git",
1744
+ repo_url_hint: "El repo (o su subcarpeta) debe tener un SKILL.md.",
1745
+ import_btn: "Importar",
1746
+ imported_ok: "Skill \"{slug}\" importada.",
1747
+ import_failed: "No se pudo importar: {msg}",
1748
+ cancel: "Cancelar",
1749
+ manager_tab: "Skills",
1750
+ rag_tab: "Config (RAG)",
1751
+ },
1752
+
1686
1753
  shared_ui: {
1687
1754
  skill_inspector_title: "Skill Inspector ({embedder}) eligió estas skills para este turno",
1688
1755
  tools_count: "{n} tools",