kcg-code 0.1.0 → 0.2.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.
- package/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +876 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- 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.
|
|
3
|
+
"version": "0.2.0",
|
|
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 {
|
|
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 +
|
|
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("
|
|
29
|
-
expect(frame).toContain("
|
|
30
|
-
expect(frame).toContain("
|
|
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("
|
|
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("
|
|
58
|
-
expect(after).toContain("
|
|
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:
|
|
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
|
|
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 =
|
|
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)
|