@usercase/mcp 0.2.5 → 0.2.6
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 +9 -5
- package/agents/install.md +1 -1
- package/agents/research.md +3 -2
- package/mcp/connect.js +175 -0
- package/mcp/report.js +115 -46
- package/mcp/server.js +120 -13
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,14 +17,18 @@ Open your repository in Claude Code and ask it to research your site. Claude rea
|
|
|
17
17
|
|
|
18
18
|
## Connect
|
|
19
19
|
|
|
20
|
-
1. In
|
|
21
|
-
2. In your repository folder, run:
|
|
20
|
+
1. In your repository folder, run:
|
|
22
21
|
|
|
23
22
|
```sh
|
|
24
|
-
claude mcp add usercase
|
|
23
|
+
claude mcp add usercase -- npx -y @usercase/mcp@latest
|
|
25
24
|
```
|
|
26
25
|
|
|
27
26
|
This is saved to your Claude Code settings (`~/.claude.json`), not to your repository. `@latest` picks up new versions each time Claude Code starts.
|
|
27
|
+
2. Run any Usercase command, such as `/mcp__usercase__install`. Your browser opens on the Usercase dashboard: sign in, check that the code matches the one in your terminal, and pick the project (or create one). The key is made on your computer and saved in `~/.usercase/credentials.json`; only its hash is sent to Usercase. It shows up in **Settings → Integrations → API keys**, where you can revoke it.
|
|
28
|
+
|
|
29
|
+
Each repository keeps its own connection, so different sites can go to different projects. To switch this repository to another project, run `/mcp__usercase__connect`.
|
|
30
|
+
|
|
31
|
+
Prefer a key? Create one under **Settings → Integrations → API keys** and add `-e USERCASE_API_KEY=eb_sk_...` before `--`. A key you set this way is used first.
|
|
28
32
|
|
|
29
33
|
## Install the script
|
|
30
34
|
|
|
@@ -34,7 +38,7 @@ The survey cards come from the Usercase script on your site. If it isn't there y
|
|
|
34
38
|
/mcp__usercase__install
|
|
35
39
|
```
|
|
36
40
|
|
|
37
|
-
Claude gets your project's script (from the
|
|
41
|
+
Claude gets your project's script (from the connected project), puts it once in your root layout or document head, shows you the diff, and opens your local site to check that it loads. It doesn't commit or deploy. Real visitors are recorded once you deploy the change.
|
|
38
42
|
|
|
39
43
|
## Run a research
|
|
40
44
|
|
|
@@ -130,7 +134,7 @@ Pass with `claude mcp add ... -e NAME=value`.
|
|
|
130
134
|
|
|
131
135
|
| Name | What it does |
|
|
132
136
|
|---|---|
|
|
133
|
-
| `USERCASE_API_KEY` |
|
|
137
|
+
| `USERCASE_API_KEY` | A project API key, instead of connecting in the browser. Used before a saved connection. |
|
|
134
138
|
| `USERCASE_AGENT_KEY` | Your firewall secret (see above). 16+ characters. |
|
|
135
139
|
| `USERCASE_ALLOW_WRITE_PATHS` | Comma-separated path patterns where non-GET requests are allowed, for sites that load pages with `POST` after sign-in. Everything else stays blocked. |
|
|
136
140
|
| `USERCASE_DIAGNOSTICS` | `off` stops sending diagnostics. On by default: when the tool cannot do something or misreads the screen (a button cut off or covered, a click that landed elsewhere, a change it did not notice), Usercase receives the kind of failure, the element's shape, the page path, the HTML around the element and a screenshot of that moment, to improve the tool. Only the Usercase team sees them; they are kept for 30 days. |
|
package/agents/install.md
CHANGED
|
@@ -6,7 +6,7 @@ You are in the site's repository. Add the Usercase script so the site starts sen
|
|
|
6
6
|
|
|
7
7
|
## 1. Get the script
|
|
8
8
|
|
|
9
|
-
Call `install_snippet`. It returns the project this repository is connected to and the exact script tag. If it says
|
|
9
|
+
Call `install_snippet`. It returns the project this repository is connected to and the exact script tag. If it says it is not connected yet, show the user its message — a browser window opens where they pick the project — and call `install_snippet` again once they say they are done. If it says the key is wrong, stop and show the user its message.
|
|
10
10
|
|
|
11
11
|
## 2. Put it on every page
|
|
12
12
|
|
package/agents/research.md
CHANGED
|
@@ -9,7 +9,7 @@ You are the coordinator. Virtual users will visit the site in a real browser, on
|
|
|
9
9
|
|
|
10
10
|
## 0. Setup (once)
|
|
11
11
|
|
|
12
|
-
Call `setup` first. If it says it installed or updated the agent, ask the user to restart
|
|
12
|
+
Call `setup` first. If it says it installed or updated the agent, ask the user to restart {{agent}} and run this again — until then the visitors would use the old one, or none.
|
|
13
13
|
|
|
14
14
|
## 1. Check the site
|
|
15
15
|
|
|
@@ -88,7 +88,8 @@ The goal is still the person's own reason for coming, in their words — never "
|
|
|
88
88
|
|
|
89
89
|
## 4. Send the visitors
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
{{warmup}}
|
|
92
|
+
For each person, start a `{{visitor}}` sub-agent{{spawn}} with a prompt like:
|
|
92
93
|
|
|
93
94
|
> Call open_visit with person "<id>" and live this visit as that person until you leave.
|
|
94
95
|
|
package/mcp/connect.js
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* 브라우저 연결 — 키를 복사해 붙여 넣지 않고 연결한다(10/28).
|
|
4
|
+
*
|
|
5
|
+
* MCP 가 키를 직접 만들고 그 sha256 만 서버에 맡긴다(POST /agent/connect). 브라우저로 대시보드 /connect/:id 를 열면
|
|
6
|
+
* 고객이 로그인해 프로젝트를 고르고, 대시보드가 그 해시로 키를 만든다. 키 원문은 이 컴퓨터를 떠나지 않는다.
|
|
7
|
+
* MCP 는 같은 키로 상태를 묻다가(GET /agent/connect/:id) 승인되면 ~/.usercase/credentials.json 에 둔다.
|
|
8
|
+
* 저장소 폴더마다 따로 둔다 — 사이트마다 저장소·프로젝트가 다르다. 저장소 A 에서 프로젝트를 바꿔도 B 는 그대로다.
|
|
9
|
+
* 처음 여는 저장소는 다른 저장소의 키를 빌리지 않고 연결부터 한다(엉뚱한 프로젝트로 올라가지 않게).
|
|
10
|
+
*
|
|
11
|
+
* 키를 고르는 순서: 직접 넣은 키(USERCASE_API_KEY) → 연결로 받은 키. 직접 넣은 키가 이번 세션에 거절되면 연결로 받은 키를 쓴다
|
|
12
|
+
* (MCP 는 사용자가 넣은 환경변수를 고칠 수 없다).
|
|
13
|
+
*/
|
|
14
|
+
const fs = require("fs");
|
|
15
|
+
const os = require("os");
|
|
16
|
+
const path = require("path");
|
|
17
|
+
const crypto = require("crypto");
|
|
18
|
+
const { spawn } = require("child_process");
|
|
19
|
+
|
|
20
|
+
const KEY_ENV = "USERCASE_API_KEY";
|
|
21
|
+
const KEY_RE = /^eb_sk_[A-Za-z0-9]{32}$/;
|
|
22
|
+
// 사람의 홈 폴더 — 저장소의 .usercase/ 에 두면 git 에 들어갈 수 있다
|
|
23
|
+
const CRED_DIR = process.env.USERCASE_HOME || path.join(os.homedir(), ".usercase");
|
|
24
|
+
const CRED = path.join(CRED_DIR, "credentials.json");
|
|
25
|
+
// 이 MCP 를 띄운 폴더(에이전트가 연 저장소). Windows 는 대소문자를 가리지 않는다
|
|
26
|
+
const FOLDER = (() => {
|
|
27
|
+
const p = path.resolve(process.cwd());
|
|
28
|
+
return process.platform === "win32" ? p.toLowerCase() : p;
|
|
29
|
+
})();
|
|
30
|
+
const POLL_MS = 3000;
|
|
31
|
+
// 도구 한 번에 기다리는 시간. Codex 의 도구 시간 제한(기본 60초)보다 짧게 — 못 끝나면 다음 호출이 이어서 기다린다
|
|
32
|
+
const WAIT_MS = 30000;
|
|
33
|
+
|
|
34
|
+
let envRejected = false;
|
|
35
|
+
let savedRejected = false; // 연결로 받은 키가 이번 세션에 거절됐다(대시보드에서 지웠다)
|
|
36
|
+
let pending = null; // { id, code, url, key, expiresAt }
|
|
37
|
+
|
|
38
|
+
/** 플러그인이 값을 못 채우면 `${user_config.api_key}` 같은 글자가 그대로 온다 — 빈 값으로 본다 */
|
|
39
|
+
const envKey = () => {
|
|
40
|
+
const v = (process.env[KEY_ENV] || "").trim();
|
|
41
|
+
return v && !v.includes("${") ? v : "";
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** { v: 2, folders: { <폴더>: { key, projectId, title, savedAt } } } — 그 전(한 개만)이면 빈 것으로 본다 */
|
|
45
|
+
function readAll() {
|
|
46
|
+
try {
|
|
47
|
+
const c = JSON.parse(fs.readFileSync(CRED, "utf8"));
|
|
48
|
+
return c && c.v === 2 && c.folders && typeof c.folders === "object" ? c : { v: 2, folders: {} };
|
|
49
|
+
} catch {
|
|
50
|
+
return { v: 2, folders: {} };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
function writeAll(c) {
|
|
54
|
+
fs.mkdirSync(CRED_DIR, { recursive: true });
|
|
55
|
+
fs.writeFileSync(CRED, JSON.stringify(c, null, 2), { mode: 0o600 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readSaved() {
|
|
59
|
+
const c = readAll().folders[FOLDER];
|
|
60
|
+
return c && KEY_RE.test(c.key) ? c : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function saveKey(key, project) {
|
|
64
|
+
const c = readAll();
|
|
65
|
+
c.folders[FOLDER] = { key, projectId: project.id, title: project.title, savedAt: new Date().toISOString() };
|
|
66
|
+
writeAll(c);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 이 폴더의 연결만 지운다 */
|
|
70
|
+
function dropSaved() {
|
|
71
|
+
const c = readAll();
|
|
72
|
+
if (!c.folders[FOLDER]) return;
|
|
73
|
+
delete c.folders[FOLDER];
|
|
74
|
+
writeAll(c);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 지금 쓸 키와 어디서 왔는지 */
|
|
78
|
+
function current() {
|
|
79
|
+
const env = envKey();
|
|
80
|
+
if (env && !envRejected) return { key: env, source: "env" };
|
|
81
|
+
const saved = readSaved();
|
|
82
|
+
if (saved) return { key: saved.key, source: "saved", title: saved.title };
|
|
83
|
+
// 직접 넣은 키가 거절됐고 연결로 받은 키도 없다 — 연결부터
|
|
84
|
+
return { key: "", source: "none", envRejected: !!env, savedRejected };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 연결로 받은 키를 버린다 — 다른 프로젝트로 다시 연결할 때 */
|
|
88
|
+
function forget() {
|
|
89
|
+
pending = null;
|
|
90
|
+
dropSaved();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 서버가 키를 거절했다(401). 연결로 받은 키면 지운다 — 다음 호출이 다시 연결한다 */
|
|
94
|
+
function rejected(source) {
|
|
95
|
+
if (source === "env") envRejected = true;
|
|
96
|
+
if (source === "saved") {
|
|
97
|
+
savedRejected = true;
|
|
98
|
+
dropSaved();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function newKey() {
|
|
103
|
+
const A = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
104
|
+
let out = "";
|
|
105
|
+
while (out.length < 32) for (const b of crypto.randomBytes(48)) if (b < 248 && out.length < 32) out += A[b % 62];
|
|
106
|
+
return `eb_sk_${out}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function openBrowser(url) {
|
|
110
|
+
try {
|
|
111
|
+
const [cmd, args] =
|
|
112
|
+
process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
|
|
113
|
+
spawn(cmd, args, { stdio: "ignore", detached: true, windowsHide: true }).unref();
|
|
114
|
+
return true;
|
|
115
|
+
} catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 연결한다 — 진행 중인 연결이 있으면 이어서 기다리고, 없으면 새로 시작해 브라우저를 연다.
|
|
124
|
+
* @param api (method, route, body, key) → json. report.js 의 fetch
|
|
125
|
+
* @param client 연결 화면에 보일 도구 이름(claude-code · codex …)
|
|
126
|
+
* @returns { status: "approved", title } | { status: "pending", url, code, opened } | { status: "denied" | "expired" }
|
|
127
|
+
*/
|
|
128
|
+
async function connect(api, client) {
|
|
129
|
+
if (pending && Date.now() > pending.expiresAt) pending = null;
|
|
130
|
+
let opened = false;
|
|
131
|
+
if (!pending) {
|
|
132
|
+
const key = newKey();
|
|
133
|
+
// 앞 10글자는 대시보드 키 목록에 보이는 부분 — 원문은 보내지 않는다
|
|
134
|
+
const r = await api("POST", "/agent/connect", { keyHash: crypto.createHash("sha256").update(key).digest("hex"), keyPrefix: key.slice(0, 10), client, host: os.hostname() });
|
|
135
|
+
pending = { id: r.id, code: r.code, url: r.url, key, expiresAt: Date.now() + (r.expiresIn || 900) * 1000 };
|
|
136
|
+
opened = openBrowser(r.url);
|
|
137
|
+
// 처음엔 기다리지 않는다 — 브라우저 화면이 "터미널의 코드와 맞는지 보라" 고 하니 코드가 먼저 터미널에 떠야 한다(10/28 Codex 시험).
|
|
138
|
+
// 사용자가 승인한 뒤 다음 호출이 이어서 확인한다
|
|
139
|
+
return { status: "pending", url: pending.url, code: pending.code, opened };
|
|
140
|
+
}
|
|
141
|
+
const until = Date.now() + WAIT_MS;
|
|
142
|
+
while (Date.now() < until) {
|
|
143
|
+
const s = await api("GET", `/agent/connect/${pending.id}`, null, pending.key).catch(() => null);
|
|
144
|
+
if (s && s.status === "approved") {
|
|
145
|
+
saveKey(pending.key, s.project);
|
|
146
|
+
savedRejected = false;
|
|
147
|
+
envRejected = !!envKey(); // 직접 넣은 키보다 방금 받은 키를 쓴다
|
|
148
|
+
pending = null;
|
|
149
|
+
return { status: "approved", title: s.project.title };
|
|
150
|
+
}
|
|
151
|
+
if (s && (s.status === "denied" || s.status === "expired")) {
|
|
152
|
+
pending = null;
|
|
153
|
+
return { status: s.status };
|
|
154
|
+
}
|
|
155
|
+
await sleep(POLL_MS);
|
|
156
|
+
}
|
|
157
|
+
return { status: "pending", url: pending.url, code: pending.code, opened };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** 연결 결과를 사람(과 에이전트)에게 할 말로 */
|
|
161
|
+
function describe(r) {
|
|
162
|
+
if (r.status === "approved") return `✓ Connected — visits go to the "${r.title}" project`;
|
|
163
|
+
if (r.status === "denied") return "Connecting was cancelled in the browser. Run the command again to connect";
|
|
164
|
+
if (r.status === "expired") return "The connect link expired. Run the command again to get a new one";
|
|
165
|
+
return [
|
|
166
|
+
`Not connected yet. Tell the user right away, before doing anything else: ${r.opened ? "a browser window opened" : "open this link"} to connect Usercase — ${r.url}`,
|
|
167
|
+
`The code on that page must be ${r.code}. They sign in, pick the project and press Connect.`,
|
|
168
|
+
"Then wait for the user to say they are done, and call this tool again — it picks up the same request.",
|
|
169
|
+
].join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 연결을 기다리는 중인가 — 프로젝트 바꾸기를 다시 불러도 새로 시작하지 않고 이어서 기다리게 */
|
|
173
|
+
const isPending = () => !!pending && Date.now() < pending.expiresAt;
|
|
174
|
+
|
|
175
|
+
module.exports = { KEY_ENV, KEY_RE, CRED, FOLDER, current, rejected, forget, isPending, connect, describe };
|
package/mcp/report.js
CHANGED
|
@@ -10,31 +10,43 @@ const fs = require("fs");
|
|
|
10
10
|
const path = require("path");
|
|
11
11
|
const crypto = require("crypto");
|
|
12
12
|
const ws = require("./workspace.js");
|
|
13
|
+
const conn = require("./connect.js");
|
|
13
14
|
|
|
14
|
-
const KEY_ENV =
|
|
15
|
+
const KEY_ENV = conn.KEY_ENV;
|
|
15
16
|
const URL_ENV = "USERCASE_API_URL";
|
|
16
17
|
const DEFAULT_API = "https://app.usercase.io/koa";
|
|
17
|
-
const KEY_RE =
|
|
18
|
+
const KEY_RE = conn.KEY_RE;
|
|
18
19
|
const OUTBOX = path.join(ws.DIR, "outbox.jsonl");
|
|
19
20
|
|
|
20
|
-
|
|
21
|
+
/** 직접 넣은 키(USERCASE_API_KEY) 또는 브라우저 연결로 받은 키(connect.js) */
|
|
22
|
+
const apiKey = () => conn.current().key;
|
|
21
23
|
// 플러그인이면 키는 플러그인 설정에 있다(server.js 의 PLUGIN 과 같은 표시)
|
|
22
24
|
const PLUGIN = process.env.USERCASE_PLUGIN === "1";
|
|
23
|
-
const NO_KEY = () =>
|
|
24
|
-
PLUGIN
|
|
25
|
-
? "No API key. Create one in the dashboard (Settings → Integrations → API keys), then enter it with /plugin → Installed → usercase → Configure"
|
|
26
|
-
: `No API key (${KEY_ENV}). Create one in the dashboard (Settings → Integrations → API keys), then add this MCP server again with -e ${KEY_ENV}=<key>`;
|
|
27
25
|
const BAD_KEY = () =>
|
|
28
|
-
`${PLUGIN ? "The API key" : KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard${PLUGIN ? " and enter it with /plugin → Installed → usercase → Configure" : ""}`;
|
|
26
|
+
`${PLUGIN ? "The API key" : KEY_ENV} does not look right (38 characters starting with eb_sk_). Copy it again from the dashboard${PLUGIN ? " and enter it with /plugin → Installed → usercase → Configure" : ""}, or leave it empty to connect in the browser`;
|
|
27
|
+
// 직접 넣은 키는 MCP 가 고칠 수 없다 — 지워도 된다고만 알린다
|
|
28
|
+
const ENV_REVOKED = () =>
|
|
29
|
+
`The API key you set (${PLUGIN ? "plugin settings" : KEY_ENV}) was rejected — it may have been revoked. You can remove it there.`;
|
|
29
30
|
const apiBase = () => (process.env[URL_ENV] || DEFAULT_API).replace(/\/$/, "");
|
|
30
31
|
|
|
31
32
|
/** 이 MCP 세션 하나 = 실행 하나. 대시보드가 실행별로 묶는다 */
|
|
32
33
|
const RUN_REF = `run_${new Date().toISOString().slice(0, 10)}_${crypto.randomBytes(6).toString("hex")}`;
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
// 어느 에이전트로 돌았나 — MCP 연결 때 클라이언트가 알려 준 이름(clientInfo.name). 대시보드는 claude-code 만 "Claude Code" 로 바꿔 보인다
|
|
36
|
+
let client = "";
|
|
37
|
+
const setClient = (name) => {
|
|
38
|
+
const n = String(name || "").trim();
|
|
39
|
+
client = /claude/i.test(n) ? "claude-code" : /codex/i.test(n) ? "codex" : n.slice(0, 40);
|
|
40
|
+
};
|
|
41
|
+
const toolName = () => client || (process.env.USERCASE_CLIENT === "codex" ? "codex" : "claude-code");
|
|
42
|
+
|
|
43
|
+
/** @param key 이 키로 부른다(연결 API). 없으면 지금 쓰는 키 */
|
|
44
|
+
async function call(method, route, body, timeoutMs = 15000, key) {
|
|
45
|
+
const cur = key === undefined ? conn.current() : null;
|
|
46
|
+
const k = key === undefined ? cur.key : key;
|
|
35
47
|
const res = await fetch(`${apiBase()}${route}`, {
|
|
36
48
|
method,
|
|
37
|
-
headers: { Authorization: `Bearer ${
|
|
49
|
+
headers: { ...(k ? { Authorization: `Bearer ${k}` } : {}), "Content-Type": "application/json" },
|
|
38
50
|
body: body ? JSON.stringify(body) : undefined,
|
|
39
51
|
signal: AbortSignal.timeout(timeoutMs),
|
|
40
52
|
});
|
|
@@ -43,39 +55,69 @@ async function call(method, route, body, timeoutMs = 15000) {
|
|
|
43
55
|
try {
|
|
44
56
|
json = JSON.parse(text);
|
|
45
57
|
} catch {}
|
|
46
|
-
if (!res.ok)
|
|
58
|
+
if (!res.ok) {
|
|
59
|
+
// 키가 거절됐다 — 연결로 받은 키면 지우고, 직접 넣은 키면 이번 세션엔 안 쓴다. 다음 호출이 브라우저 연결로 간다
|
|
60
|
+
if (res.status === 401 && cur && cur.source !== "none") {
|
|
61
|
+
conn.rejected(cur.source);
|
|
62
|
+
me = null;
|
|
63
|
+
}
|
|
64
|
+
throw Object.assign(new Error((json && json.error) || `HTTP ${res.status}`), { status: res.status });
|
|
65
|
+
}
|
|
47
66
|
return json;
|
|
48
67
|
}
|
|
49
68
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
69
|
+
/** 연결 API — 키 없이(시작) 또는 새로 만든 키로(상태) 부른다 */
|
|
70
|
+
const connectApi = (method, route, body, key) => call(method, route, body, 15000, key || "");
|
|
71
|
+
|
|
72
|
+
/** 키가 없으면 브라우저로 연결한다. 연결 못 했으면 사람에게 할 말을 던진다 */
|
|
73
|
+
async function ensureKey() {
|
|
74
|
+
const cur = conn.current();
|
|
75
|
+
if (cur.source !== "none") {
|
|
76
|
+
if (!KEY_RE.test(cur.key)) throw new Error(BAD_KEY());
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const r = await conn.connect(connectApi, toolName());
|
|
80
|
+
const why = cur.envRejected ? ENV_REVOKED() + "\n" : cur.savedRejected ? "The connection was revoked in the dashboard.\n" : "";
|
|
81
|
+
const msg = why + conn.describe(r);
|
|
82
|
+
if (r.status !== "approved") throw new Error(msg);
|
|
83
|
+
return msg;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 이 키의 프로젝트 — outbox 가 다른 프로젝트의 기록을 보내지 않게. 세션에 한 번 */
|
|
87
|
+
let me = null;
|
|
88
|
+
async function getMe() {
|
|
89
|
+
if (!me) me = await call("GET", "/agent/me");
|
|
90
|
+
return me;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 연결 상태 한 줄 — check_site 가 보여 준다. 연결이 없으면 브라우저 연결을 시작한다. sdkProject 가 있으면 키의 프로젝트와 맞는지 본다 */
|
|
94
|
+
async function describeKey(sdkProject, retried = false) {
|
|
95
|
+
// check_site 는 지금 상태를 본다 — 대시보드에서 키를 지웠을 수 있다
|
|
96
|
+
me = null;
|
|
97
|
+
let connected = null;
|
|
98
|
+
try {
|
|
99
|
+
connected = await ensureKey();
|
|
100
|
+
} catch (e) {
|
|
101
|
+
return `⚠ ${e.message}`;
|
|
102
|
+
}
|
|
55
103
|
try {
|
|
56
|
-
const
|
|
57
|
-
if (sdkProject &&
|
|
58
|
-
return `⚠
|
|
59
|
-
return `✓
|
|
104
|
+
const m = await getMe();
|
|
105
|
+
if (sdkProject && m.projectId !== sdkProject)
|
|
106
|
+
return `⚠ Connected to the "${m.title}" project, but the SDK on this site sends to another project (${sdkProject}). Visits and card answers would land in different projects. Call the \`connect\` tool to pick the right project`;
|
|
107
|
+
return connected || `✓ Connected — visits are sent to the "${m.title}" project. To use another project, call the \`connect\` tool`;
|
|
60
108
|
} catch (e) {
|
|
61
|
-
|
|
109
|
+
// 키가 거절됐다 — call 이 지웠으니 한 번만 다시(브라우저 연결로)
|
|
110
|
+
if (e.status === 401 && !retried) return describeKey(sdkProject, true);
|
|
111
|
+
if (e.status === 401) return `⚠ The connection was rejected. Run check_site again to connect in the browser`;
|
|
62
112
|
return `⚠ Could not reach the Usercase server (${e.message}). Visits can still run; records will be sent later`;
|
|
63
113
|
}
|
|
64
114
|
}
|
|
65
115
|
|
|
66
116
|
/** 키의 프로젝트와 그 설치 스크립트(서버가 대시보드 설치 화면과 같은 글을 준다) — install_snippet 이 쓴다 */
|
|
67
117
|
async function getInstall() {
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
70
|
-
|
|
71
|
-
try {
|
|
72
|
-
const me = await call("GET", "/agent/me");
|
|
73
|
-
if (!me.snippet) throw new Error("The Usercase server did not return a script. It may be an older server version");
|
|
74
|
-
return me;
|
|
75
|
-
} catch (e) {
|
|
76
|
-
if (e.status === 401) throw new Error("The API key is wrong or revoked. Create a new one in the dashboard");
|
|
77
|
-
throw e;
|
|
78
|
-
}
|
|
118
|
+
const m = await keyed("GET", "/agent/me");
|
|
119
|
+
if (!m.snippet) throw new Error("The Usercase server did not return a script. It may be an older server version");
|
|
120
|
+
return m;
|
|
79
121
|
}
|
|
80
122
|
|
|
81
123
|
function readOutbox() {
|
|
@@ -107,7 +149,7 @@ function payload(row, person, startedAt) {
|
|
|
107
149
|
return {
|
|
108
150
|
id: crypto.randomUUID(),
|
|
109
151
|
run: RUN_REF,
|
|
110
|
-
tool:
|
|
152
|
+
tool: toolName(),
|
|
111
153
|
site: row.site,
|
|
112
154
|
person: person.id,
|
|
113
155
|
personUid: person.uid,
|
|
@@ -156,23 +198,34 @@ async function sendScreens(item, screens) {
|
|
|
156
198
|
*/
|
|
157
199
|
async function sendVisit(row, person, startedAt, screens = []) {
|
|
158
200
|
if (!KEY_RE.test(apiKey())) return null;
|
|
159
|
-
|
|
201
|
+
// 기록마다 어느 프로젝트로 가야 했는지 붙여 둔다 — 다시 연결하며 다른 프로젝트를 골라도 엉뚱한 곳에 들어가지 않게
|
|
202
|
+
const project = await getMe().then((m) => m.projectId).catch(() => null);
|
|
203
|
+
const fresh = { ...(await sendScreens(payload(row, person, startedAt), screens)), _project: project };
|
|
204
|
+
const queue = [...readOutbox(), fresh];
|
|
160
205
|
const left = [];
|
|
161
206
|
let sent = 0;
|
|
207
|
+
let other = 0;
|
|
162
208
|
let lastErr = null;
|
|
163
209
|
for (const item of queue) {
|
|
210
|
+
if (item._project && project && item._project !== project) {
|
|
211
|
+
other++;
|
|
212
|
+
left.push(item);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const { _project, ...body } = item;
|
|
164
216
|
try {
|
|
165
|
-
await call("POST", "/agent/visits",
|
|
217
|
+
await call("POST", "/agent/visits", body);
|
|
166
218
|
sent++;
|
|
167
219
|
} catch (e) {
|
|
168
220
|
lastErr = e;
|
|
169
|
-
// 키가 틀렸으면 다시 보내도 소용없다 — 그래도 버리지 않고 남겨 둔다(
|
|
170
|
-
left.push(item);
|
|
221
|
+
// 키가 틀렸으면 다시 보내도 소용없다 — 그래도 버리지 않고 남겨 둔다(다시 연결하면 다음에 간다)
|
|
222
|
+
left.push({ ...item, _project: _project || project });
|
|
171
223
|
}
|
|
172
224
|
}
|
|
173
225
|
writeOutbox(left);
|
|
174
|
-
|
|
175
|
-
|
|
226
|
+
const otherLine = other ? ` · ${other} from another project kept in ${OUTBOX} until you connect that project again` : "";
|
|
227
|
+
if (left.length === other) return `· Sent to the dashboard${sent > 1 ? ` (${sent} including earlier ones)` : ""}${otherLine}`;
|
|
228
|
+
return `⚠ Could not upload to the dashboard (${lastErr?.status === 401 ? "connection rejected — run check_site to connect again" : lastErr?.message}) — ${left.length - other} kept in ${OUTBOX}, will retry next time${otherLine}`;
|
|
176
229
|
}
|
|
177
230
|
|
|
178
231
|
/**
|
|
@@ -193,16 +246,30 @@ async function sendDiagnostics(events, device, shots = []) {
|
|
|
193
246
|
}
|
|
194
247
|
}
|
|
195
248
|
|
|
196
|
-
/**
|
|
197
|
-
async function
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
249
|
+
/** 다른 프로젝트로 다시 연결한다 — 연결로 받은 키는 버리고 브라우저를 연다. 직접 넣은 키는 MCP 가 바꿀 수 없다 */
|
|
250
|
+
async function reconnect() {
|
|
251
|
+
const cur = conn.current();
|
|
252
|
+
if (cur.source === "env") return `You set an API key (${PLUGIN ? "plugin settings" : KEY_ENV}), and it is used first. Remove it there to connect in the browser, or set the other project's key instead.`;
|
|
253
|
+
// 지금 연결은 새 연결이 승인될 때까지 둔다 — 브라우저에서 취소해도 원래대로 쓴다. 다시 부르면 진행 중인 연결을 이어서 기다린다
|
|
254
|
+
const before = cur.source === "saved" ? `Connected now: "${cur.title}" (this folder only).\n` : "";
|
|
255
|
+
try {
|
|
256
|
+
const r = await conn.connect(connectApi, toolName());
|
|
257
|
+
if (r.status === "approved") me = null;
|
|
258
|
+
return before + conn.describe(r) + (r.status === "approved" ? " — for this folder only" : "");
|
|
259
|
+
} catch (e) {
|
|
260
|
+
return before + e.message;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 키가 있어야 하는 호출 — 없으면 브라우저로 연결하고, 거절되면 한 번 다시 연결한다. 못 하면 사용자에게 할 말로 던진다 */
|
|
265
|
+
async function keyed(method, route, body, timeoutMs, retried = false) {
|
|
266
|
+
await ensureKey();
|
|
201
267
|
try {
|
|
202
268
|
return await call(method, route, body, timeoutMs);
|
|
203
269
|
} catch (e) {
|
|
204
|
-
if (e.status === 401)
|
|
205
|
-
if (e.status ===
|
|
270
|
+
if (e.status === 401 && !retried) return keyed(method, route, body, timeoutMs, true);
|
|
271
|
+
if (e.status === 401) throw new Error("The connection was rejected. Call this tool again to connect in the browser");
|
|
272
|
+
if (e.status === 404) throw new Error(`${e.message}. Check the id — it must belong to the connected project`);
|
|
206
273
|
throw e;
|
|
207
274
|
}
|
|
208
275
|
}
|
|
@@ -227,7 +294,9 @@ module.exports = {
|
|
|
227
294
|
KEY_ENV,
|
|
228
295
|
URL_ENV,
|
|
229
296
|
RUN_REF,
|
|
297
|
+
setClient,
|
|
230
298
|
describeKey,
|
|
299
|
+
reconnect,
|
|
231
300
|
getInstall,
|
|
232
301
|
sendVisit,
|
|
233
302
|
sendDiagnostics,
|
package/mcp/server.js
CHANGED
|
@@ -32,8 +32,17 @@ const server = new McpServer({ name: "usercase", version: require("../package.js
|
|
|
32
32
|
// 플러그인에서는 방문자 에이전트가 플러그인에 들어 있어 이름이 usercase:usercase-visitor 이고(도구도 mcp__plugin_usercase_usercase__*),
|
|
33
33
|
// 명령은 /usercase:research 같은 스킬이다 — 스킬은 MCP 프롬프트를 못 부르니 instructions 도구로 같은 글을 받는다
|
|
34
34
|
const PLUGIN = process.env.USERCASE_PLUGIN === "1";
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
// Codex 로 떴는지(시험 중). Codex 는 MCP 프롬프트를 명령으로 안 보여 줘서 플러그인처럼 스킬($usercase-research)이 instructions 도구를 부르고,
|
|
36
|
+
// 방문자는 ~/.codex/agents/usercase_visitor.toml 의 사용자 에이전트다
|
|
37
|
+
const CODEX = process.env.USERCASE_CLIENT === "codex";
|
|
38
|
+
// 그 밖의 MCP 클라이언트(Cursor, VS Code …) — 대시보드의 MCP 설정 칸이 USERCASE_CLIENT=other 를 준다. 프롬프트를 명령으로 안 보여 주는 곳이 있어 instructions 도구를 둔다
|
|
39
|
+
const OTHER = process.env.USERCASE_CLIENT === "other";
|
|
40
|
+
const VISITOR = PLUGIN ? "usercase:usercase-visitor" : CODEX ? "usercase_visitor" : "usercase-visitor";
|
|
41
|
+
// 플러그인 스킬은 /usercase:이름. 직접 붙인 MCP 의 프롬프트는 목록에 /usercase:이름 (MCP) 로 보이지만 그렇게 치면 안 돌고 /mcp__usercase__이름 으로만 돈다(10/28 확인).
|
|
42
|
+
// Codex 플러그인 스킬은 $usercase:이름
|
|
43
|
+
const cmd = (name) => (CODEX ? `$usercase:${name}` : PLUGIN ? `/usercase:${name}` : `/mcp__usercase__${name}`);
|
|
44
|
+
// Codex 의 spawn_agent 는 기본으로 부모 대화 전체(읽은 코드·다른 사람들)를 물려준다 — 방문자는 빈 상태로 시작해야 한다(10/28)
|
|
45
|
+
const SPAWN = CODEX ? " (with `fork_turns` set to `none`, so it starts without this conversation)" : "";
|
|
37
46
|
|
|
38
47
|
const text = (t) => ({ content: [{ type: "text", text: String(t) }] });
|
|
39
48
|
/** 방문 화면 + 그 화면의 이름 없는 아이콘 그림(있으면). 방문자는 그림을 보고 모양으로 안다 */
|
|
@@ -81,7 +90,7 @@ server.registerTool(
|
|
|
81
90
|
"install_snippet",
|
|
82
91
|
{
|
|
83
92
|
description:
|
|
84
|
-
"Get the Usercase script for the project
|
|
93
|
+
"Get the Usercase script for the connected project, with where to put it per framework. If nothing is connected yet, it opens the browser to connect and says what to tell the user. Use when check_site says the SDK is not running and you are in the site's repository: add the script once to the root layout/document, show the user the diff, then run check_site on the local site to confirm. Do not commit or deploy unless asked.",
|
|
85
94
|
inputSchema: {},
|
|
86
95
|
},
|
|
87
96
|
wrap(async () => {
|
|
@@ -199,6 +208,8 @@ server.registerTool(
|
|
|
199
208
|
...ids.incoming.map((p) => `- ${p.id} · ${personLine(p)}`),
|
|
200
209
|
`${doc.people.length} in total (${doc.people.filter((x) => !x.usedAt).length} not visited yet)`,
|
|
201
210
|
coverageLine(doc.people),
|
|
211
|
+
// 절차서에만 쓰면 건너뛴다(10/28 둘째 판) — 방문자를 띄우기 직전인 여기서 한 번 더
|
|
212
|
+
...(CODEX && warmed.size < 2 ? [`\nBefore you start any visitor: ${WARMUP_STEP}`] : []),
|
|
202
213
|
].join("\n"),
|
|
203
214
|
);
|
|
204
215
|
}),
|
|
@@ -290,6 +301,43 @@ function removeOldAgent() {
|
|
|
290
301
|
return removed;
|
|
291
302
|
}
|
|
292
303
|
|
|
304
|
+
// Codex 는 플러그인에 에이전트를 넣을 수 없다(10/28 확인) — setup 이 ~/.codex/agents/ 에 깐다. 지시문은 Claude 것과 같다
|
|
305
|
+
const CODEX_AGENT_DEST = path.join(process.env.CODEX_HOME || path.join(os.homedir(), ".codex"), "agents", "usercase_visitor.toml");
|
|
306
|
+
function codexAgentToml() {
|
|
307
|
+
const body = fs
|
|
308
|
+
.readFileSync(AGENT_SRC, "utf8")
|
|
309
|
+
.replace(/\r\n/g, "\n")
|
|
310
|
+
.replace(/^---\n[\s\S]*?\n---\n+/, "")
|
|
311
|
+
.replace(/^<!--[\s\S]*?-->\n+/m, "")
|
|
312
|
+
.trim();
|
|
313
|
+
if (body.includes("'''")) throw new Error("usercase-visitor.md contains ''' and cannot go into a TOML literal string");
|
|
314
|
+
return [
|
|
315
|
+
`name = "usercase_visitor"`,
|
|
316
|
+
`description = "One Usercase virtual user visiting a website in a real browser. Give it only the person's number (e.g. p3) from .usercase/people.json. It sees nothing but the screen."`,
|
|
317
|
+
`sandbox_mode = "read-only"`,
|
|
318
|
+
"",
|
|
319
|
+
"developer_instructions = '''",
|
|
320
|
+
body,
|
|
321
|
+
"",
|
|
322
|
+
"You may only use the usercase tools open_visit, act and answer_card. Never use any other tool.",
|
|
323
|
+
"'''",
|
|
324
|
+
"",
|
|
325
|
+
"[features]",
|
|
326
|
+
"shell_tool = false",
|
|
327
|
+
"",
|
|
328
|
+
].join("\n");
|
|
329
|
+
}
|
|
330
|
+
/** 이 MCP 를 띄울 때 깔았나("installed")·바꿨나("updated") — 그럼 이 Codex 세션은 아직 그 에이전트를 모른다 */
|
|
331
|
+
let codexAgentFresh = null;
|
|
332
|
+
function installCodexAgent() {
|
|
333
|
+
const toml = codexAgentToml();
|
|
334
|
+
const had = fs.existsSync(CODEX_AGENT_DEST);
|
|
335
|
+
if (had && fs.readFileSync(CODEX_AGENT_DEST, "utf8") === toml) return "same";
|
|
336
|
+
fs.mkdirSync(path.dirname(CODEX_AGENT_DEST), { recursive: true });
|
|
337
|
+
fs.writeFileSync(CODEX_AGENT_DEST, toml);
|
|
338
|
+
return had ? "updated" : "installed";
|
|
339
|
+
}
|
|
340
|
+
|
|
293
341
|
/** 방문자 에이전트를 ~/.claude/agents/ 에 둔다. "installed" | "updated" | "same" */
|
|
294
342
|
function installAgent() {
|
|
295
343
|
const moved = removeOldAgent();
|
|
@@ -300,15 +348,34 @@ function installAgent() {
|
|
|
300
348
|
return had || moved ? "updated" : "installed";
|
|
301
349
|
}
|
|
302
350
|
|
|
351
|
+
server.registerTool(
|
|
352
|
+
"connect",
|
|
353
|
+
{
|
|
354
|
+
description:
|
|
355
|
+
"Connect Usercase to a project in the browser, or switch to another project. Opens the Usercase dashboard where the user signs in and picks the project. Other tools connect by themselves when nothing is connected — use this only when the user wants another project, or check_site says the SDK sends to a different project.",
|
|
356
|
+
inputSchema: {},
|
|
357
|
+
},
|
|
358
|
+
wrap(async () => text(await report.reconnect())),
|
|
359
|
+
);
|
|
360
|
+
|
|
303
361
|
server.registerTool(
|
|
304
362
|
"setup",
|
|
305
363
|
{
|
|
306
364
|
description:
|
|
307
|
-
"Install the
|
|
365
|
+
"Install the visitor sub-agent definition (Claude Code: ~/.claude/agents/, Codex: ~/.codex/agents/ — user-wide, nothing is added to the repository) so visitors can be started. Run once if that agent type is not available. The user may need to restart Claude Code afterwards.",
|
|
308
366
|
inputSchema: {},
|
|
309
367
|
},
|
|
310
368
|
wrap(async () => {
|
|
311
369
|
if (PLUGIN) return text(`Nothing to install — the visitor agent comes with the Usercase plugin as ${VISITOR}.`);
|
|
370
|
+
if (CODEX) {
|
|
371
|
+
// 이 세션을 띄울 때 깔았거나 바꿨으면 이 세션의 Codex 는 아직 모른다 — 일반 하위 에이전트로 대신 띄우면 코드를 본다
|
|
372
|
+
const c = codexAgentFresh || installCodexAgent();
|
|
373
|
+
return text(
|
|
374
|
+
c === "same"
|
|
375
|
+
? `Already up to date — ${CODEX_AGENT_DEST}`
|
|
376
|
+
: `${c === "updated" ? "Updated" : "Installed"} → ${CODEX_AGENT_DEST}\nThis agent can only use the three Usercase tools (open_visit, act, answer_card) and cannot read files or run commands.\nCodex reads agents only when a session starts, so ${VISITOR} is not available in this session. Stop here and ask the user to start a new Codex session (quit and run codex again) and run the command again. Never start visitors as a general sub-agent instead — it would see the code.`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
312
379
|
const r = installAgent();
|
|
313
380
|
return text(
|
|
314
381
|
r === "same"
|
|
@@ -569,6 +636,15 @@ server.registerTool(
|
|
|
569
636
|
|
|
570
637
|
// ── 가상 사용자 서브에이전트용 ──
|
|
571
638
|
|
|
639
|
+
// Codex 는 도구마다 처음 부를 때 허용을 묻는다. 방문 도중에 물으면 그동안 첫 화면에 가만히 있던 사람이 된다(10/28 첫 행동 130초).
|
|
640
|
+
// 그래서 진행자가 방문 전에 visit_id "warmup" 으로 한 번씩 불러 허용을 미리 받는다 — 아무 일도 하지 않는다
|
|
641
|
+
const WARMUP = "warmup";
|
|
642
|
+
const WARMUP_TEXT = "Permission check only — nothing happened.";
|
|
643
|
+
const WARMUP_STEP =
|
|
644
|
+
'call `act` and `answer_card` yourself, once each, with `visit_id` "warmup" and any other arguments (they do nothing). Codex then asks the user to allow these tools now — otherwise it asks in the middle of a visit, and the visitor stands still on the page until the user answers. Tell the user to allow them for good.';
|
|
645
|
+
const warmed = new Set();
|
|
646
|
+
const warm = (tool) => (warmed.add(tool), text(WARMUP_TEXT));
|
|
647
|
+
|
|
572
648
|
server.registerTool(
|
|
573
649
|
"open_visit",
|
|
574
650
|
{
|
|
@@ -597,7 +673,9 @@ server.registerTool(
|
|
|
597
673
|
times: z.number().int().min(1).max(5).optional().describe("For scroll, scroll_up or click: how many times in a row, quickly (scrolling down while skimming, clicking again because nothing happened). Default 1"),
|
|
598
674
|
},
|
|
599
675
|
},
|
|
600
|
-
wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) =>
|
|
676
|
+
wrap(async ({ visit_id, action, index, text: typed = "", why, done = false, times = 1 }) =>
|
|
677
|
+
visit_id === WARMUP ? warm("act") : screen(await hub.act(visit_id, { action, index, text: typed, why, done, times }), visit_id),
|
|
678
|
+
),
|
|
601
679
|
);
|
|
602
680
|
|
|
603
681
|
server.registerTool(
|
|
@@ -616,7 +694,9 @@ server.registerTool(
|
|
|
616
694
|
},
|
|
617
695
|
},
|
|
618
696
|
wrap(async ({ visit_id, bothered, bothered_why, action, choice_index, other_text = "", why }) =>
|
|
619
|
-
|
|
697
|
+
visit_id === WARMUP
|
|
698
|
+
? warm("answer_card")
|
|
699
|
+
: screen(await hub.answer(visit_id, { bothered, botheredWhy: bothered_why, action, choiceIndex: choice_index, otherText: other_text, why }), visit_id),
|
|
620
700
|
),
|
|
621
701
|
);
|
|
622
702
|
|
|
@@ -637,7 +717,7 @@ function researchPlan(count) {
|
|
|
637
717
|
line: unlimited ? `unlimited — rounds of ${ROUND} until the user stops` : `${n} in total, in rounds of ${ROUND}`,
|
|
638
718
|
rounds: [
|
|
639
719
|
`Work in rounds of ${ROUND} people, ${until}.`,
|
|
640
|
-
`- **Round 1:** cast ${ROUND}, show the user the list, and tell them you will keep casting new people like this ${unlimited ? "until they press Esc" : `until ${n} have visited`}, and that every visit uses their Claude usage. Wait for the go-ahead, then run the round (step 4).`,
|
|
720
|
+
`- **Round 1:** cast ${ROUND}, show the user the list, and tell them you will keep casting new people like this ${unlimited ? "until they press Esc" : `until ${n} have visited`}, and that every visit uses their ${CODEX ? "Codex" : "Claude"} usage. Wait for the go-ahead, then run the round (step 4).`,
|
|
641
721
|
"- **Next rounds:** do not ask again. Call `list_people` first and cast people unlike the ones already there — different goals, arrival paths, devices and states of mind, not variations of the same person. Run the round, then tell the user one short line (round number, who went, visits so far) and go on.",
|
|
642
722
|
"- After each round, `results` tells you whether the run is finished. Until it says so, start the next round — do not write the report.",
|
|
643
723
|
"- Stop early only if the site blocks us, or visits keep failing the same way before they start.",
|
|
@@ -663,7 +743,7 @@ function runStatus() {
|
|
|
663
743
|
const next = run.total === null ? ROUND : Math.min(ROUND, run.total - done);
|
|
664
744
|
return [
|
|
665
745
|
`▶ The run is not finished — ${done} visited so far (${left}).`,
|
|
666
|
-
`Do not write the report and do not look into the code. Tell the user one short line about this round, then: call list_people, cast ${next} new people unlike the ones already there with save_people, start one ${VISITOR} for each, and call results again when they finish.`,
|
|
746
|
+
`Do not write the report and do not look into the code. Tell the user one short line about this round, then: call list_people, cast ${next} new people unlike the ones already there with save_people, start one ${VISITOR}${SPAWN} for each, and call results again when they finish.`,
|
|
667
747
|
].join("\n");
|
|
668
748
|
}
|
|
669
749
|
|
|
@@ -708,7 +788,13 @@ const COMMANDS = {
|
|
|
708
788
|
.replace("{{count_line}}", plan.line)
|
|
709
789
|
.replace("{{rounds}}", plan.rounds)
|
|
710
790
|
.replace("{{people_rules}}", rules)
|
|
711
|
-
.replaceAll("{{visitor}}", VISITOR)
|
|
791
|
+
.replaceAll("{{visitor}}", VISITOR)
|
|
792
|
+
.replace("{{spawn}}", SPAWN)
|
|
793
|
+
.replace("{{agent}}", CODEX ? "Codex" : "Claude Code")
|
|
794
|
+
.replace(
|
|
795
|
+
"{{warmup}}\n",
|
|
796
|
+
CODEX ? `**Required before the first round:** ${WARMUP_STEP}\n\n` : "",
|
|
797
|
+
);
|
|
712
798
|
},
|
|
713
799
|
},
|
|
714
800
|
install: {
|
|
@@ -730,9 +816,23 @@ const COMMANDS = {
|
|
|
730
816
|
},
|
|
731
817
|
// 인자를 두지 않는다 — Claude Code 는 명령어 인자를 공백으로 나눠 문장을 못 싣는다. 무엇을 물을지는 대화로 묻는다
|
|
732
818
|
question: {
|
|
733
|
-
description: "Make a Usercase question for a precise moment on your site —
|
|
819
|
+
description: "Make a Usercase question for a precise moment on your site — your agent suggests the moment, question and answers",
|
|
734
820
|
body: () => agentDoc("question.md"),
|
|
735
821
|
},
|
|
822
|
+
// 프로젝트 바꾸기 — 연결은 저장소 폴더마다라 이 저장소만 바뀐다
|
|
823
|
+
connect: {
|
|
824
|
+
description: "Connect this repository to a Usercase project, or switch it to another one",
|
|
825
|
+
body: () =>
|
|
826
|
+
[
|
|
827
|
+
"# Connect this repository to a Usercase project",
|
|
828
|
+
"",
|
|
829
|
+
"Call the `connect` tool. It opens the Usercase dashboard in the browser, where the user signs in and picks the project (or creates one). Tell the user what it returns.",
|
|
830
|
+
"",
|
|
831
|
+
"If it says it is not connected yet, the browser is waiting for the user: wait until they say they are done, then call `connect` again — it carries on with the same request.",
|
|
832
|
+
"",
|
|
833
|
+
"Only this repository's folder changes. Other repositories keep their own project. Until the new project is approved, the current connection stays.",
|
|
834
|
+
].join("\n"),
|
|
835
|
+
},
|
|
736
836
|
steps: {
|
|
737
837
|
description: "Write the steps for the question open in your dashboard (New question → Several steps)",
|
|
738
838
|
argsSchema: { session: z.string().optional().describe("The window id shown in the dashboard command") },
|
|
@@ -740,12 +840,12 @@ const COMMANDS = {
|
|
|
740
840
|
},
|
|
741
841
|
};
|
|
742
842
|
|
|
743
|
-
if (PLUGIN) {
|
|
843
|
+
if (PLUGIN || CODEX || OTHER) {
|
|
744
844
|
server.registerTool(
|
|
745
845
|
"instructions",
|
|
746
846
|
{
|
|
747
847
|
description:
|
|
748
|
-
"The step-by-step instructions for a Usercase command (research, install, path, question, steps). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
848
|
+
"The step-by-step instructions for a Usercase command (research, install, path, question, steps, connect). The plugin's /usercase:* commands call this; follow what it returns as the user's request.",
|
|
749
849
|
inputSchema: {
|
|
750
850
|
command: z.enum(Object.keys(COMMANDS)),
|
|
751
851
|
...COMMANDS.research.argsSchema,
|
|
@@ -766,8 +866,15 @@ async function main() {
|
|
|
766
866
|
// 예전에 깐 방문자 에이전트가 있으면 이 버전 것으로 바꿔 둔다 — setup 은 처음 한 번만 불려서 옛 지시문이 남는다.
|
|
767
867
|
// 리서치를 한 번도 안 쓴 사람에게는 만들지 않는다
|
|
768
868
|
try {
|
|
769
|
-
if (!PLUGIN && (fs.existsSync(AGENT_DEST) || fs.existsSync(AGENT_OLD)) && installAgent() === "updated") process.stderr.write(`Updated the visitor agent → ${AGENT_DEST}\n`);
|
|
869
|
+
if (!PLUGIN && !CODEX && (fs.existsSync(AGENT_DEST) || fs.existsSync(AGENT_OLD)) && installAgent() === "updated") process.stderr.write(`Updated the visitor agent → ${AGENT_DEST}\n`);
|
|
870
|
+
// Codex 는 세션을 시작할 때만 에이전트를 읽는다(10/28 확인 — 세션 도중 깐 것은 unknown agent_type). 그래서 없어도 띄울 때 바로 깐다
|
|
871
|
+
if (CODEX) {
|
|
872
|
+
const c = installCodexAgent();
|
|
873
|
+
if (c !== "same") codexAgentFresh = c;
|
|
874
|
+
if (c !== "same") process.stderr.write(`${c === "updated" ? "Updated" : "Installed"} the visitor agent → ${CODEX_AGENT_DEST}\n`);
|
|
875
|
+
}
|
|
770
876
|
} catch {}
|
|
877
|
+
server.server.oninitialized = () => report.setClient((server.server.getClientVersion() || {}).name);
|
|
771
878
|
await server.connect(new StdioServerTransport());
|
|
772
879
|
process.stderr.write(`Usercase MCP ready — working folder ${ws.DIR}${hub.inject ? ` · local SDK injected (${hub.inject.project})` : ""}\n`);
|
|
773
880
|
}
|
package/package.json
CHANGED