From 03a6d0658233c6733ad9a4809b76c2488de35ea6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Fri, 11 Sep 2026 08:44:39 +0100 Subject: [PATCH] feat: allow @ in the body of identity and profile names An email address now names its own identity directly: claude-use @joseph.mearman@exadev.io works end to end, because the @ selector already splits its token at the first @ only. The first character stays strictly alphanumeric, so a leading @ is still rejected and that first-@ split stays unambiguous. PROFILE_NAME_RE widens in lockstep rather than by coincidence: the identity wizard offers the identity's own name as the default configuration-profile name, so an email-shaped identity would otherwise suggest a profile name its own validator rejects. The InvalidIdentityNameError message and the generated schema/Identity.schema.json pattern update with it. --- schema/Identity.schema.json | 2 +- src/config/schema.ts | 3 ++- src/configure.test.ts | 8 ++++++++ src/configure.ts | 6 +++--- src/identityManager.test.ts | 29 ++++++++++++++++++++++++----- src/identityManager.ts | 6 +++--- 6 files changed, 41 insertions(+), 13 deletions(-) diff --git a/schema/Identity.schema.json b/schema/Identity.schema.json index d45f5b4..2cf57f3 100644 --- a/schema/Identity.schema.json +++ b/schema/Identity.schema.json @@ -9,7 +9,7 @@ "name": { "type": "string", "minLength": 1, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + "pattern": "^[A-Za-z0-9][A-Za-z0-9._@-]*$" }, "defaultConfigProfile": { "type": "string", diff --git a/src/config/schema.ts b/src/config/schema.ts index b2d51d3..6737e96 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -147,7 +147,8 @@ export const IdentitySchema = z.strictObject({ name: z .string() .min(1) - .regex(/^[A-Za-z0-9][A-Za-z0-9._-]*$/), + // `@` is allowed in the body so an email address names its own identity directly (joseph.mearman@exadev.io), but the first character stays strictly alphanumeric: a leading `@` would collide with the `@name` selector syntax's first-`@` split and make an unconventional directory name under identities/. + .regex(/^[A-Za-z0-9][A-Za-z0-9._@-]*$/), defaultConfigProfile: z.string().min(1).optional(), allowAmbientCredential: z.boolean().default(false), }); diff --git a/src/configure.test.ts b/src/configure.test.ts index 98a5d6a..1be6f07 100644 --- a/src/configure.test.ts +++ b/src/configure.test.ts @@ -397,6 +397,14 @@ describe("validateProfileName", () => { expect(validateProfileName("client-acme")).toBeUndefined(); }); + it("accepts an at sign in the body, matching the identity-name shape the wizard defaults to for a new identity", () => { + expect(validateProfileName("joseph.mearman@exadev.io")).toBeUndefined(); + }); + + it("rejects a name starting with an at sign", () => { + expect(validateProfileName("@exadev")).toContain("must start with a letter or digit"); + }); + it("rejects a duplicate when existingNames is given", () => { expect(validateProfileName("base", ["base", "other"])).toContain("already exists"); }); diff --git a/src/configure.ts b/src/configure.ts index 68449f6..1e2e6cc 100644 --- a/src/configure.ts +++ b/src/configure.ts @@ -460,8 +460,8 @@ const CATEGORY_LABELS: Record<"secret" | OverridableCategory, string> = { /* runProfileWizard: the unified create-or-edit flow. */ /* -------------------------------------------------------------------------------------------------- */ -/** A profile name must match the same shape as an identity name (`IdentitySchema`): start alphanumeric, then alphanumerics/dots/dashes/underscores. */ -const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/; +/** A profile name must match the same shape as an identity name (`IdentitySchema`): start alphanumeric, then alphanumerics/dots/dashes/underscores/at signs. The `@` allowance moves in lockstep with the identity schema because `runIdentityWizard` offers the identity's own name as the default profile name — an email-shaped identity would otherwise suggest a profile name its own validator rejects. */ +const PROFILE_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._@-]*$/; /** Validates a candidate profile name for the wizard's text prompt: non-empty, matches the allowed shape, and (only when `existingNames` is given) not already taken. */ export function validateProfileName(value: string, existingNames?: readonly string[]): string | undefined { @@ -469,7 +469,7 @@ export function validateProfileName(value: string, existingNames?: readonly stri return "A name is required."; } if (!PROFILE_NAME_RE.test(value)) { - return "Names must start with a letter or digit, and contain only letters, digits, dots, dashes, and underscores."; + return "Names must start with a letter or digit, and contain only letters, digits, dots, dashes, underscores, and at signs."; } if (existingNames?.includes(value)) { return `A configuration profile named "${value}" already exists.`; diff --git a/src/identityManager.test.ts b/src/identityManager.test.ts index 0b06fa1..fc7f1bd 100644 --- a/src/identityManager.test.ts +++ b/src/identityManager.test.ts @@ -90,8 +90,14 @@ describe("identityManager", () => { expect(() => addIdentity(paths, "-bad-start")).toThrow(InvalidIdentityNameError); }); - it("throws InvalidIdentityNameError for an email-shaped name (the `@` shortcut's own name portion can be anything, including an email address)", () => { - expect(() => addIdentity(paths, "joseph.mearman@exadev.io")).toThrow(InvalidIdentityNameError); + it("creates an identity for an email-shaped name — `@` is valid in the body, so the `@name` shortcut's first-`@` split lands here intact", () => { + const identity = addIdentity(paths, "joseph.mearman@exadev.io"); + expect(identity.name).toBe("joseph.mearman@exadev.io"); + expect(readIdentity(paths, "joseph.mearman@exadev.io")?.name).toBe("joseph.mearman@exadev.io"); + }); + + it("throws InvalidIdentityNameError for a leading `@` — the first character stays strictly alphanumeric so the `@name` selector syntax's first-`@` split stays unambiguous", () => { + expect(() => addIdentity(paths, "@exadev")).toThrow(InvalidIdentityNameError); }); }); @@ -133,6 +139,12 @@ describe("identityManager", () => { expect(readActiveIdentity(paths)).toBe("exadev"); }); + it("splits an @ token at the first `@` only, so an email-shaped identity name survives intact", async () => { + addIdentity(paths, "joseph.mearman@exadev.io"); + expect(await tryRunAtIdentityShortcut(paths, ["@joseph.mearman@exadev.io"])).toBe(true); + expect(readActiveIdentity(paths)).toBe("joseph.mearman@exadev.io"); + }); + it("propagates IdentityNotFoundError for an unknown @ in a non-interactive context", async () => { await expect(tryRunAtIdentityShortcut(paths, ["@ghost"])).rejects.toThrow(IdentityNotFoundError); }); @@ -194,9 +206,16 @@ describe("identityManager", () => { it("throws InvalidIdentityNameError before any prompt appears, not a raw ZodError after the user confirms, when the name fails IdentitySchema's own naming rule", async () => { const prompts = scriptedIdentityPrompts([]); - await expect(runIdentityWizard(prompts, paths, "joseph.mearman@exadev.io")).rejects.toThrow( - InvalidIdentityNameError, - ); + await expect(runIdentityWizard(prompts, paths, "@exadev")).rejects.toThrow(InvalidIdentityNameError); + }); + + it("offers the wizard for an email-shaped name, whose `@` is valid in the body", async () => { + const prompts = scriptedIdentityPrompts(["create", "skip"]); + const result = await runIdentityWizard(prompts, paths, "joseph.mearman@exadev.io"); + + expect(result).toBe(true); + expect(readIdentity(paths, "joseph.mearman@exadev.io")).toBeDefined(); + expect(readActiveIdentity(paths)).toBe("joseph.mearman@exadev.io"); }); }); diff --git a/src/identityManager.ts b/src/identityManager.ts index 28d0322..b1115a1 100644 --- a/src/identityManager.ts +++ b/src/identityManager.ts @@ -27,11 +27,11 @@ export class IdentityAlreadyExistsError extends CliError { } } -/** Raised by `addIdentity` when `name` fails `IdentitySchema`'s own naming rule — it must start with a letter or number and contain only letters, numbers, dots, hyphens, and underscores (this excludes `@`, so an email address is not a valid identity name on its own). */ +/** Raised by `addIdentity` when `name` fails `IdentitySchema`'s own naming rule — it must start with a letter or number and may then contain letters, numbers, dots, hyphens, underscores, and at signs, so an email address names an identity directly while a *leading* `@` stays invalid (it would collide with the `@name` selector syntax's first-`@` split). */ export class InvalidIdentityNameError extends CliError { constructor(readonly attemptedName: string) { super( - `"${attemptedName}" is not a valid identity name — identity names must start with a letter or number and may only contain letters, numbers, dots, hyphens, and underscores.`, + `"${attemptedName}" is not a valid identity name — identity names must start with a letter or number and may then contain letters, numbers, dots, hyphens, underscores, and at signs.`, ); this.name = "InvalidIdentityNameError"; } @@ -82,7 +82,7 @@ export function useIdentity(paths: LayoutPaths, name: string): void { /** * The interactive setup wizard for a new identity, offered by the `@` shortcut and `identity use` when the identity doesn't exist yet and stdin is a real terminal. * - * Validates `name` against `IdentitySchema`'s own naming rule before any prompt appears — offering "Create it now?" for a name that could never validate (an email address, say, whose `@` the shortcut passes through verbatim) just to fail on confirm is a broken interaction, so an invalid name throws `InvalidIdentityNameError` immediately instead. + * Validates `name` against `IdentitySchema`'s own naming rule before any prompt appears — offering "Create it now?" for a name that could never validate (one with a leading `@`, say, or any other character the schema rejects) just to fail on confirm is a broken interaction, so an invalid name throws `InvalidIdentityNameError` immediately instead. * * Confirms the user wants to create the identity, then optionally creates a default configuration profile (reusing `runProfileWizard`), links them, and sets the identity as active. A cancel at any step writes nothing beyond what was already committed — the identity is only created after the first confirm, and the profile wizard's own cancel handling means a profile-only cancellation still leaves the identity usable. Returns `true` when the identity was created and set active; `false` when the user declined at the initial confirm. *