harness-bujang 0.2.1 → 0.4.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 +79 -0
- package/dist/index.js +1010 -27
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -20,6 +20,22 @@ npx harness-bujang init --lang=ko
|
|
|
20
20
|
npx harness-bujang init --target=./my-app --no-template
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
### See the chat-room — any stack
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# Standalone viewer (works on Next.js, Rails, Django, Express, …) — no setup
|
|
27
|
+
npx harness-bujang chat
|
|
28
|
+
# → opens http://localhost:7777 in your browser
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
The standalone viewer reads `.harness/chat.db` directly, so it works on any
|
|
32
|
+
project that uses the SQLite chat backend (the default). For projects that have
|
|
33
|
+
not posted any messages yet, pass `--create` to bootstrap an empty DB and seed:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npx harness-bujang chat --create
|
|
37
|
+
```
|
|
38
|
+
|
|
23
39
|
## What it does
|
|
24
40
|
|
|
25
41
|
1. **Scans** the project — framework (Next.js / SvelteKit / Astro / Rails / Django / …), language, DB (Supabase / Prisma / Drizzle / TypeORM), UI lib, payment integration, GitHub user.
|
|
@@ -56,6 +72,69 @@ npx harness-bujang status [path]
|
|
|
56
72
|
|
|
57
73
|
Verifies the install: agent files, `CLAUDE.md` section, learning log, chat-room UI. Counts unfilled `{{...}}` placeholders.
|
|
58
74
|
|
|
75
|
+
### `chat`
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
npx harness-bujang chat [options]
|
|
79
|
+
|
|
80
|
+
Options:
|
|
81
|
+
--target=<path> Project root (default: cwd)
|
|
82
|
+
--port=<number> Preferred port (default: 7777, falls forward if busy)
|
|
83
|
+
--no-open Don't auto-open the browser
|
|
84
|
+
--create Create an empty chat DB + schema if none exists yet
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Boots a standalone HTTP server (Node `http`, no framework) that reads
|
|
88
|
+
`<target>/.harness/chat.db` via the system `sqlite3` CLI and serves the
|
|
89
|
+
KakaoTalk-style chat-room viewer. Supports both reading and writing — the
|
|
90
|
+
input bar at the bottom of each room sends `from='대표님'` (principal) messages
|
|
91
|
+
that any agent can pick up next time they read the chat.
|
|
92
|
+
|
|
93
|
+
Requires the `sqlite3` command-line tool (preinstalled on macOS; `apt-get install
|
|
94
|
+
sqlite3` on Ubuntu/WSL; sqlite-tools binaries on Windows).
|
|
95
|
+
|
|
96
|
+
### `adapt`
|
|
97
|
+
|
|
98
|
+
```
|
|
99
|
+
npx harness-bujang adapt --to=<target> [options]
|
|
100
|
+
|
|
101
|
+
Targets:
|
|
102
|
+
cursor → .cursor/rules/bujang-*.mdc (Cursor IDE)
|
|
103
|
+
cline → .clinerules/bujang-*.md (Cline)
|
|
104
|
+
aider → CONVENTIONS.md + .aider.conf.yml (Aider)
|
|
105
|
+
codex → AGENTS.md (OpenAI Codex CLI / Copilot Coding Agent / Cody)
|
|
106
|
+
gemini → GEMINI.md + .gemini/styleguide.md (Antigravity / Gemini CLI / Code Assist)
|
|
107
|
+
all → all of the above
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Converts the canonical `.claude/agents/*.md` install into the file formats other
|
|
111
|
+
editor / agent harness tools expect. The `.claude/agents/` directory remains the
|
|
112
|
+
single source of truth — re-run `bujang adapt --to=<target>` after changes to
|
|
113
|
+
keep adapters in sync.
|
|
114
|
+
|
|
115
|
+
Examples:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
npx harness-bujang adapt --to=cursor # just Cursor
|
|
119
|
+
npx harness-bujang adapt --to=cursor,aider # multiple
|
|
120
|
+
npx harness-bujang adapt --to=all # everything
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Tools covered (5 adapter formats → 8+ tools):
|
|
124
|
+
|
|
125
|
+
| Tool | File the adapter writes |
|
|
126
|
+
|------|-------------------------|
|
|
127
|
+
| Cursor IDE | `.cursor/rules/bujang-*.mdc` (with frontmatter) |
|
|
128
|
+
| Cline | `.clinerules/bujang-*.md` |
|
|
129
|
+
| Aider | `CONVENTIONS.md` + `.aider.conf.yml` (`read:`) |
|
|
130
|
+
| OpenAI Codex CLI | `AGENTS.md` |
|
|
131
|
+
| GitHub Copilot Coding Agent | `AGENTS.md` |
|
|
132
|
+
| Sourcegraph Cody | `AGENTS.md` (recent versions) |
|
|
133
|
+
| Google Antigravity | `GEMINI.md` (highest priority) + falls back to `AGENTS.md` |
|
|
134
|
+
| Gemini CLI | `GEMINI.md` |
|
|
135
|
+
| Gemini Code Assist (workspace) | `GEMINI.md` (precedence) + `.gemini/styleguide.md` |
|
|
136
|
+
| Gemini Code Assist (GitHub PR review) | `.gemini/styleguide.md` |
|
|
137
|
+
|
|
59
138
|
## How the harness works once installed
|
|
60
139
|
|
|
61
140
|
```
|
package/dist/index.js
CHANGED
|
@@ -380,10 +380,13 @@ async function runInit(args) {
|
|
|
380
380
|
printBackendInstructions(opts.chatBackend, opts.commitChat);
|
|
381
381
|
} else {
|
|
382
382
|
console.log(
|
|
383
|
-
`${c.yellow("\
|
|
383
|
+
`${c.yellow("\u2139\uFE0E Chat-room UI (Next.js admin route) skipped")} ` + c.dim(`\u2014 your stack is detected as ${scan.framework}.`)
|
|
384
384
|
);
|
|
385
385
|
console.log(
|
|
386
|
-
c.dim("
|
|
386
|
+
` ${c.dim("To use the chat room on this stack, run")} ${c.bold("bujang chat")} ${c.dim("\u2014 it serves the")}`
|
|
387
|
+
);
|
|
388
|
+
console.log(
|
|
389
|
+
` ${c.dim("same KakaoTalk-style viewer at http://localhost:7777, no Next.js needed.")}`
|
|
387
390
|
);
|
|
388
391
|
console.log();
|
|
389
392
|
}
|
|
@@ -394,7 +397,11 @@ async function runInit(args) {
|
|
|
394
397
|
console.log(` ${c.cyan("1.")} Open Claude Code in this project`);
|
|
395
398
|
console.log(` ${c.cyan("2.")} Run ${c.bold("/bujang-status")} (if the plugin is installed) or just`);
|
|
396
399
|
console.log(` ask ${c.bold('"Director, please add a hello-world endpoint"')}`);
|
|
397
|
-
|
|
400
|
+
if (scan.framework.startsWith("Next.js") && opts.installTemplate) {
|
|
401
|
+
console.log(` ${c.cyan("3.")} Watch ${c.bold(context.ADMIN_HARNESS_ROUTE)} for live updates (after env setup)`);
|
|
402
|
+
} else {
|
|
403
|
+
console.log(` ${c.cyan("3.")} Watch the chat room: ${c.bold("npx harness-bujang chat --create")} ${c.dim("\u2192 http://localhost:7777")}`);
|
|
404
|
+
}
|
|
398
405
|
console.log();
|
|
399
406
|
}
|
|
400
407
|
async function promptInteractive(opts, scan) {
|
|
@@ -855,11 +862,11 @@ async function upsertEnvVar(envFile, key, value) {
|
|
|
855
862
|
}
|
|
856
863
|
async function confirm2(message) {
|
|
857
864
|
process.stdout.write(`${message} [y/N] `);
|
|
858
|
-
return new Promise((
|
|
865
|
+
return new Promise((resolve6) => {
|
|
859
866
|
process.stdin.setEncoding("utf8");
|
|
860
867
|
process.stdin.once("data", (chunk) => {
|
|
861
868
|
const ans = chunk.toString().trim().toLowerCase();
|
|
862
|
-
|
|
869
|
+
resolve6(ans === "y" || ans === "yes");
|
|
863
870
|
process.stdin.pause();
|
|
864
871
|
});
|
|
865
872
|
});
|
|
@@ -893,7 +900,13 @@ console.log(' \u2713 Transferred ' + total + ' messages');
|
|
|
893
900
|
return scriptPath;
|
|
894
901
|
}
|
|
895
902
|
|
|
896
|
-
// src/
|
|
903
|
+
// src/chat.ts
|
|
904
|
+
import * as http from "http";
|
|
905
|
+
import * as path5 from "path";
|
|
906
|
+
import * as fs5 from "fs";
|
|
907
|
+
import { execFile, spawn } from "child_process";
|
|
908
|
+
import { promisify } from "util";
|
|
909
|
+
var execFileP = promisify(execFile);
|
|
897
910
|
var c4 = {
|
|
898
911
|
bold: (s) => `\x1B[1m${s}\x1B[22m`,
|
|
899
912
|
dim: (s) => `\x1B[2m${s}\x1B[22m`,
|
|
@@ -902,16 +915,962 @@ var c4 = {
|
|
|
902
915
|
yellow: (s) => `\x1B[33m${s}\x1B[39m`,
|
|
903
916
|
cyan: (s) => `\x1B[36m${s}\x1B[39m`
|
|
904
917
|
};
|
|
918
|
+
async function runChat(args) {
|
|
919
|
+
const opts = parseArgs3(args);
|
|
920
|
+
try {
|
|
921
|
+
await execFileP("sqlite3", ["--version"]);
|
|
922
|
+
} catch {
|
|
923
|
+
console.log();
|
|
924
|
+
console.log(c4.red("\u2716 The `sqlite3` command-line tool is required for `bujang chat`."));
|
|
925
|
+
console.log();
|
|
926
|
+
console.log(" macOS: already installed");
|
|
927
|
+
console.log(" Ubuntu/WSL: " + c4.bold("sudo apt-get install sqlite3"));
|
|
928
|
+
console.log(" Fedora: " + c4.bold("sudo dnf install sqlite"));
|
|
929
|
+
console.log(" Windows: https://www.sqlite.org/download.html (sqlite-tools-win-x64)");
|
|
930
|
+
console.log();
|
|
931
|
+
process.exitCode = 1;
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
const dbPath = resolveDbPath(opts.target);
|
|
935
|
+
if (!fs5.existsSync(dbPath)) {
|
|
936
|
+
if (!opts.create) {
|
|
937
|
+
console.log();
|
|
938
|
+
console.log(c4.red(`\u2716 Chat DB not found at ${c4.bold(dbPath)}`));
|
|
939
|
+
console.log();
|
|
940
|
+
console.log(" This usually means the harness has not posted any messages yet,");
|
|
941
|
+
console.log(" or the project does not use the SQLite chat backend.");
|
|
942
|
+
console.log();
|
|
943
|
+
console.log(" Run with " + c4.bold("--create") + " to create an empty DB and schema:");
|
|
944
|
+
console.log(" " + c4.cyan("bujang chat --create"));
|
|
945
|
+
console.log();
|
|
946
|
+
process.exitCode = 1;
|
|
947
|
+
return;
|
|
948
|
+
}
|
|
949
|
+
fs5.mkdirSync(path5.dirname(dbPath), { recursive: true });
|
|
950
|
+
await runSql(dbPath, SCHEMA_SQL);
|
|
951
|
+
const seedId = `seed-${Date.now()}`;
|
|
952
|
+
await runSql(
|
|
953
|
+
dbPath,
|
|
954
|
+
`INSERT INTO harness_messages (id, "from", "to", type, message, severity)
|
|
955
|
+
VALUES ('${seedId}', '\uBD80\uC7A5', '\uB300\uD45C\uB2D8', 'info', '\uD1A1\uBC29\uC774 \uC0DD\uC131\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uCCAB \uBA85\uB839\uC744 \uB0B4\uB824\uC8FC\uC138\uC694.', 'info');`
|
|
956
|
+
);
|
|
957
|
+
console.log(c4.dim(` created empty DB + schema at ${dbPath}`));
|
|
958
|
+
} else {
|
|
959
|
+
await runSql(dbPath, SCHEMA_SQL);
|
|
960
|
+
}
|
|
961
|
+
const port = await findOpenPort(opts.port);
|
|
962
|
+
const server = http.createServer(async (req, res) => {
|
|
963
|
+
const url2 = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
964
|
+
if (req.method === "GET" && url2.pathname === "/") {
|
|
965
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
966
|
+
res.end(renderHtml());
|
|
967
|
+
return;
|
|
968
|
+
}
|
|
969
|
+
if (req.method === "GET" && url2.pathname === "/api/messages") {
|
|
970
|
+
const days = parseInt(url2.searchParams.get("days") ?? "7", 10);
|
|
971
|
+
try {
|
|
972
|
+
const rows = await readMessages(dbPath, days);
|
|
973
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
974
|
+
res.end(JSON.stringify({ data: rows }));
|
|
975
|
+
} catch (err) {
|
|
976
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
977
|
+
res.end(JSON.stringify({ data: [], error: String(err) }));
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (req.method === "POST" && url2.pathname === "/api/messages") {
|
|
982
|
+
try {
|
|
983
|
+
const body = await readBody(req);
|
|
984
|
+
const parsed = JSON.parse(body);
|
|
985
|
+
const id = `chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
986
|
+
const from = parsed.from || "\uB300\uD45C\uB2D8";
|
|
987
|
+
const to = parsed.to || "\uBD80\uC7A5";
|
|
988
|
+
const type = parsed.type || "command";
|
|
989
|
+
const message = parsed.message || "";
|
|
990
|
+
const severity = parsed.severity || "info";
|
|
991
|
+
if (!message.trim()) {
|
|
992
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
993
|
+
res.end(JSON.stringify({ error: "message is required" }));
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
await runSql(
|
|
997
|
+
dbPath,
|
|
998
|
+
`INSERT INTO harness_messages (id, "from", "to", type, message, severity)
|
|
999
|
+
VALUES (${q(id)}, ${q(from)}, ${q(to)}, ${q(type)}, ${q(message)}, ${q(severity)});`
|
|
1000
|
+
);
|
|
1001
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
1002
|
+
res.end(JSON.stringify({ data: { id } }));
|
|
1003
|
+
} catch (err) {
|
|
1004
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
1005
|
+
res.end(JSON.stringify({ error: String(err) }));
|
|
1006
|
+
}
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
res.writeHead(404);
|
|
1010
|
+
res.end("not found");
|
|
1011
|
+
});
|
|
1012
|
+
await new Promise((resolve6) => server.listen(port, "127.0.0.1", resolve6));
|
|
1013
|
+
const url = `http://localhost:${port}`;
|
|
1014
|
+
console.log();
|
|
1015
|
+
console.log(c4.bold(c4.green("\u{1F7E2} \uD558\uB124\uC2A4 \uD1A1\uBC29 viewer")) + c4.dim(" \u2014 " + url));
|
|
1016
|
+
console.log(c4.dim(` db: ${dbPath}`));
|
|
1017
|
+
console.log(c4.dim(` stop: Ctrl+C`));
|
|
1018
|
+
console.log();
|
|
1019
|
+
if (opts.open) {
|
|
1020
|
+
openBrowser(url);
|
|
1021
|
+
}
|
|
1022
|
+
process.on("SIGINT", () => {
|
|
1023
|
+
console.log();
|
|
1024
|
+
console.log(c4.dim(" bye \u{1F44B}"));
|
|
1025
|
+
server.close();
|
|
1026
|
+
process.exit(0);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
function parseArgs3(args) {
|
|
1030
|
+
const targetRaw = getFlag3(args, "--target") ?? ".";
|
|
1031
|
+
const portRaw = getFlag3(args, "--port");
|
|
1032
|
+
const port = portRaw ? parseInt(portRaw, 10) : 7777;
|
|
1033
|
+
if (!Number.isFinite(port) || port < 1024 || port > 65535) {
|
|
1034
|
+
throw new Error(`--port must be between 1024 and 65535, got "${portRaw}"`);
|
|
1035
|
+
}
|
|
1036
|
+
return {
|
|
1037
|
+
target: path5.resolve(targetRaw),
|
|
1038
|
+
port,
|
|
1039
|
+
open: !args.includes("--no-open"),
|
|
1040
|
+
create: args.includes("--create")
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
function getFlag3(args, name) {
|
|
1044
|
+
for (const a of args) {
|
|
1045
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
1046
|
+
}
|
|
1047
|
+
const idx = args.indexOf(name);
|
|
1048
|
+
if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith("--")) {
|
|
1049
|
+
return args[idx + 1];
|
|
1050
|
+
}
|
|
1051
|
+
return void 0;
|
|
1052
|
+
}
|
|
1053
|
+
function resolveDbPath(target) {
|
|
1054
|
+
if (process.env.HARNESS_SQLITE_PATH) return process.env.HARNESS_SQLITE_PATH;
|
|
1055
|
+
return path5.join(target, ".harness", "chat.db");
|
|
1056
|
+
}
|
|
1057
|
+
async function findOpenPort(preferred) {
|
|
1058
|
+
for (let p = preferred; p < preferred + 20; p++) {
|
|
1059
|
+
if (await portIsFree(p)) return p;
|
|
1060
|
+
}
|
|
1061
|
+
throw new Error(`Could not find a free port in range ${preferred}-${preferred + 19}`);
|
|
1062
|
+
}
|
|
1063
|
+
function portIsFree(port) {
|
|
1064
|
+
return new Promise((resolve6) => {
|
|
1065
|
+
const tester = http.createServer();
|
|
1066
|
+
tester.once("error", () => resolve6(false));
|
|
1067
|
+
tester.once("listening", () => {
|
|
1068
|
+
tester.close();
|
|
1069
|
+
resolve6(true);
|
|
1070
|
+
});
|
|
1071
|
+
tester.listen(port, "127.0.0.1");
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
function openBrowser(url) {
|
|
1075
|
+
const platform = process.platform;
|
|
1076
|
+
const cmd = platform === "darwin" ? "open" : platform === "win32" ? "start" : "xdg-open";
|
|
1077
|
+
try {
|
|
1078
|
+
spawn(cmd, [url], { detached: true, stdio: "ignore" }).unref();
|
|
1079
|
+
} catch {
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
function readBody(req) {
|
|
1083
|
+
return new Promise((resolve6, reject) => {
|
|
1084
|
+
const chunks = [];
|
|
1085
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
1086
|
+
req.on("end", () => resolve6(Buffer.concat(chunks).toString("utf8")));
|
|
1087
|
+
req.on("error", reject);
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
async function readMessages(dbPath, days) {
|
|
1091
|
+
const sql = `
|
|
1092
|
+
SELECT id, timestamp, "from" AS sender, "to" AS recipient, type, message, severity
|
|
1093
|
+
FROM harness_messages
|
|
1094
|
+
WHERE timestamp >= datetime('now', '-${Math.max(1, days | 0)} day')
|
|
1095
|
+
ORDER BY timestamp ASC;
|
|
1096
|
+
`;
|
|
1097
|
+
const { stdout } = await execFileP("sqlite3", ["-json", dbPath, sql], {
|
|
1098
|
+
maxBuffer: 32 * 1024 * 1024
|
|
1099
|
+
});
|
|
1100
|
+
if (!stdout.trim()) return [];
|
|
1101
|
+
const raw = JSON.parse(stdout);
|
|
1102
|
+
return raw.map((r) => ({
|
|
1103
|
+
id: r.id,
|
|
1104
|
+
timestamp: r.timestamp,
|
|
1105
|
+
from: r.sender,
|
|
1106
|
+
to: r.recipient,
|
|
1107
|
+
type: r.type,
|
|
1108
|
+
message: r.message,
|
|
1109
|
+
severity: r.severity
|
|
1110
|
+
}));
|
|
1111
|
+
}
|
|
1112
|
+
async function runSql(dbPath, sql) {
|
|
1113
|
+
await execFileP("sqlite3", [dbPath, sql], { maxBuffer: 1024 * 1024 });
|
|
1114
|
+
}
|
|
1115
|
+
function q(value) {
|
|
1116
|
+
return `'${value.replace(/'/g, "''")}'`;
|
|
1117
|
+
}
|
|
1118
|
+
var SCHEMA_SQL = `
|
|
1119
|
+
CREATE TABLE IF NOT EXISTS harness_messages (
|
|
1120
|
+
id TEXT PRIMARY KEY,
|
|
1121
|
+
timestamp TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
1122
|
+
"from" TEXT NOT NULL,
|
|
1123
|
+
"to" TEXT NOT NULL,
|
|
1124
|
+
type TEXT NOT NULL CHECK (type IN ('command', 'feedback', 'info', 'report')),
|
|
1125
|
+
message TEXT NOT NULL,
|
|
1126
|
+
severity TEXT CHECK (severity IS NULL OR severity IN ('info', 'warning', 'error')),
|
|
1127
|
+
data TEXT,
|
|
1128
|
+
created_at TEXT DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
1129
|
+
);
|
|
1130
|
+
CREATE INDEX IF NOT EXISTS harness_messages_timestamp_idx ON harness_messages(timestamp DESC);
|
|
1131
|
+
CREATE INDEX IF NOT EXISTS harness_messages_from_to_idx ON harness_messages("from", "to");
|
|
1132
|
+
`;
|
|
1133
|
+
function renderHtml() {
|
|
1134
|
+
return (
|
|
1135
|
+
/* html */
|
|
1136
|
+
`<!DOCTYPE html>
|
|
1137
|
+
<html lang="ko">
|
|
1138
|
+
<head>
|
|
1139
|
+
<meta charset="utf-8">
|
|
1140
|
+
<title>\uD558\uB124\uC2A4 \uD1A1\uBC29</title>
|
|
1141
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1142
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
1143
|
+
<style>
|
|
1144
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Apple SD Gothic Neo", "Pretendard", sans-serif; }
|
|
1145
|
+
.chat-bg { background: #b2c7d9; }
|
|
1146
|
+
.chat-bubble-bg { background: #ffeb3b; }
|
|
1147
|
+
@keyframes dot { 0%, 80%, 100% { opacity: 0.3; } 40% { opacity: 1; } }
|
|
1148
|
+
.dot { animation: dot 1.4s infinite; }
|
|
1149
|
+
.dot:nth-child(2) { animation-delay: 0.2s; }
|
|
1150
|
+
.dot:nth-child(3) { animation-delay: 0.4s; }
|
|
1151
|
+
</style>
|
|
1152
|
+
</head>
|
|
1153
|
+
<body class="h-screen overflow-hidden">
|
|
1154
|
+
<div id="root" class="flex h-screen">
|
|
1155
|
+
<div class="flex items-center justify-center w-full text-gray-400">\uBD88\uB7EC\uC624\uB294 \uC911...</div>
|
|
1156
|
+
</div>
|
|
1157
|
+
<script>
|
|
1158
|
+
${CLIENT_JS}
|
|
1159
|
+
</script>
|
|
1160
|
+
</body>
|
|
1161
|
+
</html>`
|
|
1162
|
+
);
|
|
1163
|
+
}
|
|
1164
|
+
var CLIENT_JS = (
|
|
1165
|
+
/* js */
|
|
1166
|
+
`
|
|
1167
|
+
const ROLES = {
|
|
1168
|
+
'\uB300\uD45C\uB2D8': { icon: '\u{1F454}', color: 'text-purple-700', bg: 'bg-purple-100', label: '\uB300\uD45C\uB2D8' },
|
|
1169
|
+
'\uBD80\uC7A5': { icon: '\u{1F9D1}\u200D\u{1F4BC}', color: 'text-blue-700', bg: 'bg-blue-100', label: '\uBD80\uC7A5' },
|
|
1170
|
+
'consultant': { icon: '\u{1F91D}', color: 'text-indigo-700', bg: 'bg-indigo-100', label: '\uCEE8\uC124\uD134\uD2B8' },
|
|
1171
|
+
'dev-team': { icon: '\u{1F4BB}', color: 'text-violet-700', bg: 'bg-violet-100', label: '\uAC1C\uBC1C\uD300' },
|
|
1172
|
+
'architect-team': { icon: '\u{1F3D7}\uFE0F', color: 'text-cyan-700', bg: 'bg-cyan-100', label: '\uC544\uD0A4\uD14D\uCC98\uD300' },
|
|
1173
|
+
'code-review-team': { icon: '\u{1F4DD}', color: 'text-yellow-700', bg: 'bg-yellow-100', label: '\uCF54\uB4DC\uB9AC\uBDF0\uD300' },
|
|
1174
|
+
'doc-sync-team': { icon: '\u{1F4C4}', color: 'text-orange-700', bg: 'bg-orange-100', label: '\uBB38\uC11C\uAD00\uB9AC\uD300' },
|
|
1175
|
+
'security-team': { icon: '\u{1F6E1}\uFE0F', color: 'text-red-700', bg: 'bg-red-100', label: '\uBCF4\uC548\uD300' },
|
|
1176
|
+
'db-guard-team': { icon: '\u{1F5C4}\uFE0F', color: 'text-green-700', bg: 'bg-green-100', label: 'DB\uD300' },
|
|
1177
|
+
'qa-team': { icon: '\u{1F9EA}', color: 'text-teal-700', bg: 'bg-teal-100', label: 'QA\uD300' },
|
|
1178
|
+
'verifier-team': { icon: '\u2705', color: 'text-emerald-700', bg: 'bg-emerald-100', label: '\uAC80\uC218\uD300' },
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
const ROOMS = [
|
|
1182
|
+
{ id: '\uB300\uD45C\uB2D8', name: '\uB300\uD45C \uBCF4\uACE0', icon: '\u{1F454}', members: ['\uB300\uD45C\uB2D8', 'consultant', '\uBD80\uC7A5'] },
|
|
1183
|
+
{ id: 'consultant', name: '\uCEE8\uC124\uD134\uD2B8', icon: '\u{1F91D}', members: ['consultant', '\uBD80\uC7A5'] },
|
|
1184
|
+
{ id: 'architect-team', name: '\uC544\uD0A4\uD14D\uCC98\uD300', icon: '\u{1F3D7}\uFE0F', members: ['\uBD80\uC7A5', 'architect-team'] },
|
|
1185
|
+
{ id: 'code-review-team', name: '\uCF54\uB4DC\uB9AC\uBDF0\uD300', icon: '\u{1F4DD}', members: ['\uBD80\uC7A5', 'code-review-team'] },
|
|
1186
|
+
{ id: 'doc-sync-team', name: '\uBB38\uC11C\uAD00\uB9AC\uD300', icon: '\u{1F4C4}', members: ['\uBD80\uC7A5', 'doc-sync-team'] },
|
|
1187
|
+
{ id: 'security-team', name: '\uBCF4\uC548\uD300', icon: '\u{1F6E1}\uFE0F', members: ['\uBD80\uC7A5', 'security-team'] },
|
|
1188
|
+
{ id: 'db-guard-team', name: 'DB\uD300', icon: '\u{1F5C4}\uFE0F', members: ['\uBD80\uC7A5', 'db-guard-team'] },
|
|
1189
|
+
{ id: 'qa-team', name: 'QA\uD300', icon: '\u{1F9EA}', members: ['\uBD80\uC7A5', 'qa-team'] },
|
|
1190
|
+
{ id: 'verifier-team', name: '\uAC80\uC218\uD300', icon: '\u2705', members: ['\uBD80\uC7A5', 'verifier-team'] },
|
|
1191
|
+
{ id: 'dev-team', name: '\uAC1C\uBC1C\uD300', icon: '\u{1F4BB}', members: ['\uBD80\uC7A5', 'dev-team'] },
|
|
1192
|
+
];
|
|
1193
|
+
|
|
1194
|
+
const STORAGE_KEY = 'harness-bujang-read';
|
|
1195
|
+
const state = {
|
|
1196
|
+
messages: [],
|
|
1197
|
+
selectedRoom: null,
|
|
1198
|
+
readCounts: JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}'),
|
|
1199
|
+
loading: true,
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
function getRole(name) {
|
|
1203
|
+
return ROLES[name] || { icon: '\u{1F4AC}', color: 'text-gray-700', bg: 'bg-gray-100', label: name };
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
function getRoleLabel(name) {
|
|
1207
|
+
return (ROLES[name] && ROLES[name].label) || name;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function escapeHtml(s) {
|
|
1211
|
+
return String(s == null ? '' : s)
|
|
1212
|
+
.replace(/&/g, '&')
|
|
1213
|
+
.replace(/</g, '<')
|
|
1214
|
+
.replace(/>/g, '>')
|
|
1215
|
+
.replace(/"/g, '"')
|
|
1216
|
+
.replace(/'/g, ''');
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
function formatTime(ts) {
|
|
1220
|
+
const d = new Date(ts);
|
|
1221
|
+
if (isNaN(d.getTime())) return '';
|
|
1222
|
+
const h = d.getHours();
|
|
1223
|
+
const m = String(d.getMinutes()).padStart(2, '0');
|
|
1224
|
+
const ampm = h < 12 ? '\uC624\uC804' : '\uC624\uD6C4';
|
|
1225
|
+
const hour = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
|
1226
|
+
return ampm + ' ' + hour + ':' + m;
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
function formatDate(ts) {
|
|
1230
|
+
const d = new Date(ts);
|
|
1231
|
+
if (isNaN(d.getTime())) return '';
|
|
1232
|
+
return d.getFullYear() + '\uB144 ' + (d.getMonth() + 1) + '\uC6D4 ' + d.getDate() + '\uC77C';
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
function filterMessages(messages, roomId) {
|
|
1236
|
+
if (roomId === 'all') return messages;
|
|
1237
|
+
const room = ROOMS.find((r) => r.id === roomId);
|
|
1238
|
+
if (!room) return [];
|
|
1239
|
+
return messages.filter((m) => {
|
|
1240
|
+
if (!room.members.includes(m.from) || !room.members.includes(m.to)) return false;
|
|
1241
|
+
const smaller = ROOMS.find(
|
|
1242
|
+
(r) =>
|
|
1243
|
+
r.id !== roomId &&
|
|
1244
|
+
r.members.length < room.members.length &&
|
|
1245
|
+
r.members.includes(m.from) &&
|
|
1246
|
+
r.members.includes(m.to),
|
|
1247
|
+
);
|
|
1248
|
+
return !smaller;
|
|
1249
|
+
});
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function getLastMessage(messages, roomId) {
|
|
1253
|
+
const filtered = filterMessages(messages, roomId);
|
|
1254
|
+
return filtered.length ? filtered[filtered.length - 1] : null;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function severityBadge(sev) {
|
|
1258
|
+
if (!sev) return '';
|
|
1259
|
+
const m = { error: 'bg-red-500', warning: 'bg-yellow-500', info: 'bg-green-500' };
|
|
1260
|
+
return '<span class="inline-block px-1.5 py-0.5 text-[10px] font-bold ' + (m[sev] || 'bg-gray-500') + ' text-white rounded mr-1">' +
|
|
1261
|
+
(sev === 'error' ? 'ERROR' : sev === 'warning' ? 'WARN' : 'INFO') + '</span>';
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function render() {
|
|
1265
|
+
const root = document.getElementById('root');
|
|
1266
|
+
if (state.loading) {
|
|
1267
|
+
root.innerHTML = '<div class="flex items-center justify-center w-full text-gray-400">\uBD88\uB7EC\uC624\uB294 \uC911...</div>';
|
|
1268
|
+
return;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const errors = state.messages.filter((m) => m.severity === 'error').length;
|
|
1272
|
+
const warnings = state.messages.filter((m) => m.severity === 'warning').length;
|
|
1273
|
+
const infos = state.messages.filter((m) => m.severity === 'info').length;
|
|
1274
|
+
|
|
1275
|
+
let html = '<div class="w-80 border-r border-gray-200 bg-white flex flex-col h-full">';
|
|
1276
|
+
html += '<div class="p-4 border-b border-gray-200">';
|
|
1277
|
+
html += '<h1 class="text-lg font-bold text-gray-900">\uD558\uB124\uC2A4 \uD1A1\uBC29</h1>';
|
|
1278
|
+
html += '<p class="text-xs text-gray-500 mt-1">\uC5D0\uC774\uC804\uD2B8 \uAC04 \uBCF4\uACE0 & \uC9C0\uC2DC \u2014 ' + state.messages.length + '\uAC1C \uBA54\uC2DC\uC9C0</p>';
|
|
1279
|
+
if (state.messages.length > 0) {
|
|
1280
|
+
html += '<div class="flex gap-2 mt-2">';
|
|
1281
|
+
if (errors) html += '<span class="px-2 py-0.5 text-xs font-bold bg-red-100 text-red-700 rounded-full">ERR ' + errors + '</span>';
|
|
1282
|
+
if (warnings) html += '<span class="px-2 py-0.5 text-xs font-bold bg-yellow-100 text-yellow-700 rounded-full">WARN ' + warnings + '</span>';
|
|
1283
|
+
if (infos) html += '<span class="px-2 py-0.5 text-xs font-bold bg-green-100 text-green-700 rounded-full">INFO ' + infos + '</span>';
|
|
1284
|
+
html += '</div>';
|
|
1285
|
+
}
|
|
1286
|
+
html += '</div>';
|
|
1287
|
+
|
|
1288
|
+
html += '<div class="flex-1 overflow-y-auto">';
|
|
1289
|
+
for (const room of ROOMS) {
|
|
1290
|
+
const last = getLastMessage(state.messages, room.id);
|
|
1291
|
+
const count = filterMessages(state.messages, room.id).length;
|
|
1292
|
+
const isSelected = state.selectedRoom === room.id;
|
|
1293
|
+
const unread = count - (state.readCounts[room.id] || 0);
|
|
1294
|
+
html += '<button data-room-id="' + escapeHtml(room.id) + '" class="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors ' +
|
|
1295
|
+
(isSelected ? 'bg-indigo-50' : 'hover:bg-gray-50') + '">';
|
|
1296
|
+
html += '<div class="flex-shrink-0 w-12 h-12 rounded-2xl bg-gray-100 flex items-center justify-center text-2xl">' + room.icon + '</div>';
|
|
1297
|
+
html += '<div class="flex-1 min-w-0">';
|
|
1298
|
+
html += '<div class="flex items-center justify-between"><span class="text-sm font-semibold text-gray-900 truncate">' +
|
|
1299
|
+
escapeHtml(room.name) + ' <span class="text-xs text-gray-400 font-normal ml-1">' + room.members.length + '</span></span>';
|
|
1300
|
+
if (last) html += '<span class="text-xs text-gray-400 flex-shrink-0 ml-2">' + formatTime(last.timestamp) + '</span>';
|
|
1301
|
+
html += '</div>';
|
|
1302
|
+
html += '<div class="flex items-center gap-1 mt-0.5">';
|
|
1303
|
+
if (last && last.severity) html += severityBadge(last.severity);
|
|
1304
|
+
html += '<p class="text-xs text-gray-500 truncate">' + (last ? escapeHtml(last.message) : '\uB300\uD654 \uC5C6\uC74C') + '</p>';
|
|
1305
|
+
html += '</div></div>';
|
|
1306
|
+
if (unread > 0) html += '<span class="flex-shrink-0 min-w-[20px] h-5 px-1.5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">' + unread + '</span>';
|
|
1307
|
+
html += '</button>';
|
|
1308
|
+
}
|
|
1309
|
+
html += '</div></div>';
|
|
1310
|
+
|
|
1311
|
+
// Right pane
|
|
1312
|
+
html += '<div class="flex-1 flex flex-col chat-bg h-full">';
|
|
1313
|
+
if (!state.selectedRoom) {
|
|
1314
|
+
html += '<div class="flex-1 flex items-center justify-center"><div class="text-center"><p class="text-5xl mb-3">\u{1F3E2}</p><p class="text-sm text-white/80">\uCC44\uD305\uBC29\uC744 \uD074\uB9AD\uD574\uC11C \uC5F4\uC5B4\uC8FC\uC138\uC694</p></div></div>';
|
|
1315
|
+
} else {
|
|
1316
|
+
const roomInfo = ROOMS.find((r) => r.id === state.selectedRoom);
|
|
1317
|
+
const roomMessages = filterMessages(state.messages, state.selectedRoom);
|
|
1318
|
+
html += '<div class="px-5 py-3 bg-white border-b border-gray-200 flex items-center gap-3">';
|
|
1319
|
+
html += '<span class="text-xl">' + (roomInfo ? roomInfo.icon : '\u{1F4AC}') + '</span>';
|
|
1320
|
+
html += '<div><h2 class="text-sm font-semibold text-gray-900">' + escapeHtml(roomInfo ? roomInfo.name : state.selectedRoom) + '</h2>';
|
|
1321
|
+
html += '<p class="text-xs text-gray-400">' + (roomInfo ? roomInfo.members.map(getRoleLabel).join(', ') : '') + '</p></div>';
|
|
1322
|
+
html += '<span class="ml-auto text-xs text-gray-400">' + roomMessages.length + '\uAC1C \uBA54\uC2DC\uC9C0</span></div>';
|
|
1323
|
+
|
|
1324
|
+
html += '<div id="conversation" class="flex-1 overflow-y-auto px-5 py-4">';
|
|
1325
|
+
if (roomMessages.length === 0) {
|
|
1326
|
+
html += '<div class="flex items-center justify-center h-full"><div class="text-center"><p class="text-4xl mb-2">\u{1F4AC}</p><p class="text-sm text-white/80">\uB300\uD654\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.</p></div></div>';
|
|
1327
|
+
} else {
|
|
1328
|
+
// Group by date
|
|
1329
|
+
let lastDate = '';
|
|
1330
|
+
for (const msg of roomMessages) {
|
|
1331
|
+
const date = formatDate(msg.timestamp);
|
|
1332
|
+
if (date !== lastDate) {
|
|
1333
|
+
html += '<div class="flex justify-center my-3"><span class="px-3 py-1 text-xs bg-white/40 text-gray-700 rounded-full">' + date + '</span></div>';
|
|
1334
|
+
lastDate = date;
|
|
1335
|
+
}
|
|
1336
|
+
const role = getRole(msg.from);
|
|
1337
|
+
const isMine = msg.from === '\uB300\uD45C\uB2D8';
|
|
1338
|
+
html += '<div class="mb-3 flex ' + (isMine ? 'justify-end' : 'gap-2') + '">';
|
|
1339
|
+
if (!isMine) {
|
|
1340
|
+
html += '<div class="flex-shrink-0 w-9 h-9 rounded-2xl ' + role.bg + ' flex items-center justify-center text-lg">' + role.icon + '</div>';
|
|
1341
|
+
}
|
|
1342
|
+
html += '<div class="' + (isMine ? 'max-w-[70%]' : 'max-w-[70%]') + '">';
|
|
1343
|
+
if (!isMine) {
|
|
1344
|
+
html += '<p class="text-xs text-gray-700 mb-1">' + escapeHtml(role.label) + '</p>';
|
|
1345
|
+
}
|
|
1346
|
+
html += '<div class="flex items-end gap-1 ' + (isMine ? 'flex-row-reverse' : '') + '">';
|
|
1347
|
+
html += '<div class="px-3 py-2 ' + (isMine ? 'chat-bubble-bg text-gray-900' : 'bg-white text-gray-900') + ' rounded-2xl shadow-sm">';
|
|
1348
|
+
if (msg.severity) html += '<div class="mb-1">' + severityBadge(msg.severity) + '</div>';
|
|
1349
|
+
html += '<p class="text-sm whitespace-pre-wrap break-words">' + escapeHtml(msg.message) + '</p>';
|
|
1350
|
+
html += '</div>';
|
|
1351
|
+
html += '<span class="text-[10px] text-gray-600 flex-shrink-0">' + formatTime(msg.timestamp) + '</span>';
|
|
1352
|
+
html += '</div></div></div>';
|
|
1353
|
+
}
|
|
1354
|
+
}
|
|
1355
|
+
html += '</div>';
|
|
1356
|
+
|
|
1357
|
+
// Input bar \u2014 for sending principal messages.
|
|
1358
|
+
html += '<div class="px-4 py-3 bg-white border-t border-gray-200 flex gap-2">';
|
|
1359
|
+
html += '<input id="msg-input" type="text" placeholder="\uBA54\uC2DC\uC9C0 \uC785\uB825 (Enter \uC804\uC1A1)" class="flex-1 px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:border-indigo-500" />';
|
|
1360
|
+
html += '<button id="send-btn" class="px-4 py-2 text-sm font-semibold bg-indigo-500 text-white rounded-lg hover:bg-indigo-600">\uC804\uC1A1</button>';
|
|
1361
|
+
html += '</div>';
|
|
1362
|
+
}
|
|
1363
|
+
html += '</div>';
|
|
1364
|
+
|
|
1365
|
+
root.innerHTML = html;
|
|
1366
|
+
|
|
1367
|
+
// Re-bind handlers
|
|
1368
|
+
document.querySelectorAll('[data-room-id]').forEach((el) => {
|
|
1369
|
+
el.addEventListener('click', () => {
|
|
1370
|
+
state.selectedRoom = el.getAttribute('data-room-id');
|
|
1371
|
+
const count = filterMessages(state.messages, state.selectedRoom).length;
|
|
1372
|
+
state.readCounts[state.selectedRoom] = count;
|
|
1373
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(state.readCounts));
|
|
1374
|
+
render();
|
|
1375
|
+
const conv = document.getElementById('conversation');
|
|
1376
|
+
if (conv) conv.scrollTop = conv.scrollHeight;
|
|
1377
|
+
});
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
const input = document.getElementById('msg-input');
|
|
1381
|
+
const sendBtn = document.getElementById('send-btn');
|
|
1382
|
+
if (input && sendBtn) {
|
|
1383
|
+
const send = async () => {
|
|
1384
|
+
const text = input.value.trim();
|
|
1385
|
+
if (!text) return;
|
|
1386
|
+
input.disabled = true;
|
|
1387
|
+
sendBtn.disabled = true;
|
|
1388
|
+
try {
|
|
1389
|
+
const target = state.selectedRoom === '\uB300\uD45C\uB2D8' ? '\uBD80\uC7A5' : (state.selectedRoom || '\uBD80\uC7A5');
|
|
1390
|
+
await fetch('/api/messages', {
|
|
1391
|
+
method: 'POST',
|
|
1392
|
+
headers: { 'content-type': 'application/json' },
|
|
1393
|
+
body: JSON.stringify({
|
|
1394
|
+
from: '\uB300\uD45C\uB2D8',
|
|
1395
|
+
to: target,
|
|
1396
|
+
type: 'command',
|
|
1397
|
+
message: text,
|
|
1398
|
+
severity: 'info',
|
|
1399
|
+
}),
|
|
1400
|
+
});
|
|
1401
|
+
input.value = '';
|
|
1402
|
+
await refresh();
|
|
1403
|
+
const conv = document.getElementById('conversation');
|
|
1404
|
+
if (conv) conv.scrollTop = conv.scrollHeight;
|
|
1405
|
+
} catch (e) {
|
|
1406
|
+
alert('\uC804\uC1A1 \uC2E4\uD328: ' + e.message);
|
|
1407
|
+
}
|
|
1408
|
+
input.disabled = false;
|
|
1409
|
+
sendBtn.disabled = false;
|
|
1410
|
+
input.focus();
|
|
1411
|
+
};
|
|
1412
|
+
sendBtn.addEventListener('click', send);
|
|
1413
|
+
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); });
|
|
1414
|
+
input.focus();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
// Auto-scroll the selected room to the bottom on first render of that room.
|
|
1418
|
+
const conv = document.getElementById('conversation');
|
|
1419
|
+
if (conv && conv.dataset.scrolled !== state.selectedRoom) {
|
|
1420
|
+
conv.scrollTop = conv.scrollHeight;
|
|
1421
|
+
conv.dataset.scrolled = state.selectedRoom;
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
async function refresh() {
|
|
1426
|
+
try {
|
|
1427
|
+
const res = await fetch('/api/messages?days=14');
|
|
1428
|
+
const json = await res.json();
|
|
1429
|
+
state.messages = (json.data || []).map((m) => ({
|
|
1430
|
+
id: m.id,
|
|
1431
|
+
timestamp: m.timestamp,
|
|
1432
|
+
from: m.from,
|
|
1433
|
+
to: m.to,
|
|
1434
|
+
type: m.type,
|
|
1435
|
+
message: m.message,
|
|
1436
|
+
severity: m.severity || undefined,
|
|
1437
|
+
}));
|
|
1438
|
+
state.loading = false;
|
|
1439
|
+
render();
|
|
1440
|
+
} catch (e) {
|
|
1441
|
+
state.loading = false;
|
|
1442
|
+
render();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
refresh();
|
|
1447
|
+
setInterval(refresh, 2000);
|
|
1448
|
+
`
|
|
1449
|
+
);
|
|
1450
|
+
|
|
1451
|
+
// src/adapt.ts
|
|
1452
|
+
import * as fs6 from "fs/promises";
|
|
1453
|
+
import * as path6 from "path";
|
|
1454
|
+
var c5 = {
|
|
1455
|
+
bold: (s) => `\x1B[1m${s}\x1B[22m`,
|
|
1456
|
+
dim: (s) => `\x1B[2m${s}\x1B[22m`,
|
|
1457
|
+
green: (s) => `\x1B[32m${s}\x1B[39m`,
|
|
1458
|
+
red: (s) => `\x1B[31m${s}\x1B[39m`,
|
|
1459
|
+
yellow: (s) => `\x1B[33m${s}\x1B[39m`,
|
|
1460
|
+
cyan: (s) => `\x1B[36m${s}\x1B[39m`
|
|
1461
|
+
};
|
|
1462
|
+
async function runAdapt(args) {
|
|
1463
|
+
const opts = parseArgs4(args);
|
|
1464
|
+
const agentsDir = path6.join(opts.target, ".claude/agents");
|
|
1465
|
+
if (!await exists5(agentsDir)) {
|
|
1466
|
+
console.log();
|
|
1467
|
+
console.log(c5.red("\u2716 No .claude/agents/ directory found at " + agentsDir));
|
|
1468
|
+
console.log();
|
|
1469
|
+
console.log(" Run " + c5.bold("npx harness-bujang init") + " first to install the canonical agents,");
|
|
1470
|
+
console.log(" then re-run this command to adapt them to your editor.");
|
|
1471
|
+
console.log();
|
|
1472
|
+
process.exitCode = 1;
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const agentFiles = await loadAgents(agentsDir);
|
|
1476
|
+
if (agentFiles.length === 0) {
|
|
1477
|
+
console.log();
|
|
1478
|
+
console.log(c5.red("\u2716 .claude/agents/ exists but contains no .md files."));
|
|
1479
|
+
console.log();
|
|
1480
|
+
process.exitCode = 1;
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
console.log();
|
|
1484
|
+
console.log(c5.bold("\u{1F501} Harness-Bujang adapt"));
|
|
1485
|
+
console.log(c5.dim(` Target: ${opts.target}`));
|
|
1486
|
+
console.log(c5.dim(` Agents: ${agentFiles.length} files at .claude/agents/`));
|
|
1487
|
+
console.log(c5.dim(` Adapting: ${opts.to.join(", ")}`));
|
|
1488
|
+
console.log();
|
|
1489
|
+
for (const target of opts.to) {
|
|
1490
|
+
if (target === "cursor") await adaptCursor(opts.target, agentFiles, opts.yes);
|
|
1491
|
+
if (target === "cline") await adaptCline(opts.target, agentFiles, opts.yes);
|
|
1492
|
+
if (target === "aider") await adaptAider(opts.target, agentFiles, opts.yes);
|
|
1493
|
+
if (target === "codex") await adaptCodex(opts.target, agentFiles, opts.yes);
|
|
1494
|
+
if (target === "gemini") await adaptGemini(opts.target, agentFiles, opts.yes);
|
|
1495
|
+
}
|
|
1496
|
+
console.log(c5.bold(c5.green("\u2705 Done.")));
|
|
1497
|
+
console.log();
|
|
1498
|
+
console.log("Next:");
|
|
1499
|
+
if (opts.to.includes("cursor")) {
|
|
1500
|
+
console.log(` ${c5.cyan("\u2022")} Cursor users: open the project \u2014 rules in ${c5.bold(".cursor/rules/")} are auto-loaded`);
|
|
1501
|
+
}
|
|
1502
|
+
if (opts.to.includes("cline")) {
|
|
1503
|
+
console.log(` ${c5.cyan("\u2022")} Cline users: rules in ${c5.bold(".clinerules/")} are auto-loaded by Cline`);
|
|
1504
|
+
}
|
|
1505
|
+
if (opts.to.includes("aider")) {
|
|
1506
|
+
console.log(` ${c5.cyan("\u2022")} Aider users: ${c5.bold("CONVENTIONS.md")} is loaded via ${c5.bold(".aider.conf.yml")} (read:)`);
|
|
1507
|
+
}
|
|
1508
|
+
if (opts.to.includes("codex")) {
|
|
1509
|
+
console.log(` ${c5.cyan("\u2022")} Codex / Copilot Coding Agent users: ${c5.bold("AGENTS.md")} at the project root is auto-loaded`);
|
|
1510
|
+
}
|
|
1511
|
+
if (opts.to.includes("gemini")) {
|
|
1512
|
+
console.log(` ${c5.cyan("\u2022")} Antigravity / Gemini CLI / Code Assist: ${c5.bold("GEMINI.md")} (highest precedence) + ${c5.bold(".gemini/styleguide.md")} (PR reviews)`);
|
|
1513
|
+
}
|
|
1514
|
+
console.log();
|
|
1515
|
+
console.log(c5.dim(" When you change .claude/agents/ later, re-run this command to refresh."));
|
|
1516
|
+
console.log();
|
|
1517
|
+
}
|
|
1518
|
+
async function adaptCursor(target, agents, overwrite) {
|
|
1519
|
+
const dst = path6.join(target, ".cursor/rules");
|
|
1520
|
+
await fs6.mkdir(dst, { recursive: true });
|
|
1521
|
+
console.log(c5.bold("\u{1F4C2} Cursor \u2014 .cursor/rules/"));
|
|
1522
|
+
for (const a of agents) {
|
|
1523
|
+
const file = path6.join(dst, `bujang-${a.slug}.mdc`);
|
|
1524
|
+
if (await exists5(file) && !overwrite) {
|
|
1525
|
+
console.log(` ${c5.yellow("\u26A0")} bujang-${a.slug}.mdc ${c5.dim("(exists, skipped \u2014 use --yes to overwrite)")}`);
|
|
1526
|
+
continue;
|
|
1527
|
+
}
|
|
1528
|
+
const description = a.frontmatter.description || `Harness-Bujang ${a.slug}`;
|
|
1529
|
+
const out = `---
|
|
1530
|
+
description: "Harness-Bujang ${a.slug}: ${escapeYamlString(description.replace(/\n/g, " ").slice(0, 240))}"
|
|
1531
|
+
alwaysApply: false
|
|
1532
|
+
---
|
|
1533
|
+
|
|
1534
|
+
# Harness-Bujang \u2014 ${a.slug} role guide
|
|
1535
|
+
|
|
1536
|
+
> Source of truth: \`.claude/agents/${a.slug}.md\` \u2014 re-run \`bujang adapt --to=cursor\` to sync.
|
|
1537
|
+
|
|
1538
|
+
When the user request matches this role's domain (see description above), follow this guide as your primary system prompt for the response. Other rules under this directory describe sibling roles in the same harness.
|
|
1539
|
+
|
|
1540
|
+
---
|
|
1541
|
+
|
|
1542
|
+
` + a.body.trim() + `
|
|
1543
|
+
`;
|
|
1544
|
+
await fs6.writeFile(file, out);
|
|
1545
|
+
console.log(` ${c5.green("\u2713")} bujang-${a.slug}.mdc`);
|
|
1546
|
+
}
|
|
1547
|
+
console.log();
|
|
1548
|
+
}
|
|
1549
|
+
async function adaptCline(target, agents, overwrite) {
|
|
1550
|
+
const dst = path6.join(target, ".clinerules");
|
|
1551
|
+
await fs6.mkdir(dst, { recursive: true });
|
|
1552
|
+
console.log(c5.bold("\u{1F4C2} Cline \u2014 .clinerules/"));
|
|
1553
|
+
for (const a of agents) {
|
|
1554
|
+
const file = path6.join(dst, `bujang-${a.slug}.md`);
|
|
1555
|
+
if (await exists5(file) && !overwrite) {
|
|
1556
|
+
console.log(` ${c5.yellow("\u26A0")} bujang-${a.slug}.md ${c5.dim("(exists, skipped \u2014 use --yes to overwrite)")}`);
|
|
1557
|
+
continue;
|
|
1558
|
+
}
|
|
1559
|
+
const description = a.frontmatter.description || "";
|
|
1560
|
+
const out = `# Harness-Bujang \u2014 ${a.slug}
|
|
1561
|
+
|
|
1562
|
+
` + (description ? `${description}
|
|
1563
|
+
|
|
1564
|
+
` : "") + `> Source of truth: \`.claude/agents/${a.slug}.md\` \u2014 re-run \`bujang adapt --to=cline\` to sync.
|
|
1565
|
+
|
|
1566
|
+
---
|
|
1567
|
+
|
|
1568
|
+
` + a.body.trim() + `
|
|
1569
|
+
`;
|
|
1570
|
+
await fs6.writeFile(file, out);
|
|
1571
|
+
console.log(` ${c5.green("\u2713")} bujang-${a.slug}.md`);
|
|
1572
|
+
}
|
|
1573
|
+
console.log();
|
|
1574
|
+
}
|
|
1575
|
+
async function adaptAider(target, agents, overwrite) {
|
|
1576
|
+
console.log(c5.bold("\u{1F4C2} Aider \u2014 CONVENTIONS.md + .aider.conf.yml"));
|
|
1577
|
+
const conventionsPath = path6.join(target, "CONVENTIONS.md");
|
|
1578
|
+
const conventionsExisted = await exists5(conventionsPath);
|
|
1579
|
+
if (conventionsExisted && !overwrite) {
|
|
1580
|
+
console.log(` ${c5.yellow("\u26A0")} CONVENTIONS.md ${c5.dim("(exists, skipped \u2014 use --yes to overwrite)")}`);
|
|
1581
|
+
} else {
|
|
1582
|
+
let body = `# Project Conventions \u2014 Harness-Bujang
|
|
1583
|
+
|
|
1584
|
+
`;
|
|
1585
|
+
body += `> Source of truth: \`.claude/agents/*.md\` \u2014 re-run \`bujang adapt --to=aider\` to sync.
|
|
1586
|
+
|
|
1587
|
+
`;
|
|
1588
|
+
body += `This file collects the multi-agent harness role guides into a single conventions file that Aider can load via \`.aider.conf.yml\`. Aider does not natively dispatch to subagents, so when the user's request matches a specific role's domain, internally adopt that role's instructions for the response.
|
|
1589
|
+
|
|
1590
|
+
`;
|
|
1591
|
+
body += `## Roles
|
|
1592
|
+
|
|
1593
|
+
`;
|
|
1594
|
+
for (const a of agents) {
|
|
1595
|
+
const desc = a.frontmatter.description || "";
|
|
1596
|
+
body += `- **${a.slug}**${desc ? ` \u2014 ${desc.replace(/\n/g, " ").slice(0, 200)}` : ""}
|
|
1597
|
+
`;
|
|
1598
|
+
}
|
|
1599
|
+
body += `
|
|
1600
|
+
---
|
|
1601
|
+
|
|
1602
|
+
`;
|
|
1603
|
+
for (const a of agents) {
|
|
1604
|
+
body += `## ${a.slug}
|
|
1605
|
+
|
|
1606
|
+
`;
|
|
1607
|
+
body += a.body.trim() + `
|
|
1608
|
+
|
|
1609
|
+
---
|
|
1610
|
+
|
|
1611
|
+
`;
|
|
1612
|
+
}
|
|
1613
|
+
await fs6.writeFile(conventionsPath, body);
|
|
1614
|
+
console.log(` ${c5.green("\u2713")} CONVENTIONS.md ${c5.dim(`(${agents.length} roles concatenated)`)}`);
|
|
1615
|
+
}
|
|
1616
|
+
const aiderConfPath = path6.join(target, ".aider.conf.yml");
|
|
1617
|
+
const existing = await exists5(aiderConfPath) ? await fs6.readFile(aiderConfPath, "utf8") : "";
|
|
1618
|
+
if (existing.includes("CONVENTIONS.md")) {
|
|
1619
|
+
console.log(` ${c5.dim("\u2022")} .aider.conf.yml already references CONVENTIONS.md \u2014 left untouched`);
|
|
1620
|
+
} else if (existing && !overwrite) {
|
|
1621
|
+
console.log(` ${c5.yellow("\u26A0")} .aider.conf.yml exists and does NOT reference CONVENTIONS.md \u2014 skipped`);
|
|
1622
|
+
console.log(` ${c5.dim("Add manually:")} read: CONVENTIONS.md`);
|
|
1623
|
+
} else {
|
|
1624
|
+
const out = existing ? existing.trimEnd() + `
|
|
1625
|
+
|
|
1626
|
+
# Added by harness-bujang adapt
|
|
1627
|
+
read: CONVENTIONS.md
|
|
1628
|
+
` : `# Aider config \u2014 auto-loads Harness-Bujang conventions
|
|
1629
|
+
read: CONVENTIONS.md
|
|
1630
|
+
`;
|
|
1631
|
+
await fs6.writeFile(aiderConfPath, out);
|
|
1632
|
+
console.log(` ${c5.green("\u2713")} .aider.conf.yml ${c5.dim("(read: CONVENTIONS.md)")}`);
|
|
1633
|
+
}
|
|
1634
|
+
console.log();
|
|
1635
|
+
}
|
|
1636
|
+
async function adaptCodex(target, agents, overwrite) {
|
|
1637
|
+
console.log(c5.bold("\u{1F4C2} Codex / Copilot Agent \u2014 AGENTS.md (project root)"));
|
|
1638
|
+
const filePath = path6.join(target, "AGENTS.md");
|
|
1639
|
+
if (await exists5(filePath) && !overwrite) {
|
|
1640
|
+
console.log(` ${c5.yellow("\u26A0")} AGENTS.md ${c5.dim("(exists, skipped \u2014 use --yes to overwrite)")}`);
|
|
1641
|
+
console.log();
|
|
1642
|
+
return;
|
|
1643
|
+
}
|
|
1644
|
+
let body = `# AGENTS.md \u2014 Harness-Bujang multi-agent harness
|
|
1645
|
+
|
|
1646
|
+
`;
|
|
1647
|
+
body += `> Source of truth: \`.claude/agents/*.md\` \u2014 re-run \`bujang adapt --to=codex\` to sync.
|
|
1648
|
+
|
|
1649
|
+
`;
|
|
1650
|
+
body += `This file follows the AGENTS.md convention adopted by OpenAI Codex CLI, GitHub Copilot Coding Agent, and several other agentic coding tools. It collects the harness role guides into a single document.
|
|
1651
|
+
|
|
1652
|
+
`;
|
|
1653
|
+
body += `When the user's request matches one of the role domains below, internally adopt that role's instructions for the response. If the request spans multiple domains, follow the **director** role's dispatch logic.
|
|
1654
|
+
|
|
1655
|
+
`;
|
|
1656
|
+
body += `## Roles
|
|
1657
|
+
|
|
1658
|
+
`;
|
|
1659
|
+
for (const a of agents) {
|
|
1660
|
+
const desc = a.frontmatter.description || "";
|
|
1661
|
+
body += `- **${a.slug}**${desc ? ` \u2014 ${desc.replace(/\n/g, " ").slice(0, 200)}` : ""}
|
|
1662
|
+
`;
|
|
1663
|
+
}
|
|
1664
|
+
body += `
|
|
1665
|
+
---
|
|
1666
|
+
|
|
1667
|
+
`;
|
|
1668
|
+
for (const a of agents) {
|
|
1669
|
+
body += `## ${a.slug}
|
|
1670
|
+
|
|
1671
|
+
`;
|
|
1672
|
+
body += a.body.trim() + `
|
|
1673
|
+
|
|
1674
|
+
---
|
|
1675
|
+
|
|
1676
|
+
`;
|
|
1677
|
+
}
|
|
1678
|
+
await fs6.writeFile(filePath, body);
|
|
1679
|
+
console.log(` ${c5.green("\u2713")} AGENTS.md ${c5.dim(`(${agents.length} roles concatenated, ${(body.length / 1024).toFixed(1)} KB)`)}`);
|
|
1680
|
+
console.log();
|
|
1681
|
+
}
|
|
1682
|
+
async function adaptGemini(target, agents, overwrite) {
|
|
1683
|
+
console.log(c5.bold("\u{1F4C2} Gemini / Antigravity \u2014 GEMINI.md + .gemini/styleguide.md"));
|
|
1684
|
+
const geminiMdPath = path6.join(target, "GEMINI.md");
|
|
1685
|
+
if (await exists5(geminiMdPath) && !overwrite) {
|
|
1686
|
+
console.log(` ${c5.yellow("\u26A0")} GEMINI.md ${c5.dim("(exists, skipped \u2014 use --yes to overwrite)")}`);
|
|
1687
|
+
} else {
|
|
1688
|
+
let body = `# GEMINI.md \u2014 Harness-Bujang multi-agent harness
|
|
1689
|
+
|
|
1690
|
+
`;
|
|
1691
|
+
body += `> Source of truth: \`.claude/agents/*.md\` \u2014 re-run \`bujang adapt --to=gemini\` to sync.
|
|
1692
|
+
|
|
1693
|
+
`;
|
|
1694
|
+
body += `This file is read by Google Antigravity (workspace highest priority), Gemini CLI, and Gemini Code Assist (workspace customization). It collects the harness role guides into a single document.
|
|
1695
|
+
|
|
1696
|
+
`;
|
|
1697
|
+
body += `When the user's request matches one of the role domains below, internally adopt that role's instructions for the response. If the request spans multiple domains, follow the **director** role's dispatch logic.
|
|
1698
|
+
|
|
1699
|
+
`;
|
|
1700
|
+
body += `## Roles
|
|
1701
|
+
|
|
1702
|
+
`;
|
|
1703
|
+
for (const a of agents) {
|
|
1704
|
+
const desc = a.frontmatter.description || "";
|
|
1705
|
+
body += `- **${a.slug}**${desc ? ` \u2014 ${desc.replace(/\n/g, " ").slice(0, 200)}` : ""}
|
|
1706
|
+
`;
|
|
1707
|
+
}
|
|
1708
|
+
body += `
|
|
1709
|
+
---
|
|
1710
|
+
|
|
1711
|
+
`;
|
|
1712
|
+
for (const a of agents) {
|
|
1713
|
+
body += `## ${a.slug}
|
|
1714
|
+
|
|
1715
|
+
`;
|
|
1716
|
+
body += a.body.trim() + `
|
|
1717
|
+
|
|
1718
|
+
---
|
|
1719
|
+
|
|
1720
|
+
`;
|
|
1721
|
+
}
|
|
1722
|
+
await fs6.writeFile(geminiMdPath, body);
|
|
1723
|
+
console.log(` ${c5.green("\u2713")} GEMINI.md ${c5.dim(`(${agents.length} roles concatenated, ${(body.length / 1024).toFixed(1)} KB)`)}`);
|
|
1724
|
+
}
|
|
1725
|
+
const styleguideDir = path6.join(target, ".gemini");
|
|
1726
|
+
await fs6.mkdir(styleguideDir, { recursive: true });
|
|
1727
|
+
const styleguidePath = path6.join(styleguideDir, "styleguide.md");
|
|
1728
|
+
if (await exists5(styleguidePath) && !overwrite) {
|
|
1729
|
+
console.log(` ${c5.yellow("\u26A0")} .gemini/styleguide.md ${c5.dim("(exists, skipped)")}`);
|
|
1730
|
+
} else {
|
|
1731
|
+
const reviewRoles = ["code-review-team", "security-team", "db-guard-team", "verifier-team"];
|
|
1732
|
+
const reviewAgents = agents.filter((a) => reviewRoles.includes(a.slug));
|
|
1733
|
+
let body = `# Code Review Style Guide \u2014 Harness-Bujang
|
|
1734
|
+
|
|
1735
|
+
`;
|
|
1736
|
+
body += `> Source of truth: \`.claude/agents/*.md\` \u2014 re-run \`bujang adapt --to=gemini\` to sync.
|
|
1737
|
+
|
|
1738
|
+
`;
|
|
1739
|
+
body += `This style guide is read by Gemini Code Assist for GitHub when reviewing PRs. It distills the review-relevant subset of the Harness-Bujang harness (code review, security, DB guard, verifier teams) into review criteria.
|
|
1740
|
+
|
|
1741
|
+
`;
|
|
1742
|
+
body += `When reviewing a PR, apply the following audit lenses in order:
|
|
1743
|
+
|
|
1744
|
+
`;
|
|
1745
|
+
for (const a of reviewAgents) {
|
|
1746
|
+
body += `## ${a.slug}
|
|
1747
|
+
|
|
1748
|
+
`;
|
|
1749
|
+
body += a.body.trim() + `
|
|
1750
|
+
|
|
1751
|
+
---
|
|
1752
|
+
|
|
1753
|
+
`;
|
|
1754
|
+
}
|
|
1755
|
+
if (reviewAgents.length === 0) {
|
|
1756
|
+
body += `_(No review-team agents found in .claude/agents/. Re-run init to install the canonical set.)_
|
|
1757
|
+
`;
|
|
1758
|
+
}
|
|
1759
|
+
await fs6.writeFile(styleguidePath, body);
|
|
1760
|
+
console.log(` ${c5.green("\u2713")} .gemini/styleguide.md ${c5.dim(`(${reviewAgents.length} review roles)`)}`);
|
|
1761
|
+
}
|
|
1762
|
+
console.log();
|
|
1763
|
+
}
|
|
1764
|
+
async function loadAgents(agentsDir) {
|
|
1765
|
+
const entries = await fs6.readdir(agentsDir);
|
|
1766
|
+
const out = [];
|
|
1767
|
+
for (const name of entries) {
|
|
1768
|
+
if (!name.endsWith(".md")) continue;
|
|
1769
|
+
const src = path6.join(agentsDir, name);
|
|
1770
|
+
const raw = await fs6.readFile(src, "utf8");
|
|
1771
|
+
const slug = name.replace(/\.md$/, "");
|
|
1772
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
1773
|
+
out.push({ slug, frontmatter, body, src });
|
|
1774
|
+
}
|
|
1775
|
+
out.sort((a, b) => {
|
|
1776
|
+
if (a.slug === "director") return -1;
|
|
1777
|
+
if (b.slug === "director") return 1;
|
|
1778
|
+
return a.slug.localeCompare(b.slug);
|
|
1779
|
+
});
|
|
1780
|
+
return out;
|
|
1781
|
+
}
|
|
1782
|
+
function splitFrontmatter(raw) {
|
|
1783
|
+
if (!raw.startsWith("---\n")) {
|
|
1784
|
+
return { frontmatter: {}, body: raw };
|
|
1785
|
+
}
|
|
1786
|
+
const end = raw.indexOf("\n---\n", 4);
|
|
1787
|
+
if (end < 0) {
|
|
1788
|
+
return { frontmatter: {}, body: raw };
|
|
1789
|
+
}
|
|
1790
|
+
const fmRaw = raw.slice(4, end);
|
|
1791
|
+
const body = raw.slice(end + 5);
|
|
1792
|
+
const frontmatter = {};
|
|
1793
|
+
const lines = fmRaw.split(/\r?\n/);
|
|
1794
|
+
let currentKey = null;
|
|
1795
|
+
for (const line of lines) {
|
|
1796
|
+
const m = /^([a-zA-Z_-]+):\s?(.*)$/.exec(line);
|
|
1797
|
+
if (m && m[1] && !line.startsWith(" ") && !line.startsWith(" ")) {
|
|
1798
|
+
currentKey = m[1];
|
|
1799
|
+
frontmatter[currentKey] = m[2] ?? "";
|
|
1800
|
+
} else if (currentKey && (line.startsWith(" ") || line.startsWith(" "))) {
|
|
1801
|
+
frontmatter[currentKey] = (frontmatter[currentKey] ?? "") + " " + line.trim();
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return { frontmatter, body };
|
|
1805
|
+
}
|
|
1806
|
+
function escapeYamlString(s) {
|
|
1807
|
+
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
1808
|
+
}
|
|
1809
|
+
function parseArgs4(args) {
|
|
1810
|
+
const targetRaw = getFlag4(args, "--target") ?? ".";
|
|
1811
|
+
const toRaw = getFlag4(args, "--to");
|
|
1812
|
+
if (!toRaw) {
|
|
1813
|
+
throw new Error(
|
|
1814
|
+
`--to=<cursor|cline|aider|codex|gemini|all> is required. Examples:
|
|
1815
|
+
bujang adapt --to=cursor
|
|
1816
|
+
bujang adapt --to=codex # AGENTS.md at project root
|
|
1817
|
+
bujang adapt --to=gemini # GEMINI.md + .gemini/styleguide.md
|
|
1818
|
+
bujang adapt --to=cursor,aider # multiple
|
|
1819
|
+
bujang adapt --to=all # cursor + cline + aider + codex + gemini`
|
|
1820
|
+
);
|
|
1821
|
+
}
|
|
1822
|
+
const targets = toRaw === "all" ? ["cursor", "cline", "aider", "codex", "gemini"] : toRaw.split(",").map((t) => t.trim());
|
|
1823
|
+
for (const t of targets) {
|
|
1824
|
+
if (!["cursor", "cline", "aider", "codex", "gemini"].includes(t)) {
|
|
1825
|
+
throw new Error(`Unknown adapter target "${t}" \u2014 expected one of: cursor, cline, aider, codex, gemini, all`);
|
|
1826
|
+
}
|
|
1827
|
+
}
|
|
1828
|
+
return {
|
|
1829
|
+
target: path6.resolve(targetRaw),
|
|
1830
|
+
to: targets,
|
|
1831
|
+
yes: args.includes("--yes") || args.includes("-y")
|
|
1832
|
+
};
|
|
1833
|
+
}
|
|
1834
|
+
function getFlag4(args, name) {
|
|
1835
|
+
for (const a of args) {
|
|
1836
|
+
if (a.startsWith(`${name}=`)) return a.slice(name.length + 1);
|
|
1837
|
+
}
|
|
1838
|
+
const idx = args.indexOf(name);
|
|
1839
|
+
if (idx >= 0 && idx + 1 < args.length && !args[idx + 1].startsWith("--")) {
|
|
1840
|
+
return args[idx + 1];
|
|
1841
|
+
}
|
|
1842
|
+
return void 0;
|
|
1843
|
+
}
|
|
1844
|
+
async function exists5(p) {
|
|
1845
|
+
try {
|
|
1846
|
+
await fs6.access(p);
|
|
1847
|
+
return true;
|
|
1848
|
+
} catch {
|
|
1849
|
+
return false;
|
|
1850
|
+
}
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
// src/index.ts
|
|
1854
|
+
var c6 = {
|
|
1855
|
+
bold: (s) => `\x1B[1m${s}\x1B[22m`,
|
|
1856
|
+
dim: (s) => `\x1B[2m${s}\x1B[22m`,
|
|
1857
|
+
green: (s) => `\x1B[32m${s}\x1B[39m`,
|
|
1858
|
+
red: (s) => `\x1B[31m${s}\x1B[39m`,
|
|
1859
|
+
yellow: (s) => `\x1B[33m${s}\x1B[39m`,
|
|
1860
|
+
cyan: (s) => `\x1B[36m${s}\x1B[39m`
|
|
1861
|
+
};
|
|
905
1862
|
var HELP = `
|
|
906
|
-
${
|
|
907
|
-
${
|
|
1863
|
+
${c6.bold("harness-bujang")} \u2014 Korean-style multi-agent harness director for Claude Code
|
|
1864
|
+
${c6.dim("https://github.com/bjcho4141/harness-bujang")}
|
|
908
1865
|
|
|
909
|
-
${
|
|
910
|
-
npx harness-bujang ${
|
|
911
|
-
npx harness-bujang ${
|
|
912
|
-
npx harness-bujang ${
|
|
1866
|
+
${c6.bold("Usage:")}
|
|
1867
|
+
npx harness-bujang ${c6.cyan("init")} [options] Install the harness into a project
|
|
1868
|
+
npx harness-bujang ${c6.cyan("status")} [options] Verify the harness install
|
|
1869
|
+
npx harness-bujang ${c6.cyan("chat")} [options] Open the standalone chat-room viewer (any stack)
|
|
1870
|
+
npx harness-bujang ${c6.cyan("adapt")} --to=<cursor|cline|aider|codex|gemini|all> Convert .claude/agents/ for other tools
|
|
1871
|
+
npx harness-bujang ${c6.cyan("migrate")} --to=<sqlite|supabase> Move chat data between backends
|
|
913
1872
|
|
|
914
|
-
${
|
|
1873
|
+
${c6.bold("Options for init:")}
|
|
915
1874
|
--lang=<ko|en> Agent language (default: en)
|
|
916
1875
|
--chat=<sqlite|supabase> Chat-room backend (default: sqlite \u2014 local file, no setup)
|
|
917
1876
|
--commit-chat Don't gitignore .harness/ (for solo cross-machine sync via git)
|
|
@@ -923,31 +1882,49 @@ ${c4.bold("Options for init:")}
|
|
|
923
1882
|
--no-learning-log Skip learning log seed
|
|
924
1883
|
--yes, -y Skip prompts and overwrite (non-interactive \u2014 for CI / scripts)
|
|
925
1884
|
|
|
926
|
-
${
|
|
1885
|
+
${c6.dim("Run without --yes for an interactive setup (prompts for language, backend, etc.).")}
|
|
927
1886
|
|
|
928
|
-
${
|
|
1887
|
+
${c6.bold("Options for chat:")}
|
|
1888
|
+
--target=<path> Project root (default: cwd)
|
|
1889
|
+
--port=<number> Preferred port (default: 7777, falls forward if busy)
|
|
1890
|
+
--no-open Don't auto-open the browser
|
|
1891
|
+
--create Create an empty chat DB + schema if none exists yet
|
|
1892
|
+
|
|
1893
|
+
${c6.bold("Options for adapt:")}
|
|
1894
|
+
--to=<cursor|cline|aider|codex|gemini|all> Required \u2014 comma-separated list also OK
|
|
1895
|
+
--target=<path> Project root (default: cwd)
|
|
1896
|
+
--yes, -y Overwrite existing adapter files
|
|
1897
|
+
|
|
1898
|
+
${c6.dim("Adapter targets:")}
|
|
1899
|
+
${c6.dim(" cursor \u2192 .cursor/rules/bujang-*.mdc (Cursor IDE)")}
|
|
1900
|
+
${c6.dim(" cline \u2192 .clinerules/bujang-*.md (Cline)")}
|
|
1901
|
+
${c6.dim(" aider \u2192 CONVENTIONS.md + .aider.conf.yml (Aider)")}
|
|
1902
|
+
${c6.dim(" codex \u2192 AGENTS.md (Codex CLI / Copilot Coding Agent / Cody)")}
|
|
1903
|
+
${c6.dim(" gemini \u2192 GEMINI.md + .gemini/styleguide.md (Antigravity / Gemini CLI / Code Assist)")}
|
|
1904
|
+
|
|
1905
|
+
${c6.bold("Options for migrate:")}
|
|
929
1906
|
--to=<sqlite|supabase> Required \u2014 target backend
|
|
930
1907
|
--target=<path> Project root (default: cwd)
|
|
931
1908
|
--yes, -y Skip confirmation
|
|
932
1909
|
|
|
933
|
-
${
|
|
934
|
-
${
|
|
1910
|
+
${c6.bold("Examples:")}
|
|
1911
|
+
${c6.dim("# Install Korean Bujang persona, SQLite chat (default \u2014 zero setup)")}
|
|
935
1912
|
npx harness-bujang init --lang=ko
|
|
936
1913
|
|
|
937
|
-
${
|
|
938
|
-
npx harness-bujang
|
|
939
|
-
${
|
|
1914
|
+
${c6.dim("# Open the standalone chat-room \u2014 works on ANY stack (Next.js, Rails, Django, \u2026)")}
|
|
1915
|
+
npx harness-bujang chat
|
|
1916
|
+
${c6.dim("# \u2192 opens http://localhost:7777 in your browser")}
|
|
940
1917
|
|
|
941
|
-
${
|
|
1918
|
+
${c6.dim("# Solo, multiple machines \u2014 sync chat history via git")}
|
|
942
1919
|
npx harness-bujang init --commit-chat
|
|
943
1920
|
|
|
944
|
-
${
|
|
1921
|
+
${c6.dim("# Production project with team sharing \u2014 Supabase backend")}
|
|
945
1922
|
npx harness-bujang init --chat=supabase
|
|
946
1923
|
|
|
947
|
-
${
|
|
1924
|
+
${c6.dim("# Started solo, now scaling up \u2014 promote to cloud")}
|
|
948
1925
|
bujang migrate --to=supabase
|
|
949
1926
|
|
|
950
|
-
${
|
|
1927
|
+
${c6.dim("# Going back to solo / archive \u2014 pull cloud data into local SQLite")}
|
|
951
1928
|
bujang migrate --to=sqlite
|
|
952
1929
|
`;
|
|
953
1930
|
async function main() {
|
|
@@ -960,12 +1937,18 @@ async function main() {
|
|
|
960
1937
|
case "status":
|
|
961
1938
|
await runStatus(args.slice(1));
|
|
962
1939
|
break;
|
|
1940
|
+
case "chat":
|
|
1941
|
+
await runChat(args.slice(1));
|
|
1942
|
+
break;
|
|
1943
|
+
case "adapt":
|
|
1944
|
+
await runAdapt(args.slice(1));
|
|
1945
|
+
break;
|
|
963
1946
|
case "migrate":
|
|
964
1947
|
await runMigrate(args.slice(1));
|
|
965
1948
|
break;
|
|
966
1949
|
case "--version":
|
|
967
1950
|
case "-v":
|
|
968
|
-
console.log("0.
|
|
1951
|
+
console.log("0.4.0");
|
|
969
1952
|
break;
|
|
970
1953
|
case "--help":
|
|
971
1954
|
case "-h":
|
|
@@ -973,13 +1956,13 @@ async function main() {
|
|
|
973
1956
|
console.log(HELP);
|
|
974
1957
|
break;
|
|
975
1958
|
default:
|
|
976
|
-
console.error(
|
|
1959
|
+
console.error(c6.red(`Unknown command: ${command}`));
|
|
977
1960
|
console.log(HELP);
|
|
978
1961
|
process.exit(1);
|
|
979
1962
|
}
|
|
980
1963
|
}
|
|
981
1964
|
main().catch((err) => {
|
|
982
|
-
console.error(
|
|
1965
|
+
console.error(c6.red(`
|
|
983
1966
|
\u2716 ${err.message}`));
|
|
984
1967
|
if (process.env.DEBUG) console.error(err.stack);
|
|
985
1968
|
process.exit(1);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "harness-bujang",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Install the Harness-Bujang multi-agent harness into any project — Director, 7 specialist teams, real-time chat-room UI. Korean and English personas. Works with Claude Code, Cursor, Cline, Aider, or any tool that reads .claude/agents/.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude-code",
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
"build": "npm run prepare-templates && tsup src/index.ts --format esm --target node20 --clean --shims",
|
|
44
44
|
"dev": "tsx src/index.ts",
|
|
45
45
|
"typecheck": "tsc --noEmit",
|
|
46
|
+
"sandbox-test": "bash scripts/sandbox-test.sh",
|
|
46
47
|
"prepublishOnly": "npm run build"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|