@coopcli/specplan 2026.902.2 → 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 +3 -4
- package/dist/cli/index.js +16 -761
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
# @coopcli/specplan
|
|
2
2
|
|
|
3
|
-
|
|
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
|
|
6
|
-
import { dirname as
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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.
|
|
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 =
|
|
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 =
|
|
3695
|
+
const here = dirname7(fileURLToPath(moduleUrl));
|
|
4421
3696
|
const candidates = [
|
|
4422
|
-
|
|
3697
|
+
join13(here, "..", "client"),
|
|
4423
3698
|
// packed: dist/cli -> dist/client
|
|
4424
|
-
|
|
3699
|
+
join13(here, "..", "dist", "client")
|
|
4425
3700
|
// repo: server/ -> dist/client
|
|
4426
3701
|
];
|
|
4427
3702
|
for (const dir of candidates) {
|
|
4428
|
-
if (existsSync7(
|
|
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 :
|
|
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 =
|
|
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 =
|
|
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.
|
|
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": {
|