kcg-code 0.1.0 → 0.2.1

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 (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -4,6 +4,9 @@
4
4
  * - Header: judul + pemilih Project (pil "Install to"). Project terpilih =
5
5
  * `?project=` (dari sidebar) > pilihan terakhir (localStorage) > satu-
6
6
  * satunya Project. Pilihan memperbarui URL (`replaceState`).
7
+ * - Skill terpasang dibaca dari KEDUA agent (OpenCode & Claude Code) lalu
8
+ * digabung (`mergeInstalledSkills`); tiap kartu menampilkan logo agent
9
+ * tempat skill itu terpasang. Agent tujuan dipilih di dialog Install.
7
10
  * - Dua tab memakai kolom cari yang sama, tapi query disimpan per tab:
8
11
  * - Browse: hasil skills.sh (debounce, min. 2 karakter); saat kosong
9
12
  * tampil chip saran kata kunci.
@@ -37,6 +40,7 @@ import {
37
40
  import { Skeleton } from "@/components/ui/skeleton";
38
41
  import { Spinner } from "@/components/ui/spinner";
39
42
  import { useRouter } from "@/hooks/useRouter";
43
+ import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
40
44
  import { ApiError, apiFetch } from "@/lib/api";
41
45
  import { parseSkillsProject, projectsPath, skillsPath } from "@/lib/routes";
42
46
  import {
@@ -47,9 +51,10 @@ import {
47
51
  useInstalls,
48
52
  } from "@/lib/skill-installs";
49
53
  import {
50
- installedProjectSkillNames,
51
- isInstalled,
54
+ type InstalledSkill,
55
+ installedAgentsFor,
52
56
  LAST_SKILLS_PROJECT_KEY,
57
+ mergeInstalledSkills,
53
58
  resolveSkillsProject,
54
59
  SEARCH_SUGGESTIONS,
55
60
  visibleInstalledSkills,
@@ -57,7 +62,9 @@ import {
57
62
  import { cn } from "@/lib/utils";
58
63
  import type { SkillInfo } from "@/server/services/opencode-client";
59
64
  import type { RegistrySkill } from "@/server/services/skills-registry";
60
- import type { Project } from "@/types";
65
+ import type { AgentType, Project } from "@/types";
66
+
67
+ const EMPTY_INSTALLED: InstalledSkill[] = [];
61
68
 
62
69
  /** Jeda ketik sebelum memanggil skills.sh. */
63
70
  const SEARCH_DEBOUNCE_MS = 350;
@@ -219,7 +226,9 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
219
226
  const [queries, setQueries] = useState<Record<Tab, string>>({ browse: "", installed: "" });
220
227
  const query = queries[tab];
221
228
  const setQuery = useCallback((q: string) => setQueries((prev) => ({ ...prev, [tab]: q })), [tab]);
222
- const [installed, setInstalled] = useState<Load<SkillInfo[]>>({ phase: "idle" });
229
+ const [installed, setInstalled] = useState<Load<InstalledSkill[]>>({ phase: "idle" });
230
+ /** Sebagian agent gagal dibaca (daftar tetap tampil dari agent lain). */
231
+ const [installedWarning, setInstalledWarning] = useState<string | null>(null);
223
232
  const [reloadKey, setReloadKey] = useState(0);
224
233
  const [results, setResults] = useState<Load<RegistrySkill[]>>({ phase: "idle" });
225
234
  /** Permintaan pencarian sedang berjalan (hasil lama tetap tampil). */
@@ -244,22 +253,41 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
244
253
  return () => window.removeEventListener("keydown", onKey);
245
254
  }, []);
246
255
 
247
- // Skill terpasang (dibaca dari server headless opencode Project).
256
+ // Skill terpasang dari semua agent (opencode: server headless Project;
257
+ // Claude Code: `.claude/skills` Project + global), dibaca bersamaan lalu
258
+ // digabung. Satu agent gagal tidak menggagalkan yang lain.
248
259
  useEffect(() => {
249
260
  void reloadKey;
250
261
  if (!project) return;
251
262
  let cancelled = false;
252
263
  setInstalled((prev) => (prev.phase === "ready" ? prev : { phase: "loading" }));
253
- apiFetch(`/api/projects/${project.id}/skills`)
254
- .then((res) => res.json() as Promise<{ skills: SkillInfo[] }>)
255
- .then(({ skills }) => {
256
- if (!cancelled) setInstalled({ phase: "ready", data: skills });
257
- })
258
- .catch((e) => {
259
- if (!cancelled) {
260
- setInstalled({ phase: "error", message: errorMessage(e, "Failed to load skills") });
261
- }
264
+ void Promise.allSettled(
265
+ AGENT_TYPES.map((a) =>
266
+ apiFetch(`/api/projects/${project.id}/skills${agentQuery(a)}`)
267
+ .then((res) => res.json() as Promise<{ skills: SkillInfo[] }>)
268
+ .then(({ skills }) => skills),
269
+ ),
270
+ ).then((results) => {
271
+ if (cancelled) return;
272
+ const byAgent: Partial<Record<AgentType, SkillInfo[]>> = {};
273
+ const failed: { agent: AgentType; message: string }[] = [];
274
+ results.forEach((r, i) => {
275
+ const agent = AGENT_TYPES[i] as AgentType;
276
+ if (r.status === "fulfilled") byAgent[agent] = r.value;
277
+ else failed.push({ agent, message: errorMessage(r.reason, "Failed to load skills") });
262
278
  });
279
+ if (failed.length === AGENT_TYPES.length) {
280
+ setInstalled({ phase: "error", message: failed[0]?.message ?? "Failed to load skills" });
281
+ setInstalledWarning(null);
282
+ return;
283
+ }
284
+ setInstalled({ phase: "ready", data: mergeInstalledSkills(byAgent) });
285
+ setInstalledWarning(
286
+ failed.length > 0
287
+ ? `Couldn't read ${failed.map((f) => AGENT_TYPE_LABELS[f.agent]).join(", ")} skills: ${failed[0]?.message}`
288
+ : null,
289
+ );
290
+ });
263
291
  return () => {
264
292
  cancelled = true;
265
293
  };
@@ -298,11 +326,7 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
298
326
  };
299
327
  }, [browseQuery, canSearch]);
300
328
 
301
- const installedNames = useMemo(
302
- () =>
303
- installed.phase === "ready" ? installedProjectSkillNames(installed.data) : new Set<string>(),
304
- [installed],
305
- );
329
+ const installedList = installed.phase === "ready" ? installed.data : EMPTY_INSTALLED;
306
330
  const installedAll = useMemo(
307
331
  () => (installed.phase === "ready" ? visibleInstalledSkills(installed.data, "") : []),
308
332
  [installed],
@@ -325,12 +349,12 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
325
349
  }, [project]);
326
350
 
327
351
  /** Mulai job instalasi; progres & output tampil di notifikasi global. */
328
- const install = async (skill: RegistrySkill) => {
352
+ const install = async (skill: RegistrySkill, agents: AgentType[]) => {
329
353
  if (!project) return;
330
354
  setStarting(true);
331
355
  setStartError(null);
332
356
  try {
333
- await startInstall(project.id, skill.source, skill.skillId);
357
+ await startInstall(project.id, skill.source, skill.skillId, agents);
334
358
  setPending(null);
335
359
  } catch (e) {
336
360
  // Dialog tetap terbuka agar user melihat alasannya.
@@ -440,7 +464,7 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
440
464
  query={trimmed}
441
465
  canSearch={canSearch}
442
466
  results={results}
443
- installedNames={installedNames}
467
+ installedAgents={(skill) => installedAgentsFor(skill, installedList)}
444
468
  isInstalling={(skill) =>
445
469
  findRunningInstall(installs, project.id, skill.source, skill.skillId)?.job.id ??
446
470
  null
@@ -454,6 +478,7 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
454
478
  ) : (
455
479
  <InstalledPanel
456
480
  state={installed}
481
+ warning={installedWarning}
457
482
  skills={installedFiltered}
458
483
  total={installedAll.length}
459
484
  query={trimmed}
@@ -468,6 +493,7 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
468
493
  <InstallSkillDialog
469
494
  skill={pending}
470
495
  projectName={project.name}
496
+ installedAgents={pending ? installedAgentsFor(pending, installedList) : []}
471
497
  starting={starting}
472
498
  startError={startError}
473
499
  onOpenChange={(open) => {
@@ -476,7 +502,7 @@ function SkillsWorkspace({ project }: SkillsWorkspaceProps) {
476
502
  setStartError(null);
477
503
  }
478
504
  }}
479
- onConfirm={(s) => void install(s)}
505
+ onConfirm={(s, agents) => void install(s, agents)}
480
506
  />
481
507
  </>
482
508
  )}
@@ -531,7 +557,7 @@ function BrowsePanel({
531
557
  query,
532
558
  canSearch,
533
559
  results,
534
- installedNames,
560
+ installedAgents,
535
561
  isInstalling,
536
562
  onSuggest,
537
563
  onInstall,
@@ -539,7 +565,8 @@ function BrowsePanel({
539
565
  query: string;
540
566
  canSearch: boolean;
541
567
  results: Load<RegistrySkill[]>;
542
- installedNames: ReadonlySet<string>;
568
+ /** Agent yang sudah memuat skill katalog ini dari folder Project. */
569
+ installedAgents: (skill: RegistrySkill) => AgentType[];
543
570
  /** Id job instalasi berjalan untuk skill ini (null bila tidak ada). */
544
571
  isInstalling: (skill: RegistrySkill) => string | null;
545
572
  onSuggest: (q: string) => void;
@@ -615,7 +642,7 @@ function BrowsePanel({
615
642
  <RegistrySkillCard
616
643
  key={skill.id}
617
644
  skill={skill}
618
- installed={isInstalled(skill, installedNames)}
645
+ installedAgents={installedAgents(skill)}
619
646
  installing={jobId !== null}
620
647
  onInstall={onInstall}
621
648
  onShowProgress={() => {
@@ -631,13 +658,16 @@ function BrowsePanel({
631
658
 
632
659
  function InstalledPanel({
633
660
  state,
661
+ warning,
634
662
  skills,
635
663
  total,
636
664
  query,
637
665
  onBrowse,
638
666
  }: {
639
- state: Load<SkillInfo[]>;
640
- skills: SkillInfo[];
667
+ state: Load<InstalledSkill[]>;
668
+ /** Sebagian agent gagal dibaca. */
669
+ warning: string | null;
670
+ skills: InstalledSkill[];
641
671
  total: number;
642
672
  query: string;
643
673
  onBrowse: () => void;
@@ -654,25 +684,34 @@ function InstalledPanel({
654
684
  );
655
685
  }
656
686
 
687
+ const warningNote = warning && (
688
+ <p role="status" className="mb-3 text-xs text-muted-foreground">
689
+ {warning}
690
+ </p>
691
+ );
692
+
657
693
  if (total === 0) {
658
694
  return (
659
- <Empty className="border">
660
- <EmptyHeader>
661
- <EmptyMedia variant="icon">
662
- <SparklesIcon />
663
- </EmptyMedia>
664
- <EmptyTitle>No skills installed yet</EmptyTitle>
665
- <EmptyDescription>
666
- Browse skills.sh to add your first skill to this project.
667
- </EmptyDescription>
668
- </EmptyHeader>
669
- <EmptyContent>
670
- <Button type="button" variant="outline" onClick={onBrowse}>
671
- <SearchIcon data-icon="inline-start" />
672
- Browse skills
673
- </Button>
674
- </EmptyContent>
675
- </Empty>
695
+ <>
696
+ {warningNote}
697
+ <Empty className="border">
698
+ <EmptyHeader>
699
+ <EmptyMedia variant="icon">
700
+ <SparklesIcon />
701
+ </EmptyMedia>
702
+ <EmptyTitle>No skills installed yet</EmptyTitle>
703
+ <EmptyDescription>
704
+ Browse skills.sh to add your first skill to this project.
705
+ </EmptyDescription>
706
+ </EmptyHeader>
707
+ <EmptyContent>
708
+ <Button type="button" variant="outline" onClick={onBrowse}>
709
+ <SearchIcon data-icon="inline-start" />
710
+ Browse skills
711
+ </Button>
712
+ </EmptyContent>
713
+ </Empty>
714
+ </>
676
715
  );
677
716
  }
678
717
 
@@ -686,6 +725,11 @@ function InstalledPanel({
686
725
 
687
726
  return (
688
727
  <section aria-label="Installed skills" className="flex flex-col gap-3">
728
+ {warning && (
729
+ <p role="status" className="text-xs text-muted-foreground">
730
+ {warning}
731
+ </p>
732
+ )}
689
733
  {query !== "" && (
690
734
  <p className="text-xs text-muted-foreground" aria-live="polite">
691
735
  {skills.length} of {total} installed skills
@@ -261,6 +261,7 @@ describe("createKcgServer — alur utama e2e (headless)", () => {
261
261
  let app: KcgServer;
262
262
  let root: string;
263
263
  let servers: FakeServers;
264
+ let claudeServers: FakeServers;
264
265
  /** Instalasi yang diminta ke Skills_Registry palsu: [cwd, source, skill]. */
265
266
  const installCalls: [string, string, string][] = [];
266
267
  const skillsRegistry: SkillsRegistry = {
@@ -297,10 +298,12 @@ describe("createKcgServer — alur utama e2e (headless)", () => {
297
298
  root = mkdtempSync(path.join(tmpdir(), "kcg-e2e2-"));
298
299
  mkdirSync(path.join(root, "proj"), { recursive: true });
299
300
  servers = makeFakeServers();
301
+ claudeServers = makeFakeServers();
300
302
  app = createKcgServer({
301
303
  config: { sandboxRoot: root, configPath: "test" },
302
304
  store,
303
305
  servers: servers.manager,
306
+ claudeServers: claudeServers.manager,
304
307
  uploadsRoot: path.join(root, "uploads"),
305
308
  port: 0,
306
309
  skillsRegistry,
@@ -658,7 +661,7 @@ describe("createKcgServer — alur utama e2e (headless)", () => {
658
661
  expect(sessRes.status).toBe(200);
659
662
  });
660
663
 
661
- test("agentType claude-code ditolak (v1 headless hanya opencode)", async () => {
664
+ test("agentType claude-code memakai backend Claude Code; tipe tak dikenal ditolak", async () => {
662
665
  const projRes = await fetch(`${baseUrl()}/api/projects`, {
663
666
  method: "POST",
664
667
  headers: { "content-type": "application/json" },
@@ -678,10 +681,20 @@ describe("createKcgServer — alur utama e2e (headless)", () => {
678
681
  const sessRes = await fetch(`${baseUrl()}/api/sessions`, {
679
682
  method: "POST",
680
683
  headers: { "content-type": "application/json" },
681
- body: JSON.stringify({ agentType: "claude-code", projectId: body2.project.id }),
684
+ body: JSON.stringify({ agentType: "codex", projectId: body2.project.id }),
682
685
  });
683
686
  expect(sessRes.status).toBe(400);
684
687
  expect(((await sessRes.json()) as { error: string }).error).toBe("UNSUPPORTED_AGENT_TYPE");
688
+
689
+ const claudeRes = await fetch(`${baseUrl()}/api/sessions`, {
690
+ method: "POST",
691
+ headers: { "content-type": "application/json" },
692
+ body: JSON.stringify({ agentType: "claude-code", projectId: body2.project.id }),
693
+ });
694
+ expect(claudeRes.status).toBe(201);
695
+ const { session } = (await claudeRes.json()) as { session: Session };
696
+ expect(session.agentType).toBe("claude-code");
697
+ expect(session.ocSessionId).toBe(`ses_${body2.project.id}`);
685
698
  });
686
699
 
687
700
  test("body JSON tidak valid -> 400 INVALID_JSON", async () => {
package/src/server/app.ts CHANGED
@@ -42,6 +42,7 @@ import { workspaceRoutes } from "./routes/workspace.routes";
42
42
  import { type AttachmentManager, createAttachmentManager } from "./services/attachments";
43
43
  import { type AuthService, createAuthService } from "./services/auth";
44
44
  import { createPasskeyService } from "./services/auth-passkeys";
45
+ import { createClaudeCodeServerManager } from "./services/claude-code-client";
45
46
  import { createFrpcInstaller } from "./services/frpc-installer";
46
47
  import {
47
48
  createOpenCodeServerManager,
@@ -79,6 +80,8 @@ export interface KcgServerOptions {
79
80
  store?: SessionStore;
80
81
  /** Injeksi OpenCode_Server_Manager (untuk pengujian, task 20.2). */
81
82
  servers?: OpenCodeServerManager;
83
+ /** Injeksi backend Claude Code (Agent SDK) untuk pengujian. */
84
+ claudeServers?: OpenCodeServerManager;
82
85
  /** Injeksi Skills_Registry (skills.sh + CLI `skills`) untuk pengujian. */
83
86
  skillsRegistry?: SkillsRegistry;
84
87
  /** Injeksi Auth_Service (mis. hash cepat untuk test). */
@@ -125,6 +128,10 @@ export interface KcgServer {
125
128
  auth: AuthService;
126
129
  /** Tunnel publik (diekspos untuk dashboard terminal & pengujian). */
127
130
  tunnel: TunnelManager;
131
+ /** Tunnel localhost.run (diekspos untuk dashboard terminal). */
132
+ lhr: LhrManager;
133
+ /** Provider tunnel terpilih: "kcg" | "lhr". */
134
+ tunnelProvider: () => "kcg" | "lhr";
128
135
  /** Pola rute `/api/*` yang terdaftar (untuk test cakupan penjaga). */
129
136
  apiRoutePatterns: string[];
130
137
  /** Shutdown: simpan status running (budget 5s) -> stop server -> tutup store. */
@@ -166,6 +173,8 @@ export function createKcgServer(opts: KcgServerOptions = {}): KcgServer {
166
173
 
167
174
  const projectManager = createProjectManager(config.sandboxRoot, store);
168
175
  const servers = opts.servers ?? createOpenCodeServerManager();
176
+ // Proses `claude` baru di-spawn saat Session Claude Code menerima prompt.
177
+ const claudeServers = opts.claudeServers ?? createClaudeCodeServerManager();
169
178
  const attachments: AttachmentManager = createAttachmentManager(uploadsRoot);
170
179
 
171
180
  // Skills_Registry & job instalasi dibuat lebih dulu: session manager
@@ -174,7 +183,7 @@ export function createKcgServer(opts: KcgServerOptions = {}): KcgServer {
174
183
  const skillsRegistry = opts.skillsRegistry ?? createSkillsRegistry();
175
184
  const skillInstalls = createSkillInstallJobs({
176
185
  registry: skillsRegistry,
177
- refreshSkills: (projectId) => sessionManager.refreshSkills(projectId),
186
+ refreshSkills: (projectId, agentType) => sessionManager.refreshSkills(projectId, agentType),
178
187
  validate: (source, skill) => isValidSource(source) && isValidSkillId(skill),
179
188
  });
180
189
 
@@ -182,6 +191,7 @@ export function createKcgServer(opts: KcgServerOptions = {}): KcgServer {
182
191
  const sessionManager = createSessionManager({
183
192
  store,
184
193
  servers,
194
+ claudeServers,
185
195
  attachments,
186
196
  onMessage: (message) => gateway.notifyMessage(message.sessionId, message),
187
197
  onMessagePart: (sessionId, messageId, part) =>
@@ -409,6 +419,8 @@ export function createKcgServer(opts: KcgServerOptions = {}): KcgServer {
409
419
  skillInstalls,
410
420
  auth,
411
421
  tunnel,
422
+ lhr,
423
+ tunnelProvider: () => store.getTunnelSettings(Date.now()).provider,
412
424
  apiRoutePatterns: Object.keys(apiRoutes),
413
425
  close,
414
426
  };
@@ -9,9 +9,19 @@
9
9
  * (`app.ts`) bersama tabel rute lain — pola kcgcode (`*.routes.ts`).
10
10
  */
11
11
  import type { BunRequest } from "bun";
12
+ import type { AgentType } from "../../types";
12
13
  import { errorStatus, json, readJson, serverError } from "./helpers";
13
14
  import type { ApiRouteContext } from "./types";
14
15
 
16
+ /**
17
+ * Backend yang ditanyakan lewat `?agent=` (default opencode) — daftar model &
18
+ * agent berbeda antara opencode dan Claude Code.
19
+ */
20
+ function agentTypeParam(req: Request): AgentType {
21
+ const agent = new URL(req.url).searchParams.get("agent");
22
+ return agent === "claude-code" ? "claude-code" : "opencode";
23
+ }
24
+
15
25
  export function projectsRoutes(ctx: ApiRouteContext) {
16
26
  const { projectManager, sessionManager, notifyDataChanged } = ctx;
17
27
  return {
@@ -107,7 +117,7 @@ export function projectsRoutes(ctx: ApiRouteContext) {
107
117
  "/api/projects/:id/models": {
108
118
  GET: async (req: BunRequest<"/api/projects/:id/models">) => {
109
119
  try {
110
- const res = await sessionManager.listModels(req.params.id);
120
+ const res = await sessionManager.listModels(req.params.id, agentTypeParam(req));
111
121
  if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
112
122
  return json({ models: res.data });
113
123
  } catch (e) {
@@ -120,7 +130,7 @@ export function projectsRoutes(ctx: ApiRouteContext) {
120
130
  "/api/projects/:id/mcp": {
121
131
  GET: async (req: BunRequest<"/api/projects/:id/mcp">) => {
122
132
  try {
123
- const res = await sessionManager.listMcp(req.params.id);
133
+ const res = await sessionManager.listMcp(req.params.id, agentTypeParam(req));
124
134
  if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
125
135
  return json({ mcp: res.data });
126
136
  } catch (e) {
@@ -132,7 +142,7 @@ export function projectsRoutes(ctx: ApiRouteContext) {
132
142
  "/api/projects/:id/skills": {
133
143
  GET: async (req: BunRequest<"/api/projects/:id/skills">) => {
134
144
  try {
135
- const res = await sessionManager.listSkills(req.params.id);
145
+ const res = await sessionManager.listSkills(req.params.id, agentTypeParam(req));
136
146
  if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
137
147
  return json({ skills: res.data });
138
148
  } catch (e) {
@@ -145,7 +155,7 @@ export function projectsRoutes(ctx: ApiRouteContext) {
145
155
  "/api/projects/:id/agents": {
146
156
  GET: async (req: BunRequest<"/api/projects/:id/agents">) => {
147
157
  try {
148
- const res = await sessionManager.listAgents(req.params.id);
158
+ const res = await sessionManager.listAgents(req.params.id, agentTypeParam(req));
149
159
  if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
150
160
  return json({ agents: res.data });
151
161
  } catch (e) {
@@ -127,7 +127,7 @@ export function sessionsRoutes(ctx: ApiRouteContext) {
127
127
  const cur = sessionManager.getSession(req.params.id);
128
128
  if (!cur.ok)
129
129
  return json({ error: "SESSION_NOT_FOUND" }, errorStatus("SESSION_NOT_FOUND"));
130
- const res = await sessionManager.findFiles(cur.data.projectId, query);
130
+ const res = await sessionManager.findFiles(cur.data.projectId, query, cur.data.agentType);
131
131
  if (!res.ok) return json({ error: res.error }, errorStatus(res.error));
132
132
  return json({ files: res.data });
133
133
  } catch (e) {
@@ -3,20 +3,32 @@
3
3
  *
4
4
  * - `GET /api/skills/search?q=` — cari skill di skills.sh (proxy + cache).
5
5
  * - `GET /api/skills/audit?source=&skill=` — hasil audit keamanan skill.
6
- * - `POST /api/projects/:id/skills/install` `{ source, skill }` — mulai job
7
- * instalasi (202 + job). CLI `skills` berjalan di latar.
6
+ * - `POST /api/projects/:id/skills/install` `{ source, skill, agents? }` —
7
+ * mulai job instalasi (202 + job) untuk satu atau beberapa agent
8
+ * (`opencode` default, `claude-code`). CLI `skills` berjalan di latar.
8
9
  * - `GET /api/skills/installs` — job berjalan & yang baru selesai.
9
10
  * - `GET /api/skills/installs/:jobId?from=<n>` — status job + baris log
10
11
  * mulai indeks `n` (polling bertahap untuk output live).
11
12
  * - `POST /api/skills/installs/:jobId/cancel` — batalkan job berjalan.
12
13
  *
13
- * Daftar skill terpasang tetap lewat `GET /api/projects/:id/skills`
14
- * (dibaca dari opencode) di `projects.routes.ts`.
14
+ * Daftar skill terpasang tetap lewat `GET /api/projects/:id/skills?agent=`
15
+ * di `projects.routes.ts`.
15
16
  */
16
17
  import type { BunRequest } from "bun";
18
+ import type { AgentType } from "../../types";
17
19
  import { errorStatus, json, readJson, serverError } from "./helpers";
18
20
  import type { ApiRouteContext } from "./types";
19
21
 
22
+ const INSTALL_AGENTS: readonly AgentType[] = ["opencode", "claude-code"];
23
+
24
+ /** `agents` body -> daftar unik terurut; `null` bila tidak valid / kosong. */
25
+ function parseInstallAgents(raw: unknown): AgentType[] | null {
26
+ if (raw === undefined) return ["opencode"];
27
+ if (!Array.isArray(raw) || raw.length === 0) return null;
28
+ if (!raw.every((a) => INSTALL_AGENTS.includes(a as AgentType))) return null;
29
+ return INSTALL_AGENTS.filter((a) => raw.includes(a));
30
+ }
31
+
20
32
  export function skillsRoutes(ctx: ApiRouteContext) {
21
33
  const { projectManager, skillsRegistry, skillInstalls } = ctx;
22
34
  return {
@@ -54,10 +66,16 @@ export function skillsRoutes(ctx: ApiRouteContext) {
54
66
  try {
55
67
  const body = await readJson(req);
56
68
  if (!body.ok) return json({ error: "INVALID_JSON" }, 400);
57
- const { source, skill } = (body.data ?? {}) as { source?: unknown; skill?: unknown };
69
+ const { source, skill, agents } = (body.data ?? {}) as {
70
+ source?: unknown;
71
+ skill?: unknown;
72
+ agents?: unknown;
73
+ };
58
74
  if (typeof source !== "string" || typeof skill !== "string") {
59
75
  return json({ error: "INVALID_SKILL_ID" }, 400);
60
76
  }
77
+ const agentTypes = parseInstallAgents(agents);
78
+ if (agentTypes === null) return json({ error: "UNSUPPORTED_AGENT_TYPE" }, 400);
61
79
  const project = projectManager.getProject(req.params.id);
62
80
  if (!project.ok) return json({ error: project.error }, errorStatus(project.error));
63
81
 
@@ -67,6 +85,7 @@ export function skillsRoutes(ctx: ApiRouteContext) {
67
85
  projectPath: project.data.path,
68
86
  source,
69
87
  skillId: skill,
88
+ agentTypes,
70
89
  });
71
90
  if (!job.ok) return json({ error: job.error }, errorStatus(job.error));
72
91
  return json({ job: job.data }, 202);