@coopcli/specsketch 2026.902.1 → 2026.904.1

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
@@ -1,5 +1,8 @@
1
1
  # coopcli specsketch
2
2
 
3
+ > **There is no login command.** The CLI logins were retired (`retire-cli-login`): the browser flow opens `/cli-auth` on an origin whose Clerk instance moved to specsesh.com, so it cannot complete. Credentials already in `~/.coopcli/config.json` keep working — reading them outlives writing them.
4
+
5
+
3
6
  Draw the system. Get the spec.
4
7
 
5
8
  An Excalidraw canvas that turns architecture diagrams into **OpenSpec** change proposals —
@@ -8,7 +11,6 @@ them, ready for an implementing agent. Local-only: the server binds to 127.0.0.1
8
11
  generation runs with your own Anthropic credentials.
9
12
 
10
13
  ## Quick start
11
-
12
14
  ```bash
13
15
  npx @coopcli/specsketch ./openspec/changes/new-feature-change
14
16
  # or: npm install -g @coopcli/specsketch && specsketch ./openspec/changes/new-feature-change
@@ -16,7 +18,6 @@ npx @coopcli/specsketch ./openspec/changes/new-feature-change
16
18
 
17
19
  Open the printed URL, draw labeled boxes and arrows (loose text becomes design notes), and
18
20
  press **Generate Spec**. The change directory is the workspace:
19
-
20
21
  ```
21
22
  openspec/changes/new-feature-change/
22
23
  ├── diagram.excalidraw ← the drawing, autosaved, agent-readable
@@ -103,10 +104,8 @@ The right-hand panel has a menu with three options:
103
104
  ```
104
105
 
105
106
  ## CLI reference
106
-
107
107
  ```
108
108
  specsketch <path-to-openspec-change-dir> [--port <n>] [--share]
109
- specsketch login [--profile <name>] [--web-url <url>]
110
109
  ```
111
110
 
112
111
  - The directory is created if it doesn't exist; its basename becomes the change name.
@@ -121,9 +120,7 @@ specsketch login [--profile <name>] [--web-url <url>]
121
120
  retention.
122
121
 
123
122
  ## Shared sessions (`--share`)
124
-
125
123
  ```bash
126
- specsketch login # once per machine
127
124
  specsketch ./openspec/changes/new-feature-change --share
128
125
  ```
129
126
 
@@ -135,8 +132,6 @@ live pill with the participant count and a copy-link button.
135
132
 
136
133
  Worth knowing:
137
134
 
138
- - The host needs a CoopCLI account (`specsketch login` — opens a browser, saves
139
- credentials to `~/.coopcli/config.json`, the same file `coop login` writes); guests
140
135
  never do. The link itself is the capability: anyone holding it can view **and edit**
141
136
  until the session ends. The server stores only a hash of the link token.
142
137
  - The local server still binds to 127.0.0.1 only — sharing runs over a single outbound
@@ -158,7 +153,7 @@ Worth knowing:
158
153
  | "Node NN detected — specsketch needs Node >= 22" | Upgrade Node (https://nodejs.org) |
159
154
  | Port already in use | Handled automatically — the server moves to the next free port and prints it; `--port <n>` sets the preferred starting port |
160
155
  | Regeneration changed a file you hand-edited | Expected when the drawing implicates it — the diagram is the source of truth; use `git diff`/`git checkout` to recover, and keep change dirs in version control |
161
- | `--share` says it needs login | Run `specsketch login` once on the hosting machine; `--profile` selects a non-default CoopCLI config profile |
156
+
162
157
  | Share link says it no longer works | The host ended the session (Ctrl-C) or it idled out — ask for a fresh link |
163
158
 
164
159
  More: [product page](https://coopcli.com/products/specsketch) ·
package/dist/cli/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // server/cli.ts
4
4
  import { existsSync as existsSync10, readFileSync as readFileSync8, readdirSync as readdirSync8 } from "node:fs";
5
- import { homedir as homedir4 } from "node:os";
6
- import { dirname as dirname6, extname, join as join12, normalize as normalize2, resolve as resolve4 } from "node:path";
5
+ import { homedir as homedir3 } from "node:os";
6
+ import { dirname as dirname5, extname, join as join11, normalize as normalize2, resolve as resolve4 } from "node:path";
7
7
  import { fileURLToPath } from "node:url";
8
- import { parseArgs as parseArgs2 } from "node:util";
8
+ import { parseArgs } from "node:util";
9
9
  import { serve } from "@hono/node-server";
10
10
  import Anthropic from "@anthropic-ai/sdk";
11
11
 
@@ -1181,730 +1181,6 @@ ${refContext}` : "");
1181
1181
  return app;
1182
1182
  }
1183
1183
 
1184
- // ../shared/src/cli-auth.ts
1185
- import { exec } from "node:child_process";
1186
- import { randomBytes } from "node:crypto";
1187
- import { createServer } from "node:http";
1188
- import { hostname as hostname2 } from "node:os";
1189
- import { URL as URL2 } from "node:url";
1190
-
1191
- // ../shared/src/cli-config.ts
1192
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
1193
- import { homedir as homedir2, hostname } from "node:os";
1194
- import { join as join3, dirname } from "node:path";
1195
-
1196
- // ../shared/src/schemas.ts
1197
- import { z as z3 } from "zod";
1198
- var planTierSchema = z3.enum(["hobby", "squad", "org"]);
1199
- var jurisdictionSchema = z3.enum(["eu", "fedramp"]);
1200
- var locationHintSchema = z3.enum([
1201
- "wnam",
1202
- "enam",
1203
- "weur",
1204
- "eeur",
1205
- "apac",
1206
- "oc",
1207
- "afr",
1208
- "me",
1209
- "sam"
1210
- ]);
1211
- var sessionNameSchema = z3.string().min(1).max(63).regex(
1212
- /^[a-z0-9][a-z0-9-]*$/,
1213
- "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1214
- );
1215
- var teamNameSchema = z3.string().min(1).max(63).regex(
1216
- /^[a-z0-9][a-z0-9-]*$/,
1217
- "Team name must be lowercase alphanumeric with hyphens"
1218
- );
1219
- var messageKindSchema = z3.enum(["text", "context", "request", "system"]);
1220
- var messageContentSchema = z3.object({
1221
- kind: messageKindSchema,
1222
- // No .max() here: the 10 000-char body limit is business logic owned by the
1223
- // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
1224
- // both REST and WS. A schema cap would pre-empt it as a generic 400.
1225
- body: z3.string().min(1),
1226
- // Two-arg z.record: identical semantics in zod 3, required form in zod 4
1227
- // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
1228
- metadata: z3.record(z3.string(), z3.unknown()).optional()
1229
- });
1230
- var createTeamInputSchema = z3.object({
1231
- name: teamNameSchema
1232
- });
1233
- var createSessionInputSchema = z3.object({
1234
- name: sessionNameSchema,
1235
- teamId: z3.string().optional(),
1236
- jurisdiction: jurisdictionSchema.optional(),
1237
- locationHint: locationHintSchema.optional()
1238
- });
1239
- var sendMessageInputSchema = z3.object({
1240
- content: messageContentSchema
1241
- });
1242
- var inviteInputSchema = z3.object({
1243
- email: z3.string().email(),
1244
- role: z3.enum(["admin", "member"]).default("member")
1245
- });
1246
- var joinTeamInputSchema = z3.object({
1247
- inviteCode: z3.string().min(1)
1248
- });
1249
- var markReadInputSchema = z3.object({
1250
- notificationIds: z3.array(z3.string().min(1))
1251
- });
1252
- var pollQuerySchema = z3.object({
1253
- since: z3.string().optional(),
1254
- limit: z3.coerce.number().int().min(1).max(100).default(50),
1255
- session: z3.string().optional()
1256
- });
1257
- var messagesQuerySchema = z3.object({
1258
- cursor: z3.string().optional(),
1259
- limit: z3.coerce.number().int().min(1).max(100).default(50)
1260
- });
1261
- var teamTypeSchema = z3.enum(["personal", "shared"]);
1262
- var teamRoleSchema = z3.enum(["owner", "admin", "member"]);
1263
- var accessPermissionSchema = z3.enum(["read", "write", "admin"]);
1264
- var accessGrantInputSchema = z3.object({
1265
- userId: z3.string().min(1),
1266
- sessionName: sessionNameSchema.nullable().default(null),
1267
- // null = general
1268
- permission: accessPermissionSchema
1269
- });
1270
- var machineIdSchema = z3.string().min(1).max(63).regex(
1271
- /^[a-z0-9][a-z0-9-]*$/,
1272
- "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
1273
- );
1274
- var clerkProfileInputSchema = z3.object({
1275
- email: z3.string().email().nullable(),
1276
- displayName: z3.string().min(1).max(100),
1277
- avatarUrl: z3.string().url().nullable(),
1278
- provider: z3.string().min(1).max(50),
1279
- providerUserId: z3.string().min(1).max(100)
1280
- });
1281
- var sessionEnrollmentSchema = z3.object({
1282
- sessionId: z3.string(),
1283
- team: z3.string(),
1284
- teamName: z3.string().optional(),
1285
- key: z3.string().startsWith("api_"),
1286
- enrolledAt: z3.string().datetime(),
1287
- apiUrl: z3.string().url().optional(),
1288
- machineId: z3.string().optional()
1289
- });
1290
- var authConfigSchema = z3.object({
1291
- key: z3.string().startsWith("api_"),
1292
- userId: z3.string().min(1),
1293
- displayName: z3.string().nullable().optional().default(null),
1294
- email: z3.string().email().nullable(),
1295
- machineId: z3.string().nullable(),
1296
- authenticatedAt: z3.string().datetime()
1297
- });
1298
- var configBaseFields = {
1299
- apiUrl: z3.string().url(),
1300
- machineId: z3.string().nullable(),
1301
- sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1302
- defaults: z3.object({
1303
- team: z3.string().nullable(),
1304
- session: z3.string().nullable()
1305
- }),
1306
- poll: z3.object({
1307
- lastPollAt: z3.string().datetime().nullable()
1308
- })
1309
- };
1310
- var coopConfigV1Schema = z3.object({
1311
- version: z3.literal(1),
1312
- ...configBaseFields
1313
- });
1314
- var coopConfigV2Schema = z3.object({
1315
- version: z3.literal(2),
1316
- ...configBaseFields,
1317
- auth: authConfigSchema.optional()
1318
- });
1319
- var profileNameSchema = z3.string().regex(
1320
- /^[a-z0-9][a-z0-9_-]{0,63}$/,
1321
- "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
1322
- );
1323
- var coopProfileSchema = z3.object({
1324
- apiUrl: z3.string().url(),
1325
- auth: authConfigSchema.optional(),
1326
- sessions: z3.record(z3.string(), sessionEnrollmentSchema),
1327
- defaults: z3.object({
1328
- team: z3.string().nullable(),
1329
- session: z3.string().nullable()
1330
- }),
1331
- poll: z3.object({
1332
- lastPollAt: z3.string().datetime().nullable()
1333
- })
1334
- });
1335
- var coopConfigV3ObjectSchema = z3.object({
1336
- version: z3.literal(3),
1337
- machineId: z3.string().nullable(),
1338
- defaultProfile: z3.string(),
1339
- profiles: z3.record(profileNameSchema, coopProfileSchema)
1340
- });
1341
- function requireDefaultProfilePointer(config, ctx) {
1342
- if (!(config.defaultProfile in config.profiles)) {
1343
- ctx.addIssue({
1344
- code: "custom",
1345
- path: ["defaultProfile"],
1346
- message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
1347
- });
1348
- }
1349
- }
1350
- var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
1351
- requireDefaultProfilePointer
1352
- );
1353
- var coopConfigSchema = z3.discriminatedUnion("version", [
1354
- coopConfigV1Schema,
1355
- coopConfigV2Schema,
1356
- coopConfigV3ObjectSchema
1357
- ]).superRefine((config, ctx) => {
1358
- if (config.version === 3) {
1359
- requireDefaultProfilePointer(config, ctx);
1360
- }
1361
- });
1362
- var hookStdinSchema = z3.object({
1363
- session_id: z3.string().optional(),
1364
- cwd: z3.string().optional(),
1365
- hook_event_name: z3.string().optional()
1366
- }).passthrough();
1367
- var wsClientMessageSchema = z3.discriminatedUnion("type", [
1368
- z3.object({ type: z3.literal("message"), content: messageContentSchema }),
1369
- z3.object({ type: z3.literal("ack"), data: z3.object({ messageId: z3.string() }) }),
1370
- z3.object({ type: z3.literal("presence_request") }),
1371
- z3.object({ type: z3.literal("ping") })
1372
- ]);
1373
- var workKindSchema = z3.enum(["implement", "generate"]);
1374
- var workCompletionStatusSchema = z3.enum(["succeeded", "failed"]);
1375
- var poolJoinInputSchema = z3.object({}).passthrough();
1376
- var poolCheckInInputSchema = z3.object({}).passthrough();
1377
- var poolCompleteInputSchema = z3.object({
1378
- workId: z3.string().min(1),
1379
- status: workCompletionStatusSchema,
1380
- resultRef: z3.string().max(512),
1381
- assignmentSeq: z3.number().int().nonnegative()
1382
- });
1383
- var poolEnqueueInputSchema = z3.object({
1384
- kind: workKindSchema,
1385
- source: z3.string().min(1),
1386
- dedupKey: z3.string().min(1).max(512).optional()
1387
- });
1388
- var poolCancelInputSchema = z3.object({
1389
- workId: z3.string().min(1)
1390
- });
1391
-
1392
- // ../shared/src/cli-config.ts
1393
- var DEFAULT_CONFIG_DIR = join3(homedir2(), ".coopcli");
1394
- var DEFAULT_CONFIG_PATH = join3(DEFAULT_CONFIG_DIR, "config.json");
1395
- var PROD_API_URL = "https://api.coopcli.com";
1396
- function emptyProfile(apiUrl) {
1397
- return {
1398
- apiUrl,
1399
- sessions: {},
1400
- defaults: { team: null, session: null },
1401
- poll: { lastPollAt: null }
1402
- };
1403
- }
1404
- var DEFAULT_CONFIG = {
1405
- version: 3,
1406
- machineId: hostname(),
1407
- defaultProfile: "default",
1408
- profiles: { default: emptyProfile(PROD_API_URL) }
1409
- };
1410
- function upgradeToV3(parsed) {
1411
- if (parsed.version === 3) return parsed;
1412
- const profile = {
1413
- apiUrl: parsed.apiUrl,
1414
- ...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
1415
- sessions: parsed.sessions,
1416
- defaults: parsed.defaults,
1417
- poll: parsed.poll
1418
- };
1419
- return {
1420
- version: 3,
1421
- machineId: parsed.machineId,
1422
- defaultProfile: "default",
1423
- profiles: { default: profile }
1424
- };
1425
- }
1426
- function profileFromEnv(env, envVars) {
1427
- for (const name of envVars) {
1428
- const value = env[name];
1429
- if (value) return value;
1430
- }
1431
- return null;
1432
- }
1433
- var ConfigStore = class {
1434
- config = null;
1435
- selectedProfile = null;
1436
- configPath;
1437
- configDir;
1438
- constructor(configPath) {
1439
- this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
1440
- this.configDir = dirname(this.configPath);
1441
- }
1442
- /**
1443
- * Select the profile this store operates on for the rest of the process
1444
- * (--profile flag / COOP_PROFILE env). Without a selection, the
1445
- * defaultProfile pointer applies.
1446
- */
1447
- selectProfile(name) {
1448
- this.selectedProfile = name;
1449
- }
1450
- activeProfileName(config) {
1451
- return this.selectedProfile ?? config.defaultProfile;
1452
- }
1453
- view(config) {
1454
- const profileName = this.activeProfileName(config);
1455
- const profile = config.profiles[profileName];
1456
- if (!profile) {
1457
- const available = Object.keys(config.profiles).join(", ");
1458
- throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
1459
- }
1460
- return { profileName, machineId: config.machineId, ...profile };
1461
- }
1462
- /** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
1463
- cached() {
1464
- return this.view(this.cachedRaw());
1465
- }
1466
- /** Returns the full previously loaded config. Throws if load() hasn't been called. */
1467
- cachedRaw() {
1468
- if (this.config === null) {
1469
- throw new Error("Config not loaded yet. Call load() first.");
1470
- }
1471
- return this.config;
1472
- }
1473
- /** Load the config file and return the active profile's view. */
1474
- async load() {
1475
- return this.view(await this.loadRaw());
1476
- }
1477
- /** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
1478
- async loadRaw() {
1479
- if (this.config !== null) {
1480
- return this.config;
1481
- }
1482
- try {
1483
- const raw = await readFile(this.configPath, "utf8");
1484
- const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
1485
- if (!parsed.machineId) {
1486
- parsed.machineId = hostname();
1487
- }
1488
- this.config = parsed;
1489
- return parsed;
1490
- } catch (err) {
1491
- if (isNodeError(err) && err.code === "ENOENT") {
1492
- this.config = structuredClone(DEFAULT_CONFIG);
1493
- return this.config;
1494
- }
1495
- if (err instanceof SyntaxError || err?.name === "ZodError") {
1496
- throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
1497
- }
1498
- throw err;
1499
- }
1500
- }
1501
- async save(config) {
1502
- await mkdir(this.configDir, { recursive: true, mode: 448 });
1503
- const content = JSON.stringify(config, null, 2);
1504
- const tmpPath = this.configPath + ".tmp";
1505
- await writeFile(tmpPath, content, { mode: 384 });
1506
- const { rename } = await import("node:fs/promises");
1507
- await rename(tmpPath, this.configPath);
1508
- try {
1509
- await chmod(this.configPath, 384);
1510
- } catch {
1511
- }
1512
- this.config = config;
1513
- }
1514
- /** Load, transform the active profile, and save — the single write path for profile-scoped state. */
1515
- async saveProfile(mutate) {
1516
- const config = await this.loadRaw();
1517
- const name = this.activeProfileName(config);
1518
- const profile = config.profiles[name];
1519
- if (!profile) {
1520
- throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
1521
- }
1522
- await this.save({
1523
- ...config,
1524
- profiles: { ...config.profiles, [name]: mutate(profile) }
1525
- });
1526
- }
1527
- /** Validate config for common misconfigurations. Returns warning strings. */
1528
- validate() {
1529
- if (!this.config) return [];
1530
- let view;
1531
- try {
1532
- view = this.view(this.config);
1533
- } catch {
1534
- return [];
1535
- }
1536
- const warnings = [];
1537
- try {
1538
- const url = new URL(view.apiUrl);
1539
- if (!["http:", "https:"].includes(url.protocol)) {
1540
- warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
1541
- }
1542
- } catch {
1543
- warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
1544
- }
1545
- if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
1546
- warnings.push(
1547
- `profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
1548
- );
1549
- }
1550
- return warnings;
1551
- }
1552
- async enrollSession(name, enrollment) {
1553
- await this.saveProfile((profile) => ({
1554
- ...profile,
1555
- sessions: { ...profile.sessions, [name]: enrollment }
1556
- }));
1557
- }
1558
- async unenrollSession(name) {
1559
- await this.saveProfile((profile) => {
1560
- const sessions = { ...profile.sessions };
1561
- delete sessions[name];
1562
- const defaults = { ...profile.defaults };
1563
- if (defaults.session === name) {
1564
- defaults.session = null;
1565
- }
1566
- return { ...profile, sessions, defaults };
1567
- });
1568
- }
1569
- async setDefault(key, value) {
1570
- await this.saveProfile((profile) => ({
1571
- ...profile,
1572
- defaults: { ...profile.defaults, [key]: value }
1573
- }));
1574
- }
1575
- async getDefaultSession() {
1576
- const view = await this.load();
1577
- return view.defaults.session;
1578
- }
1579
- async getSessionKey(sessionName) {
1580
- const view = await this.load();
1581
- const enrollment = view.sessions[sessionName];
1582
- if (!enrollment) {
1583
- throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
1584
- }
1585
- return enrollment.key;
1586
- }
1587
- /** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
1588
- async getApiUrl(sessionName) {
1589
- const view = await this.load();
1590
- if (sessionName) {
1591
- const enrollment = view.sessions[sessionName];
1592
- if (enrollment?.apiUrl) return enrollment.apiUrl;
1593
- }
1594
- return view.apiUrl;
1595
- }
1596
- /** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
1597
- async getMachineId(sessionName) {
1598
- const view = await this.load();
1599
- if (sessionName) {
1600
- const enrollment = view.sessions[sessionName];
1601
- if (enrollment?.machineId) return enrollment.machineId;
1602
- }
1603
- if (view.machineId) return view.machineId;
1604
- if (view.auth?.machineId) return view.auth.machineId;
1605
- return null;
1606
- }
1607
- async setMachineId(machineId) {
1608
- const config = await this.loadRaw();
1609
- await this.save({ ...config, machineId });
1610
- }
1611
- /** Set the active profile's API base URL. */
1612
- async setApiUrl(apiUrl) {
1613
- await this.saveProfile((profile) => ({ ...profile, apiUrl }));
1614
- }
1615
- /** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
1616
- async ensureActiveProfile(apiUrl) {
1617
- const config = await this.loadRaw();
1618
- const name = this.activeProfileName(config);
1619
- if (config.profiles[name]) return;
1620
- await this.save({
1621
- ...config,
1622
- profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
1623
- });
1624
- }
1625
- /** Re-point the defaultProfile fallback. The target must exist. */
1626
- async setDefaultProfile(name) {
1627
- const config = await this.loadRaw();
1628
- if (!config.profiles[name]) {
1629
- const available = Object.keys(config.profiles).join(", ");
1630
- throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
1631
- }
1632
- await this.save({ ...config, defaultProfile: name });
1633
- }
1634
- /** True if any profile holds an auth block (used to detect the first login). */
1635
- async hasAnyAuth() {
1636
- const config = await this.loadRaw();
1637
- return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
1638
- }
1639
- /**
1640
- * Remove a profile including its credentials and enrollments. The
1641
- * defaultProfile pointer target cannot be deleted — re-point first.
1642
- */
1643
- async deleteProfile(name) {
1644
- const config = await this.loadRaw();
1645
- if (!config.profiles[name]) {
1646
- const available = Object.keys(config.profiles).join(", ");
1647
- throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
1648
- }
1649
- if (config.defaultProfile === name) {
1650
- throw new Error(
1651
- `Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
1652
- );
1653
- }
1654
- const profiles = { ...config.profiles };
1655
- delete profiles[name];
1656
- await this.save({ ...config, profiles });
1657
- }
1658
- async updateLastPoll() {
1659
- await this.saveProfile((profile) => ({
1660
- ...profile,
1661
- poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
1662
- }));
1663
- }
1664
- /** Returns true if the active profile has an auth block. */
1665
- isAuthenticated() {
1666
- if (this.config === null) return false;
1667
- return this.view(this.config).auth !== void 0;
1668
- }
1669
- /** Returns the user-scoped API key, or throws if not authenticated. */
1670
- getAuthKey() {
1671
- const auth = this.config === null ? void 0 : this.view(this.config).auth;
1672
- if (!auth) {
1673
- throw new Error("Not authenticated. Run `coop login` first.");
1674
- }
1675
- return auth.key;
1676
- }
1677
- /** Returns the user ID, or throws if not authenticated. */
1678
- getUserId() {
1679
- const auth = this.config === null ? void 0 : this.view(this.config).auth;
1680
- if (!auth) {
1681
- throw new Error("Not authenticated. Run `coop login` first.");
1682
- }
1683
- return auth.userId;
1684
- }
1685
- /**
1686
- * Saves the auth block into the active profile. Legacy v1/v2 files are
1687
- * normalized to v3 by load(), so the write always persists v3.
1688
- */
1689
- async setAuth(auth) {
1690
- await this.saveProfile((profile) => ({ ...profile, auth }));
1691
- }
1692
- /**
1693
- * Returns true if the user should be nudged to run `coop login`:
1694
- * the active profile has no auth block.
1695
- */
1696
- needsLoginNudge() {
1697
- if (this.config === null) return true;
1698
- return this.view(this.config).auth === void 0;
1699
- }
1700
- /**
1701
- * Removes the auth block from the active profile (for `coop logout`).
1702
- * Sessions are preserved.
1703
- */
1704
- async clearAuth() {
1705
- await this.saveProfile((profile) => {
1706
- const { auth: _removed, ...rest } = profile;
1707
- return rest;
1708
- });
1709
- }
1710
- };
1711
- function isNodeError(err) {
1712
- return err instanceof Error && "code" in err;
1713
- }
1714
-
1715
- // ../shared/src/cli-auth.ts
1716
- function resolveDisplayName(name, email, userId) {
1717
- if (name && !name.startsWith("user_")) return name;
1718
- if (email) return email.split("@")[0];
1719
- if (userId) return userId.slice(0, 12);
1720
- if (name) return name.slice(0, 12);
1721
- return "unknown";
1722
- }
1723
- async function openBrowser(url) {
1724
- const escaped = url.replace(/"/g, '\\"');
1725
- const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
1726
- return new Promise((resolve5) => {
1727
- exec(cmd, () => resolve5());
1728
- });
1729
- }
1730
- var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
1731
- function htmlPage(title, body) {
1732
- return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
1733
- }
1734
- function startLocalhostServer(options) {
1735
- const timeout = options.timeout ?? 12e4;
1736
- const loginCommand = options.loginCommand ?? "coop login";
1737
- return new Promise((resolveServer, rejectServer) => {
1738
- let resolveCallback;
1739
- let rejectCallback;
1740
- const callbackPromise = new Promise((res, rej) => {
1741
- resolveCallback = res;
1742
- rejectCallback = rej;
1743
- });
1744
- const timer = setTimeout(() => {
1745
- rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
1746
- server.close();
1747
- }, timeout);
1748
- const server = createServer((req, res) => {
1749
- const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
1750
- if (url.pathname === "/") {
1751
- res.writeHead(200, { "Content-Type": "text/html" });
1752
- res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
1753
- return;
1754
- }
1755
- if (url.pathname === "/callback") {
1756
- const state = url.searchParams.get("state") ?? "";
1757
- if (state !== options.expectedState) {
1758
- res.writeHead(400, { "Content-Type": "text/html" });
1759
- res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
1760
- return;
1761
- }
1762
- const key = url.searchParams.get("key") ?? "";
1763
- if (!key) {
1764
- res.writeHead(400, { "Content-Type": "text/html" });
1765
- res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
1766
- return;
1767
- }
1768
- res.writeHead(200, { "Content-Type": "text/html" });
1769
- res.end(htmlPage("Authentication Complete", "You can close this tab."));
1770
- clearTimeout(timer);
1771
- resolveCallback({
1772
- key,
1773
- userId: url.searchParams.get("userId") ?? "",
1774
- email: url.searchParams.get("email") ?? "",
1775
- displayName: url.searchParams.get("displayName") ?? "",
1776
- state,
1777
- apiUrl: url.searchParams.get("apiUrl") ?? ""
1778
- });
1779
- return;
1780
- }
1781
- res.writeHead(404, { "Content-Type": "text/plain" });
1782
- res.end("Not Found");
1783
- });
1784
- server.listen(0, "127.0.0.1", () => {
1785
- const addr = server.address();
1786
- const port = typeof addr === "object" && addr ? addr.port : 0;
1787
- resolveServer({
1788
- port,
1789
- close: () => {
1790
- clearTimeout(timer);
1791
- server.close();
1792
- },
1793
- waitForCallback: () => callbackPromise
1794
- });
1795
- });
1796
- server.on("error", (err) => {
1797
- clearTimeout(timer);
1798
- rejectServer(err);
1799
- });
1800
- });
1801
- }
1802
- function deriveApiUrl(webUrl) {
1803
- const url = new URL2(webUrl);
1804
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
1805
- return "http://localhost:8788";
1806
- }
1807
- return `https://api.${url.hostname}`;
1808
- }
1809
- async function login(input, deps) {
1810
- const state = randomBytes(16).toString("hex");
1811
- const machineId = input.machine ?? hostname2();
1812
- const log = deps.log ?? ((message) => void process.stderr.write(message));
1813
- const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
1814
- try {
1815
- const webUrl = input.webUrl ?? "https://coopcli.com";
1816
- const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
1817
- log(`Opening browser for authentication...
1818
- `);
1819
- await deps.openBrowser(authUrl);
1820
- log(`Waiting for callback on port ${server.port}...
1821
- `);
1822
- const params = await server.waitForCallback();
1823
- const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
1824
- const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
1825
- const isFirstLogin = !await deps.config.hasAnyAuth();
1826
- await deps.config.ensureActiveProfile(resolvedApiUrl);
1827
- await deps.config.setAuth({
1828
- key: params.key,
1829
- userId: params.userId,
1830
- displayName,
1831
- email: params.email || null,
1832
- machineId,
1833
- authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
1834
- });
1835
- const config = await deps.config.load();
1836
- if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
1837
- log(
1838
- `
1839
- Backend mismatch: you authenticated against ${params.apiUrl}
1840
- but your config points to ${config.apiUrl}
1841
- Updating config to ${resolvedApiUrl}
1842
-
1843
- `
1844
- );
1845
- }
1846
- if (resolvedApiUrl !== config.apiUrl) {
1847
- await deps.config.setApiUrl(resolvedApiUrl);
1848
- }
1849
- if (isFirstLogin) {
1850
- await deps.config.setDefaultProfile(config.profileName);
1851
- }
1852
- return {
1853
- userId: params.userId,
1854
- email: params.email || null,
1855
- displayName
1856
- };
1857
- } finally {
1858
- server.close();
1859
- }
1860
- }
1861
-
1862
- // server/login.ts
1863
- import { parseArgs } from "node:util";
1864
- var LOGIN_USAGE = `Usage: specsketch login [--profile <name>] [--web-url <url>]
1865
-
1866
- Opens your browser to sign in to CoopCLI and saves credentials to
1867
- ~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
1868
-
1869
- --profile config profile to log into (default: SPECSKETCH_COOP_PROFILE,
1870
- then COOP_PROFILE, then the config's default profile). A profile
1871
- that doesn't exist yet is created.
1872
- --web-url web app URL for browser auth (default: https://coopcli.com)`;
1873
- var LoginUsageError = class extends Error {
1874
- };
1875
- function parseLoginArgs(argv) {
1876
- let parsed;
1877
- try {
1878
- parsed = parseArgs({
1879
- args: argv,
1880
- options: { profile: { type: "string" }, "web-url": { type: "string" } },
1881
- allowPositionals: true
1882
- });
1883
- } catch (err) {
1884
- throw new LoginUsageError(err instanceof Error ? err.message : String(err));
1885
- }
1886
- if (parsed.positionals.length > 0) {
1887
- throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
1888
- }
1889
- return {
1890
- profile: parsed.values.profile ?? null,
1891
- ...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
1892
- };
1893
- }
1894
- async function runLogin(args, deps = {}) {
1895
- const env = deps.env ?? process.env;
1896
- const config = deps.config ?? new ConfigStore();
1897
- const profile = args.profile ?? profileFromEnv(env, ["SPECSKETCH_COOP_PROFILE", "COOP_PROFILE"]);
1898
- if (profile) config.selectProfile(profile);
1899
- const result = await login(
1900
- { webUrl: args.webUrl, loginCommand: "specsketch login" },
1901
- // Forward the writer too, so a caller supplying one gets ALL of login's output
1902
- // rather than just this command's closing line (issue #1).
1903
- { config, openBrowser: deps.openBrowser ?? openBrowser, log: deps.log }
1904
- );
1905
- (deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
1906
- }
1907
-
1908
1184
  // server/workspace.ts
1909
1185
  import { createHash as createHash2 } from "node:crypto";
1910
1186
  import {
@@ -1915,20 +1191,20 @@ import {
1915
1191
  renameSync as renameSync2,
1916
1192
  writeFileSync as writeFileSync2
1917
1193
  } from "node:fs";
1918
- import { basename as basename2, dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
1194
+ import { basename as basename2, dirname as dirname2, join as join4, resolve as resolve2 } from "node:path";
1919
1195
 
1920
1196
  // ../shared/src/openspec-change-meta.ts
1921
1197
  import { createHash } from "node:crypto";
1922
1198
  import { existsSync as existsSync3, readdirSync as readdirSync3, realpathSync as realpathSync2 } from "node:fs";
1923
- import { join as join4 } from "node:path";
1199
+ import { join as join3 } from "node:path";
1924
1200
  import { Document, parseDocument } from "yaml";
1925
- import { z as z4 } from "zod";
1201
+ import { z as z3 } from "zod";
1926
1202
 
1927
1203
  // ../shared/src/fs-atomic.ts
1928
1204
  import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, renameSync, writeFileSync } from "node:fs";
1929
- import { dirname as dirname2 } from "node:path";
1205
+ import { dirname } from "node:path";
1930
1206
  function writeAtomicFile(target, content) {
1931
- mkdirSync(dirname2(target), { recursive: true });
1207
+ mkdirSync(dirname(target), { recursive: true });
1932
1208
  const tmp = `${target}.tmp`;
1933
1209
  writeFileSync(tmp, content, "utf8");
1934
1210
  renameSync(tmp, target);
@@ -1964,12 +1240,12 @@ var sha256 = (s) => createHash("sha256").update(s).digest("hex");
1964
1240
  function listBodyFiles(changeDir, fsx = nodeChangeMetaFs) {
1965
1241
  const files = [];
1966
1242
  for (const name of ["proposal.md", "design.md", "tasks.md"]) {
1967
- if (fsx.exists(join4(changeDir, name))) files.push(name);
1243
+ if (fsx.exists(join3(changeDir, name))) files.push(name);
1968
1244
  }
1969
- const specsDir = join4(changeDir, "specs");
1245
+ const specsDir = join3(changeDir, "specs");
1970
1246
  for (const entry of fsx.readdir(specsDir).sort((a, b) => a.name.localeCompare(b.name))) {
1971
- const rel = join4("specs", entry.name, "spec.md");
1972
- if (entry.directory && fsx.exists(join4(changeDir, rel))) files.push(rel);
1247
+ const rel = join3("specs", entry.name, "spec.md");
1248
+ if (entry.directory && fsx.exists(join3(changeDir, rel))) files.push(rel);
1973
1249
  }
1974
1250
  return files;
1975
1251
  }
@@ -1978,24 +1254,24 @@ function bodyHash(changeDir, fsx = nodeChangeMetaFs) {
1978
1254
  if (files.length === 0) return null;
1979
1255
  const parts = files.map(
1980
1256
  (rel) => `${rel}
1981
- ${sha256(fsx.readIfExists(join4(changeDir, rel)) ?? "")}`
1257
+ ${sha256(fsx.readIfExists(join3(changeDir, rel)) ?? "")}`
1982
1258
  );
1983
1259
  return `sha256:${sha256(parts.join("\n"))}`;
1984
1260
  }
1985
1261
  var SPEC_STATUSES = ["draft", "in-progress"];
1986
- var SpecStatus = z4.enum(SPEC_STATUSES);
1262
+ var SpecStatus = z3.enum(SPEC_STATUSES);
1987
1263
  var LEGACY_STATUS_COERCION = {
1988
1264
  active: "in-progress",
1989
1265
  done: "in-progress",
1990
1266
  deferred: "draft"
1991
1267
  };
1992
- var StoredSpecStatus = z4.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
1268
+ var StoredSpecStatus = z3.enum([...SPEC_STATUSES, "active", "done", "deferred"]).transform(
1993
1269
  (s) => s in LEGACY_STATUS_COERCION ? LEGACY_STATUS_COERCION[s] : s
1994
1270
  );
1995
- var SpecMetaSchema = z4.object({
1996
- specId: z4.string(),
1997
- userStories: z4.array(z4.string()),
1998
- dependencies: z4.array(z4.string()),
1271
+ var SpecMetaSchema = z3.object({
1272
+ specId: z3.string(),
1273
+ userStories: z3.array(z3.string()),
1274
+ dependencies: z3.array(z3.string()),
1999
1275
  /** Working state (retired values coerce on read); absent = draft. */
2000
1276
  status: StoredSpecStatus.optional(),
2001
1277
  /**
@@ -2004,18 +1280,18 @@ var SpecMetaSchema = z4.object({
2004
1280
  * into specs/ and archived (apply-first: the fold happens at approval).
2005
1281
  * Absent = not-approved.
2006
1282
  */
2007
- approved: z4.boolean().optional(),
1283
+ approved: z3.boolean().optional(),
2008
1284
  /**
2009
1285
  * Legacy spelling of the fold axis (pre-rename metadata files). Read-only
2010
1286
  * compatibility: readers treat `applied: true` as `approved: true`; no
2011
1287
  * writer ever sets this field again.
2012
1288
  */
2013
- applied: z4.boolean().optional(),
1289
+ applied: z3.boolean().optional(),
2014
1290
  /**
2015
1291
  * Build flag — user-set, independent of the fold: true once the code has
2016
1292
  * been built to match the approved diff. Absent = not-implemented.
2017
1293
  */
2018
- implemented: z4.boolean().optional(),
1294
+ implemented: z3.boolean().optional(),
2019
1295
  /**
2020
1296
  * Minimal implementation-task record written on fold (specplan-timeline-apply
2021
1297
  * "Implementation task record on fold"): the creation time and a reference to
@@ -2024,18 +1300,18 @@ var SpecMetaSchema = z4.object({
2024
1300
  * state — task execution/dispatch is a separate, not-yet-defined concern. A
2025
1301
  * field addition to this sidecar, not a redefinition of its format.
2026
1302
  */
2027
- task: z4.object({
2028
- createdAt: z4.string(),
2029
- source: z4.string()
1303
+ task: z3.object({
1304
+ createdAt: z3.string(),
1305
+ source: z3.string()
2030
1306
  }).optional(),
2031
- generation: z4.object({
1307
+ generation: z3.object({
2032
1308
  /** sha256 of the last generated body files — detects hand edits. */
2033
- lastGeneratedBodyHash: z4.string(),
2034
- lastGeneratedAt: z4.string()
1309
+ lastGeneratedBodyHash: z3.string(),
1310
+ lastGeneratedAt: z3.string()
2035
1311
  }).optional()
2036
1312
  });
2037
1313
  function writeChangeMeta(fsx, changeDir, meta) {
2038
- const path = join4(changeDir, CHANGE_META_FILE);
1314
+ const path = join3(changeDir, CHANGE_META_FILE);
2039
1315
  const raw = fsx.readIfExists(path);
2040
1316
  const doc = raw === void 0 ? new Document({
2041
1317
  specId: meta.specId,
@@ -2078,14 +1354,14 @@ var ChangeWorkspace = class {
2078
1354
  if (options.create !== false) mkdirSync2(this.dir, { recursive: true });
2079
1355
  }
2080
1356
  writeAtomic(relPath, content) {
2081
- const target = join5(this.dir, relPath);
2082
- mkdirSync2(dirname3(target), { recursive: true });
1357
+ const target = join4(this.dir, relPath);
1358
+ mkdirSync2(dirname2(target), { recursive: true });
2083
1359
  const tmp = `${target}.tmp`;
2084
1360
  writeFileSync2(tmp, content, "utf8");
2085
1361
  renameSync2(tmp, target);
2086
1362
  }
2087
1363
  readIfExists(relPath) {
2088
- const target = join5(this.dir, relPath);
1364
+ const target = join4(this.dir, relPath);
2089
1365
  return existsSync4(target) ? readFileSync3(target, "utf8") : void 0;
2090
1366
  }
2091
1367
  // ── Scene ────────────────────────────────────────────────────────────────
@@ -2147,11 +1423,11 @@ var ChangeWorkspace = class {
2147
1423
  /** Current artifact files, however they were authored. */
2148
1424
  readArtifacts() {
2149
1425
  const specs = [];
2150
- const specsDir = join5(this.dir, "specs");
1426
+ const specsDir = join4(this.dir, "specs");
2151
1427
  if (existsSync4(specsDir)) {
2152
1428
  for (const entry of readdirSync4(specsDir, { withFileTypes: true })) {
2153
1429
  if (!entry.isDirectory()) continue;
2154
- const spec = this.readIfExists(join5("specs", entry.name, "spec.md"));
1430
+ const spec = this.readIfExists(join4("specs", entry.name, "spec.md"));
2155
1431
  if (spec !== void 0) specs.push({ capability: entry.name, spec });
2156
1432
  }
2157
1433
  }
@@ -2185,7 +1461,7 @@ var ChangeWorkspace = class {
2185
1461
  "design.md": bundle.design,
2186
1462
  "tasks.md": bundle.tasks
2187
1463
  };
2188
- for (const s of bundle.specs) files[join5("specs", s.capability, "spec.md")] = s.spec;
1464
+ for (const s of bundle.specs) files[join4("specs", s.capability, "spec.md")] = s.spec;
2189
1465
  const written = [];
2190
1466
  const manifest = {};
2191
1467
  for (const [relPath, content] of Object.entries(files)) {
@@ -2236,11 +1512,11 @@ var ChangeWorkspace = class {
2236
1512
 
2237
1513
  // server/workbench-host.ts
2238
1514
  import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:fs";
2239
- import { dirname as dirname5, join as join11, resolve as resolve3 } from "node:path";
1515
+ import { dirname as dirname4, join as join10, resolve as resolve3 } from "node:path";
2240
1516
 
2241
1517
  // src/workbench/core/capability-store.ts
2242
1518
  import { readFileSync as readFileSync4, readdirSync as readdirSync5, statSync as statSync2 } from "node:fs";
2243
- import { join as join6 } from "node:path";
1519
+ import { join as join5 } from "node:path";
2244
1520
 
2245
1521
  // src/workbench/core/spec-parse.ts
2246
1522
  var REQUIREMENT_HEADING = /^###[ \t]+Requirement:[ \t]*(.*)$/;
@@ -2382,8 +1658,8 @@ function readIfPresent(path) {
2382
1658
  return readFileSync4(path, "utf8");
2383
1659
  }
2384
1660
  function createFoldedSpecReader(repoRoot) {
2385
- const specsDir = join6(repoRoot, "openspec", "specs");
2386
- const specPath = (name) => join6(specsDir, name, SPEC_FILE);
1661
+ const specsDir = join5(repoRoot, "openspec", "specs");
1662
+ const specPath = (name) => join5(specsDir, name, SPEC_FILE);
2387
1663
  const readCapability = (name) => {
2388
1664
  const invalid = validateCapabilityName(name);
2389
1665
  if (invalid) throw new RangeError(invalid);
@@ -2512,22 +1788,22 @@ function agentRailProfile() {
2512
1788
 
2513
1789
  // src/workbench/agent/credential-probe.ts
2514
1790
  import { existsSync as existsSync5, readdirSync as readdirSync6, statSync as statSync3 } from "node:fs";
2515
- import { homedir as homedir3 } from "node:os";
2516
- import { delimiter, join as join7 } from "node:path";
1791
+ import { homedir as homedir2 } from "node:os";
1792
+ import { delimiter, join as join6 } from "node:path";
2517
1793
  function claudeOnPath(env) {
2518
1794
  const path = env["PATH"];
2519
1795
  if (path === void 0 || path === "") return false;
2520
1796
  for (const dir of path.split(delimiter)) {
2521
1797
  if (dir === "") continue;
2522
1798
  try {
2523
- if (statSync3(join7(dir, "claude")).isFile()) return true;
1799
+ if (statSync3(join6(dir, "claude")).isFile()) return true;
2524
1800
  } catch {
2525
1801
  }
2526
1802
  }
2527
1803
  return false;
2528
1804
  }
2529
1805
  function antAuthProfile(home) {
2530
- const dir = join7(home, ".config", "anthropic", "credentials");
1806
+ const dir = join6(home, ".config", "anthropic", "credentials");
2531
1807
  try {
2532
1808
  return readdirSync6(dir).some((f) => f.endsWith(".json")) ? "token" : "empty-profile";
2533
1809
  } catch {
@@ -2535,7 +1811,7 @@ function antAuthProfile(home) {
2535
1811
  }
2536
1812
  }
2537
1813
  function subscriptionStatus(home, installed) {
2538
- if (existsSync5(join7(home, ".claude", ".credentials.json"))) return { kind: "available" };
1814
+ if (existsSync5(join6(home, ".claude", ".credentials.json"))) return { kind: "available" };
2539
1815
  return {
2540
1816
  kind: "unavailable",
2541
1817
  reason: installed ? "installed-not-authenticated" : "not-installed"
@@ -2564,7 +1840,7 @@ function antAuthStatus(env, home) {
2564
1840
  }
2565
1841
  function probeMachineCredentials(input = {}) {
2566
1842
  const env = input.env ?? process.env;
2567
- const home = input.home ?? homedir3();
1843
+ const home = input.home ?? homedir2();
2568
1844
  const installed = input.claudeInstalled ?? claudeOnPath(env);
2569
1845
  const rejected = new Set(input.rejected ?? []);
2570
1846
  const statuses = {
@@ -2664,7 +1940,7 @@ async function runRailTurn(input, deps = {}) {
2664
1940
  }
2665
1941
 
2666
1942
  // src/workbench/agent/tools.ts
2667
- import { z as z5 } from "zod";
1943
+ import { z as z4 } from "zod";
2668
1944
  var ROOT_ARTIFACTS = ["proposal.md", "design.md", "tasks.md"];
2669
1945
  var SPEC_ARTIFACT = /^specs\/([a-z0-9]+(?:-[a-z0-9]+)*)\/spec\.md$/;
2670
1946
  var AGENT_PROPOSAL_TOOLS = [
@@ -2681,22 +1957,22 @@ var AGENT_PROPOSAL_TOOLS = [
2681
1957
  description: "Propose a requirement, appended to a delta spec. `capability` must be one the change already has a delta spec for. `block` must be full markdown: a `### Requirement:` heading, RFC-2119 SHALL text, and at least one `#### Scenario:` with WHEN/THEN steps."
2682
1958
  }
2683
1959
  ];
2684
- var proposalCall = z5.object({
2685
- tool: z5.enum(AGENT_PROPOSAL_TOOLS.map((t) => t.name)).describe("Which kind of edit to propose."),
2686
- artifact: z5.string().optional().describe("edit_artifact: the artifact this edit is against."),
2687
- mode: z5.string().optional().describe("edit_artifact: `replace` or `append`. Defaults to append."),
2688
- text: z5.string().optional().describe("edit_artifact: the markdown, exactly as it should read."),
2689
- title: z5.string().optional().describe("add_task_group: the group heading."),
2690
- tasks: z5.array(z5.string()).optional().describe("add_task_group: task titles, one per task, each one concrete action."),
2691
- capability: z5.string().optional().describe("add_requirement: the delta spec\u2019s capability. add_task_group: the citation\u2019s."),
2692
- requirement: z5.string().optional().describe("add_task_group: the exact requirement name, as it appears after `### Requirement:`."),
2693
- block: z5.string().optional().describe("add_requirement: the complete requirement block, in markdown.")
1960
+ var proposalCall = z4.object({
1961
+ tool: z4.enum(AGENT_PROPOSAL_TOOLS.map((t) => t.name)).describe("Which kind of edit to propose."),
1962
+ artifact: z4.string().optional().describe("edit_artifact: the artifact this edit is against."),
1963
+ mode: z4.string().optional().describe("edit_artifact: `replace` or `append`. Defaults to append."),
1964
+ text: z4.string().optional().describe("edit_artifact: the markdown, exactly as it should read."),
1965
+ title: z4.string().optional().describe("add_task_group: the group heading."),
1966
+ tasks: z4.array(z4.string()).optional().describe("add_task_group: task titles, one per task, each one concrete action."),
1967
+ capability: z4.string().optional().describe("add_requirement: the delta spec\u2019s capability. add_task_group: the citation\u2019s."),
1968
+ requirement: z4.string().optional().describe("add_task_group: the exact requirement name, as it appears after `### Requirement:`."),
1969
+ block: z4.string().optional().describe("add_requirement: the complete requirement block, in markdown.")
2694
1970
  });
2695
- var agentTurnResult = z5.object({
2696
- answer: z5.string().describe("Brief prose for the author: what you proposed and why, or your answer if nothing."),
2697
- proposals: z5.array(proposalCall).describe("The edits to propose. Empty when the turn only answers.")
1971
+ var agentTurnResult = z4.object({
1972
+ answer: z4.string().describe("Brief prose for the author: what you proposed and why, or your answer if nothing."),
1973
+ proposals: z4.array(proposalCall).describe("The edits to propose. Empty when the turn only answers.")
2698
1974
  });
2699
- var AGENT_TURN_SCHEMA = z5.toJSONSchema(agentTurnResult, {
1975
+ var AGENT_TURN_SCHEMA = z4.toJSONSchema(agentTurnResult, {
2700
1976
  target: "draft-7"
2701
1977
  });
2702
1978
  function knownArtifacts(shape) {
@@ -2901,7 +2177,7 @@ import {
2901
2177
  statSync as statSync4,
2902
2178
  writeFileSync as writeFileSync3
2903
2179
  } from "node:fs";
2904
- import { dirname as dirname4, join as join8 } from "node:path";
2180
+ import { dirname as dirname3, join as join7 } from "node:path";
2905
2181
 
2906
2182
  // src/workbench/shell/lifecycle.ts
2907
2183
  var SPEC_DRIVEN = Object.freeze({
@@ -2941,7 +2217,7 @@ function resolveSchema(yamlText, known = [SPEC_DRIVEN]) {
2941
2217
 
2942
2218
  // server/change-files.ts
2943
2219
  function changeDirectory(repoRoot, changeId) {
2944
- return join8(repoRoot, "openspec", "changes", changeId);
2220
+ return join7(repoRoot, "openspec", "changes", changeId);
2945
2221
  }
2946
2222
  var ROOT_ARTIFACTS2 = Object.freeze(["proposal.md", "design.md", "tasks.md"]);
2947
2223
  var CAPABILITY = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
@@ -2977,7 +2253,7 @@ function readArtifact(changeDir, name, io = REAL_IO) {
2977
2253
  if (artifact === null) {
2978
2254
  return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
2979
2255
  }
2980
- const path = join8(changeDir, artifact);
2256
+ const path = join7(changeDir, artifact);
2981
2257
  let size;
2982
2258
  try {
2983
2259
  const stat = statSync4(path);
@@ -3011,17 +2287,17 @@ function readArtifact(changeDir, name, io = REAL_IO) {
3011
2287
  function listArtifacts(changeDir) {
3012
2288
  const found = [];
3013
2289
  for (const name of ROOT_ARTIFACTS2) {
3014
- if (existsSync6(join8(changeDir, name))) found.push(name);
2290
+ if (existsSync6(join7(changeDir, name))) found.push(name);
3015
2291
  }
3016
2292
  let capabilities = [];
3017
2293
  try {
3018
- capabilities = readdirSync7(join8(changeDir, "specs"), { withFileTypes: true }).filter((e) => e.isDirectory() && CAPABILITY.test(e.name)).map((e) => e.name).sort();
2294
+ capabilities = readdirSync7(join7(changeDir, "specs"), { withFileTypes: true }).filter((e) => e.isDirectory() && CAPABILITY.test(e.name)).map((e) => e.name).sort();
3019
2295
  } catch {
3020
2296
  capabilities = [];
3021
2297
  }
3022
2298
  for (const capability of capabilities) {
3023
2299
  const artifact = `specs/${capability}/spec.md`;
3024
- if (existsSync6(join8(changeDir, artifact))) found.push(artifact);
2300
+ if (existsSync6(join7(changeDir, artifact))) found.push(artifact);
3025
2301
  }
3026
2302
  return found;
3027
2303
  }
@@ -3030,14 +2306,14 @@ function writeArtifact(changeDir, name, content, options = {}) {
3030
2306
  if (artifact === null) {
3031
2307
  return { outcome: "refused", artifact: name, reason: notAnArtifact(name) };
3032
2308
  }
3033
- const path = join8(changeDir, artifact);
2309
+ const path = join7(changeDir, artifact);
3034
2310
  if (options.expected !== void 0 && options.overwrite !== true) {
3035
2311
  const conflict = conflictWith(path, artifact, options.expected);
3036
2312
  if (conflict) return conflict;
3037
2313
  }
3038
2314
  const materialised = !existsSync6(changeDir);
3039
2315
  try {
3040
- mkdirSync3(dirname4(path), { recursive: true });
2316
+ mkdirSync3(dirname3(path), { recursive: true });
3041
2317
  writeFileSync3(path, Buffer.from(content, "utf8"));
3042
2318
  writeSchemaFile(changeDir);
3043
2319
  } catch (err) {
@@ -3073,7 +2349,7 @@ function conflictWith(path, artifact, expected) {
3073
2349
  };
3074
2350
  }
3075
2351
  function writeSchemaFile(changeDir) {
3076
- const path = join8(changeDir, ".openspec.yaml");
2352
+ const path = join7(changeDir, ".openspec.yaml");
3077
2353
  if (existsSync6(path)) return;
3078
2354
  writeFileSync3(path, `schema: ${SPEC_DRIVEN.name}
3079
2355
  `, "utf8");
@@ -3099,7 +2375,7 @@ function describeErrno(artifact, err) {
3099
2375
  // server/openspec-validate.ts
3100
2376
  import { spawnSync } from "node:child_process";
3101
2377
  import { existsSync as existsSync7 } from "node:fs";
3102
- import { join as join9 } from "node:path";
2378
+ import { join as join8 } from "node:path";
3103
2379
 
3104
2380
  // src/workbench/core/validate.ts
3105
2381
  var QUOTED_SUBJECT = /"([^"]+)"/;
@@ -3136,7 +2412,7 @@ function openSpecFindings(issues, changeId = "") {
3136
2412
 
3137
2413
  // server/openspec-validate.ts
3138
2414
  var runOpenSpecValidate = (repoRoot, changeId) => {
3139
- const local = join9(repoRoot, "node_modules", ".bin", "openspec");
2415
+ const local = join8(repoRoot, "node_modules", ".bin", "openspec");
3140
2416
  const command = existsSync7(local) ? local : "openspec";
3141
2417
  const run = spawnSync(command, ["validate", changeId, "--strict", "--json", "--no-interactive"], {
3142
2418
  cwd: repoRoot,
@@ -3189,13 +2465,13 @@ function validateChangeOnDisk(repoRoot, changeId, run = runOpenSpecValidate) {
3189
2465
 
3190
2466
  // server/approvals.ts
3191
2467
  import { existsSync as existsSync8, readFileSync as readFileSync6, statSync as statSync5, writeFileSync as writeFileSync4 } from "node:fs";
3192
- import { join as join10 } from "node:path";
2468
+ import { join as join9 } from "node:path";
3193
2469
  var SIDECAR = ".specsketch.json";
3194
2470
  var OVERLAY_DECLARATION = "authoring-overlay \u2014 specsketch only, never lifecycle truth";
3195
2471
  var STAGE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
3196
2472
  var REAL_IO2 = { readText: (path) => readFileSync6(path, "utf8") };
3197
2473
  function readApprovals(changeDir, io = REAL_IO2) {
3198
- const path = join10(changeDir, SIDECAR);
2474
+ const path = join9(changeDir, SIDECAR);
3199
2475
  let size;
3200
2476
  try {
3201
2477
  const stat = statSync5(path);
@@ -3281,7 +2557,7 @@ function setApproval(changeDir, stage, approved, now = /* @__PURE__ */ new Date(
3281
2557
  else delete next[stage];
3282
2558
  try {
3283
2559
  writeFileSync4(
3284
- join10(changeDir, SIDECAR),
2560
+ join9(changeDir, SIDECAR),
3285
2561
  `${JSON.stringify({ overlay: OVERLAY_DECLARATION, approvals: next }, null, " ")}
3286
2562
  `,
3287
2563
  "utf8"
@@ -3311,7 +2587,7 @@ function changeContextFor(target, source) {
3311
2587
  let changeText = "";
3312
2588
  if (!target.resuming) return { read, changeText };
3313
2589
  for (const name of ["proposal.md", "tasks.md"]) {
3314
- const file = join11(target.changeDir, name);
2590
+ const file = join10(target.changeDir, name);
3315
2591
  if (!existsSync9(file)) continue;
3316
2592
  changeText += `
3317
2593
 
@@ -3320,7 +2596,7 @@ ${readFileSync7(file, "utf8")}`;
3320
2596
  read.push(name);
3321
2597
  }
3322
2598
  for (const capability of source.openCapabilities ?? []) {
3323
- const file = join11(target.changeDir, "specs", capability, "spec.md");
2599
+ const file = join10(target.changeDir, "specs", capability, "spec.md");
3324
2600
  if (!existsSync9(file)) continue;
3325
2601
  changeText += `
3326
2602
 
@@ -3347,8 +2623,8 @@ var WorkbenchHostError = class extends Error {
3347
2623
  function findRepoRoot(start) {
3348
2624
  let dir = resolve3(start);
3349
2625
  for (; ; ) {
3350
- if (existsSync9(join11(dir, "openspec"))) return dir;
3351
- const parent = dirname5(dir);
2626
+ if (existsSync9(join10(dir, "openspec"))) return dir;
2627
+ const parent = dirname4(dir);
3352
2628
  if (parent === dir) return null;
3353
2629
  dir = parent;
3354
2630
  }
@@ -3373,7 +2649,7 @@ function resolveTarget(cwd, changeId) {
3373
2649
  }
3374
2650
  function readSchema(target) {
3375
2651
  try {
3376
- return resolveSchema(readFileSync7(join11(target.changeDir, ".openspec.yaml"), "utf8"));
2652
+ return resolveSchema(readFileSync7(join10(target.changeDir, ".openspec.yaml"), "utf8"));
3377
2653
  } catch {
3378
2654
  return { kind: "unknown-schema", name: "" };
3379
2655
  }
@@ -3500,9 +2776,8 @@ function mountWorkbenchHost(app, options) {
3500
2776
  }
3501
2777
 
3502
2778
  // server/cli.ts
3503
- var CLI_VERSION = true ? "2026.09.02.1" : "dev";
2779
+ var CLI_VERSION = true ? "2026.09.04.1" : "dev";
3504
2780
  var USAGE = `Usage: specsketch <change-id> [--port <n>] [--fixture]
3505
- specsketch login [--profile <name>] [--web-url <url>]
3506
2781
  specsketch --version
3507
2782
 
3508
2783
  specsketch add-user-search
@@ -3527,7 +2802,7 @@ var UsageError = class extends Error {
3527
2802
  function parseCliArgs(argv) {
3528
2803
  let parsed;
3529
2804
  try {
3530
- parsed = parseArgs2({
2805
+ parsed = parseArgs({
3531
2806
  args: argv,
3532
2807
  options: { port: { type: "string" }, fixture: { type: "boolean" } },
3533
2808
  allowPositionals: true
@@ -3573,15 +2848,15 @@ var MIME = {
3573
2848
  ".woff": "font/woff"
3574
2849
  };
3575
2850
  function resolveClientDir(moduleUrl) {
3576
- const here = dirname6(fileURLToPath(moduleUrl));
2851
+ const here = dirname5(fileURLToPath(moduleUrl));
3577
2852
  const candidates = [
3578
- join12(here, "..", "client"),
2853
+ join11(here, "..", "client"),
3579
2854
  // packed: dist/cli -> dist/client
3580
- join12(here, "..", "dist", "client")
2855
+ join11(here, "..", "dist", "client")
3581
2856
  // repo: server/ -> dist/client
3582
2857
  ];
3583
2858
  for (const dir of candidates) {
3584
- if (existsSync10(join12(dir, "index.html"))) return dir;
2859
+ if (existsSync10(join11(dir, "index.html"))) return dir;
3585
2860
  }
3586
2861
  return null;
3587
2862
  }
@@ -3589,7 +2864,7 @@ function staticResponse(clientDir, pathname) {
3589
2864
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
3590
2865
  const target = resolve4(clientDir, rel === "" ? "index.html" : rel);
3591
2866
  if (!target.startsWith(resolve4(clientDir))) return null;
3592
- const file = existsSync10(target) && extname(target) ? target : join12(clientDir, "index.html");
2867
+ const file = existsSync10(target) && extname(target) ? target : join11(clientDir, "index.html");
3593
2868
  if (!existsSync10(file)) return null;
3594
2869
  return new Response(readFileSync8(file), {
3595
2870
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
@@ -3603,11 +2878,11 @@ but "Generate Spec" needs one of:
3603
2878
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
3604
2879
  brew install anthropics/tap/ant # macOS
3605
2880
  ant auth login`;
3606
- function detectCredentialSource(env = process.env, home = homedir4()) {
2881
+ function detectCredentialSource(env = process.env, home = homedir3()) {
3607
2882
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
3608
2883
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
3609
2884
  try {
3610
- const dir = join12(home, ".config", "anthropic", "credentials");
2885
+ const dir = join11(home, ".config", "anthropic", "credentials");
3611
2886
  if (readdirSync8(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
3612
2887
  } catch {
3613
2888
  }
@@ -3627,26 +2902,6 @@ async function main(argv) {
3627
2902
  return;
3628
2903
  }
3629
2904
  preflight();
3630
- if (argv[0] === "login") {
3631
- let loginArgs;
3632
- try {
3633
- loginArgs = parseLoginArgs(argv.slice(1));
3634
- } catch (err) {
3635
- console.error(
3636
- err instanceof LoginUsageError ? `specsketch: ${err.message}
3637
-
3638
- ${LOGIN_USAGE}` : err
3639
- );
3640
- process.exit(1);
3641
- }
3642
- try {
3643
- await runLogin(loginArgs);
3644
- } catch (err) {
3645
- console.error(`specsketch: ${err instanceof Error ? err.message : String(err)}`);
3646
- process.exit(1);
3647
- }
3648
- return;
3649
- }
3650
2905
  let args;
3651
2906
  try {
3652
2907
  args = parseCliArgs(argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coopcli/specsketch",
3
- "version": "2026.902.1",
3
+ "version": "2026.904.1",
4
4
  "description": "coopcli specsketch — draw an architecture diagram, generate an OpenSpec change proposal into your openspec/ change directory. Local-only.",
5
5
  "type": "module",
6
6
  "bin": {