@coopcli/specplan 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,7 +1,9 @@
1
1
  # @coopcli/specplan
2
2
 
3
- Plan a project as a DAG of **specs** and **user stories** on a React Flow canvas, and generate OpenSpec changes per spec card. Local-only: the server binds to 127.0.0.1 and model calls run with your own Anthropic credentials.
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
+
4
5
 
6
+ Plan a project as a DAG of **specs** and **user stories** on a React Flow canvas, and generate OpenSpec changes per spec card. Local-only: the server binds to 127.0.0.1 and model calls run with your own Anthropic credentials.
5
7
  ```bash
6
8
  npx @coopcli/specplan ./openspec
7
9
  ```
@@ -15,12 +17,10 @@ npx @coopcli/specplan ./openspec
15
17
  - **Layered YAML storage** — the DAG lives at `<root>/specplan.yaml`, plus one `specplan.yaml` per change directory (stories, dependencies, generation hash). Comments in hand-edited files survive round trips; the filesystem is the source of truth.
16
18
 
17
19
  ## CLI
18
-
19
20
  ```
20
21
  specplan [rootDir] [--port <n>] # launch the browser app (default ./openspec, port 8789)
21
22
  specplan generate <specId> [rootDir] # headless generation; exit non-zero on strict-validation failure
22
23
  specplan validate [rootDir] # schema + acyclicity + story-formula check, named errors
23
- specplan login [--profile] [--web-url] # CoopCLI account (browser OAuth, ~/.coopcli/config.json)
24
24
  ```
25
25
 
26
26
  ## Credentials
@@ -39,7 +39,6 @@ Chat and generation need Anthropic credentials on the machine — `ANTHROPIC_API
39
39
  | `@` references don't find your files | The `@` scope is the directory you launched `specplan` from, not the plan root — launch from the repo root |
40
40
 
41
41
  ## Development (repo)
42
-
43
42
  ```bash
44
43
  pnpm --filter @coopcli/specplan dev # vite HMR (:5173-ish) + API on :8789
45
44
  pnpm --filter @coopcli/specplan check # tsc
package/dist/cli/index.js CHANGED
@@ -2,10 +2,10 @@
2
2
 
3
3
  // server/cli.ts
4
4
  import { existsSync as existsSync7, readFileSync as readFileSync5, readdirSync as readdirSync5 } from "node:fs";
5
- import { homedir as homedir3 } from "node:os";
6
- import { dirname as dirname8, extname, join as join14, normalize as normalize2, resolve as resolve6 } from "node:path";
5
+ import { homedir as homedir2 } from "node:os";
6
+ import { dirname as dirname7, extname, join as join13, normalize as normalize2, resolve as resolve6 } 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
 
@@ -3431,728 +3431,6 @@ ${renderPlanContext(chatPlan, statuses)}`;
3431
3431
  return app;
3432
3432
  }
3433
3433
 
3434
- // server/login.ts
3435
- import { parseArgs } from "node:util";
3436
-
3437
- // ../shared/src/cli-config.ts
3438
- import { readFile, writeFile, mkdir, chmod } from "node:fs/promises";
3439
- import { homedir as homedir2, hostname } from "node:os";
3440
- import { join as join12, dirname as dirname7 } from "node:path";
3441
-
3442
- // ../shared/src/schemas.ts
3443
- import { z as z5 } from "zod";
3444
- var planTierSchema = z5.enum(["hobby", "squad", "org"]);
3445
- var jurisdictionSchema = z5.enum(["eu", "fedramp"]);
3446
- var locationHintSchema = z5.enum([
3447
- "wnam",
3448
- "enam",
3449
- "weur",
3450
- "eeur",
3451
- "apac",
3452
- "oc",
3453
- "afr",
3454
- "me",
3455
- "sam"
3456
- ]);
3457
- var sessionNameSchema = z5.string().min(1).max(63).regex(
3458
- /^[a-z0-9][a-z0-9-]*$/,
3459
- "Session name must be lowercase alphanumeric with hyphens, starting with a letter or digit"
3460
- );
3461
- var teamNameSchema = z5.string().min(1).max(63).regex(
3462
- /^[a-z0-9][a-z0-9-]*$/,
3463
- "Team name must be lowercase alphanumeric with hyphens"
3464
- );
3465
- var messageKindSchema = z5.enum(["text", "context", "request", "system"]);
3466
- var messageContentSchema = z5.object({
3467
- kind: messageKindSchema,
3468
- // No .max() here: the 10 000-char body limit is business logic owned by the
3469
- // SessionActor (MAX_BODY_BYTES), which rejects with 413 MESSAGE_TOO_LARGE on
3470
- // both REST and WS. A schema cap would pre-empt it as a generic 400.
3471
- body: z5.string().min(1),
3472
- // Two-arg z.record: identical semantics in zod 3, required form in zod 4
3473
- // (specsketch bundles this file against zod 4 — keep every construct dual-safe).
3474
- metadata: z5.record(z5.string(), z5.unknown()).optional()
3475
- });
3476
- var createTeamInputSchema = z5.object({
3477
- name: teamNameSchema
3478
- });
3479
- var createSessionInputSchema = z5.object({
3480
- name: sessionNameSchema,
3481
- teamId: z5.string().optional(),
3482
- jurisdiction: jurisdictionSchema.optional(),
3483
- locationHint: locationHintSchema.optional()
3484
- });
3485
- var sendMessageInputSchema = z5.object({
3486
- content: messageContentSchema
3487
- });
3488
- var inviteInputSchema = z5.object({
3489
- email: z5.string().email(),
3490
- role: z5.enum(["admin", "member"]).default("member")
3491
- });
3492
- var joinTeamInputSchema = z5.object({
3493
- inviteCode: z5.string().min(1)
3494
- });
3495
- var markReadInputSchema = z5.object({
3496
- notificationIds: z5.array(z5.string().min(1))
3497
- });
3498
- var pollQuerySchema = z5.object({
3499
- since: z5.string().optional(),
3500
- limit: z5.coerce.number().int().min(1).max(100).default(50),
3501
- session: z5.string().optional()
3502
- });
3503
- var messagesQuerySchema = z5.object({
3504
- cursor: z5.string().optional(),
3505
- limit: z5.coerce.number().int().min(1).max(100).default(50)
3506
- });
3507
- var teamTypeSchema = z5.enum(["personal", "shared"]);
3508
- var teamRoleSchema = z5.enum(["owner", "admin", "member"]);
3509
- var accessPermissionSchema = z5.enum(["read", "write", "admin"]);
3510
- var accessGrantInputSchema = z5.object({
3511
- userId: z5.string().min(1),
3512
- sessionName: sessionNameSchema.nullable().default(null),
3513
- // null = general
3514
- permission: accessPermissionSchema
3515
- });
3516
- var machineIdSchema = z5.string().min(1).max(63).regex(
3517
- /^[a-z0-9][a-z0-9-]*$/,
3518
- "Machine ID must be lowercase alphanumeric with hyphens, starting with a letter or digit"
3519
- );
3520
- var clerkProfileInputSchema = z5.object({
3521
- email: z5.string().email().nullable(),
3522
- displayName: z5.string().min(1).max(100),
3523
- avatarUrl: z5.string().url().nullable(),
3524
- provider: z5.string().min(1).max(50),
3525
- providerUserId: z5.string().min(1).max(100)
3526
- });
3527
- var sessionEnrollmentSchema = z5.object({
3528
- sessionId: z5.string(),
3529
- team: z5.string(),
3530
- teamName: z5.string().optional(),
3531
- key: z5.string().startsWith("api_"),
3532
- enrolledAt: z5.string().datetime(),
3533
- apiUrl: z5.string().url().optional(),
3534
- machineId: z5.string().optional()
3535
- });
3536
- var authConfigSchema = z5.object({
3537
- key: z5.string().startsWith("api_"),
3538
- userId: z5.string().min(1),
3539
- displayName: z5.string().nullable().optional().default(null),
3540
- email: z5.string().email().nullable(),
3541
- machineId: z5.string().nullable(),
3542
- authenticatedAt: z5.string().datetime()
3543
- });
3544
- var configBaseFields = {
3545
- apiUrl: z5.string().url(),
3546
- machineId: z5.string().nullable(),
3547
- sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3548
- defaults: z5.object({
3549
- team: z5.string().nullable(),
3550
- session: z5.string().nullable()
3551
- }),
3552
- poll: z5.object({
3553
- lastPollAt: z5.string().datetime().nullable()
3554
- })
3555
- };
3556
- var coopConfigV1Schema = z5.object({
3557
- version: z5.literal(1),
3558
- ...configBaseFields
3559
- });
3560
- var coopConfigV2Schema = z5.object({
3561
- version: z5.literal(2),
3562
- ...configBaseFields,
3563
- auth: authConfigSchema.optional()
3564
- });
3565
- var profileNameSchema = z5.string().regex(
3566
- /^[a-z0-9][a-z0-9_-]{0,63}$/,
3567
- "Profile name must be lowercase alphanumeric with dashes/underscores, max 64 chars"
3568
- );
3569
- var coopProfileSchema = z5.object({
3570
- apiUrl: z5.string().url(),
3571
- auth: authConfigSchema.optional(),
3572
- sessions: z5.record(z5.string(), sessionEnrollmentSchema),
3573
- defaults: z5.object({
3574
- team: z5.string().nullable(),
3575
- session: z5.string().nullable()
3576
- }),
3577
- poll: z5.object({
3578
- lastPollAt: z5.string().datetime().nullable()
3579
- })
3580
- });
3581
- var coopConfigV3ObjectSchema = z5.object({
3582
- version: z5.literal(3),
3583
- machineId: z5.string().nullable(),
3584
- defaultProfile: z5.string(),
3585
- profiles: z5.record(profileNameSchema, coopProfileSchema)
3586
- });
3587
- function requireDefaultProfilePointer(config, ctx) {
3588
- if (!(config.defaultProfile in config.profiles)) {
3589
- ctx.addIssue({
3590
- code: "custom",
3591
- path: ["defaultProfile"],
3592
- message: `defaultProfile "${config.defaultProfile}" does not name an existing profile`
3593
- });
3594
- }
3595
- }
3596
- var coopConfigV3Schema = coopConfigV3ObjectSchema.superRefine(
3597
- requireDefaultProfilePointer
3598
- );
3599
- var coopConfigSchema = z5.discriminatedUnion("version", [
3600
- coopConfigV1Schema,
3601
- coopConfigV2Schema,
3602
- coopConfigV3ObjectSchema
3603
- ]).superRefine((config, ctx) => {
3604
- if (config.version === 3) {
3605
- requireDefaultProfilePointer(config, ctx);
3606
- }
3607
- });
3608
- var hookStdinSchema = z5.object({
3609
- session_id: z5.string().optional(),
3610
- cwd: z5.string().optional(),
3611
- hook_event_name: z5.string().optional()
3612
- }).passthrough();
3613
- var wsClientMessageSchema = z5.discriminatedUnion("type", [
3614
- z5.object({ type: z5.literal("message"), content: messageContentSchema }),
3615
- z5.object({ type: z5.literal("ack"), data: z5.object({ messageId: z5.string() }) }),
3616
- z5.object({ type: z5.literal("presence_request") }),
3617
- z5.object({ type: z5.literal("ping") })
3618
- ]);
3619
- var workKindSchema = z5.enum(["implement", "generate"]);
3620
- var workCompletionStatusSchema = z5.enum(["succeeded", "failed"]);
3621
- var poolJoinInputSchema = z5.object({}).passthrough();
3622
- var poolCheckInInputSchema = z5.object({}).passthrough();
3623
- var poolCompleteInputSchema = z5.object({
3624
- workId: z5.string().min(1),
3625
- status: workCompletionStatusSchema,
3626
- resultRef: z5.string().max(512),
3627
- assignmentSeq: z5.number().int().nonnegative()
3628
- });
3629
- var poolEnqueueInputSchema = z5.object({
3630
- kind: workKindSchema,
3631
- source: z5.string().min(1),
3632
- dedupKey: z5.string().min(1).max(512).optional()
3633
- });
3634
- var poolCancelInputSchema = z5.object({
3635
- workId: z5.string().min(1)
3636
- });
3637
-
3638
- // ../shared/src/cli-config.ts
3639
- var DEFAULT_CONFIG_DIR = join12(homedir2(), ".coopcli");
3640
- var DEFAULT_CONFIG_PATH = join12(DEFAULT_CONFIG_DIR, "config.json");
3641
- var PROD_API_URL = "https://api.coopcli.com";
3642
- function emptyProfile(apiUrl) {
3643
- return {
3644
- apiUrl,
3645
- sessions: {},
3646
- defaults: { team: null, session: null },
3647
- poll: { lastPollAt: null }
3648
- };
3649
- }
3650
- var DEFAULT_CONFIG = {
3651
- version: 3,
3652
- machineId: hostname(),
3653
- defaultProfile: "default",
3654
- profiles: { default: emptyProfile(PROD_API_URL) }
3655
- };
3656
- function upgradeToV3(parsed) {
3657
- if (parsed.version === 3) return parsed;
3658
- const profile = {
3659
- apiUrl: parsed.apiUrl,
3660
- ...parsed.version === 2 && parsed.auth ? { auth: parsed.auth } : {},
3661
- sessions: parsed.sessions,
3662
- defaults: parsed.defaults,
3663
- poll: parsed.poll
3664
- };
3665
- return {
3666
- version: 3,
3667
- machineId: parsed.machineId,
3668
- defaultProfile: "default",
3669
- profiles: { default: profile }
3670
- };
3671
- }
3672
- function profileFromEnv(env, envVars) {
3673
- for (const name of envVars) {
3674
- const value = env[name];
3675
- if (value) return value;
3676
- }
3677
- return null;
3678
- }
3679
- var ConfigStore = class {
3680
- config = null;
3681
- selectedProfile = null;
3682
- configPath;
3683
- configDir;
3684
- constructor(configPath) {
3685
- this.configPath = configPath ?? DEFAULT_CONFIG_PATH;
3686
- this.configDir = dirname7(this.configPath);
3687
- }
3688
- /**
3689
- * Select the profile this store operates on for the rest of the process
3690
- * (--profile flag / COOP_PROFILE env). Without a selection, the
3691
- * defaultProfile pointer applies.
3692
- */
3693
- selectProfile(name) {
3694
- this.selectedProfile = name;
3695
- }
3696
- activeProfileName(config) {
3697
- return this.selectedProfile ?? config.defaultProfile;
3698
- }
3699
- view(config) {
3700
- const profileName = this.activeProfileName(config);
3701
- const profile = config.profiles[profileName];
3702
- if (!profile) {
3703
- const available = Object.keys(config.profiles).join(", ");
3704
- throw new Error(`Unknown profile "${profileName}". Available profiles: ${available}`);
3705
- }
3706
- return { profileName, machineId: config.machineId, ...profile };
3707
- }
3708
- /** Returns the active-profile view of the previously loaded config. Throws if load() hasn't been called. */
3709
- cached() {
3710
- return this.view(this.cachedRaw());
3711
- }
3712
- /** Returns the full previously loaded config. Throws if load() hasn't been called. */
3713
- cachedRaw() {
3714
- if (this.config === null) {
3715
- throw new Error("Config not loaded yet. Call load() first.");
3716
- }
3717
- return this.config;
3718
- }
3719
- /** Load the config file and return the active profile's view. */
3720
- async load() {
3721
- return this.view(await this.loadRaw());
3722
- }
3723
- /** Load the config file, normalized to v3. Legacy v1/v2 files upgrade in memory (persisted on next save). */
3724
- async loadRaw() {
3725
- if (this.config !== null) {
3726
- return this.config;
3727
- }
3728
- try {
3729
- const raw = await readFile(this.configPath, "utf8");
3730
- const parsed = upgradeToV3(coopConfigSchema.parse(JSON.parse(raw)));
3731
- if (!parsed.machineId) {
3732
- parsed.machineId = hostname();
3733
- }
3734
- this.config = parsed;
3735
- return parsed;
3736
- } catch (err) {
3737
- if (isNodeError(err) && err.code === "ENOENT") {
3738
- this.config = structuredClone(DEFAULT_CONFIG);
3739
- return this.config;
3740
- }
3741
- if (err instanceof SyntaxError || err?.name === "ZodError") {
3742
- throw new Error(`Config file (${this.configPath}) is corrupt. Reset with: rm ${this.configPath}`);
3743
- }
3744
- throw err;
3745
- }
3746
- }
3747
- async save(config) {
3748
- await mkdir(this.configDir, { recursive: true, mode: 448 });
3749
- const content = JSON.stringify(config, null, 2);
3750
- const tmpPath = this.configPath + ".tmp";
3751
- await writeFile(tmpPath, content, { mode: 384 });
3752
- const { rename } = await import("node:fs/promises");
3753
- await rename(tmpPath, this.configPath);
3754
- try {
3755
- await chmod(this.configPath, 384);
3756
- } catch {
3757
- }
3758
- this.config = config;
3759
- }
3760
- /** Load, transform the active profile, and save — the single write path for profile-scoped state. */
3761
- async saveProfile(mutate) {
3762
- const config = await this.loadRaw();
3763
- const name = this.activeProfileName(config);
3764
- const profile = config.profiles[name];
3765
- if (!profile) {
3766
- throw new Error(`Profile "${name}" not found in config (${this.configPath})`);
3767
- }
3768
- await this.save({
3769
- ...config,
3770
- profiles: { ...config.profiles, [name]: mutate(profile) }
3771
- });
3772
- }
3773
- /** Validate config for common misconfigurations. Returns warning strings. */
3774
- validate() {
3775
- if (!this.config) return [];
3776
- let view;
3777
- try {
3778
- view = this.view(this.config);
3779
- } catch {
3780
- return [];
3781
- }
3782
- const warnings = [];
3783
- try {
3784
- const url = new URL(view.apiUrl);
3785
- if (!["http:", "https:"].includes(url.protocol)) {
3786
- warnings.push(`apiUrl protocol "${url.protocol}" is not http/https`);
3787
- }
3788
- } catch {
3789
- warnings.push(`apiUrl "${view.apiUrl}" is not a valid URL`);
3790
- }
3791
- if (this.selectedProfile === null && (view.apiUrl.includes("localhost") || view.apiUrl.includes("127.0.0.1"))) {
3792
- warnings.push(
3793
- `profile "${view.profileName}" points to localhost \u2014 select it explicitly with --profile, or run coop login`
3794
- );
3795
- }
3796
- return warnings;
3797
- }
3798
- async enrollSession(name, enrollment) {
3799
- await this.saveProfile((profile) => ({
3800
- ...profile,
3801
- sessions: { ...profile.sessions, [name]: enrollment }
3802
- }));
3803
- }
3804
- async unenrollSession(name) {
3805
- await this.saveProfile((profile) => {
3806
- const sessions = { ...profile.sessions };
3807
- delete sessions[name];
3808
- const defaults = { ...profile.defaults };
3809
- if (defaults.session === name) {
3810
- defaults.session = null;
3811
- }
3812
- return { ...profile, sessions, defaults };
3813
- });
3814
- }
3815
- async setDefault(key, value) {
3816
- await this.saveProfile((profile) => ({
3817
- ...profile,
3818
- defaults: { ...profile.defaults, [key]: value }
3819
- }));
3820
- }
3821
- async getDefaultSession() {
3822
- const view = await this.load();
3823
- return view.defaults.session;
3824
- }
3825
- async getSessionKey(sessionName) {
3826
- const view = await this.load();
3827
- const enrollment = view.sessions[sessionName];
3828
- if (!enrollment) {
3829
- throw new Error(`Not enrolled in session "${sessionName}". Join it first.`);
3830
- }
3831
- return enrollment.key;
3832
- }
3833
- /** Resolve the API URL for a session, falling back to the active profile's apiUrl. */
3834
- async getApiUrl(sessionName) {
3835
- const view = await this.load();
3836
- if (sessionName) {
3837
- const enrollment = view.sessions[sessionName];
3838
- if (enrollment?.apiUrl) return enrollment.apiUrl;
3839
- }
3840
- return view.apiUrl;
3841
- }
3842
- /** Resolve the machine ID for a session, falling back to the global machineId or the profile's auth. */
3843
- async getMachineId(sessionName) {
3844
- const view = await this.load();
3845
- if (sessionName) {
3846
- const enrollment = view.sessions[sessionName];
3847
- if (enrollment?.machineId) return enrollment.machineId;
3848
- }
3849
- if (view.machineId) return view.machineId;
3850
- if (view.auth?.machineId) return view.auth.machineId;
3851
- return null;
3852
- }
3853
- async setMachineId(machineId) {
3854
- const config = await this.loadRaw();
3855
- await this.save({ ...config, machineId });
3856
- }
3857
- /** Set the active profile's API base URL. */
3858
- async setApiUrl(apiUrl) {
3859
- await this.saveProfile((profile) => ({ ...profile, apiUrl }));
3860
- }
3861
- /** Create the active profile (empty, at the given endpoint) if it does not exist yet. */
3862
- async ensureActiveProfile(apiUrl) {
3863
- const config = await this.loadRaw();
3864
- const name = this.activeProfileName(config);
3865
- if (config.profiles[name]) return;
3866
- await this.save({
3867
- ...config,
3868
- profiles: { ...config.profiles, [name]: emptyProfile(apiUrl) }
3869
- });
3870
- }
3871
- /** Re-point the defaultProfile fallback. The target must exist. */
3872
- async setDefaultProfile(name) {
3873
- const config = await this.loadRaw();
3874
- if (!config.profiles[name]) {
3875
- const available = Object.keys(config.profiles).join(", ");
3876
- throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
3877
- }
3878
- await this.save({ ...config, defaultProfile: name });
3879
- }
3880
- /** True if any profile holds an auth block (used to detect the first login). */
3881
- async hasAnyAuth() {
3882
- const config = await this.loadRaw();
3883
- return Object.values(config.profiles).some((profile) => profile.auth !== void 0);
3884
- }
3885
- /**
3886
- * Remove a profile including its credentials and enrollments. The
3887
- * defaultProfile pointer target cannot be deleted — re-point first.
3888
- */
3889
- async deleteProfile(name) {
3890
- const config = await this.loadRaw();
3891
- if (!config.profiles[name]) {
3892
- const available = Object.keys(config.profiles).join(", ");
3893
- throw new Error(`Unknown profile "${name}". Available profiles: ${available}`);
3894
- }
3895
- if (config.defaultProfile === name) {
3896
- throw new Error(
3897
- `Profile "${name}" is the default profile. Re-point it first: coop profile set-default <other>`
3898
- );
3899
- }
3900
- const profiles = { ...config.profiles };
3901
- delete profiles[name];
3902
- await this.save({ ...config, profiles });
3903
- }
3904
- async updateLastPoll() {
3905
- await this.saveProfile((profile) => ({
3906
- ...profile,
3907
- poll: { lastPollAt: (/* @__PURE__ */ new Date()).toISOString() }
3908
- }));
3909
- }
3910
- /** Returns true if the active profile has an auth block. */
3911
- isAuthenticated() {
3912
- if (this.config === null) return false;
3913
- return this.view(this.config).auth !== void 0;
3914
- }
3915
- /** Returns the user-scoped API key, or throws if not authenticated. */
3916
- getAuthKey() {
3917
- const auth = this.config === null ? void 0 : this.view(this.config).auth;
3918
- if (!auth) {
3919
- throw new Error("Not authenticated. Run `coop login` first.");
3920
- }
3921
- return auth.key;
3922
- }
3923
- /** Returns the user ID, or throws if not authenticated. */
3924
- getUserId() {
3925
- const auth = this.config === null ? void 0 : this.view(this.config).auth;
3926
- if (!auth) {
3927
- throw new Error("Not authenticated. Run `coop login` first.");
3928
- }
3929
- return auth.userId;
3930
- }
3931
- /**
3932
- * Saves the auth block into the active profile. Legacy v1/v2 files are
3933
- * normalized to v3 by load(), so the write always persists v3.
3934
- */
3935
- async setAuth(auth) {
3936
- await this.saveProfile((profile) => ({ ...profile, auth }));
3937
- }
3938
- /**
3939
- * Returns true if the user should be nudged to run `coop login`:
3940
- * the active profile has no auth block.
3941
- */
3942
- needsLoginNudge() {
3943
- if (this.config === null) return true;
3944
- return this.view(this.config).auth === void 0;
3945
- }
3946
- /**
3947
- * Removes the auth block from the active profile (for `coop logout`).
3948
- * Sessions are preserved.
3949
- */
3950
- async clearAuth() {
3951
- await this.saveProfile((profile) => {
3952
- const { auth: _removed, ...rest } = profile;
3953
- return rest;
3954
- });
3955
- }
3956
- };
3957
- function isNodeError(err) {
3958
- return err instanceof Error && "code" in err;
3959
- }
3960
-
3961
- // ../shared/src/cli-auth.ts
3962
- import { exec } from "node:child_process";
3963
- import { randomBytes } from "node:crypto";
3964
- import { createServer } from "node:http";
3965
- import { hostname as hostname2 } from "node:os";
3966
- import { URL as URL2 } from "node:url";
3967
- function resolveDisplayName(name, email, userId) {
3968
- if (name && !name.startsWith("user_")) return name;
3969
- if (email) return email.split("@")[0];
3970
- if (userId) return userId.slice(0, 12);
3971
- if (name) return name.slice(0, 12);
3972
- return "unknown";
3973
- }
3974
- async function openBrowser(url) {
3975
- const escaped = url.replace(/"/g, '\\"');
3976
- const cmd = process.platform === "darwin" ? `open "${escaped}"` : process.platform === "win32" ? `start "" "${escaped}"` : `xdg-open "${escaped}"`;
3977
- return new Promise((resolve7) => {
3978
- exec(cmd, () => resolve7());
3979
- });
3980
- }
3981
- var PAGE_STYLE = "body{background:#0f0f0f;color:#e8e8e8;font-family:system-ui;display:flex;justify-content:center;padding-top:4rem}";
3982
- function htmlPage(title, body) {
3983
- return `<!DOCTYPE html><html><head><title>${title}</title><style>${PAGE_STYLE}</style></head><body><div><h2>${title}</h2><p>${body}</p></div></body></html>`;
3984
- }
3985
- function startLocalhostServer(options) {
3986
- const timeout = options.timeout ?? 12e4;
3987
- const loginCommand = options.loginCommand ?? "coop login";
3988
- return new Promise((resolveServer, rejectServer) => {
3989
- let resolveCallback;
3990
- let rejectCallback;
3991
- const callbackPromise = new Promise((res, rej) => {
3992
- resolveCallback = res;
3993
- rejectCallback = rej;
3994
- });
3995
- const timer = setTimeout(() => {
3996
- rejectCallback(new Error(`Login timed out. No browser callback received. Check your browser and try \`${loginCommand}\` again.`));
3997
- server.close();
3998
- }, timeout);
3999
- const server = createServer((req, res) => {
4000
- const url = new URL2(req.url ?? "/", `http://127.0.0.1`);
4001
- if (url.pathname === "/") {
4002
- res.writeHead(200, { "Content-Type": "text/html" });
4003
- res.end(htmlPage("Waiting for Authentication", `This page was opened by <code>${loginCommand}</code>. Complete sign-in in the browser window.`));
4004
- return;
4005
- }
4006
- if (url.pathname === "/callback") {
4007
- const state = url.searchParams.get("state") ?? "";
4008
- if (state !== options.expectedState) {
4009
- res.writeHead(400, { "Content-Type": "text/html" });
4010
- res.end(htmlPage("State Mismatch", `Possible CSRF attack. Try <code>${loginCommand}</code> again.`));
4011
- return;
4012
- }
4013
- const key = url.searchParams.get("key") ?? "";
4014
- if (!key) {
4015
- res.writeHead(400, { "Content-Type": "text/html" });
4016
- res.end(htmlPage("Missing Key", `No API key received. Try <code>${loginCommand}</code> again.`));
4017
- return;
4018
- }
4019
- res.writeHead(200, { "Content-Type": "text/html" });
4020
- res.end(htmlPage("Authentication Complete", "You can close this tab."));
4021
- clearTimeout(timer);
4022
- resolveCallback({
4023
- key,
4024
- userId: url.searchParams.get("userId") ?? "",
4025
- email: url.searchParams.get("email") ?? "",
4026
- displayName: url.searchParams.get("displayName") ?? "",
4027
- state,
4028
- apiUrl: url.searchParams.get("apiUrl") ?? ""
4029
- });
4030
- return;
4031
- }
4032
- res.writeHead(404, { "Content-Type": "text/plain" });
4033
- res.end("Not Found");
4034
- });
4035
- server.listen(0, "127.0.0.1", () => {
4036
- const addr = server.address();
4037
- const port = typeof addr === "object" && addr ? addr.port : 0;
4038
- resolveServer({
4039
- port,
4040
- close: () => {
4041
- clearTimeout(timer);
4042
- server.close();
4043
- },
4044
- waitForCallback: () => callbackPromise
4045
- });
4046
- });
4047
- server.on("error", (err) => {
4048
- clearTimeout(timer);
4049
- rejectServer(err);
4050
- });
4051
- });
4052
- }
4053
- function deriveApiUrl(webUrl) {
4054
- const url = new URL2(webUrl);
4055
- if (url.hostname === "localhost" || url.hostname === "127.0.0.1") {
4056
- return "http://localhost:8788";
4057
- }
4058
- return `https://api.${url.hostname}`;
4059
- }
4060
- async function login(input, deps) {
4061
- const state = randomBytes(16).toString("hex");
4062
- const machineId = input.machine ?? hostname2();
4063
- const log = deps.log ?? ((message) => void process.stderr.write(message));
4064
- const server = await startLocalhostServer({ expectedState: state, loginCommand: input.loginCommand });
4065
- try {
4066
- const webUrl = input.webUrl ?? "https://coopcli.com";
4067
- const authUrl = `${webUrl}/cli-auth?port=${server.port}&state=${state}&machine=${encodeURIComponent(machineId)}`;
4068
- log(`Opening browser for authentication...
4069
- `);
4070
- await deps.openBrowser(authUrl);
4071
- log(`Waiting for callback on port ${server.port}...
4072
- `);
4073
- const params = await server.waitForCallback();
4074
- const displayName = resolveDisplayName(params.displayName, params.email, params.userId);
4075
- const resolvedApiUrl = input.apiUrl ?? (params.apiUrl || deriveApiUrl(webUrl));
4076
- const isFirstLogin = !await deps.config.hasAnyAuth();
4077
- await deps.config.ensureActiveProfile(resolvedApiUrl);
4078
- await deps.config.setAuth({
4079
- key: params.key,
4080
- userId: params.userId,
4081
- displayName,
4082
- email: params.email || null,
4083
- machineId,
4084
- authenticatedAt: (/* @__PURE__ */ new Date()).toISOString()
4085
- });
4086
- const config = await deps.config.load();
4087
- if (params.apiUrl && config.apiUrl && params.apiUrl !== config.apiUrl) {
4088
- log(
4089
- `
4090
- Backend mismatch: you authenticated against ${params.apiUrl}
4091
- but your config points to ${config.apiUrl}
4092
- Updating config to ${resolvedApiUrl}
4093
-
4094
- `
4095
- );
4096
- }
4097
- if (resolvedApiUrl !== config.apiUrl) {
4098
- await deps.config.setApiUrl(resolvedApiUrl);
4099
- }
4100
- if (isFirstLogin) {
4101
- await deps.config.setDefaultProfile(config.profileName);
4102
- }
4103
- return {
4104
- userId: params.userId,
4105
- email: params.email || null,
4106
- displayName
4107
- };
4108
- } finally {
4109
- server.close();
4110
- }
4111
- }
4112
-
4113
- // server/login.ts
4114
- var LOGIN_USAGE = `Usage: specplan login [--profile <name>] [--web-url <url>]
4115
-
4116
- Opens your browser to sign in to CoopCLI and saves credentials to
4117
- ~/.coopcli/config.json \u2014 the same file and format \`coop login\` uses.
4118
-
4119
- --profile config profile to log into (default: SPECPLAN_COOP_PROFILE,
4120
- then COOP_PROFILE, then the config's default profile). A profile
4121
- that doesn't exist yet is created.
4122
- --web-url web app URL for browser auth (default: https://coopcli.com)`;
4123
- var LoginUsageError = class extends Error {
4124
- };
4125
- function parseLoginArgs(argv) {
4126
- let parsed;
4127
- try {
4128
- parsed = parseArgs({
4129
- args: argv,
4130
- options: { profile: { type: "string" }, "web-url": { type: "string" } },
4131
- allowPositionals: true
4132
- });
4133
- } catch (err) {
4134
- throw new LoginUsageError(err instanceof Error ? err.message : String(err));
4135
- }
4136
- if (parsed.positionals.length > 0) {
4137
- throw new LoginUsageError(`unexpected argument: ${parsed.positionals[0]}`);
4138
- }
4139
- return {
4140
- profile: parsed.values.profile ?? null,
4141
- ...parsed.values["web-url"] !== void 0 ? { webUrl: parsed.values["web-url"] } : {}
4142
- };
4143
- }
4144
- async function runLogin(args, deps = {}) {
4145
- const env = deps.env ?? process.env;
4146
- const config = deps.config ?? new ConfigStore();
4147
- const profile = args.profile ?? profileFromEnv(env, ["SPECPLAN_COOP_PROFILE", "COOP_PROFILE"]);
4148
- if (profile) config.selectProfile(profile);
4149
- const result = await login(
4150
- { webUrl: args.webUrl, loginCommand: "specplan login" },
4151
- { config, openBrowser: deps.openBrowser ?? openBrowser }
4152
- );
4153
- (deps.log ?? console.log)(`Authenticated as ${result.email ?? result.userId}. Credentials saved.`);
4154
- }
4155
-
4156
3434
  // server/validate.ts
4157
3435
  function collectTreeErrors(rootDir) {
4158
3436
  const store = new PlanStore(rootDir);
@@ -4203,7 +3481,7 @@ function validateCommand(rootDir, log = console.log, logError = console.error) {
4203
3481
  }
4204
3482
 
4205
3483
  // server/workspace.ts
4206
- import { join as join13, resolve as resolve5 } from "node:path";
3484
+ import { join as join12, resolve as resolve5 } from "node:path";
4207
3485
  var SIDECAR_FILE = "specplan-ui.json";
4208
3486
  var PlanWorkspace = class {
4209
3487
  /** Resolved openspec root directory. */
@@ -4217,7 +3495,7 @@ var PlanWorkspace = class {
4217
3495
  }
4218
3496
  // ── Sidecar ──────────────────────────────────────────────────────────────
4219
3497
  readSidecar() {
4220
- const raw = this.fsx.readIfExists(join13(this.dir, SIDECAR_FILE));
3498
+ const raw = this.fsx.readIfExists(join12(this.dir, SIDECAR_FILE));
4221
3499
  if (!raw) return {};
4222
3500
  try {
4223
3501
  return JSON.parse(raw);
@@ -4232,7 +3510,7 @@ var PlanWorkspace = class {
4232
3510
  delete next.positions;
4233
3511
  next.createdAt ??= now;
4234
3512
  next.updatedAt = now;
4235
- this.fsx.writeAtomicFile(join13(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
3513
+ this.fsx.writeAtomicFile(join12(this.dir, SIDECAR_FILE), JSON.stringify(next, null, 2));
4236
3514
  }
4237
3515
  readModel() {
4238
3516
  return this.readSidecar().model;
@@ -4280,13 +3558,12 @@ var PlanWorkspace = class {
4280
3558
  };
4281
3559
 
4282
3560
  // server/cli.ts
4283
- var CLI_VERSION = true ? "2026.09.02.1" : "dev";
3561
+ var CLI_VERSION = true ? "2026.09.04.1" : "dev";
4284
3562
  var USAGE = `Usage: specplan [rootDir] [--port <n>]
4285
3563
  specplan --version
4286
3564
  specplan generate <specId> [rootDir]
4287
3565
  specplan apply <proposalId> [rootDir] [--confirm-hand-edit]
4288
3566
  specplan validate [rootDir]
4289
- specplan login [--profile <name>] [--web-url <url>]
4290
3567
 
4291
3568
  specplan # plan ./openspec in the browser
4292
3569
  specplan ../other-repo/openspec --port 9000
@@ -4307,9 +3584,7 @@ Pass --confirm-hand-edit to fold a body edited since it was last generated.
4307
3584
 
4308
3585
  validate checks the plan files (schema, acyclicity, containment, story
4309
3586
  formula, id references) and exits non-zero with named errors.
4310
-
4311
- login authenticates this machine against your CoopCLI account (opens a
4312
- browser, saves credentials to ~/.coopcli/config.json).`;
3587
+ `;
4313
3588
  var UsageError = class extends Error {
4314
3589
  };
4315
3590
  var DEFAULT_ROOT_DIR = "./openspec";
@@ -4376,7 +3651,7 @@ ${USAGE}`
4376
3651
  function parseCliArgs(argv) {
4377
3652
  let parsed;
4378
3653
  try {
4379
- parsed = parseArgs2({
3654
+ parsed = parseArgs({
4380
3655
  args: argv,
4381
3656
  options: { port: { type: "string" } },
4382
3657
  allowPositionals: true
@@ -4417,15 +3692,15 @@ var MIME = {
4417
3692
  ".woff": "font/woff"
4418
3693
  };
4419
3694
  function resolveClientDir(moduleUrl) {
4420
- const here = dirname8(fileURLToPath(moduleUrl));
3695
+ const here = dirname7(fileURLToPath(moduleUrl));
4421
3696
  const candidates = [
4422
- join14(here, "..", "client"),
3697
+ join13(here, "..", "client"),
4423
3698
  // packed: dist/cli -> dist/client
4424
- join14(here, "..", "dist", "client")
3699
+ join13(here, "..", "dist", "client")
4425
3700
  // repo: server/ -> dist/client
4426
3701
  ];
4427
3702
  for (const dir of candidates) {
4428
- if (existsSync7(join14(dir, "index.html"))) return dir;
3703
+ if (existsSync7(join13(dir, "index.html"))) return dir;
4429
3704
  }
4430
3705
  return null;
4431
3706
  }
@@ -4433,7 +3708,7 @@ function staticResponse(clientDir, pathname) {
4433
3708
  const rel = normalize2(decodeURIComponent(pathname)).replace(/^\/+/, "");
4434
3709
  const target = resolve6(clientDir, rel === "" ? "index.html" : rel);
4435
3710
  if (!target.startsWith(resolve6(clientDir))) return null;
4436
- const file = existsSync7(target) && extname(target) ? target : join14(clientDir, "index.html");
3711
+ const file = existsSync7(target) && extname(target) ? target : join13(clientDir, "index.html");
4437
3712
  if (!existsSync7(file)) return null;
4438
3713
  return new Response(readFileSync5(file), {
4439
3714
  headers: { "Content-Type": MIME[extname(file)] ?? "application/octet-stream" }
@@ -4447,11 +3722,11 @@ but chat and "Generate OpenSpec" need one of:
4447
3722
  \u2022 or the Anthropic CLI's login profile (no key handling needed):
4448
3723
  brew install anthropics/tap/ant # macOS
4449
3724
  ant auth login`;
4450
- function detectCredentialSource(env = process.env, home = homedir3()) {
3725
+ function detectCredentialSource(env = process.env, home = homedir2()) {
4451
3726
  if (env.ANTHROPIC_API_KEY) return "ANTHROPIC_API_KEY";
4452
3727
  if (env.ANTHROPIC_AUTH_TOKEN) return "ANTHROPIC_AUTH_TOKEN";
4453
3728
  try {
4454
- const dir = join14(home, ".config", "anthropic", "credentials");
3729
+ const dir = join13(home, ".config", "anthropic", "credentials");
4455
3730
  if (readdirSync5(dir).some((f) => f.endsWith(".json"))) return "anthropic profile";
4456
3731
  } catch {
4457
3732
  }
@@ -4471,26 +3746,6 @@ async function main(argv) {
4471
3746
  return;
4472
3747
  }
4473
3748
  preflight();
4474
- if (argv[0] === "login") {
4475
- let loginArgs;
4476
- try {
4477
- loginArgs = parseLoginArgs(argv.slice(1));
4478
- } catch (err) {
4479
- console.error(
4480
- err instanceof LoginUsageError ? `specplan: ${err.message}
4481
-
4482
- ${LOGIN_USAGE}` : err
4483
- );
4484
- process.exit(1);
4485
- }
4486
- try {
4487
- await runLogin(loginArgs);
4488
- } catch (err) {
4489
- console.error(`specplan: ${err instanceof Error ? err.message : String(err)}`);
4490
- process.exit(1);
4491
- }
4492
- return;
4493
- }
4494
3749
  if (argv[0] === "validate") {
4495
3750
  const [rootDir = DEFAULT_ROOT_DIR, ...rest] = argv.slice(1);
4496
3751
  if (rest.length > 0 || rootDir.startsWith("-")) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coopcli/specplan",
3
- "version": "2026.902.1",
3
+ "version": "2026.904.1",
4
4
  "description": "coopcli specplan \u2014 plan your project as a DAG of specs and user stories on a React Flow canvas, generate OpenSpec changes per spec card. Local-only.",
5
5
  "type": "module",
6
6
  "bin": {