cozyclay 3.1.2 → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +1 -1
  3. package/bin/agent/agent-runner.mjs +160 -151
  4. package/bin/agent/agent-stream.mjs +86 -0
  5. package/bin/agent/anthropic-subscription-events.mjs +120 -0
  6. package/bin/agent/anthropic-subscription-prompt.mjs +116 -0
  7. package/bin/agent/anthropic-subscription-stream.mjs +251 -0
  8. package/bin/agent/anthropic-subscription.mjs +73 -0
  9. package/bin/agent/claude-code-executable.mjs +93 -0
  10. package/bin/agent/cliproxy-provider.mjs +25 -11
  11. package/bin/agent/model-roles.mjs +112 -0
  12. package/bin/agent/openai-compatible-provider.mjs +95 -0
  13. package/bin/agent/pi-tools.mjs +19 -10
  14. package/bin/agent/provider-login.mjs +3 -1
  15. package/bin/agent/provider-routes.mjs +2 -0
  16. package/bin/agent/providers.mjs +35 -130
  17. package/bin/cozyclay.mjs +2 -48
  18. package/bin/star-prompt.mjs +123 -0
  19. package/dist/app/index.html +5 -5
  20. package/dist/assets/TaskBoard-U1Yl-4F1.js +1 -0
  21. package/dist/assets/app-BdRlMf4J.css +1 -0
  22. package/dist/assets/app-KXdgBC-S.js +5144 -0
  23. package/dist/assets/{motion3-compile-dgGhrvPK.js → motion3-compile-DDG3wklD.js} +1 -1
  24. package/dist/assets/scene-asset-sync-Bz59Ghdz.js +13 -0
  25. package/dist/assets/task-board-DFfNa0xg.js +15 -0
  26. package/dist/assets/{workflow-C87p6E1E.js → workflow-BMJGd_gZ.js} +1 -1
  27. package/dist/cozyclay-package.json +1 -1
  28. package/dist/workflow/index.html +3 -3
  29. package/package.json +6 -4
  30. package/src/App.jsx +28 -28
  31. package/src/agent-image-client.js +2 -2
  32. package/src/asset-pane.jsx +14 -14
  33. package/src/character-kind.js +13 -12
  34. package/src/commands/project.js +2 -1
  35. package/src/commands/shot.js +1 -1
  36. package/src/commands/view.js +2 -2
  37. package/src/domains/cast.js +15 -15
  38. package/src/domains/motion/motion-range-pin-rules.js +1 -1
  39. package/src/domains/motion.js +11 -11
  40. package/src/domains/objects.js +20 -30
  41. package/src/domains/scenes.js +14 -21
  42. package/src/domains/shots.js +3 -3
  43. package/src/first-success-guide.jsx +19 -3
  44. package/src/hierarchy-panel.jsx +6 -7
  45. package/src/locale-zh/app.js +124 -0
  46. package/src/locale-zh/domains.js +36 -0
  47. package/src/locale-zh/labels.js +148 -0
  48. package/src/locale-zh/panels.js +225 -0
  49. package/src/locale-zh/shell.js +230 -0
  50. package/src/locale-zh/timeline.js +145 -0
  51. package/src/locale-zh/workflow.js +104 -0
  52. package/src/locale-zh/workspace.js +190 -0
  53. package/src/locale-zh.js +21 -0
  54. package/src/locale.js +16 -6
  55. package/src/main.jsx +1 -1
  56. package/src/motion/beat-board-model.js +4 -4
  57. package/src/motion/beat-board.jsx +1 -1
  58. package/src/motion/ik-pick.js +24 -0
  59. package/src/motion/take-segments.jsx +5 -5
  60. package/src/motion/timeline.jsx +30 -40
  61. package/src/motion/waypoints.js +6 -6
  62. package/src/object-catalog.jsx +4 -5
  63. package/src/panels/CameraPanel.jsx +3 -3
  64. package/src/panels/HdrEnvironmentControls.jsx +2 -1
  65. package/src/panels/ObjectTransformPanel.jsx +19 -29
  66. package/src/panels/PoseImagePanel.jsx +8 -8
  67. package/src/panels/PosePanel.jsx +2 -2
  68. package/src/panels/ReferenceImageField.jsx +2 -2
  69. package/src/panels/RigControlPanel.jsx +2 -2
  70. package/src/panels/SculptPanel.jsx +9 -9
  71. package/src/panels/SubjectBox.jsx +3 -3
  72. package/src/panels/SubjectsPanel.jsx +1 -1
  73. package/src/posestudio.jsx +10 -8
  74. package/src/project-browser.css +7 -6
  75. package/src/project-browser.jsx +7 -6
  76. package/src/project-open-file.js +19 -0
  77. package/src/qa-label.js +1 -1
  78. package/src/range-pin-panel.jsx +14 -14
  79. package/src/resource-status.jsx +3 -3
  80. package/src/result-modal.jsx +10 -10
  81. package/src/settings-menu.jsx +4 -1
  82. package/src/shell/BoardSlot.jsx +5 -5
  83. package/src/shell/CompositionGuideButton.jsx +1 -1
  84. package/src/shell/GhostsMenuGroup.jsx +1 -1
  85. package/src/shell/StatusBar.jsx +4 -4
  86. package/src/shell/ViewportToolbar.jsx +3 -3
  87. package/src/shell/add-camera-shot.js +2 -2
  88. package/src/shell/details-selection.js +1 -1
  89. package/src/shell/preference-controls.jsx +2 -1
  90. package/src/shot-guides.js +5 -5
  91. package/src/stage-hdr.js +3 -3
  92. package/src/stage-labels-ko.js +2 -2
  93. package/src/stage-startup-defaults.js +3 -3
  94. package/src/studio-actions.js +2 -2
  95. package/src/styles/tokens.css +1 -1
  96. package/src/styles.css +1 -1
  97. package/src/use-case-question.jsx +18 -18
  98. package/src/workflow/AgentPanel.jsx +4 -4
  99. package/src/workflow/AgentPlanCard.jsx +1 -1
  100. package/src/workflow/AgentProgressCard.jsx +4 -4
  101. package/src/workflow/AgentTurnSteps.jsx +1 -1
  102. package/src/workflow/LoginPanel.jsx +1 -1
  103. package/src/workflow/TaskBoard.jsx +1 -1
  104. package/src/workflow/TaskInline.jsx +1 -1
  105. package/src/workflow/agent-client.js +1 -1
  106. package/src/workflow/agent-turn.js +5 -5
  107. package/src/workflow/task-board.js +4 -4
  108. package/tools/loc-baseline.json +2 -2
  109. package/tools/run-tests.mjs +5 -5
  110. package/dist/assets/TaskBoard-GR8xZm5c.js +0 -1
  111. package/dist/assets/app-Bhe0TpGz.css +0 -1
  112. package/dist/assets/app-C4QTcDcb.js +0 -5144
  113. package/dist/assets/scene-asset-sync-qoxcdrhj.js +0 -13
  114. package/dist/assets/task-board-BqReHzOi.js +0 -15
@@ -5,6 +5,9 @@ import { createCredentialStore } from "./credential-store.mjs";
5
5
  import { readCredentials } from "./provider-keys.mjs";
6
6
  import { cliproxyBase, cliproxyProvider, extendCliproxyProvider, registryLiveCliproxy } from "./cliproxy-provider.mjs";
7
7
  export { cliproxyLiveModel } from "./cliproxy-provider.mjs";
8
+ import { OPENAI_COMPATIBLE_ID, openaiCompatibleProvider, refreshOpenAICompatible } from "./openai-compatible-provider.mjs";
9
+ import { rolesByMain } from "./model-roles.mjs";
10
+ export { MODEL_ROLES, fallbackChain, resolveRoleModels } from "./model-roles.mjs";
8
11
 
9
12
  // #379 (F2 pass 5 finding 4): pi never forwards a per-request `fetch` to the
10
13
  // codex API module — `pi-agent-core`'s `createRequestOptions` (the options
@@ -58,30 +61,38 @@ function ensureCodexFetchPatched() {
58
61
  export const PROVIDERS = [
59
62
  { id: "openai-codex", label: "ChatGPT (OpenAI Codex)", auth: "chatgpt-oauth", env: [] },
60
63
  { id: "anthropic", label: "Anthropic", auth: "api_key", env: ["ANTHROPIC_API_KEY"] },
64
+ // #claude-subscription: Claude Pro/Max OAuth sign-in; requests run through the official
65
+ // Claude Code binary (anthropic-subscription.mjs), never against the API with a raw token.
66
+ { id: "anthropic-subscription", label: "Claude (Anthropic)", auth: "oauth", env: [] },
61
67
  { id: "openai", label: "OpenAI", auth: "api_key", env: ["OPENAI_API_KEY"] },
62
68
  { id: "google", label: "Google Gemini", auth: "api_key", env: ["GEMINI_API_KEY", "GOOGLE_API_KEY"] },
69
+ { id: "minimax", label: "MiniMax", auth: "api_key", env: ["MINIMAX_API_KEY"] }, { id: "minimax-cn", label: "MiniMax (China)", auth: "api_key", env: ["MINIMAX_CN_API_KEY"] },
63
70
  { id: "openrouter", label: "OpenRouter", auth: "api_key", env: ["OPENROUTER_API_KEY"] },
64
71
  { id: "cliproxy", label: "CLIProxyAPI", auth: "api_key", env: ["CLIPROXY_API_KEY"] },
72
+ // #885: LM Studio, Ollama or any OpenAI-compatible server; its address rides the stored credential
73
+ { id: OPENAI_COMPATIBLE_ID, label: "OpenAI-compatible server", auth: "api_key", env: [] },
65
74
  ];
66
75
 
67
76
  const factories = {
68
- "openai-codex": "openaiCodexProvider",
69
- anthropic: "anthropicProvider",
70
- openai: "openaiProvider",
71
- google: "googleProvider",
77
+ "openai-codex": "openaiCodexProvider", openai: "openaiProvider", google: "googleProvider", minimax: "minimaxProvider", "minimax-cn": "minimaxCnProvider",
72
78
  openrouter: "openrouterProvider",
73
79
  };
74
80
 
75
81
  const providerConfig = (id) => PROVIDERS.find((provider) => provider.id === id);
76
82
 
77
- export async function loadProvider(id, options = {}) {
78
- const { baseUrl, env = process.env } = options;
79
- const config = providerConfig(id);
80
- if (!config) throw Object.assign(new Error(`Unknown provider: ${id}`), { code: "UNKNOWN_PROVIDER" });
83
+ export async function loadProvider(id, { baseUrl, env = process.env } = {}) {
84
+ if (!providerConfig(id)) throw Object.assign(new Error(`Unknown provider: ${id}`), { code: "UNKNOWN_PROVIDER" });
81
85
  if (id === "openai-codex") ensureCodexFetchPatched();
86
+ // Both Anthropic lanes come from CozyClay's own module: the subscription lane, and pi-ai's
87
+ // API-key lane minus the OAuth login it also advertises (see anthropicLaneProvider).
82
88
  const provider = id === "cliproxy"
83
89
  ? await cliproxyProvider({ baseUrl, env })
84
- : await import(`@earendil-works/pi-ai/providers/${id}`).then((module) => module[factories[id]]());
90
+ : id === OPENAI_COMPATIBLE_ID ? await openaiCompatibleProvider() : id === "anthropic" || id === "anthropic-subscription"
91
+ ? await import("./anthropic-subscription.mjs").then((module) => module.anthropicLaneProvider(id))
92
+ : await import(`@earendil-works/pi-ai/providers/${id}`).then((module) => module[factories[id]]());
93
+ // pi 0.99 gave the API-key OpenAI lane a ChatGPT sign-in too; CozyClay's ChatGPT sign-in is the
94
+ // openai-codex lane, so the panel keeps showing one ChatGPT button.
95
+ if (id === "openai") provider.auth = { ...provider.auth, oauth: undefined };
85
96
  if (baseUrl && id !== "cliproxy") {
86
97
  provider.baseUrl = baseUrl;
87
98
  const getModels = provider.getModels.bind(provider);
@@ -95,19 +106,18 @@ const piEffort = (level) => level === "none" ? "off" : level === "ultra" ? "max"
95
106
  const wireEffort = (level) => (level === "off" ? "none" : level);
96
107
 
97
108
  const registryCatalogueState = new WeakMap();
98
-
99
109
  function catalogueState(models) {
100
110
  let state = registryCatalogueState.get(models);
101
111
  if (!state) {
102
- state = { fetch: null, models: [], extendedProviders: new WeakSet(), cliproxyFetch: null };
112
+ state = { fetch: null, models: [], extendedProviders: new WeakSet(), cliproxyFetch: null, cliproxyFetchError: null };
103
113
  registryCatalogueState.set(models, state);
104
114
  }
105
115
  return state;
106
116
  }
107
-
108
117
  function livePiModel(model, template) {
109
118
  const supported = new Set(model.efforts.map(piEffort));
110
- const thinkingLevelMap = Object.fromEntries(PI_EFFORT_LEVELS.map((level) => [level, supported.has(level) ? level : null]));
119
+ // map values are what goes on the wire: pi sends a model's off mapping, and Codex calls it "none"
120
+ const thinkingLevelMap = Object.fromEntries(PI_EFFORT_LEVELS.map((level) => [level, supported.has(level) ? wireEffort(level) : null]));
111
121
  return { ...template, id: model.id, name: model.label, thinkingLevelMap };
112
122
  }
113
123
 
@@ -171,6 +181,8 @@ export async function resolveModel(requested, options = {}) {
171
181
  if (!modelId) throw unknownModel(requested);
172
182
  const models = options.models ?? await createModels(options);
173
183
  if (!providerConfig(provider) && !models.getProvider?.(provider)) throw unknownModel(requested);
184
+ // a custom server's models are its live /v1/models list, read again when a turn names one not listed yet (#885)
185
+ if (provider === OPENAI_COMPATIBLE_ID && !models.getModel(provider, modelId)) await refreshOpenAICompatible(models);
174
186
  const model = models.getModel(provider, modelId);
175
187
  if (!model) throw unknownModel(requested);
176
188
  return { provider, modelId, model };
@@ -207,7 +219,8 @@ function resolveAuthSource(provider, { auth, saved, env }) {
207
219
  }
208
220
 
209
221
  export function providerStatus({ auth = defaultAuth, keys = defaultKeys, env = process.env } = {}) {
210
- const saved = keys.readKeys();
222
+ // a stored API key (keys file) and an OAuth sign-in such as a subscription (auth.json) both count
223
+ const saved = { ...keys.readKeys(), ...readCredentials() };
211
224
  return PROVIDERS.map((provider) => {
212
225
  const authSource = resolveAuthSource(provider, { auth, saved, env });
213
226
  return { id: provider.id, label: provider.label, authSource, signedIn: !!authSource };
@@ -254,119 +267,6 @@ function liveModelsCodex(result) {
254
267
  }).filter((model) => model.id);
255
268
  }
256
269
 
257
- // --- model roles (#717) ------------------------------------------------------
258
- //
259
- // One picked model (main) implies the rest of the lineup a turn may lean on: a
260
- // cheap helper, a mid-tier advisor, a model that can see images, and the
261
- // ordered alternatives to try when main fails. Subscription accounts usually
262
- // have exactly one signed-in provider, so every derived role stays on main's
263
- // provider; only the fallback chain may cross to another signed-in provider
264
- // that serves the very same model id. Both functions are pure over a
265
- // listAgentModels-shaped `{ providers }` catalog and never throw.
266
-
267
- export const MODEL_ROLES = ["main", "helper", "vision", "advisor"];
268
-
269
- const CLAUDE_TIERS = ["opus", "sonnet", "haiku"];
270
- const GPT_MINI_CLASS = /mini|nano/;
271
- const newestFirst = (a, b) => b.id.localeCompare(a.id, undefined, { numeric: true });
272
-
273
- function splitModelKey(key) {
274
- const slash = typeof key === "string" ? key.indexOf("/") : -1;
275
- return slash > 0 ? { providerId: key.slice(0, slash), modelId: key.slice(slash + 1) } : null;
276
- }
277
-
278
- const modelFamily = (id) => /^claude-/.test(id) ? "claude" : /^gpt-/.test(id) ? "gpt" : null;
279
- const claudeTier = (id) => CLAUDE_TIERS.find((tier) => id.startsWith(`claude-${tier}`)) ?? null;
280
- const takesImages = (model) => Array.isArray(model?.input) && model.input.includes("image");
281
- const modelKeyOf = (providerId, model) => model.key ?? `${providerId}/${model.id}`;
282
-
283
- /** main's provider entry, main's own model entry and the same-family models on that provider. */
284
- function locateMain(mainKey, catalog) {
285
- const parts = splitModelKey(mainKey);
286
- const providers = Array.isArray(catalog?.providers) ? catalog.providers : [];
287
- const provider = parts && providers.find((entry) => entry?.id === parts.providerId);
288
- const models = Array.isArray(provider?.models) ? provider.models.filter((model) => typeof model?.id === "string") : [];
289
- const main = parts && models.find((model) => model.id === parts.modelId);
290
- if (!main) return null;
291
- const family = modelFamily(main.id);
292
- return { providers, provider, models, main, family, sameFamily: family ? models.filter((model) => modelFamily(model.id) === family) : [] };
293
- }
294
-
295
- /** The first preference that matches a model: an exact id, or the newest id a pattern matches. */
296
- function pickModel(models, preferences) {
297
- for (const preference of preferences) {
298
- const match = typeof preference === "string"
299
- ? models.find((model) => model.id === preference)
300
- : [...models].filter((model) => preference.test(model.id)).sort(newestFirst)[0];
301
- if (match) return match;
302
- }
303
- return null;
304
- }
305
-
306
- /** One tier of a Claude lineup, preferring main's own version (opus-5-5 -> sonnet-5-5). */
307
- function claudeTierModel(models, mainId, tier) {
308
- const current = claudeTier(mainId);
309
- return pickModel(models, [current ? mainId.replace(`claude-${current}`, `claude-${tier}`) : null, new RegExp(`^claude-${tier}`)].filter(Boolean));
310
- }
311
-
312
- export function resolveRoleModels(mainKey, catalog) {
313
- const main = typeof mainKey === "string" ? mainKey : "";
314
- const found = locateMain(main, catalog);
315
- if (!found) return { main, helper: main, vision: main, advisor: main };
316
- const { provider, models, main: model, family, sameFamily } = found;
317
- const key = (entry) => (entry ? modelKeyOf(provider.id, entry) : main);
318
- const helper = family === "claude" ? pickModel(sameFamily, ["claude-haiku-5-5", /^claude-haiku/, /^claude-sonnet/])
319
- : family === "gpt" ? pickModel(sameFamily, [GPT_MINI_CLASS, /luna/])
320
- : null;
321
- const advisor = family === "claude" ? pickModel(sameFamily, ["claude-sonnet-5-5", /^claude-sonnet/])
322
- : family === "gpt" ? pickModel(sameFamily, ["gpt-6-astra"])
323
- : null;
324
- const vision = takesImages(model) ? model : models.find(takesImages);
325
- return { main, helper: key(helper), vision: key(vision), advisor: key(advisor) };
326
- }
327
-
328
- export function fallbackChain(mainKey, catalog) {
329
- const found = locateMain(mainKey, catalog);
330
- if (!found) return [];
331
- const { providers, provider, main, family, sameFamily } = found;
332
- const chain = [];
333
- // The same model served by another signed-in provider is the closest substitute there is.
334
- for (const other of providers) {
335
- if (other === provider || !other?.signedIn || !Array.isArray(other.models)) continue;
336
- const same = other.models.find((model) => model?.id === main.id);
337
- if (same) chain.push(modelKeyOf(other.id, same));
338
- }
339
- // Then a step down main's own family on main's provider.
340
- if (family === "claude") {
341
- const tier = CLAUDE_TIERS.indexOf(claudeTier(main.id));
342
- if (tier !== -1) {
343
- for (const lower of CLAUDE_TIERS.slice(tier + 1)) {
344
- const model = claudeTierModel(sameFamily, main.id, lower);
345
- if (model) chain.push(modelKeyOf(provider.id, model));
346
- }
347
- }
348
- } else if (family === "gpt" && !GPT_MINI_CLASS.test(main.id)) {
349
- for (const model of [pickModel(sameFamily, ["gpt-6-astra"]), pickModel(sameFamily, [GPT_MINI_CLASS])]) {
350
- if (model) chain.push(modelKeyOf(provider.id, model));
351
- }
352
- }
353
- const mainKeyFound = modelKeyOf(provider.id, main);
354
- return [...new Set(chain)].filter((key) => key !== mainKeyFound).slice(0, 3);
355
- }
356
-
357
- /** `roles.byMain` for listAgentModels: every model of every signed-in provider. */
358
- function rolesByMain(catalog) {
359
- const byMain = {};
360
- for (const provider of catalog.providers) {
361
- if (!provider.signedIn) continue;
362
- for (const model of provider.models) {
363
- const { helper, vision, advisor } = resolveRoleModels(model.key, catalog);
364
- byMain[model.key] = { helper, vision, advisor, fallback: fallbackChain(model.key, catalog) };
365
- }
366
- }
367
- return { byMain };
368
- }
369
-
370
270
  const astraFirst = (a, b) => Number(b.id === "gpt-6-astra") - Number(a.id === "gpt-6-astra");
371
271
  const cliproxyFirst = (a, b) => astraFirst(a, b) || Number(b.id === "claude-fable-5-1") - Number(a.id === "claude-fable-5-1");
372
272
 
@@ -379,7 +279,8 @@ const cliproxyFirst = (a, b) => astraFirst(a, b) || Number(b.id === "claude-fabl
379
279
  export async function listAgentModels({ models, codex, auth = defaultAuth, keys = defaultKeys, env = process.env } = {}) {
380
280
  const { getSupportedThinkingLevels } = await import("@earendil-works/pi-ai");
381
281
  const resolvedModels = models ?? await createModels({ auth, keys, env });
382
- const saved = keys.readKeys();
282
+ // a stored API key (keys file) and an OAuth sign-in such as a subscription (auth.json) both count
283
+ const saved = { ...keys.readKeys(), ...readCredentials() };
383
284
  const providers = [];
384
285
  for (const provider of PROVIDERS) {
385
286
  const authResult = await resolvedModels.getAuth(provider.id).catch(() => undefined);
@@ -408,14 +309,18 @@ export async function listAgentModels({ models, codex, auth = defaultAuth, keys
408
309
  }
409
310
  }
410
311
  list = [...list].sort(cliproxyFirst);
312
+ const error = catalogueState(resolvedModels).cliproxyFetchError; providers.push({ id: provider.id, label: provider.label, signedIn, authSource, models: list, ...(error ? { error } : {}) }); continue;
313
+ } else if (provider.id === OPENAI_COMPATIBLE_ID && signedIn) {
314
+ // the server's own /v1/models is the lineup, read on every listing so a model loaded since then shows up
315
+ const error = await refreshOpenAICompatible(resolvedModels);
316
+ providers.push({ id: provider.id, label: provider.label, signedIn, authSource, models: catalogModels(resolvedModels, provider.id, getSupportedThinkingLevels), ...(error ? { error } : {}) }); continue;
411
317
  }
412
318
  providers.push({ id: provider.id, label: provider.label, signedIn, authSource, models: list });
413
319
  }
414
320
  // a provider outside the curated list joins the picker once a pi login stored its credential here (#pi-login); ambient
415
321
  // credentials (an AWS profile, cloud ADC files) alone do not list it, so the picker shows what the author connected
416
- const stored = new Set([...Object.keys(readCredentials()), ...Object.keys(saved)]);
417
322
  for (const provider of resolvedModels.getProviders?.() ?? []) {
418
- const check = providerConfig(provider.id) || !stored.has(provider.id) ? undefined : await resolvedModels.checkAuth?.(provider.id).catch(() => undefined);
323
+ const check = providerConfig(provider.id) || !(provider.id in saved) ? undefined : await resolvedModels.checkAuth?.(provider.id).catch(() => undefined);
419
324
  if (check) providers.push({ id: provider.id, label: provider.name ?? provider.id, signedIn: true, authSource: check.type === "oauth" || /stored/i.test(check.source ?? "") ? "file" : "env", models: catalogModels(resolvedModels, provider.id, getSupportedThinkingLevels) });
420
325
  }
421
326
  // The flat union is what the panel's dropdown reads today (`models[].id`);
package/bin/cozyclay.mjs CHANGED
@@ -23,6 +23,7 @@ import { createInterface } from "node:readline";
23
23
  import { fileURLToPath } from "node:url";
24
24
  import { ensureMcpRuntime, runMcp } from "./mcp-runtime.mjs";
25
25
  import { openBrowser } from "./open-browser.mjs";
26
+ import { maybeAskForStar } from "./star-prompt.mjs";
26
27
  import { checkForUpdate, readUpdateStatus, runUpdate } from "./update-check.mjs";
27
28
  import { handleOAuthRequest } from "./codex-auth.mjs";
28
29
  import { createAgentHandler } from "./agent/agent-routes.mjs";
@@ -120,12 +121,8 @@ function parseArgs(argv) {
120
121
  return opts;
121
122
  }
122
123
 
123
- const REPO_URL = "https://github.com/NomaDamas/CozyClay";
124
124
  const STATE_DIR = join(process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "cozyclay");
125
125
  const STATE_FILE = join(STATE_DIR, "state.json");
126
- // Only worth asking someone who actually used the thing. A prompt three
127
- // seconds in is a popup; a prompt after a real session is a question.
128
- const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
129
126
 
130
127
  async function askFirstLaunchSource() {
131
128
  if (!process.stdin.isTTY || !process.stdout.isTTY) return null;
@@ -197,49 +194,6 @@ function writeState(patch) {
197
194
  }
198
195
  }
199
196
 
200
- async function starCount() {
201
- try {
202
- const res = await fetch("https://api.github.com/repos/NomaDamas/CozyClay", {
203
- headers: { accept: "application/vnd.github+json" },
204
- signal: AbortSignal.timeout(2500),
205
- });
206
- if (!res.ok) return null;
207
- const body = await res.json();
208
- return Number.isInteger(body?.stargazers_count) ? body.stargazers_count : null;
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- // Asked once, on the way out, and never again on this machine. A CLI that
215
- // nags on every run is worse than one that never asks.
216
- async function maybeAskForStar(startedAt, opts) {
217
- if (!opts.star) return;
218
- if (process.env.CI || process.env.COZYCLAY_NO_STAR) return;
219
- if (!process.stdin.isTTY || !process.stdout.isTTY) return;
220
- if (Date.now() - startedAt < STAR_AFTER_MS) return;
221
- if (readState().starPromptedAt) return;
222
-
223
- writeState({ starPromptedAt: new Date().toISOString() });
224
- const stars = await starCount();
225
- const tally = stars === null ? "" : ` (${stars} so far)`;
226
- const rl = createInterface({ input: process.stdin, output: process.stdout });
227
- const answer = await new Promise((done) => {
228
- const timer = setTimeout(() => done(""), 12_000);
229
- rl.question(`\nWas CozyClay any good? A star helps people find it${tally}. Open GitHub? [Y/n] `, (a) => {
230
- clearTimeout(timer);
231
- done(a);
232
- });
233
- });
234
- rl.close();
235
- if (/^(y|yes|)$/i.test(answer.trim())) {
236
- openBrowser(REPO_URL);
237
- console.log("Thanks. Opening the repo.");
238
- } else {
239
- console.log(`Fair enough. ${REPO_URL} if you change your mind.`);
240
- }
241
- }
242
-
243
197
  // Read on every launch now, so it must not be able to take the launcher down:
244
198
  // the version is cosmetic here, and "0.0.0" just suppresses a bogus notice.
245
199
  function readVersion() {
@@ -343,7 +297,7 @@ async function shutdown(exitCode = 0) {
343
297
  shuttingDown = true;
344
298
  if (server?.listening) await new Promise((resolvePromise) => server.close(() => resolvePromise()));
345
299
  try {
346
- await maybeAskForStar(startedAt, opts);
300
+ await maybeAskForStar(startedAt, opts, { readState, writeState });
347
301
  } catch {
348
302
  /* never let the goodbye prompt hold the process hostage */
349
303
  }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The one-time "star the repo" question on the way out of `npx cozyclay` (#900).
3
+ *
4
+ * With the GitHub CLI installed and signed in, a "y" stars the repo through `gh` on
5
+ * the spot: no browser hop, which is where most people who meant to star it got lost.
6
+ * Without gh, a "yes" opens the repo page as before. Asked once per machine, after a
7
+ * real session, never in CI or without a terminal, and never to an account that has
8
+ * already starred it. Every failure here is silent: this runs while the launcher exits.
9
+ */
10
+ import { execFile } from "node:child_process";
11
+ import { createInterface } from "node:readline";
12
+ import { openBrowser } from "./open-browser.mjs";
13
+
14
+ const REPO = "NomaDamas/CozyClay";
15
+ const REPO_URL = `https://github.com/${REPO}`;
16
+ const STARRED_PATH = `/user/starred/${REPO}`;
17
+ const ANSWER_TIMEOUT_MS = 12_000;
18
+ // Only worth asking someone who actually used the thing. A prompt three
19
+ // seconds in is a popup; a prompt after a real session is a question.
20
+ const STAR_AFTER_MS = Number(process.env.COZYCLAY_STAR_AFTER_MS ?? 60_000);
21
+
22
+ /** Runs gh and resolves true on exit 0. No gh on PATH, a timeout or an error status is false. */
23
+ function runGh(args) {
24
+ return new Promise((resolve) => {
25
+ try {
26
+ execFile("gh", args, {
27
+ timeout: 4000,
28
+ windowsHide: true,
29
+ env: { ...process.env, GH_PROMPT_DISABLED: "1", GH_NO_UPDATE_NOTIFIER: "1" },
30
+ }, (error) => resolve(!error));
31
+ } catch {
32
+ resolve(false);
33
+ }
34
+ });
35
+ }
36
+
37
+ async function starCount() {
38
+ try {
39
+ const res = await fetch(`https://api.github.com/repos/${REPO}`, {
40
+ headers: { accept: "application/vnd.github+json" },
41
+ signal: AbortSignal.timeout(2500),
42
+ });
43
+ if (!res.ok) return null;
44
+ const body = await res.json();
45
+ return Number.isInteger(body?.stargazers_count) ? body.stargazers_count : null;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ function askLine(question) {
52
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
53
+ return new Promise((done) => {
54
+ const timer = setTimeout(() => {
55
+ rl.close();
56
+ done("");
57
+ }, ANSWER_TIMEOUT_MS);
58
+ rl.question(question, (answer) => {
59
+ clearTimeout(timer);
60
+ rl.close();
61
+ done(answer);
62
+ });
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Asked once, on the way out, and never again on this machine. A CLI that nags on
68
+ * every run is worse than one that never asks. Everything after `state` is injectable
69
+ * so the test can drive it without a terminal, gh, or the network.
70
+ */
71
+ export async function maybeAskForStar(startedAt, opts, {
72
+ readState,
73
+ writeState,
74
+ env = process.env,
75
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
76
+ now = Date.now,
77
+ minSessionMs = STAR_AFTER_MS,
78
+ gh = runGh,
79
+ ask = askLine,
80
+ stars = starCount,
81
+ open = openBrowser,
82
+ log = console.log,
83
+ }) {
84
+ if (!opts.star) return;
85
+ if (env.CI || env.COZYCLAY_NO_STAR) return;
86
+ if (!interactive) return;
87
+ if (now() - startedAt < minSessionMs) return;
88
+ if (readState().starPromptedAt) return;
89
+
90
+ const promptedAt = new Date(now()).toISOString();
91
+ const viaGh = await gh(["auth", "status"]);
92
+ // GET /user/starred/<repo> answers 204 for a starred repo and 404 otherwise.
93
+ if (viaGh && await gh(["api", STARRED_PATH, "--silent"])) {
94
+ writeState({ starPromptedAt: promptedAt });
95
+ return;
96
+ }
97
+ writeState({ starPromptedAt: promptedAt });
98
+ const count = await stars();
99
+ const tally = count === null ? "" : ` (${count} so far)`;
100
+
101
+ if (viaGh) {
102
+ const answer = await ask(`\nWas CozyClay any good? A star helps people find it${tally}. Star ${REPO} with your gh account? [y/N] `);
103
+ if (!/^y(es)?$/i.test(answer.trim())) {
104
+ log(`No problem. ${REPO_URL} if you change your mind.`);
105
+ return;
106
+ }
107
+ if (await gh(["api", "--method", "PUT", STARRED_PATH, "--silent"])) {
108
+ log(`Starred ${REPO}. Thank you!`);
109
+ return;
110
+ }
111
+ log("gh could not star it, so opening the repo instead.");
112
+ open(REPO_URL);
113
+ return;
114
+ }
115
+
116
+ const answer = await ask(`\nWas CozyClay any good? A star helps people find it${tally}. Open GitHub? [Y/n] `);
117
+ if (/^(y|yes|)$/i.test(answer.trim())) {
118
+ open(REPO_URL);
119
+ log("Thanks. Opening the repo.");
120
+ } else {
121
+ log(`Fair enough. ${REPO_URL} if you change your mind.`);
122
+ }
123
+ }
@@ -34,15 +34,15 @@
34
34
  <title>CozyClay Studio — 3D staging and previs in your browser</title>
35
35
 
36
36
  <script defer src="https://static.cloudflareinsights.com/beacon.min.js" data-cf-beacon='{"token": "6a4384d1375d4ffc8a30a6346a8ad1a0"}'></script>
37
- <script type="module" crossorigin src="/assets/app-C4QTcDcb.js"></script>
37
+ <script type="module" crossorigin src="/assets/app-KXdgBC-S.js"></script>
38
38
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-hePW80VL.js">
39
39
  <link rel="modulepreload" crossorigin href="/assets/modulepreload-polyfill-P2Xu9kJm.js">
40
- <link rel="modulepreload" crossorigin href="/assets/task-board-BqReHzOi.js">
40
+ <link rel="modulepreload" crossorigin href="/assets/task-board-DFfNa0xg.js">
41
41
  <link rel="modulepreload" crossorigin href="/assets/fflate.module-KbGj5-H6.js">
42
- <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-qoxcdrhj.js">
43
- <link rel="modulepreload" crossorigin href="/assets/motion3-compile-dgGhrvPK.js">
42
+ <link rel="modulepreload" crossorigin href="/assets/scene-asset-sync-Bz59Ghdz.js">
43
+ <link rel="modulepreload" crossorigin href="/assets/motion3-compile-DDG3wklD.js">
44
44
  <link rel="stylesheet" crossorigin href="/assets/scene-asset-sync-BEjjhNEo.css">
45
- <link rel="stylesheet" crossorigin href="/assets/app-Bhe0TpGz.css">
45
+ <link rel="stylesheet" crossorigin href="/assets/app-BdRlMf4J.css">
46
46
  </head>
47
47
  <body>
48
48
  <div id="root">
@@ -0,0 +1 @@
1
+ import{r as e}from"./rolldown-runtime-hePW80VL.js";import{Ti as t,c as n,d as r,f as i,fa as a,l as o,m as s,o as c,v as l}from"./task-board-DFfNa0xg.js";import{o as u,r as d,t as f}from"./motion3-compile-DDG3wklD.js";var p=e(a(),1),m=2e3,h=(e,t)=>new Promise(n=>{let r=setTimeout(n,e);t.addEventListener(`abort`,()=>{clearTimeout(r),n()},{once:!0})});function g(e,n){(0,p.useEffect)(()=>{let r=new Map;for(let[t,i]of Object.entries(e))for(let e of i)r.set(e,n[t]??t);let i=()=>{for(let e of document.querySelectorAll(`[data-node-id]`)){let n=r.get(e.getAttribute(`data-node-id`));n?(e.setAttribute(`data-task-scope`,n),e.setAttribute(`title`,t(`In task: ${n}`,`작업 중: ${n}`,`任务中:${n}`))):e.hasAttribute(`data-task-scope`)&&(e.removeAttribute(`data-task-scope`),e.removeAttribute(`title`))}};i();let a=new MutationObserver(i);return a.observe(document.body,{childList:!0,subtree:!0}),()=>{a.disconnect(),r.clear(),i()}},[e,n])}function _({sessionId:e,pulse:t,labelOf:a,onMainNote:_}){let v=(0,p.useMemo)(()=>c(),[]),y=u(),[b,x]=(0,p.useReducer)((e,t)=>t.seed?s(e,t.seed):t.reset?n():r(e,t.event),void 0,n),[S,C]=(0,p.useState)({}),[w,T]=(0,p.useState)(f),E=(0,p.useRef)(0),D=(0,p.useRef)(_);(0,p.useEffect)(()=>{E.current=b.after,D.current=_},[b.after,_]),(0,p.useEffect)(()=>{x({reset:!0}),E.current=0},[e]),(0,p.useEffect)(()=>d(T),[]),(0,p.useEffect)(()=>y.subscribe(e=>{typeof e?.type!=`string`||!e.type.startsWith(`task.`)||(C(t=>i(t,e)),(e.type===`task.userEdit`||e.type===`task.undone`)&&x({event:e}))}),[y]),(0,p.useEffect)(()=>{if(!e)return;let t=new AbortController;return(async()=>{for(;!t.signal.aborted;){try{let{tasks:n}=await v.board(e);x({seed:n}),await v.events(e,E.current,e=>{if(e.type===`main.turn`&&e.turnId){let n=``;v.frames(e.turnId,e=>{e.type===`text.delta`&&(n+=e.text??``),e.type===`tool.start`&&(n=``)},t.signal).then(()=>D.current?.(n),()=>{})}x({event:e})},t.signal)}catch{}await h(m,t.signal)}})(),()=>t.abort()},[v,e,t]);let O=b.order.filter(e=>b.tasks[e]?.status===`running`&&b.tasks[e]?.turnId),k=O.map(e=>`${e}:${b.tasks[e].turnId}`).join();return(0,p.useEffect)(()=>{let e=O.map(e=>{let t=new AbortController,{turnId:n}=b.tasks[e];return v.frames(n,t=>{let n=o(t,a);n&&x({event:{type:`task.progress`,taskId:e,status:`running`,progress:n}})},t.signal).catch(()=>{}),t});return()=>e.forEach(e=>e.abort())},[v,k,a]),g(S,(0,p.useMemo)(()=>Object.fromEntries(b.order.map(e=>[e,b.tasks[e].title])),[b])),(0,p.useEffect)(()=>{l.set({client:v,labelOf:a,undo:async e=>{let t=await y.run(`task.undo`,{taskId:e});if(t?.ok===!1)throw Error(t.message??t.code);x({event:{type:`task.undone`,taskId:e}})}})},[y,v,a]),(0,p.useEffect)(()=>{l.set({board:b,activity:w})},[b,w]),(0,p.useEffect)(()=>()=>l.set({board:n(),open:null}),[e]),null}export{_ as default};