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
package/README.md CHANGED
@@ -155,6 +155,7 @@ Skills run with your agent's full permissions. Review a skill and its audit befo
155
155
  | `-H, --host <h>` | Bind host (default `127.0.0.1`, env `KCG_HOST`) |
156
156
  | `-c, --config <f>` | Config path (default `~/.kcgcode/config.json`) |
157
157
  | `--open` | Open the web app in your browser |
158
+ | `--allow-sleep` | Let the OS sleep while running (by default kcgcode keeps the machine awake so the tunnel stays connected) |
158
159
 
159
160
  Dashboard keys: `q` quit · `o` open browser · `Ctrl+C` stop.
160
161
 
@@ -164,6 +165,7 @@ Dashboard keys: `q` quit · `o` open browser · `Ctrl+C` stop.
164
165
  | `KCG_HOST` | Bind host |
165
166
  | `KCG_CONFIG_PATH` | Config file path |
166
167
  | `KCG_DB_PATH` | SQLite path (default `~/.kcgcode/data/kcg-code.sqlite`) |
168
+ | `KCG_ALLOW_SLEEP=1` | Same as `--allow-sleep` |
167
169
 
168
170
  ## Config & data location
169
171
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kcg-code",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Control CLI AI agents (OpenCode / Claude Code) from your phone — mobile PWA + local headless bridge.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "postpack": "bun scripts/stamp-build-config.ts --reset"
45
45
  },
46
46
  "dependencies": {
47
+ "@anthropic-ai/claude-agent-sdk": "^0.3.296",
47
48
  "@radix-ui/react-label": "^2.1.7",
48
49
  "@radix-ui/react-select": "^2.2.6",
49
50
  "@radix-ui/react-slot": "^1.2.3",
@@ -0,0 +1,84 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { parseArgs } from "../args";
6
+ import { childArgv, daemonStatePath, isAlive, runningDaemon, writeDaemonState } from "../daemon";
7
+
8
+ describe("parseArgs daemon", () => {
9
+ test("start -d + command stop/status/logs", () => {
10
+ expect(parseArgs(["start", "-d"]).detach).toBe(true);
11
+ expect(parseArgs(["--detach", "-p", "4000"])).toMatchObject({
12
+ command: "start",
13
+ detach: true,
14
+ port: 4000,
15
+ });
16
+ expect(parseArgs(["stop"]).command).toBe("stop");
17
+ expect(parseArgs(["status"]).command).toBe("status");
18
+ expect(parseArgs(["logs", "-f", "-n", "20"])).toMatchObject({
19
+ command: "logs",
20
+ follow: true,
21
+ lines: 20,
22
+ });
23
+ expect(parseArgs(["logs", "-n", "abc"]).command).toBe("invalid");
24
+ });
25
+
26
+ test("childArgv meneruskan opsi server, tanpa -d / --open", () => {
27
+ const args = parseArgs([
28
+ "start",
29
+ "-d",
30
+ "--open",
31
+ "-p",
32
+ "4000",
33
+ "-H",
34
+ "0.0.0.0",
35
+ "--allow-sleep",
36
+ ]);
37
+ expect(childArgv(args)).toEqual([
38
+ "start",
39
+ "--port",
40
+ "4000",
41
+ "--host",
42
+ "0.0.0.0",
43
+ "--allow-sleep",
44
+ ]);
45
+ });
46
+ });
47
+
48
+ describe("daemon state", () => {
49
+ let dir: string;
50
+ const prevDb = process.env.KCG_DB_PATH;
51
+
52
+ beforeEach(() => {
53
+ dir = mkdtempSync(path.join(tmpdir(), "kcg-daemon-"));
54
+ process.env.KCG_DB_PATH = path.join(dir, "kcg.sqlite");
55
+ });
56
+ afterEach(() => {
57
+ if (prevDb === undefined) delete process.env.KCG_DB_PATH;
58
+ else process.env.KCG_DB_PATH = prevDb;
59
+ rmSync(dir, { recursive: true, force: true });
60
+ });
61
+
62
+ const state = (pid: number) => ({
63
+ pid,
64
+ port: 3000,
65
+ hostname: "127.0.0.1",
66
+ localUrl: "http://127.0.0.1:3000",
67
+ publicUrl: null,
68
+ startedAt: Date.now(),
69
+ logFile: path.join(dir, "kcgcode.log"),
70
+ });
71
+
72
+ test("state dengan pid hidup dikembalikan", async () => {
73
+ await writeDaemonState(state(process.pid));
74
+ expect(isAlive(process.pid)).toBe(true);
75
+ expect((await runningDaemon())?.pid).toBe(process.pid);
76
+ });
77
+
78
+ test("state basi (pid mati) dibersihkan", async () => {
79
+ const dead = Bun.spawnSync(["true"]).pid;
80
+ await writeDaemonState(state(dead));
81
+ expect(await runningDaemon()).toBeNull();
82
+ expect(existsSync(daemonStatePath())).toBe(false);
83
+ });
84
+ });
@@ -1,5 +1,14 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { type DashboardInfo, renderDashboard } from "../dashboard";
2
+ import {
3
+ activeProvider,
4
+ bestUrl,
5
+ type DashboardInfo,
6
+ menuItems,
7
+ portInputError,
8
+ renderDashboard,
9
+ renderSnapshot,
10
+ takeSnapshot,
11
+ } from "../dashboard";
3
12
 
4
13
  function makeInfo(overrides: Partial<DashboardInfo> = {}): DashboardInfo {
5
14
  return {
@@ -20,17 +29,43 @@ function makeInfo(overrides: Partial<DashboardInfo> = {}): DashboardInfo {
20
29
  }
21
30
 
22
31
  describe("renderDashboard", () => {
23
- test("logo KCG + card SERVER dan ACTIVITY", () => {
32
+ test("logo KCG + status simpel Running", () => {
24
33
  const frame = renderDashboard(makeInfo(), "0.1.0");
25
34
  expect(frame).toContain("kcgcode");
26
35
  expect(frame).toContain("v0.1.0");
27
36
  expect(frame).toContain("██╗");
28
- expect(frame).toContain("SERVER");
29
- expect(frame).toContain("ACTIVITY");
30
- expect(frame).toContain("active job");
37
+ expect(frame).toContain("Running");
38
+ expect(frame).toContain("Local");
39
+ expect(frame).toContain("PID");
40
+ expect(frame).toContain("up 0s");
41
+ expect(frame).toContain("Tunnel");
42
+ expect(frame).toContain("Open in browser");
43
+ expect(frame).toContain("↑↓ move");
31
44
  expect(frame).not.toContain("CONFIG");
32
45
  });
33
46
 
47
+ test("baris Awake menampilkan status keep-awake", () => {
48
+ const on = renderDashboard(
49
+ makeInfo({
50
+ keepAwake: { status: () => ({ active: true, method: "systemd-inhibit", error: null }) },
51
+ }),
52
+ "0.1.0",
53
+ );
54
+ expect(on).toContain("Awake");
55
+ expect(on).toContain("sleep blocked");
56
+
57
+ const off = renderDashboard(
58
+ makeInfo({
59
+ keepAwake: {
60
+ status: () => ({ active: false, method: null, error: "disabled (--allow-sleep)" }),
61
+ },
62
+ }),
63
+ "0.1.0",
64
+ );
65
+ expect(off).toContain("off · disabled");
66
+ expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("Awake");
67
+ });
68
+
34
69
  test("stats berubah saat data store berubah (refresh in-place)", () => {
35
70
  let sessions: { id: string; status: string; title: string | null }[] = [];
36
71
  const info = makeInfo({
@@ -50,13 +85,140 @@ describe("renderDashboard", () => {
50
85
  });
51
86
 
52
87
  const before = renderDashboard(info, "0.1.0");
53
- expect(before).toContain("sessions");
88
+ expect(before).toContain("Sessions");
54
89
 
55
90
  sessions = [{ id: "s9", status: "running", title: "fresh" }];
56
91
  const after = renderDashboard(info, "0.1.0");
57
- expect(after).toContain("fresh");
58
- expect(after).toContain("TUNNEL");
92
+ expect(after).toContain("1 running");
93
+ expect(after).toContain("Tunnel");
59
94
  expect(after).toContain("https://demo.example.com");
60
95
  expect(after).not.toBe(before);
61
96
  });
97
+
98
+ test("menu simpel + info provider kcg/lhr", () => {
99
+ const info = makeInfo({
100
+ server: {
101
+ store: {
102
+ listProjects: () => [],
103
+ listSessions: () => [],
104
+ getTunnelSettings: () => ({ provider: "lhr" }),
105
+ },
106
+ tunnel: {
107
+ status: () => ({ phase: "stopped", account: null, error: null, pairing: null }),
108
+ },
109
+ lhr: {
110
+ status: () => ({ phase: "online", url: "https://abc.lhr.life", error: null }),
111
+ },
112
+ tunnelProvider: () => "lhr" as const,
113
+ } as unknown as DashboardInfo["server"],
114
+ });
115
+
116
+ expect(activeProvider(info)).toBe("lhr");
117
+ expect(bestUrl(info)).toEqual({ label: "public (lhr)", url: "https://abc.lhr.life" });
118
+ expect(menuItems({}).map((m) => m.id)).toEqual([
119
+ "open",
120
+ "copy",
121
+ "qr",
122
+ "detail",
123
+ "port",
124
+ "background",
125
+ "quit",
126
+ ]);
127
+
128
+ const frame = renderDashboard(info, "0.1.0", { showQr: true, showDetail: true, menuIndex: 2 });
129
+ expect(frame).toContain("Running");
130
+ expect(frame).toContain("TUNNEL DETAIL");
131
+ expect(frame).toContain("OPEN ON PHONE");
132
+ expect(frame).toContain("lhr");
133
+ expect(frame).toContain("https://abc.lhr.life");
134
+ expect(frame).toContain("Hide phone QR");
135
+ });
136
+
137
+ test("URL publik punya baris sendiri", () => {
138
+ const info = makeInfo({
139
+ server: {
140
+ store: { listProjects: () => [], listSessions: () => [] },
141
+ tunnel: {
142
+ status: () => ({
143
+ phase: "online",
144
+ account: { url: "https://me.example.com" },
145
+ error: null,
146
+ }),
147
+ },
148
+ } as unknown as DashboardInfo["server"],
149
+ });
150
+ const frame = renderDashboard(info, "0.1.0");
151
+ expect(frame).toMatch(/Public\s+https:\/\/me\.example\.com/);
152
+ expect(frame).toContain("kcg · online");
153
+ });
154
+
155
+ test("konfirmasi quit saat ada session running", () => {
156
+ const frame = renderDashboard(makeInfo(), "0.1.0", { confirm: "quit" });
157
+ expect(frame).toContain("press q again");
158
+ expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("press q again");
159
+ });
160
+
161
+ test("form ganti port + catatan port fallback", () => {
162
+ const info = makeInfo({ portNote: "3000 in use", port: 3001 });
163
+ const frame = renderDashboard(info, "0.1.0", {
164
+ portInput: "40",
165
+ portError: "Enter a port number",
166
+ });
167
+ expect(frame).toContain("CHANGE PORT");
168
+ expect(frame).toContain("current 3001");
169
+ expect(frame).toContain("› 40");
170
+ expect(frame).toContain("Enter a port number");
171
+ expect(frame).toContain("(3000 in use)");
172
+ expect(frame).toContain("esc cancel");
173
+ expect(renderDashboard(makeInfo(), "0.1.0")).not.toContain("CHANGE PORT");
174
+ });
175
+
176
+ test("status Restarting saat ganti port", () => {
177
+ expect(renderDashboard(makeInfo({ restarting: true }), "0.1.0")).toContain("Restarting");
178
+ });
179
+ });
180
+
181
+ describe("portInputError", () => {
182
+ test("validasi input port", () => {
183
+ expect(portInputError("")).not.toBeNull();
184
+ expect(portInputError("0")).not.toBeNull();
185
+ expect(portInputError("70000")).not.toBeNull();
186
+ expect(portInputError("80")).toContain("1024");
187
+ expect(portInputError("4000")).toBeNull();
188
+ });
189
+ });
190
+
191
+ describe("mode background (dashboard untuk daemon)", () => {
192
+ test("menu Stop / Run in foreground / Close dashboard", () => {
193
+ expect(menuItems({}, "background").map((m) => m.id)).toEqual([
194
+ "open",
195
+ "copy",
196
+ "qr",
197
+ "detail",
198
+ "port",
199
+ "foreground",
200
+ "stop",
201
+ "quit",
202
+ ]);
203
+ });
204
+
205
+ test("render snapshot daemon", () => {
206
+ const snap = { ...takeSnapshot(makeInfo()), mode: "background" as const, pid: 4242 };
207
+ const frame = renderSnapshot(snap, "0.1.0");
208
+ expect(frame).toContain("Running in background");
209
+ expect(frame).toContain("PID 4242");
210
+ expect(frame).toContain("Stop server");
211
+ expect(frame).toContain("Close dashboard");
212
+ expect(frame).toContain("server keeps running");
213
+ expect(frame).toContain("q close");
214
+ expect(frame).not.toContain("Run in background");
215
+
216
+ const busy = renderSnapshot({ ...snap, restarting: true, busyLabel: "Stopping…" }, "0.1.0");
217
+ expect(busy).toContain("Stopping…");
218
+ });
219
+
220
+ test("snapshot bisa diserialisasi ke daemon.json", () => {
221
+ const snap = takeSnapshot(makeInfo(), "background");
222
+ expect(JSON.parse(JSON.stringify(snap))).toEqual(snap);
223
+ });
62
224
  });
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { parseArgs } from "../args";
3
+ import { disabledKeepAwake, inhibitCommand, startKeepAwake } from "../keep-awake";
4
+
5
+ describe("inhibitCommand", () => {
6
+ test("linux: systemd-inhibit memblok sleep & idle, child cat terikat stdin", () => {
7
+ const cmd = inhibitCommand("linux", 123);
8
+ expect(cmd?.[0]).toBe("systemd-inhibit");
9
+ expect(cmd).toContain("--what=sleep:idle");
10
+ expect(cmd).toContain("--mode=block");
11
+ expect(cmd?.at(-1)).toBe("cat");
12
+ });
13
+
14
+ test("darwin: caffeinate menunggu pid kcgcode", () => {
15
+ expect(inhibitCommand("darwin", 123)).toEqual(["caffeinate", "-i", "-s", "-w", "123"]);
16
+ });
17
+
18
+ test("platform lain tidak didukung", () => {
19
+ expect(inhibitCommand("freebsd", 123)).toBeNull();
20
+ });
21
+ });
22
+
23
+ describe("startKeepAwake", () => {
24
+ test("platform tak didukung → nonaktif dengan alasan", async () => {
25
+ const ka = await startKeepAwake("aix");
26
+ expect(ka.status()).toEqual({ active: false, method: null, error: "unsupported on aix" });
27
+ ka.stop();
28
+ });
29
+
30
+ test("disabledKeepAwake melaporkan --allow-sleep", () => {
31
+ expect(disabledKeepAwake().status().active).toBe(false);
32
+ expect(disabledKeepAwake().status().error).toContain("--allow-sleep");
33
+ });
34
+ });
35
+
36
+ describe("parseArgs --allow-sleep", () => {
37
+ test("default keep awake", () => {
38
+ expect(parseArgs([]).allowSleep).toBeUndefined();
39
+ });
40
+
41
+ test("flag mengizinkan sleep", () => {
42
+ expect(parseArgs(["start", "--allow-sleep"]).allowSleep).toBe(true);
43
+ });
44
+ });
@@ -0,0 +1,86 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import path from "node:path";
5
+ import { resolveConfig, saveConfigPort } from "../../config";
6
+ import { choosePort, portIsFree, requestedPort } from "../port";
7
+
8
+ describe("requestedPort", () => {
9
+ test("prioritas flag > env > config > default", () => {
10
+ expect(requestedPort({ flag: 1, env: "2", config: 3 })).toEqual({ port: 1, source: "flag" });
11
+ expect(requestedPort({ env: "2", config: 3 })).toEqual({ port: 2, source: "env" });
12
+ expect(requestedPort({ env: "abc", config: 3 })).toEqual({ port: 3, source: "config" });
13
+ expect(requestedPort({})).toEqual({ port: 3000, source: "default" });
14
+ });
15
+ });
16
+
17
+ describe("choosePort", () => {
18
+ const busy = (taken: number[]) => (_h: string, p: number) => !taken.includes(p);
19
+
20
+ test("port kosong dipakai apa adanya", async () => {
21
+ expect(await choosePort("127.0.0.1", { port: 3000, source: "default" }, busy([]))).toEqual({
22
+ ok: true,
23
+ port: 3000,
24
+ fallbackFrom: null,
25
+ });
26
+ });
27
+
28
+ test("default/config terpakai → pindah ke port kosong berikutnya", async () => {
29
+ expect(
30
+ await choosePort("127.0.0.1", { port: 3000, source: "default" }, busy([3000, 3001])),
31
+ ).toEqual({
32
+ ok: true,
33
+ port: 3002,
34
+ fallbackFrom: 3000,
35
+ });
36
+ expect(
37
+ await choosePort("127.0.0.1", { port: 4000, source: "config" }, busy([4000])),
38
+ ).toMatchObject({
39
+ ok: true,
40
+ port: 4001,
41
+ });
42
+ });
43
+
44
+ test("port eksplisit terpakai → error, tanpa fallback", async () => {
45
+ const res = await choosePort("127.0.0.1", { port: 3000, source: "flag" }, busy([3000]));
46
+ expect(res.ok).toBe(false);
47
+ if (!res.ok) expect(res.error).toContain("--port");
48
+ });
49
+
50
+ test("portIsFree mendeteksi port yang sedang dipakai", async () => {
51
+ const s = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() });
52
+ try {
53
+ expect(await portIsFree("127.0.0.1", s.port as number)).toBe(false);
54
+ } finally {
55
+ s.stop(true);
56
+ }
57
+ });
58
+ });
59
+
60
+ describe("config port", () => {
61
+ let dir = "";
62
+ afterEach(() => rmSync(dir, { recursive: true, force: true }));
63
+
64
+ test("saveConfigPort menyimpan port & mempertahankan key lain", () => {
65
+ dir = mkdtempSync(path.join(tmpdir(), "kcg-port-"));
66
+ const file = path.join(dir, "config.json");
67
+ writeFileSync(file, JSON.stringify({ sandboxRoot: dir, extra: true }));
68
+
69
+ expect(saveConfigPort(file, 4321).ok).toBe(true);
70
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({
71
+ sandboxRoot: dir,
72
+ extra: true,
73
+ port: 4321,
74
+ });
75
+
76
+ const cfg = resolveConfig(file);
77
+ expect(cfg.ok && cfg.data.port).toBe(4321);
78
+ });
79
+
80
+ test("port tidak valid di config ditolak", () => {
81
+ dir = mkdtempSync(path.join(tmpdir(), "kcg-port-"));
82
+ const file = path.join(dir, "config.json");
83
+ writeFileSync(file, JSON.stringify({ sandboxRoot: dir, port: "abc" }));
84
+ expect(resolveConfig(file).ok).toBe(false);
85
+ });
86
+ });
package/src/cli/args.ts CHANGED
@@ -2,13 +2,32 @@
2
2
  * Parser argumen CLI kcgcode (tanpa dependency eksternal).
3
3
  */
4
4
 
5
+ import { c } from "./theme";
6
+
5
7
  export interface CliArgs {
6
- command: "start" | "init" | "reset-lock" | "help" | "version" | "invalid";
8
+ command:
9
+ | "start"
10
+ | "stop"
11
+ | "status"
12
+ | "logs"
13
+ | "init"
14
+ | "reset-lock"
15
+ | "help"
16
+ | "version"
17
+ | "invalid";
7
18
  port?: number;
8
19
  host?: string;
9
20
  /** Path berkas konfigurasi (override KCG_CONFIG_PATH). */
10
21
  config?: string;
11
22
  open: boolean;
23
+ /** Izinkan OS sleep (matikan keep-awake). */
24
+ allowSleep?: boolean;
25
+ /** Jalankan di background (lepas dari terminal). */
26
+ detach?: boolean;
27
+ /** `logs`: ikuti log baru. */
28
+ follow?: boolean;
29
+ /** `logs`: jumlah baris terakhir. */
30
+ lines?: number;
12
31
  /** Argumen tak dikenal / pesan error. */
13
32
  error?: string;
14
33
  }
@@ -17,20 +36,31 @@ const HELP_TEXT = `kcgcode — control CLI AI agents from your phone
17
36
 
18
37
  USAGE
19
38
  kcgcode [start] [options]
39
+ kcgcode start -d [options]
40
+ kcgcode stop | status | logs [-f]
20
41
  kcgcode init [options]
21
42
  kcgcode help | --help | -h
22
43
  kcgcode version | --version | -v
23
44
 
24
45
  COMMANDS
25
- start (default) Run server + terminal dashboard
46
+ start (default) Run server + terminal dashboard; if it already runs
47
+ in background, open its dashboard instead
48
+ stop Stop the background server
49
+ status Show background server status
50
+ logs Show background server log (-f to follow)
26
51
  init Create kcg-code.config.json & local sandbox folder
27
52
  reset-lock Remove the app lock (forgot PIN/password) & sign out all devices
28
53
 
29
54
  OPTIONS
30
- -p, --port <n> HTTP port (default 3000 / env KCG_PORT)
55
+ -p, --port <n> HTTP port (default: config "port" or 3000; a busy
56
+ default is swapped for the next free port)
31
57
  -H, --host <h> Bind host (default 127.0.0.1 / env KCG_HOST)
32
58
  -c, --config <f> Config path (default ~/.kcgcode/config.json)
59
+ -d, --detach Run in background (keeps running after terminal closes)
33
60
  --open Open the web dashboard in a browser after start
61
+ -f, --follow logs: keep printing new lines
62
+ -n, --lines <n> logs: number of lines to show (default 50)
63
+ --allow-sleep Let the OS sleep while running (default: keep awake)
34
64
  -h, --help Show help
35
65
  -v, --version Show version
36
66
 
@@ -39,16 +69,28 @@ ENV
39
69
  KCG_HOST Bind host
40
70
  KCG_CONFIG_PATH Config file path
41
71
  KCG_DB_PATH SQLite path (default ~/.kcgcode/data/kcg-code.sqlite)
72
+ KCG_ALLOW_SLEEP=1 Same as --allow-sleep
42
73
 
43
74
  EXAMPLES
44
75
  kcgcode
45
76
  kcgcode init
46
77
  kcgcode start --port 4000 --open
78
+ kcgcode start -d && kcgcode logs -f
47
79
  kcgcode init --config ~/.kcgcode/config.json
48
80
  `;
49
81
 
82
+ /** Help berwarna: heading cyan, flag/command hijau, deskripsi redup. */
50
83
  export function helpText(): string {
51
- return HELP_TEXT;
84
+ return HELP_TEXT.split("\n")
85
+ .map((line, i) => {
86
+ if (i === 0) return c.cyanBright(line);
87
+ if (/^[A-Z]+$/.test(line)) return c.cyanBright(line);
88
+ const m = line.match(/^(\s{2})(\S.*?)(\s{2,})(\S.*)$/);
89
+ if (m) return `${m[1]}${c.green(m[2] as string)}${m[3]}${c.dim(m[4] as string)}`;
90
+ if (/^\s+kcgcode/.test(line)) return c.white(line);
91
+ return line;
92
+ })
93
+ .join("\n");
52
94
  }
53
95
 
54
96
  export function parseArgs(argv: string[]): CliArgs {
@@ -63,8 +105,8 @@ export function parseArgs(argv: string[]): CliArgs {
63
105
  } else if (first === "reset-lock") {
64
106
  args.command = "reset-lock";
65
107
  rest.shift();
66
- } else if (first === "start") {
67
- args.command = "start";
108
+ } else if (first === "start" || first === "stop" || first === "status" || first === "logs") {
109
+ args.command = first;
68
110
  rest.shift();
69
111
  } else if (first === "help") {
70
112
  args.command = "help";
@@ -88,6 +130,27 @@ export function parseArgs(argv: string[]): CliArgs {
88
130
  args.open = true;
89
131
  continue;
90
132
  }
133
+ if (a === "-d" || a === "--detach") {
134
+ args.detach = true;
135
+ continue;
136
+ }
137
+ if (a === "-f" || a === "--follow") {
138
+ args.follow = true;
139
+ continue;
140
+ }
141
+ if (a === "-n" || a === "--lines") {
142
+ const raw = rest[++i];
143
+ const n = Number(raw);
144
+ if (raw === undefined || !Number.isInteger(n) || n <= 0) {
145
+ return { ...args, command: "invalid", error: `Invalid value for ${a}` };
146
+ }
147
+ args.lines = n;
148
+ continue;
149
+ }
150
+ if (a === "--allow-sleep") {
151
+ args.allowSleep = true;
152
+ continue;
153
+ }
91
154
  if (a === "-p" || a === "--port") {
92
155
  const raw = rest[++i];
93
156
  if (raw === undefined)