poseref 0.1.0 → 0.1.2

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 CHANGED
@@ -20,6 +20,8 @@ npx poseref setup
20
20
  終わったら Claude Code / Codex を起動し直し、「PoseRef で、椅子に座って頬杖をつくポーズを斜め前から撮って」のように頼んでください。
21
21
  エージェントが最初に PoseRef のツールを使ったときに、PoseRef が起動してブラウザで画面が開きます。
22
22
  3D の描画はブラウザで行うので、このタブは開いたままにしてください。ヘッダーに「AI連携: 接続中」と表示されていれば準備できています。
23
+ タブを閉じてしまっても、次にツールを使ったときに開き直します(ユーザーがわざと閉じた直後に何度も開かないよう、自動で開くのは 60 秒に 1 回までです)。
24
+ 「PoseRef を開いて」と頼むと、エージェントが `open_poseref` で画面を開きます。すでにタブが開いていれば、新しいタブは増やしません。
23
25
 
24
26
  登録するコマンドはバージョンを固定しています(`npx -y poseref@<バージョン> mcp`)。新しい版が公開されても、知らないうちに別のコードが動くことはありません。
25
27
  更新するときは `npx poseref@latest setup` を実行し直してください。
@@ -42,8 +44,9 @@ npx poseref stop # 起動中の PoseRef を止める
42
44
 
43
45
  ### Claude Code と Codex の確認
44
46
 
45
- Codex は、読み取り専用ではない MCP ツールを呼ぶたびに承認を求めます。`set_scene` と `reach` は PoseRef の画面だけを変える操作で、
46
- 「元に戻す」で取り消せるので、`npx poseref setup` はこの 2 つを承認なしで実行する設定を `~/.codex/config.toml` に追記します。
47
+ Codex は、読み取り専用ではない MCP ツールを呼ぶたびに承認を求めます。`set_scene` と `reach` は PoseRef の画面だけを変え、
48
+ 「元に戻す」で取り消せる操作で、`open_poseref` は PoseRef の画面を開くだけの操作です。そのため `npx poseref setup` は、この 3 つを承認なしで実行する設定を
49
+ `~/.codex/config.toml` に追記します。
47
50
  追記の前に同じ場所へバックアップを作り、Codex が設定を読めなくなった場合は元に戻します。
48
51
  この設定がないと、ポーズを直すたびに承認することになり、`codex exec` のような非対話の実行では `set_scene` がキャンセルされます。
49
52
 
@@ -54,6 +57,7 @@ Claude Code もツールを呼ぶたびに許可を求めることがありま
54
57
 
55
58
  | ツール | 内容 |
56
59
  | --- | --- |
60
+ | `open_poseref` | PoseRef が止まっていれば起動し、ブラウザで画面を開く。タブがすでに開いていれば何もしない |
57
61
  | `get_pose_spec` | ボーンの軸と符号の決まり、接地、カメラ、プリセットの実例をまとめた説明書を返す。内容はコードの定数から生成される |
58
62
  | `get_scene` | 現在のポーズ・キャラクターの向きと位置・カメラ・アスペクト比を JSON で返す |
59
63
  | `set_scene` | ポーズ・キャラクター・カメラ・アスペクト比を適用する。ポーズを指定したときは、体の最下点が床にちょうど着くよう腰の高さを自動で合わせる(ジャンプなどは `pose.ground: false` で無効にできる)。未知のボーン名や範囲外の値は、何も適用せずにエラーを返す |
@@ -129,6 +133,9 @@ approval_mode = "approve"
129
133
 
130
134
  [mcp_servers.poseref-dev.tools.reach]
131
135
  approval_mode = "approve"
136
+
137
+ [mcp_servers.poseref-dev.tools.open_poseref]
138
+ approval_mode = "approve"
132
139
  ```
133
140
 
134
141
  npm への公開は `npm publish` で行います。`prepublishOnly` でビルドとテストが走ります。
@@ -18,6 +18,9 @@ import { createInterface } from "node:readline/promises";
18
18
  var REQUEST_TIMEOUT_MS$1 = 2e4;
19
19
  /** Thrown when no browser tab is connected; the message is meant for the agent to relay. */
20
20
  var NoBrowserError = class extends Error {};
21
+ function noBrowserError(url) {
22
+ return new NoBrowserError(`No PoseRef browser tab is connected. Call open_poseref, or ask the user to open ${url} in a browser, then retry. If that page does not load, PoseRef is not running: the user can start it with \`npx poseref\`.`);
23
+ }
21
24
  /**
22
25
  * Tracks the PoseRef browser tabs connected over WebSocket and forwards agent
23
26
  * requests to one of them. The tab that most recently gained focus wins, so
@@ -27,9 +30,9 @@ var BrowserBridge = class {
27
30
  tabs = [];
28
31
  pending = /* @__PURE__ */ new Map();
29
32
  nextId = 1;
30
- appUrl;
31
- constructor(appUrl) {
32
- this.appUrl = appUrl;
33
+ opener;
34
+ constructor(opener) {
35
+ this.opener = opener;
33
36
  }
34
37
  get connectedTabs() {
35
38
  return this.tabs.length;
@@ -40,9 +43,16 @@ var BrowserBridge = class {
40
43
  socket.on("close", () => this.detach(socket));
41
44
  socket.on("error", () => socket.close());
42
45
  }
46
+ async open(automatic) {
47
+ return {
48
+ ...await this.opener.ensureTab(automatic, async () => this.tabs.length),
49
+ url: this.opener.url,
50
+ startedServer: false
51
+ };
52
+ }
43
53
  request(method, params) {
44
54
  const socket = this.tabs[this.tabs.length - 1];
45
- if (!socket) return Promise.reject(new NoBrowserError(`No PoseRef browser tab is connected. Ask the user to open ${this.appUrl} in a browser, then retry. If that page does not load, PoseRef is not running: the user can start it with \`npx poseref\`.`));
55
+ if (!socket) return Promise.reject(noBrowserError(this.opener.url));
46
56
  const id = this.nextId++;
47
57
  const message = {
48
58
  type: "request",
@@ -1837,6 +1847,7 @@ var DEFAULT_VIEW_SIZE = 480;
1837
1847
  /** A contact within 2 cm reads as touching in the render. */
1838
1848
  var REACHED_WITHIN = .02;
1839
1849
  var INSTRUCTIONS = `PoseRef controls a posable mannequin and camera in the user's browser to build pose and composition reference images.
1850
+ The tools open the PoseRef page in the browser by themselves when no tab is connected. When the user asks to open or show PoseRef, call open_poseref.
1840
1851
  Call get_pose_spec once before your first set_scene: it explains the bone axes and sign conventions, which are easy to get wrong.
1841
1852
  Before posing, decide which way the character acts (a target, a direction) and keep that direction across the picture so the action reads in silhouette; aiming, throwing and swinging are done side-on (see the spec's "Composition and readability").
1842
1853
  Then loop: set_scene -> render_views -> compare with the request and check the silhouette from the 'current' view -> adjust. When the request has body parts touching (hand on hip, elbow on knee, hands together), set the rough pose first and then place the contacts with reach (inverse kinematics) instead of guessing angles; confirm with get_joint_positions and only report contact the numbers show. set_scene puts the body on the floor automatically. Stop after two or three rounds when the pose is roughly right; the user fine-tunes by hand and can undo your changes in the app.`;
@@ -1857,6 +1868,20 @@ function compactScene(scene) {
1857
1868
  }
1858
1869
  });
1859
1870
  }
1871
+ /**
1872
+ * Runs a browser request. When no tab is connected, opens the page (the bridge
1873
+ * rate-limits automatic opening) and tries once more, so a closed tab does not
1874
+ * leave the agent stuck asking the user.
1875
+ */
1876
+ async function withTab(bridge, call) {
1877
+ try {
1878
+ return await call();
1879
+ } catch (error) {
1880
+ if (!(error instanceof NoBrowserError)) throw error;
1881
+ if ((await bridge.open(true)).tabs === 0) throw error;
1882
+ return await call();
1883
+ }
1884
+ }
1860
1885
  function failure(error) {
1861
1886
  return {
1862
1887
  content: [{
@@ -1895,7 +1920,7 @@ function createPoseRefMcpServer(bridge) {
1895
1920
  annotations: { readOnlyHint: true }
1896
1921
  }, async () => {
1897
1922
  try {
1898
- return text(compactScene(await bridge.request("getScene", {})));
1923
+ return text(compactScene(await withTab(bridge, () => bridge.request("getScene", {}))));
1899
1924
  } catch (error) {
1900
1925
  return failure(error);
1901
1926
  }
@@ -1906,10 +1931,10 @@ function createPoseRefMcpServer(bridge) {
1906
1931
  inputSchema: sceneSchema
1907
1932
  }, async (scene) => {
1908
1933
  try {
1909
- const { scene: applied, groundShift } = await bridge.request("setScene", {
1934
+ const { scene: applied, groundShift } = await withTab(bridge, () => bridge.request("setScene", {
1910
1935
  version: 1,
1911
1936
  ...scene
1912
- });
1937
+ }));
1913
1938
  return text(`Applied.${groundShift === null ? "" : groundShift === 0 ? " Already resting on the floor." : ` Grounded: hips moved ${groundShift > 0 ? "up" : "down"} by ${Math.abs(groundShift)} m so the lowest body point rests on the floor.`} Current scene (bones not listed are 0): ${compactScene(applied)}`);
1914
1939
  } catch (error) {
1915
1940
  return failure(error);
@@ -1922,10 +1947,10 @@ function createPoseRefMcpServer(bridge) {
1922
1947
  annotations: { readOnlyHint: true }
1923
1948
  }, async ({ views, size }) => {
1924
1949
  try {
1925
- const result = await bridge.request("renderViews", {
1950
+ const result = await withTab(bridge, () => bridge.request("renderViews", {
1926
1951
  views: views ?? [...DEFAULT_VIEWS],
1927
1952
  size: size ?? DEFAULT_VIEW_SIZE
1928
- });
1953
+ }));
1929
1954
  const path = await saveRender(result.pngBase64);
1930
1955
  return { content: [{
1931
1956
  type: "image",
@@ -1946,7 +1971,7 @@ function createPoseRefMcpServer(bridge) {
1946
1971
  annotations: { readOnlyHint: true }
1947
1972
  }, async ({ pairs }) => {
1948
1973
  try {
1949
- const report = await bridge.request("getJoints", {});
1974
+ const report = await withTab(bridge, () => bridge.request("getJoints", {}));
1950
1975
  const distances = (pairs ?? []).map(([a, b]) => {
1951
1976
  const p = report.joints[a];
1952
1977
  const q = report.joints[b];
@@ -1972,7 +1997,7 @@ function createPoseRefMcpServer(bridge) {
1972
1997
  inputSchema: reachSchema
1973
1998
  }, async ({ goals, ground }) => {
1974
1999
  try {
1975
- const scene = await bridge.request("getScene", {});
2000
+ const scene = await withTab(bridge, () => bridge.request("getScene", {}));
1976
2001
  const frame = {
1977
2002
  bones: scene.pose.bones,
1978
2003
  rootOffset: scene.pose.rootOffset,
@@ -1986,14 +2011,14 @@ function createPoseRefMcpServer(bridge) {
1986
2011
  }
1987
2012
  const result = solveIk(frame, goals, chains);
1988
2013
  const bones = Object.fromEntries(result.changed.map((bone) => [bone, result.bones[bone]]));
1989
- const { groundShift } = await bridge.request("setScene", {
2014
+ const { groundShift } = await withTab(bridge, () => bridge.request("setScene", {
1990
2015
  version: 1,
1991
2016
  pose: {
1992
2017
  mode: "merge",
1993
2018
  bones,
1994
2019
  ground
1995
2020
  }
1996
- });
2021
+ }));
1997
2022
  const lines = result.goals.map((goal) => `- ${goal.effector}: ${goal.error <= REACHED_WITHIN ? "reached" : "NOT reached"}, ${goal.error} m from the target (rotated ${goal.chain.join(", ")})`);
1998
2023
  const advice = result.goals.some((goal) => goal.error > REACHED_WITHIN) ? "\nA target out of reach leaves the limb pointing at it but short. Move the body closer first (lean the chest, bend the legs), add a parent bone such as chest to that goal's chain, or add a goal that moves the other body part toward this one." : "";
1999
2024
  const grounding = groundShift ? `\nGrounded: hips moved ${groundShift > 0 ? "up" : "down"} by ${Math.abs(groundShift)} m.` : "";
@@ -2002,6 +2027,27 @@ function createPoseRefMcpServer(bridge) {
2002
2027
  return failure(error);
2003
2028
  }
2004
2029
  });
2030
+ server.registerTool("open_poseref", {
2031
+ title: "Open PoseRef",
2032
+ description: "Starts PoseRef if it is not running and opens it in the user's browser. Does nothing when a PoseRef tab is already open. Use it when the user asks to open or show PoseRef; the other tools also open the page on their own when no tab is connected.",
2033
+ annotations: {
2034
+ readOnlyHint: false,
2035
+ destructiveHint: false,
2036
+ idempotentHint: true,
2037
+ openWorldHint: false
2038
+ }
2039
+ }, async () => {
2040
+ try {
2041
+ const result = await bridge.open(false);
2042
+ const started = result.startedServer ? "Started the PoseRef server. " : "";
2043
+ if (result.tabs > 0 && !result.opened) return text(`${started}PoseRef is already open at ${result.url} (${result.tabs} tab${result.tabs === 1 ? "" : "s"}).`);
2044
+ if (result.tabs > 0) return text(`${started}Opened PoseRef at ${result.url}.`);
2045
+ if (result.disabled) return text(`${started}Did not open a browser because POSEREF_NO_OPEN is set. Ask the user to open ${result.url}.`);
2046
+ return text(`${started}Opened ${result.url}, but the page has not connected yet. Ask the user to check the browser window.`);
2047
+ } catch (error) {
2048
+ return failure(error);
2049
+ }
2050
+ });
2005
2051
  return server;
2006
2052
  }
2007
2053
  /** Stateless Streamable HTTP handler: every request gets a fresh McpServer bound to the shared bridge. */
@@ -2009,6 +2055,79 @@ function createPoseRefMcpHandler(bridge) {
2009
2055
  return createMcpHandler(() => createPoseRefMcpServer(bridge));
2010
2056
  }
2011
2057
  //#endregion
2058
+ //#region server/browser.ts
2059
+ /**
2060
+ * Opens the PoseRef page in the default browser. The URL is always one PoseRef
2061
+ * built itself (http://127.0.0.1:<port>, or http://localhost:<port> for the Vite
2062
+ * dev server), and no shell is involved, so nothing from outside can reach the
2063
+ * command line.
2064
+ */
2065
+ function openBrowser(url, log) {
2066
+ if (!/^http:\/\/(?:127\.0\.0\.1|localhost):\d+\/?$/.test(url)) throw new Error(`Refusing to open unexpected URL: ${url}`);
2067
+ const [command, args] = process.platform === "win32" ? ["explorer.exe", [url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
2068
+ const child = spawn(command, args, {
2069
+ detached: true,
2070
+ stdio: "ignore",
2071
+ windowsHide: true
2072
+ });
2073
+ child.on("error", () => log(`ブラウザで ${url} を開いてください。`));
2074
+ child.unref();
2075
+ }
2076
+ //#endregion
2077
+ //#region server/opener.ts
2078
+ var TAB_WAIT_MS = 2e4;
2079
+ var POLL_MS$1 = 300;
2080
+ /**
2081
+ * Automatic opening (a tool found no tab) happens at most this often, so a user
2082
+ * who closes the tab on purpose is not handed a new one on every tool call.
2083
+ * An explicit open_poseref call is never throttled.
2084
+ */
2085
+ var AUTO_OPEN_COOLDOWN_MS = 6e4;
2086
+ var sleep$1 = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2087
+ /** Decides whether to open the PoseRef page, opens it, and waits for the tab to connect. */
2088
+ var PageOpener = class {
2089
+ options;
2090
+ lastAutoOpen = Number.NEGATIVE_INFINITY;
2091
+ constructor(options) {
2092
+ this.options = options;
2093
+ }
2094
+ get url() {
2095
+ return this.options.url;
2096
+ }
2097
+ async ensureTab(automatic, countTabs) {
2098
+ let tabs = await countTabs();
2099
+ if (tabs > 0) return {
2100
+ tabs,
2101
+ opened: false,
2102
+ disabled: false
2103
+ };
2104
+ if (!this.options.enabled) return {
2105
+ tabs,
2106
+ opened: false,
2107
+ disabled: true
2108
+ };
2109
+ if (automatic) {
2110
+ if (Date.now() - this.lastAutoOpen < AUTO_OPEN_COOLDOWN_MS) return {
2111
+ tabs,
2112
+ opened: false,
2113
+ disabled: false
2114
+ };
2115
+ this.lastAutoOpen = Date.now();
2116
+ }
2117
+ (this.options.openPage ?? ((url) => openBrowser(url, this.options.log)))(this.options.url);
2118
+ const deadline = Date.now() + TAB_WAIT_MS;
2119
+ while (tabs === 0 && Date.now() < deadline) {
2120
+ await sleep$1(POLL_MS$1);
2121
+ tabs = await countTabs();
2122
+ }
2123
+ return {
2124
+ tabs,
2125
+ opened: true,
2126
+ disabled: false
2127
+ };
2128
+ }
2129
+ };
2130
+ //#endregion
2012
2131
  //#region server/app.ts
2013
2132
  var MCP_PATH = "/mcp";
2014
2133
  /** Loopback only: the server drives the user's browser tab and must not be reachable from the network. */
@@ -2053,7 +2172,12 @@ function createPoseRefServer(options) {
2053
2172
  `http://localhost:${options.port}`,
2054
2173
  ...options.extraOrigins ?? []
2055
2174
  ]);
2056
- const bridge = new BrowserBridge(appUrl);
2175
+ const bridge = new BrowserBridge(new PageOpener({
2176
+ url: options.pageUrl ?? appUrl,
2177
+ enabled: !process.env.POSEREF_NO_OPEN,
2178
+ log: (message) => process.stderr.write(`${message}\n`),
2179
+ openPage: options.openPage
2180
+ }));
2057
2181
  const mcp = toNodeHandler(createPoseRefMcpHandler(bridge));
2058
2182
  const staticRoot = options.staticDir ? resolve(options.staticDir) : null;
2059
2183
  const validateHost = localhostHostValidation();
@@ -2113,24 +2237,6 @@ function createPoseRefServer(options) {
2113
2237
  };
2114
2238
  }
2115
2239
  //#endregion
2116
- //#region server/browser.ts
2117
- /**
2118
- * Opens the PoseRef page in the default browser. The URL is always one PoseRef
2119
- * built itself (http://127.0.0.1:<port>), and no shell is involved, so nothing
2120
- * from outside can reach the command line.
2121
- */
2122
- function openBrowser(url, log) {
2123
- if (!/^http:\/\/127\.0\.0\.1:\d+\/?$/.test(url)) throw new Error(`Refusing to open unexpected URL: ${url}`);
2124
- const [command, args] = process.platform === "win32" ? ["explorer.exe", [url]] : process.platform === "darwin" ? ["open", [url]] : ["xdg-open", [url]];
2125
- const child = spawn(command, args, {
2126
- detached: true,
2127
- stdio: "ignore",
2128
- windowsHide: true
2129
- });
2130
- child.on("error", () => log(`ブラウザで ${url} を開いてください。`));
2131
- child.unref();
2132
- }
2133
- //#endregion
2134
2240
  //#region server/port.ts
2135
2241
  /** Fixed default so the MCP URL registered with agents stays valid across restarts. */
2136
2242
  var DEFAULT_PORT = 47173;
@@ -2141,7 +2247,6 @@ function resolvePort() {
2141
2247
  //#endregion
2142
2248
  //#region server/remote.ts
2143
2249
  var START_TIMEOUT_MS = 15e3;
2144
- var TAB_WAIT_MS = 2e4;
2145
2250
  var POLL_MS = 300;
2146
2251
  var REQUEST_TIMEOUT_MS = 3e4;
2147
2252
  var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
@@ -2157,11 +2262,18 @@ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
2157
2262
  var RemoteBridge = class {
2158
2263
  options;
2159
2264
  baseUrl;
2265
+ opener;
2160
2266
  ready = null;
2161
- browserOpened = false;
2267
+ startedServer = false;
2162
2268
  constructor(options) {
2163
2269
  this.options = options;
2164
2270
  this.baseUrl = `http://127.0.0.1:${options.port}`;
2271
+ this.opener = new PageOpener({
2272
+ url: this.baseUrl,
2273
+ enabled: options.openBrowser,
2274
+ log: options.log,
2275
+ openPage: options.openPage
2276
+ });
2165
2277
  }
2166
2278
  async request(method, params) {
2167
2279
  try {
@@ -2174,6 +2286,21 @@ var RemoteBridge = class {
2174
2286
  return await this.call(method, params);
2175
2287
  }
2176
2288
  }
2289
+ async open(automatic) {
2290
+ this.ready = null;
2291
+ await this.ensureReady();
2292
+ const result = await this.opener.ensureTab(automatic, async () => {
2293
+ const probe = await this.probe();
2294
+ return probe.kind === "poseref" ? probe.tabs : 0;
2295
+ });
2296
+ const startedServer = this.startedServer;
2297
+ this.startedServer = false;
2298
+ return {
2299
+ ...result,
2300
+ url: this.baseUrl,
2301
+ startedServer
2302
+ };
2303
+ }
2177
2304
  ensureReady() {
2178
2305
  this.ready ??= this.prepare().catch((error) => {
2179
2306
  this.ready = null;
@@ -2181,21 +2308,18 @@ var RemoteBridge = class {
2181
2308
  });
2182
2309
  return this.ready;
2183
2310
  }
2311
+ /** Makes sure the listener on the port is PoseRef, starting it if nothing is there. */
2184
2312
  async prepare() {
2185
2313
  let probe = await this.probe();
2186
2314
  if (probe.kind === "foreign") throw this.foreignError();
2187
2315
  if (probe.kind === "absent") {
2188
2316
  this.options.log("Starting the PoseRef server…");
2189
2317
  this.options.startServer();
2318
+ this.startedServer = true;
2190
2319
  probe = await this.waitFor((next) => next.kind !== "absent", START_TIMEOUT_MS);
2191
2320
  if (probe.kind === "foreign") throw this.foreignError();
2192
2321
  if (probe.kind === "absent") throw new Error("The PoseRef server did not start. Run `npx poseref start` in a terminal to see why.");
2193
2322
  }
2194
- if (this.options.openBrowser && probe.kind === "poseref" && probe.tabs === 0 && !this.browserOpened) {
2195
- this.browserOpened = true;
2196
- openBrowser(this.baseUrl, this.options.log);
2197
- await this.waitFor((next) => next.kind === "poseref" && next.tabs > 0, TAB_WAIT_MS);
2198
- }
2199
2323
  }
2200
2324
  async waitFor(done, timeoutMs) {
2201
2325
  const deadline = Date.now() + timeoutMs;
@@ -2247,6 +2371,7 @@ var RemoteBridge = class {
2247
2371
  }
2248
2372
  const body = await response.json().catch(() => ({}));
2249
2373
  if (body.ok === true) return body.result;
2374
+ if (response.status === 503) throw noBrowserError(this.baseUrl);
2250
2375
  if (body.ok === false && body.error) throw new Error(body.error);
2251
2376
  throw new Error(`The PoseRef server rejected the request (HTTP ${response.status}${body.error ? `: ${body.error}` : ""}).`);
2252
2377
  }
@@ -2272,8 +2397,15 @@ function spawnDetachedServer(cliPath, log) {
2272
2397
  }
2273
2398
  //#endregion
2274
2399
  //#region server/setup.ts
2275
- /** Tools that change the scene. Codex asks before each call unless they are approved in config.toml. */
2276
- var WRITE_TOOLS = ["set_scene", "reach"];
2400
+ /**
2401
+ * Tools that are not read-only: two change the scene and open_poseref opens a
2402
+ * browser tab. Codex asks before each call unless they are approved in config.toml.
2403
+ */
2404
+ var WRITE_TOOLS = [
2405
+ "set_scene",
2406
+ "reach",
2407
+ "open_poseref"
2408
+ ];
2277
2409
  /**
2278
2410
  * The command agents run to start PoseRef's stdio MCP server. The version is
2279
2411
  * pinned so a later release (or a compromised one) never runs without the user
@@ -2308,7 +2440,7 @@ function appendCodexApprovals(toml, server = "poseref") {
2308
2440
  };
2309
2441
  const blocks = added.map((tool) => `[mcp_servers.${server}.tools.${tool}]\napproval_mode = "approve"\n`);
2310
2442
  return {
2311
- text: `${toml}${toml.length === 0 || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n"}# Added by \`poseref setup\`: these tools only change the PoseRef tab, and the change can be undone there.
2443
+ text: `${toml}${toml.length === 0 || toml.endsWith("\n\n") ? "" : toml.endsWith("\n") ? "\n" : "\n\n"}# Added by \`poseref setup\`: these tools only change or open the PoseRef tab; scene changes can be undone there.
2312
2444
  ${blocks.join("\n")}`,
2313
2445
  added: [...added]
2314
2446
  };
@@ -2324,14 +2456,29 @@ function registrationEnv(port = resolvePort()) {
2324
2456
  }
2325
2457
  /** Every argument PoseRef passes to another CLI is a fixed word or a version, never user text. */
2326
2458
  var SAFE_ARG = /^[\w@.:/=-]+$/;
2327
- function run(command, args) {
2459
+ /**
2460
+ * Joins a command for the Windows shell. claude and codex are .cmd shims there,
2461
+ * which Node can only start through a shell; building the line ourselves (rather
2462
+ * than passing an args array with `shell: true`, which Node deprecates because it
2463
+ * concatenates without escaping) keeps the check on every piece explicit. Pieces
2464
+ * are limited to SAFE_ARG, which has no spaces or shell metacharacters, so they
2465
+ * need no quoting.
2466
+ */
2467
+ function shellCommandLine(command, args) {
2328
2468
  for (const arg of [command, ...args]) if (!SAFE_ARG.test(arg)) throw new Error(`Refusing to run a command with an unexpected argument: ${arg}`);
2329
- const result = spawnSync(command, args, {
2469
+ return [command, ...args].join(" ");
2470
+ }
2471
+ function run(command, args) {
2472
+ const line = shellCommandLine(command, args);
2473
+ const options = {
2330
2474
  encoding: "utf8",
2331
- shell: process.platform === "win32",
2332
2475
  windowsHide: true,
2333
2476
  timeout: 6e4
2334
- });
2477
+ };
2478
+ const result = process.platform === "win32" ? spawnSync(line, {
2479
+ ...options,
2480
+ shell: true
2481
+ }) : spawnSync(command, args, options);
2335
2482
  return {
2336
2483
  ok: result.status === 0,
2337
2484
  output: `${result.stdout ?? ""}${result.stderr ?? ""}`.trim()
@@ -2406,7 +2553,7 @@ function codexSteps(launch, env, log) {
2406
2553
  if (!added.ok) throw new Error(`codex mcp add failed${exists ? REMOVED_NOTE : ""}:\n${added.output}`);
2407
2554
  }
2408
2555
  }, {
2409
- describe: `Let ${WRITE_TOOLS.join(" and ")} run without a prompt each time: append approval settings to ${configPath} (a backup is kept next to it)`,
2556
+ describe: `Let ${WRITE_TOOLS.join(", ")} run without a prompt each time: append approval settings to ${configPath} (a backup is kept next to it)`,
2410
2557
  apply: () => {
2411
2558
  const { text, added } = appendCodexApprovals(existsSync(configPath) ? readFileSync(configPath, "utf8") : "");
2412
2559
  if (added.length === 0) return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "poseref",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Pose and composition reference maker for image-generation AI, with an MCP server so Claude Code and Codex can pose it",
5
5
  "keywords": [
6
6
  "pose",