@unoverse-platform/studio 0.1.53 → 0.1.55

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/bin/studio.mjs CHANGED
@@ -21,6 +21,7 @@ import { dirname, resolve, join, basename } from "path";
21
21
  import { fileURLToPath } from "url";
22
22
  import { existsSync, readdirSync } from "fs";
23
23
  import { createInterface } from "readline/promises";
24
+ import { execSync } from "child_process";
24
25
  import { designHome, hasDesignHome } from "../local/design-home.mjs";
25
26
  import { createFirstProject, checkProjectName } from "../local/scaffold.mjs";
26
27
 
@@ -161,8 +162,63 @@ await scaffoldIfEmpty();
161
162
  */
162
163
  const projectRoot = findProjectRootFromCwd();
163
164
  process.env.UNOVERSE_HOME = projectRoot;
165
+ // Samples this workspace has never seen arrive on launch (new ones ship with Studio
166
+ // releases); anything it ever had — edited or deleted — is never touched. scaffold.mjs
167
+ // topUpSamples explains the record that makes that distinction safe.
168
+ {
169
+ const { topUpSamples } = await import("../local/scaffold.mjs");
170
+ try { topUpSamples(projectRoot); } catch { /* a read-only folder must not block launch */ }
171
+ }
164
172
  console.log(`\n Studio reading: ${designHome(projectRoot)}\n`);
165
173
 
174
+ /**
175
+ * LAUNCHING STUDIO IS THE DECISION; A STALE STUDIO YIELDS THE PORT.
176
+ *
177
+ * Studio is always 4108 with strictPort, so the address in your browser's history is
178
+ * always the right one. The price of one fixed port used to be a raw vite "Port 4108
179
+ * is already in use" whenever a previous Studio was still running — the tab from the
180
+ * last session, forgotten, holding the port for a project you have moved on from.
181
+ * Typing `unoverse studio` again IS the answer to "which one do you want": the old
182
+ * instance is stopped and this one takes the port.
183
+ *
184
+ * ONLY a recognisable Studio is stopped. Anything else on 4108 is somebody's own
185
+ * process, and killing it on sight would be worse than the error, so that case names
186
+ * the process and stops.
187
+ */
188
+ function freePortOfStaleStudio(port) {
189
+ let pids = [];
190
+ try {
191
+ pids = execSync(`lsof -nP -ti tcp:${port} -s tcp:LISTEN`, { stdio: ["ignore", "pipe", "ignore"] })
192
+ .toString().trim().split("\n").filter(Boolean);
193
+ } catch {
194
+ return; // nothing listening, or no lsof: let vite find out
195
+ }
196
+ for (const pid of pids) {
197
+ if (pid === String(process.pid)) continue;
198
+ let command = "";
199
+ try {
200
+ command = execSync(`ps -p ${pid} -o command=`, { stdio: ["ignore", "pipe", "ignore"] }).toString().trim();
201
+ } catch { continue; } // already gone
202
+ if (/unoverse-studio|@unoverse-platform\/studio|packages\/studio/.test(command)) {
203
+ console.log(` A previous Studio was still holding :${port}. Stopping it and taking over.`);
204
+ try { process.kill(Number(pid), "SIGTERM"); } catch { /* already gone */ }
205
+ // SIGTERM first so it closes cleanly; escalate only if it lingers.
206
+ const deadline = Date.now() + 3000;
207
+ for (;;) {
208
+ try { process.kill(Number(pid), 0); } catch { break; } // no longer exists
209
+ if (Date.now() > deadline) { try { process.kill(Number(pid), "SIGKILL"); } catch { /* gone */ } break; }
210
+ execSync("sleep 0.2");
211
+ }
212
+ } else {
213
+ console.error(`\n Port ${port} is held by something that is not Studio:\n ${command}\n`);
214
+ console.error(` Stop it (kill ${pid}) or find out what it is (lsof -nP -i :${port}), then run unoverse studio again.\n`);
215
+ process.exit(1);
216
+ }
217
+ }
218
+ }
219
+
220
+ freePortOfStaleStudio(4108);
221
+
166
222
  const server = await createServer({
167
223
  // The APP is the vite root; the developer's project is found by walking up from the
168
224
  // working directory (findProjectRoot), so Studio works from anywhere inside it.
@@ -0,0 +1,22 @@
1
+ ---
2
+ name: Concise Answers
3
+ description: Keeps replies short, direct, and free of filler — the answer first, detail only on request
4
+ tags: [formatting, tone]
5
+ ---
6
+
7
+ <!-- A WORKED EXAMPLE prompt block, shipped with Studio so prompts/blocks/ is never an
8
+ empty folder. Edit it, copy it, or delete it — it is yours. A block is a reusable
9
+ prompt fragment workflows compose into an agent's system prompt: smaller than a
10
+ skill, no routing of its own, and it must stand alone (no references to other
11
+ blocks). The folder above it is its category. -->
12
+
13
+ Answer the question first, in one or two sentences, before any background.
14
+
15
+ Keep every reply as short as it can be while still being complete. Cut greetings,
16
+ restatements of the question, and closing filler ("I hope this helps").
17
+
18
+ Use plain words over formal ones. Prefer an example over an explanation when one
19
+ example makes the point.
20
+
21
+ If the full answer would be long, give the short version and offer the detail:
22
+ "Want the full breakdown?"
@@ -0,0 +1,52 @@
1
+ ---
2
+ name: sample-complaint-handling
3
+ title: Complaint Handling
4
+ description: How the assistant responds when a customer is unhappy, from first reply to escalation
5
+ whenToUse: Handle an unhappy customer — a complaint, frustration with the product or service, a request for a refund, or a demand to speak to a person. Not for neutral questions or how-to requests.
6
+ version: 1.0.0
7
+ category: ai
8
+ triggers:
9
+ - complaint
10
+ - refund
11
+ - unhappy
12
+ - speak to a human
13
+ - escalate
14
+ ---
15
+
16
+ # Complaint Handling
17
+
18
+ <!-- A WORKED EXAMPLE, shipped with Studio so prompts/skills/ is never an empty folder.
19
+ Edit it, copy it, or delete it — it is yours. An agent skill is prose the
20
+ platform's AI agents discover at runtime and follow: the frontmatter above is the
21
+ routing (whenToUse is ranked against what the user actually says), the body below
22
+ is the behavior. Format + rules: https://docs.unoverse.ai/nodes/node-discoverability.md -->
23
+
24
+ ## When to Use This Skill
25
+
26
+ The customer is expressing dissatisfaction: something broke, something was late, they
27
+ were charged wrongly, or they are simply frustrated. The goal of every reply is that
28
+ the customer feels heard first and helped second.
29
+
30
+ ## Approach
31
+
32
+ 1. **Acknowledge before anything else.** One sentence that names their problem in
33
+ their words. Never open with policy.
34
+ 2. **Ask for the one detail you need**, if any, and only one at a time.
35
+ 3. **Offer the concrete next step you can actually do.** Do not promise outcomes you
36
+ cannot see through.
37
+ 4. **Escalate when asked, and say so plainly.** If the customer asks for a person
38
+ twice, or mentions legal action, stop resolving and hand off: "I'm passing this to
39
+ our team; you'll hear from a person."
40
+
41
+ ## Do / Don't
42
+
43
+ - Do keep replies to three sentences or fewer while the customer is upset.
44
+ - Do use their name if you have it.
45
+ - Don't defend the company or explain why the problem happened, unless they ask.
46
+ - Don't offer discounts or refunds beyond what the workflow's tools allow.
47
+
48
+ ## Examples
49
+
50
+ Customer: "This is the second time my order arrived broken. Ridiculous."
51
+ Reply: "Twice in a row is genuinely not okay — I'm sorry. I can send a replacement
52
+ today or refund the order in full: which would you prefer?"
@@ -0,0 +1,23 @@
1
+ # Your project's styles
2
+
3
+ This folder restyles the whole design system for your brand — without editing a
4
+ single component. Three rules:
5
+
6
+ 1. **Token NAMES are the contract, VALUES are yours.** Components reference names
7
+ like `text.primary` and `action.primary`; you change what those names mean.
8
+ 2. **Name a token to override it; omit it to inherit.** The cascade is per token,
9
+ never per file. Never copy a whole file to change one line — the untouched lines
10
+ would stop tracking platform improvements.
11
+ 3. **Ingredients in `base/`, meaning in `themes/`.** `base/color.yaml` names your
12
+ brand's raw colors (`color.brand.ink`); a theme file assigns them to roles
13
+ (`text.primary: {color.brand.ink}`).
14
+
15
+ Start here:
16
+
17
+ - `base/color.yaml` — name your brand ingredients (a worked example is inside).
18
+ - Want your own theme? Copy the foundation's theme file whole — the ONE place
19
+ copying is correct, because a theme must assign every role:
20
+ https://github.com/unoverse-platform/marketplace/tree/main/definitions/styles/themes
21
+ → save as `themes/light.yaml` here, then point roles at your `color.brand.*`.
22
+
23
+ Full guide: https://docs.unoverse.ai/design/styles-and-tokens.md
@@ -0,0 +1,18 @@
1
+ # BRAND INGREDIENTS — raw colors named for what they ARE (ink, paper, a signature
2
+ # hue), never for where they are used ("button-blue" is a decision, not an ingredient;
3
+ # a theme file makes decisions). These are inert until a theme assigns them to roles,
4
+ # so this file is safe to grow before you have a theme at all.
5
+ #
6
+ # Delete the examples and name your own. Guide: see README.md in this folder.
7
+ color:
8
+ $type: color
9
+ brand:
10
+ ink:
11
+ $value: "#1a1a2e"
12
+ $description: "Near-black with a hint of the brand hue — body text and headings"
13
+ paper:
14
+ $value: "#fbfaf8"
15
+ $description: "Warm off-white — page and card backgrounds"
16
+ signature:
17
+ $value: "#4f46e5"
18
+ $description: "The brand's signature hue — actions and accents"
@@ -13,7 +13,7 @@
13
13
  *
14
14
  * See docs/architecture/LOCAL_STUDIO.md.
15
15
  */
16
- import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync } from "node:fs";
16
+ import { existsSync, mkdirSync, writeFileSync, cpSync, readdirSync, readFileSync } from "node:fs";
17
17
  import { designHome } from "./design-home.mjs";
18
18
  import { join, dirname, resolve } from "node:path";
19
19
  import { fileURLToPath } from "node:url";
@@ -71,9 +71,73 @@ export function createProject(home, name) {
71
71
 
72
72
  for (const d of ["components/welcome", "templates", "styles"]) mkdirSync(join(root, d), { recursive: true });
73
73
  writeFileSync(join(root, "components/welcome/welcome.yaml"), WELCOME);
74
+
75
+ // Worked example styles (sample-styles: brand ingredients + the override rules), so
76
+ // styles/ is never an empty folder with no hint of the token cascade. Per PROJECT,
77
+ // because every project is a publishable org with its own pack. Copied only when the
78
+ // folder is empty: these are the developer's files, and a later run must never put
79
+ // back what they threw away.
80
+ const sampleStyles = join(dirname(fileURLToPath(import.meta.url)), "sample-styles");
81
+ if (existsSync(sampleStyles) && readdirSync(join(root, "styles")).length === 0) {
82
+ cpSync(sampleStyles, join(root, "styles"), { recursive: true });
83
+ }
74
84
  return root;
75
85
  }
76
86
 
87
+ /**
88
+ * The worked example content (sample nodes, a sample agent skill, a sample prompt
89
+ * block), keyed by the workspace path each lands at. `dir` is relative to this file.
90
+ *
91
+ * A NEW ENTRY HERE REACHES EXISTING WORKSPACES on their next Studio launch, because
92
+ * topUpSamples runs at every launch, not only at scaffold. The record below is what
93
+ * makes that safe.
94
+ */
95
+ const SAMPLES = [
96
+ { id: "nodes/samples", dir: "sample-nodes/samples", to: "nodes/samples" },
97
+ { id: "prompts/skills/sample-complaint-handling", dir: "sample-prompts/skills/sample-complaint-handling", to: "prompts/skills/sample-complaint-handling" },
98
+ { id: "prompts/blocks/sample-concise-answers", dir: "sample-prompts/blocks/formatting/sample-concise-answers.md", to: "prompts/blocks/formatting/sample-concise-answers.md" },
99
+ ];
100
+
101
+ /**
102
+ * Install every sample this workspace has NEVER SEEN, and only those.
103
+ *
104
+ * "Absent from disk" is ambiguous — it means either "deleted on purpose" or "shipped
105
+ * after this workspace was made" — so presence is not the test. The test is the
106
+ * RECORD (.unoverse/samples.json): a sample id listed there was installed here once,
107
+ * and whatever the developer did to it since (edit, rename, delete) is theirs and
108
+ * permanent. A sample id NOT listed is genuinely new to this workspace and arrives.
109
+ * That is what lets `unoverse studio` top up new samples on every launch without ever
110
+ * resurrecting deleted work or overwriting changed files.
111
+ */
112
+ export function topUpSamples(home) {
113
+ const record = join(home, ".unoverse", "samples.json");
114
+ let seen = [];
115
+ try {
116
+ seen = JSON.parse(readFileSync(record, "utf8")).installed ?? [];
117
+ } catch {
118
+ // No record: a workspace from before records existed. Treat every sample already
119
+ // on disk as seen (it was installed by an old scaffold), so nothing is re-copied.
120
+ seen = SAMPLES.filter((s) => existsSync(join(home, s.to))).map((s) => s.id);
121
+ }
122
+ const here = dirname(fileURLToPath(import.meta.url));
123
+ let changed = false;
124
+ for (const s of SAMPLES) {
125
+ if (seen.includes(s.id)) continue;
126
+ const src = join(here, s.dir);
127
+ if (!existsSync(src)) continue;
128
+ if (!existsSync(join(home, s.to))) {
129
+ mkdirSync(dirname(join(home, s.to)), { recursive: true });
130
+ cpSync(src, join(home, s.to), { recursive: true });
131
+ }
132
+ seen.push(s.id);
133
+ changed = true;
134
+ }
135
+ if (changed || !existsSync(record)) {
136
+ mkdirSync(dirname(record), { recursive: true });
137
+ writeFileSync(record, JSON.stringify({ installed: seen }, null, 2) + "\n");
138
+ }
139
+ }
140
+
77
141
  /**
78
142
  * The FIRST project, plus the folders that live beside the design tree rather than inside it.
79
143
  *
@@ -84,36 +148,20 @@ export function createFirstProject(home, name) {
84
148
  const root = createProject(home, name);
85
149
  for (const d of ["prompts/skills", "prompts/blocks", "nodes"]) mkdirSync(join(home, d), { recursive: true });
86
150
 
87
- // Two worked example nodes (sample-nodes/samples: a PromiseNode and a CallbackNode,
88
- // heavily commented, linking docs.unoverse.ai/nodes), so `nodes/` is never an empty
89
- // folder with no hint of what goes in it. Copied only when absent: these are the
90
- // developer's files to edit or delete, and a later scaffold run must never put back
91
- // what they threw away or overwrite what they changed.
92
- const samples = join(dirname(fileURLToPath(import.meta.url)), "sample-nodes/samples");
93
- if (existsSync(samples) && !existsSync(join(home, "nodes/samples"))) {
94
- cpSync(samples, join(home, "nodes/samples"), { recursive: true });
95
- }
151
+ topUpSamples(home);
96
152
 
97
- // The Claude Code authoring skills, so "open the folder and describe what you want"
98
- // works in every Studio project from its first minute. Vendored into the published
99
- // app (vendor/skills, written by prepack from the monorepo's .claude/skills, the one
100
- // authoring home, mirrored publicly to github.com/unoverse-platform/skills); read
101
- // from that home when running as source. `unoverse update` refreshes installed
102
- // copies from the public mirror.
153
+ // SCAFFOLD WRITES NO `.claude/skills` (2026-08-21). It used to copy the Claude Code
154
+ // authoring skills out of `vendor/skills` and into `<home>/.claude/skills`, which meant
155
+ // every remote developer who launched Studio got the platform repo's own `.claude`
156
+ // folder reproduced on their disk, pinned to whatever Studio version they happened to
157
+ // install and sitting in a workspace they might commit.
103
158
  //
104
- // ALL of them. One Studio project is where a person designs a brand AND writes the node
105
- // behind it, so they need the whole set: naming one skill here shipped the authoring
106
- // playbooks and silently withheld the rest.
107
- const app = resolve(dirname(fileURLToPath(import.meta.url)), "..");
108
- const skillsDir = [join(app, "vendor/skills"), resolve(app, "../../.claude/skills")].find((d) => existsSync(d));
109
- if (skillsDir) {
110
- const names = readdirSync(skillsDir, { withFileTypes: true })
111
- .filter((e) => e.isDirectory() && existsSync(join(skillsDir, e.name, "SKILL.md")))
112
- .map((e) => e.name);
113
- if (names.length) mkdirSync(join(home, ".claude/skills"), { recursive: true });
114
- for (const name of names) {
115
- cpSync(join(skillsDir, name), join(home, ".claude/skills", name), { recursive: true });
116
- }
117
- }
159
+ // The skills are PER DEVELOPER, not per project (the same reason prompts/ and nodes/ are
160
+ // made once here and never by `createProject`). They now install to `~/.claude/skills`
161
+ // from the public mirror, on `unoverse update` — see packages/cli/lib/skills.mjs.
162
+ //
163
+ // `vendor/skills` STAYS, and is not a distribution channel: it is what Studio's builder
164
+ // MCP reads to teach a connected agent the authoring rules (local/mcp.ts SKILL_DIRS).
165
+ // Nothing about that copy reaches the developer's filesystem.
118
166
  return root;
119
167
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unoverse-platform/studio",
3
- "version": "0.1.53",
3
+ "version": "0.1.55",
4
4
  "type": "module",
5
5
  "description": "Unoverse Studio: the local authoring app. Reads your own rx/ folder, no backend.",
6
6
  "scripts": {
package/src/NodesView.tsx CHANGED
@@ -383,7 +383,12 @@ export function NodesView() {
383
383
  </div>
384
384
  <div className="ml-auto flex shrink-0 items-center gap-2">
385
385
  {current.testData && (
386
- <button onClick={loadSample} className="rounded border border-gray-700 px-3 py-1.5 text-sm font-medium text-gray-300 hover:bg-white/5">
386
+ <button
387
+ onClick={loadSample}
388
+ disabled={!current?.testData}
389
+ title={current?.testData ? "Fill the form from the node's test fixture (test.yaml)" : "This node declares no test fixture"}
390
+ className="rounded border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-40"
391
+ >
387
392
  Load sample
388
393
  </button>
389
394
  )}