setup-agent 0.0.2 → 0.0.4

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.
@@ -1 +1 @@
1
- {"version":3,"file":"protocol.js","names":["Checkout","StepStatus","Step","id","title","detail","status","note","product","createdAt","Product","slug","name","guide","InputStatus","InputKind","ChoiceVariant","label","ChoiceOption","description","icon","variants","InputHelp","summary","href","ModelAnswer","provider","model","reasoningEffort","Input","phase","Status","kind","preset","prompt","placeholder","options","multiple","default","help","optional","answer","stepId","answeredAt","LogEntry","role","acknowledgedAt","at","text","PlanStatus","Plan","revision","markdown","submittedAt","decidedAt","feedback","Completion","proposedAt","preview","State","version","completion","products","instructions","agent","lastSeenAt","connected","cwd","introducedAt","plans","steps","inputs","log","ProductSeed","InputSeed","PlanDecision","decision","Commands","params","inputId","messageId","active","RejectionReason","AGENT_PRESENCE_MS","AGENT_HEARTBEAT_MS","initialCheckoutState","isAgentPresent","state","now","Date","isClosed","finishProposed","undefined","followedUpSinceProposal","some","entry","LOOPBACK_HOSTS","Set","parsePreviewUrl","value","url","URL","protocol","username","password","loopback","has","hostname","endsWith","openInputs","filter","input","currentPlan","planNeedsReview","OPTION_ICONS","const","OptionIcon","isOptionIcon","includes","parseChoiceAnswer","option","variant","split","parseMultipleAnswer","JSON","parse","Array","isArray","every","ChoiceAnswerKind","classifyChoiceEntry","match","find","candidate","trim","length","classifyChoiceAnswer","entries","kinds","map","REASONING_EFFORTS","isReasoningEffort","NonNullable","parseModelAnswer","Record","isValidModelAnswer","parsed","stepProgress","total","done","step"],"sources":["../src/protocol.ts"],"sourcesContent":["export namespace Checkout {\n export type StepStatus =\n | \"pending\"\n | \"active\"\n | \"done\"\n | \"skipped\"\n | \"blocked\";\n\n /** A unit of install work the agent declared; the catalog seeds none. */\n export type Step = {\n id: string;\n title: string;\n detail?: string;\n status: StepStatus;\n note?: string;\n /** The product this step belongs to, when the agent said. */\n product?: string;\n createdAt: number;\n };\n\n export type Product = {\n slug: string;\n name: string;\n /** The install reference the agent fetches while planning. */\n guide?: string;\n };\n\n export type InputStatus = \"open\" | \"answered\" | \"dismissed\";\n\n /**\n * product: the agent proposes adding a product to this checkout, such as one\n * another product depends on. The browser owns the catalog, so it answers\n * with `checkout/add-product`; dismissing the input declines.\n */\n export type InputKind = \"text\" | \"choice\" | \"model\" | \"product\";\n\n export type ChoiceVariant = { id: string; label: string };\n\n export type ChoiceOption = {\n id: string;\n label: string;\n description?: string;\n /** A name from OPTION_ICONS, or the key of a brand mark on a preset option; unknown keys render without one. */\n icon?: string;\n /** A second pick under this option, such as the language of a framework. */\n variants?: ChoiceVariant[];\n };\n\n /** A short nudge for users who need help choosing, with a longer guide to link to. */\n export type InputHelp = { summary: string; href?: string };\n\n /**\n * The answer to a model input, stored as JSON text. The API key itself is\n * never part of it: the browser deposits it with the checkout and the\n * agent's `env` command writes it straight to a file.\n */\n export type ModelAnswer = {\n provider: string;\n model: string;\n reasoningEffort?: \"low\" | \"medium\" | \"high\";\n };\n\n /**\n * A choice answer is an option id, `<option>:<variant>` when the option has\n * variants, or the user's own text when none of the options fit; a multiple\n * choice answers a JSON array of those. A model answer is a JSON-encoded\n * ModelAnswer whose provider is one of the options.\n */\n export type Input = {\n phase: Status;\n id: string;\n kind: InputKind;\n /** The standard input this was built from, when it was. */\n preset?: string;\n prompt: string;\n placeholder?: string;\n options?: ChoiceOption[];\n /** The user may pick any number of options; the answer is a JSON array. */\n multiple?: true;\n /** The slug a product input proposes. */\n product?: string;\n default?: string;\n help?: InputHelp;\n optional: boolean;\n status: InputStatus;\n answer?: string;\n /** A remark the user attached to the answer. */\n note?: string;\n stepId?: string;\n createdAt: number;\n answeredAt?: number;\n };\n\n export type LogEntry = {\n phase: Status;\n id: string;\n role: \"agent\" | \"user\";\n acknowledgedAt?: number;\n at: number;\n text: string;\n /** The step that was active when the line was posted; none while planning. */\n stepId?: string;\n };\n\n export type PlanStatus = \"proposed\" | \"approved\" | \"changes-requested\";\n\n /** One revision of the agent's plan. Markdown the browser renders. */\n export type Plan = {\n revision: number;\n markdown: string;\n status: PlanStatus;\n submittedAt: number;\n decidedAt?: number;\n /** What the user asked to change, when they did. */\n feedback?: string;\n };\n\n /**\n * waiting: no agent has joined. planning: the agent investigates and\n * proposes a plan. installing: the plan is approved and steps run.\n */\n export type Status =\n | \"waiting\"\n | \"planning\"\n | \"installing\"\n | \"done\"\n | \"cancelled\";\n\n /**\n * The agent's proposal to close the checkout. Only the user closes it, and\n * they may keep messaging the agent instead; a later `done` renews it.\n * `preview` is the loopback URL of a dev server the agent left running for\n * the user to try before they close.\n */\n export type Completion = { proposedAt: number; preview?: string };\n\n export type State = {\n version: 2;\n /** The id the browser gave the current checkout; a create with another id replaces it on the same link. */\n id: string | null;\n status: Status;\n completion?: Completion;\n createdAt: number | null;\n products: Product[];\n instructions: string;\n agent: {\n lastSeenAt: number | null;\n /** False once the agent's stream said goodbye; heartbeats set it again. */\n connected: boolean;\n cwd: string | null;\n /** The coding agent that ran the command, as the CLI detected it from its environment. */\n kind: string | null;\n /** When the command was first run against this checkout, before the agent joined the stream. */\n introducedAt: number | null;\n };\n /** Every revision in order; the last one is current. */\n plans: Plan[];\n steps: Step[];\n inputs: Input[];\n log: LogEntry[];\n };\n\n export type ProductSeed = { slug: string; name: string; guide?: string };\n\n export type InputSeed = {\n kind?: InputKind;\n preset?: string;\n prompt: string;\n placeholder?: string;\n options?: ChoiceOption[];\n multiple?: true;\n product?: string;\n default?: string;\n help?: InputHelp;\n optional?: boolean;\n };\n\n export type PlanDecision =\n | { decision: \"approve\" }\n | { decision: \"revise\"; feedback: string };\n\n export type Commands = {\n \"checkout/create\": (params: {\n id: string;\n products: ProductSeed[];\n instructions?: string;\n }) => void;\n \"checkout/begin-plan\": () => void;\n \"checkout/answer\": (params: {\n inputId: string;\n answer: string;\n note?: string;\n }) => void;\n \"checkout/add-product\": (params: {\n inputId: string;\n product: ProductSeed;\n }) => void;\n \"checkout/message\": (params: { text: string }) => void;\n \"agent/ack\": (params: { messageId: string }) => void;\n \"checkout/dismiss\": (params: { inputId: string }) => void;\n \"checkout/plan\": (params: PlanDecision) => void;\n \"checkout/cancel\": () => void;\n \"checkout/finish\": () => void;\n \"agent/intro\": (params: { kind?: string }) => void;\n \"agent/hello\": (params: { cwd?: string; kind?: string }) => void;\n \"agent/heartbeat\": () => void;\n \"agent/bye\": () => void;\n \"agent/plan\": (params: { markdown: string }) => { revision: number };\n \"agent/add-step\": (params: {\n title: string;\n detail?: string;\n product?: string;\n /** Start the step in the same call. */\n active?: boolean;\n }) => { stepId: string };\n \"agent/step\": (params: {\n stepId: string;\n status: StepStatus;\n note?: string;\n }) => void;\n \"agent/ask\": (params: InputSeed & { stepId?: string }) => {\n inputId: string;\n };\n \"agent/log\": (params: { text: string }) => void;\n \"agent/done\": (params?: { summary?: string; preview?: string }) => void;\n };\n\n export type RejectionReason =\n | \"already-created\"\n | \"not-created\"\n | \"closed\"\n | \"planning-not-started\"\n | \"plan-required\"\n | \"no-plan\"\n | \"plan-decided\"\n | \"finish-not-proposed\"\n | \"feedback-required\"\n | \"empty-plan\"\n | \"unknown-step\"\n | \"unknown-product\"\n | \"unknown-input\"\n | \"input-closed\"\n | \"invalid-input\"\n | \"invalid-answer\"\n | \"invalid-preview\"\n | \"empty-message\"\n | \"unknown-message\";\n}\n\nexport const AGENT_PRESENCE_MS = 15_000;\nexport const AGENT_HEARTBEAT_MS = 5_000;\n\nexport const initialCheckoutState = (): Checkout.State => ({\n version: 2,\n id: null,\n status: \"waiting\",\n createdAt: null,\n products: [],\n instructions: \"\",\n agent: {\n lastSeenAt: null,\n connected: false,\n cwd: null,\n kind: null,\n introducedAt: null,\n },\n plans: [],\n steps: [],\n inputs: [],\n log: [],\n});\n\nexport const isAgentPresent = (state: Checkout.State, now = Date.now()) =>\n state.agent.connected &&\n state.agent.lastSeenAt !== null &&\n now - state.agent.lastSeenAt < AGENT_PRESENCE_MS;\n\nexport const isClosed = (state: Checkout.State) =>\n state.status === \"done\" || state.status === \"cancelled\";\n\n/** True while the agent's proposal to close waits for the user. */\nexport const finishProposed = (state: Checkout.State) =>\n !isClosed(state) && state.completion !== undefined;\n\n/** True when the user messaged the agent after it last proposed to close. */\nexport const followedUpSinceProposal = (state: Checkout.State) => {\n const proposedAt = state.completion?.proposedAt;\n return (\n proposedAt !== undefined &&\n state.log.some((entry) => entry.role === \"user\" && entry.at > proposedAt)\n );\n};\n\nconst LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\"]);\n\n/**\n * The URL a preview names, or `undefined` unless it is an http(s) URL on this\n * machine's loopback interface. The browser opens it, so nothing else passes.\n */\nexport const parsePreviewUrl = (value: string | undefined) => {\n if (!value) return undefined;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return undefined;\n if (url.username !== \"\" || url.password !== \"\") return undefined;\n const loopback =\n LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith(\".localhost\");\n return loopback ? url : undefined;\n};\n\nexport const openInputs = (state: Checkout.State) =>\n state.inputs.filter((input) => input.status === \"open\");\n\n/** The current plan revision, or `undefined` before the agent proposed one. */\nexport const currentPlan = (state: Checkout.State): Checkout.Plan | undefined =>\n state.plans.at(-1);\n\n/** True while a proposed plan waits for the user's decision. */\nexport const planNeedsReview = (state: Checkout.State) =>\n !isClosed(state) && currentPlan(state)?.status === \"proposed\";\n\n/** The icons an agent can put on its own options, by name. */\nexport const OPTION_ICONS = [\n \"code\",\n \"terminal\",\n \"braces\",\n \"git-branch\",\n \"package\",\n \"database\",\n \"server\",\n \"cloud\",\n \"globe\",\n \"monitor\",\n \"smartphone\",\n \"key\",\n \"lock\",\n \"shield\",\n \"brain\",\n \"sparkles\",\n \"bot\",\n \"workflow\",\n \"file\",\n \"folder\",\n \"book\",\n \"table\",\n \"image\",\n \"video\",\n \"mic\",\n \"speech\",\n \"paperclip\",\n \"message\",\n \"mail\",\n \"bell\",\n \"calendar\",\n \"clock\",\n \"user\",\n \"users\",\n \"settings\",\n \"wrench\",\n \"plug\",\n \"link\",\n \"palette\",\n \"search\",\n \"zap\",\n \"check\",\n \"question\",\n] as const;\n\nexport type OptionIcon = (typeof OPTION_ICONS)[number];\n\nexport const isOptionIcon = (value: string): value is OptionIcon =>\n (OPTION_ICONS as readonly string[]).includes(value);\n\nexport const parseChoiceAnswer = (answer: string) => {\n const [option = \"\", variant] = answer.split(\":\", 2);\n return { option, ...(variant !== undefined && { variant }) };\n};\n\n/** The entries of a multiple choice answer, or `undefined` when the text is not a JSON array of strings. */\nexport const parseMultipleAnswer = (answer: string): string[] | undefined => {\n let value: unknown;\n try {\n value = JSON.parse(answer);\n } catch {\n return undefined;\n }\n return Array.isArray(value) &&\n value.every((entry) => typeof entry === \"string\")\n ? value\n : undefined;\n};\n\ntype ChoiceAnswerKind = \"option\" | \"custom\" | \"invalid\";\n\nconst classifyChoiceEntry = (\n input: Checkout.Input,\n answer: string,\n): ChoiceAnswerKind => {\n const { option, variant } = parseChoiceAnswer(answer);\n const match = input.options?.find((candidate) => candidate.id === option);\n if (!match) return answer.trim() === \"\" ? \"invalid\" : \"custom\";\n if (!match.variants || match.variants.length === 0) {\n return variant === undefined ? \"option\" : \"invalid\";\n }\n return match.variants.some((candidate) => candidate.id === variant)\n ? \"option\"\n : \"invalid\";\n};\n\n/**\n * \"option\" when `answer` names one of the input's options (with a variant\n * when the option has them), \"custom\" for the user's own text, \"invalid\"\n * for an option named without its variant or with an unknown one. A\n * multiple choice is classified entry by entry: \"invalid\" when any entry is\n * or the array is empty, \"custom\" when any entry is the user's own text.\n */\nexport const classifyChoiceAnswer = (\n input: Checkout.Input,\n answer: string,\n): ChoiceAnswerKind => {\n if (!input.multiple) return classifyChoiceEntry(input, answer);\n const entries = parseMultipleAnswer(answer);\n if (!entries || entries.length === 0) return \"invalid\";\n const kinds = entries.map((entry) => classifyChoiceEntry(input, entry));\n if (kinds.includes(\"invalid\")) return \"invalid\";\n return kinds.includes(\"custom\") ? \"custom\" : \"option\";\n};\n\nconst REASONING_EFFORTS = new Set([\"low\", \"medium\", \"high\"]);\n\nconst isReasoningEffort = (\n value: unknown,\n): value is NonNullable<Checkout.ModelAnswer[\"reasoningEffort\"]> =>\n typeof value === \"string\" && REASONING_EFFORTS.has(value);\n\n/** The model answer an input holds, or `undefined` when the text is not one. */\nexport const parseModelAnswer = (\n answer: string,\n): Checkout.ModelAnswer | undefined => {\n let value: unknown;\n try {\n value = JSON.parse(answer);\n } catch {\n return undefined;\n }\n if (typeof value !== \"object\" || value === null) return undefined;\n const { provider, model, reasoningEffort } = value as Record<string, unknown>;\n if (\n typeof provider !== \"string\" ||\n typeof model !== \"string\" ||\n provider === \"\" ||\n model === \"\"\n ) {\n return undefined;\n }\n if (reasoningEffort === undefined) return { provider, model };\n if (!isReasoningEffort(reasoningEffort)) return undefined;\n return { provider, model, reasoningEffort };\n};\n\n/** Whether `answer` is a model answer naming one of the input's providers. */\nexport const isValidModelAnswer = (input: Checkout.Input, answer: string) => {\n const parsed = parseModelAnswer(answer);\n return (\n parsed !== undefined &&\n (input.options?.some((option) => option.id === parsed.provider) ?? false)\n );\n};\n\nexport const stepProgress = (state: Checkout.State) => ({\n total: state.steps.length,\n done: state.steps.filter(\n (step) => step.status === \"done\" || step.status === \"skipped\",\n ).length,\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"protocol.js","names":[],"sources":["../src/protocol.ts"],"sourcesContent":["export namespace Checkout {\n export type StepStatus =\n | \"pending\"\n | \"active\"\n | \"done\"\n | \"skipped\"\n | \"blocked\";\n\n /** A unit of install work the agent declared; the catalog seeds none. */\n export type Step = {\n id: string;\n title: string;\n detail?: string;\n status: StepStatus;\n note?: string;\n /** The product this step belongs to, when the agent said. */\n product?: string;\n createdAt: number;\n };\n\n export type Product = {\n slug: string;\n name: string;\n /** The install reference the agent fetches while planning. */\n guide?: string;\n };\n\n export type InputStatus = \"open\" | \"answered\" | \"dismissed\";\n\n /**\n * product: the agent proposes adding a product to this checkout, such as one\n * another product depends on. The browser owns the catalog, so it answers\n * with `checkout/add-product`; dismissing the input declines.\n */\n export type InputKind = \"text\" | \"choice\" | \"model\" | \"product\";\n\n export type ChoiceVariant = { id: string; label: string };\n\n export type ChoiceOption = {\n id: string;\n label: string;\n description?: string;\n /** A name from OPTION_ICONS, or the key of a brand mark on a preset option; unknown keys render without one. */\n icon?: string;\n /** A second pick under this option, such as the language of a framework. */\n variants?: ChoiceVariant[];\n };\n\n /** A short nudge for users who need help choosing, with a longer guide to link to. */\n export type InputHelp = { summary: string; href?: string };\n\n /**\n * The answer to a model input, stored as JSON text. The API key itself is\n * never part of it: the browser deposits it with the checkout and the\n * agent's `env` command writes it straight to a file.\n */\n export type ModelAnswer = {\n provider: string;\n model: string;\n reasoningEffort?: \"low\" | \"medium\" | \"high\";\n };\n\n /**\n * A choice answer is an option id, `<option>:<variant>` when the option has\n * variants, or the user's own text when none of the options fit; a multiple\n * choice answers a JSON array of those. A model answer is a JSON-encoded\n * ModelAnswer whose provider is one of the options.\n */\n export type Input = {\n phase: Status;\n id: string;\n kind: InputKind;\n /** The standard input this was built from, when it was. */\n preset?: string;\n prompt: string;\n placeholder?: string;\n options?: ChoiceOption[];\n /** The user may pick any number of options; the answer is a JSON array. */\n multiple?: true;\n /** The slug a product input proposes. */\n product?: string;\n default?: string;\n help?: InputHelp;\n optional: boolean;\n status: InputStatus;\n answer?: string;\n /** A remark the user attached to the answer. */\n note?: string;\n stepId?: string;\n createdAt: number;\n answeredAt?: number;\n };\n\n export type LogEntry = {\n phase: Status;\n id: string;\n role: \"agent\" | \"user\";\n acknowledgedAt?: number;\n at: number;\n text: string;\n /** The step that was active when the line was posted; none while planning. */\n stepId?: string;\n };\n\n export type PlanStatus = \"proposed\" | \"approved\" | \"changes-requested\";\n\n /** One revision of the agent's plan. Markdown the browser renders. */\n export type Plan = {\n revision: number;\n markdown: string;\n status: PlanStatus;\n submittedAt: number;\n decidedAt?: number;\n /** What the user asked to change, when they did. */\n feedback?: string;\n };\n\n /**\n * waiting: no agent has joined. planning: the agent investigates and\n * proposes a plan. installing: the plan is approved and steps run.\n */\n export type Status =\n | \"waiting\"\n | \"planning\"\n | \"installing\"\n | \"done\"\n | \"cancelled\";\n\n /**\n * The agent's proposal to close the checkout. Only the user closes it, and\n * they may keep messaging the agent instead; a later `done` renews it.\n * `preview` is the loopback URL of a dev server the agent left running for\n * the user to try before they close.\n */\n export type Completion = { proposedAt: number; preview?: string };\n\n export type State = {\n version: 2;\n /** The id the browser gave the current checkout; a create with another id replaces it on the same link. */\n id: string | null;\n status: Status;\n completion?: Completion;\n createdAt: number | null;\n products: Product[];\n instructions: string;\n agent: {\n lastSeenAt: number | null;\n /** False once the agent's stream said goodbye; heartbeats set it again. */\n connected: boolean;\n cwd: string | null;\n /** The coding agent that ran the command, as the CLI detected it from its environment. */\n kind: string | null;\n /** When the command was first run against this checkout, before the agent joined the stream. */\n introducedAt: number | null;\n };\n /** Every revision in order; the last one is current. */\n plans: Plan[];\n steps: Step[];\n inputs: Input[];\n log: LogEntry[];\n };\n\n export type ProductSeed = { slug: string; name: string; guide?: string };\n\n export type InputSeed = {\n kind?: InputKind;\n preset?: string;\n prompt: string;\n placeholder?: string;\n options?: ChoiceOption[];\n multiple?: true;\n product?: string;\n default?: string;\n help?: InputHelp;\n optional?: boolean;\n };\n\n export type PlanDecision =\n | { decision: \"approve\" }\n | { decision: \"revise\"; feedback: string };\n\n export type Commands = {\n \"checkout/create\": (params: {\n id: string;\n products: ProductSeed[];\n instructions?: string;\n }) => void;\n \"checkout/begin-plan\": () => void;\n \"checkout/answer\": (params: {\n inputId: string;\n answer: string;\n note?: string;\n }) => void;\n \"checkout/add-product\": (params: {\n inputId: string;\n product: ProductSeed;\n }) => void;\n \"checkout/message\": (params: { text: string }) => void;\n \"agent/ack\": (params: { messageId: string }) => void;\n \"checkout/dismiss\": (params: { inputId: string }) => void;\n \"checkout/plan\": (params: PlanDecision) => void;\n \"checkout/cancel\": () => void;\n \"checkout/finish\": () => void;\n \"agent/intro\": (params: { kind?: string }) => void;\n \"agent/hello\": (params: { cwd?: string; kind?: string }) => void;\n \"agent/heartbeat\": () => void;\n \"agent/bye\": () => void;\n \"agent/plan\": (params: { markdown: string }) => { revision: number };\n \"agent/add-step\": (params: {\n title: string;\n detail?: string;\n product?: string;\n /** Start the step in the same call. */\n active?: boolean;\n }) => { stepId: string };\n \"agent/step\": (params: {\n stepId: string;\n status: StepStatus;\n note?: string;\n }) => void;\n \"agent/ask\": (params: InputSeed & { stepId?: string }) => {\n inputId: string;\n };\n \"agent/log\": (params: { text: string }) => void;\n \"agent/done\": (params?: { summary?: string; preview?: string }) => void;\n };\n\n export type RejectionReason =\n | \"already-created\"\n | \"not-created\"\n | \"closed\"\n | \"planning-not-started\"\n | \"plan-required\"\n | \"no-plan\"\n | \"plan-decided\"\n | \"finish-not-proposed\"\n | \"feedback-required\"\n | \"empty-plan\"\n | \"unknown-step\"\n | \"unknown-product\"\n | \"unknown-input\"\n | \"input-closed\"\n | \"invalid-input\"\n | \"invalid-answer\"\n | \"invalid-preview\"\n | \"empty-message\"\n | \"unknown-message\";\n}\n\nexport const AGENT_PRESENCE_MS = 15_000;\nexport const AGENT_HEARTBEAT_MS = 5_000;\n\nexport const initialCheckoutState = (): Checkout.State => ({\n version: 2,\n id: null,\n status: \"waiting\",\n createdAt: null,\n products: [],\n instructions: \"\",\n agent: {\n lastSeenAt: null,\n connected: false,\n cwd: null,\n kind: null,\n introducedAt: null,\n },\n plans: [],\n steps: [],\n inputs: [],\n log: [],\n});\n\nexport const isAgentPresent = (state: Checkout.State, now = Date.now()) =>\n state.agent.connected &&\n state.agent.lastSeenAt !== null &&\n now - state.agent.lastSeenAt < AGENT_PRESENCE_MS;\n\nexport const isClosed = (state: Checkout.State) =>\n state.status === \"done\" || state.status === \"cancelled\";\n\n/** True while the agent's proposal to close waits for the user. */\nexport const finishProposed = (state: Checkout.State) =>\n !isClosed(state) && state.completion !== undefined;\n\n/** True while a message the user sent after the agent proposed to close still waits for the agent. */\nexport const followedUpSinceProposal = (state: Checkout.State) => {\n const proposedAt = state.completion?.proposedAt;\n return (\n proposedAt !== undefined &&\n state.log.some(\n (entry) =>\n entry.role === \"user\" &&\n entry.at > proposedAt &&\n entry.acknowledgedAt === undefined,\n )\n );\n};\n\nconst LOOPBACK_HOSTS = new Set([\"localhost\", \"127.0.0.1\", \"[::1]\"]);\n\n/**\n * The URL a preview names, or `undefined` unless it is an http(s) URL on this\n * machine's loopback interface. The browser opens it, so nothing else passes.\n */\nexport const parsePreviewUrl = (value: string | undefined) => {\n if (!value) return undefined;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return undefined;\n }\n if (url.protocol !== \"http:\" && url.protocol !== \"https:\") return undefined;\n if (url.username !== \"\" || url.password !== \"\") return undefined;\n const loopback =\n LOOPBACK_HOSTS.has(url.hostname) || url.hostname.endsWith(\".localhost\");\n return loopback ? url : undefined;\n};\n\nexport const openInputs = (state: Checkout.State) =>\n state.inputs.filter((input) => input.status === \"open\");\n\n/** The current plan revision, or `undefined` before the agent proposed one. */\nexport const currentPlan = (state: Checkout.State): Checkout.Plan | undefined =>\n state.plans.at(-1);\n\n/** True while a proposed plan waits for the user's decision. */\nexport const planNeedsReview = (state: Checkout.State) =>\n !isClosed(state) && currentPlan(state)?.status === \"proposed\";\n\n/** The icons an agent can put on its own options, by name. */\nexport const OPTION_ICONS = [\n \"code\",\n \"terminal\",\n \"braces\",\n \"git-branch\",\n \"package\",\n \"database\",\n \"server\",\n \"cloud\",\n \"globe\",\n \"monitor\",\n \"smartphone\",\n \"key\",\n \"lock\",\n \"shield\",\n \"brain\",\n \"sparkles\",\n \"bot\",\n \"workflow\",\n \"file\",\n \"folder\",\n \"book\",\n \"table\",\n \"image\",\n \"video\",\n \"mic\",\n \"speech\",\n \"paperclip\",\n \"message\",\n \"mail\",\n \"bell\",\n \"calendar\",\n \"clock\",\n \"user\",\n \"users\",\n \"settings\",\n \"wrench\",\n \"plug\",\n \"link\",\n \"palette\",\n \"search\",\n \"zap\",\n \"check\",\n \"question\",\n] as const;\n\nexport type OptionIcon = (typeof OPTION_ICONS)[number];\n\nexport const isOptionIcon = (value: string): value is OptionIcon =>\n (OPTION_ICONS as readonly string[]).includes(value);\n\nexport const parseChoiceAnswer = (answer: string) => {\n const parts = answer.split(\":\");\n if (parts.length > 2) return { option: answer };\n const [option = \"\", variant] = parts;\n return { option, ...(variant !== undefined && { variant }) };\n};\n\n/** The entries of a multiple choice answer, or `undefined` when the text is not a JSON array of strings. */\nexport const parseMultipleAnswer = (answer: string): string[] | undefined => {\n let value: unknown;\n try {\n value = JSON.parse(answer);\n } catch {\n return undefined;\n }\n return Array.isArray(value) &&\n value.every((entry) => typeof entry === \"string\")\n ? value\n : undefined;\n};\n\ntype ChoiceAnswerKind = \"option\" | \"custom\" | \"invalid\";\n\nconst classifyChoiceEntry = (\n input: Checkout.Input,\n answer: string,\n): ChoiceAnswerKind => {\n const { option, variant } = parseChoiceAnswer(answer);\n const match = input.options?.find((candidate) => candidate.id === option);\n if (!match) return answer.trim() === \"\" ? \"invalid\" : \"custom\";\n if (!match.variants || match.variants.length === 0) {\n return variant === undefined ? \"option\" : \"invalid\";\n }\n return match.variants.some((candidate) => candidate.id === variant)\n ? \"option\"\n : \"invalid\";\n};\n\n/**\n * \"option\" when `answer` names one of the input's options (with a variant\n * when the option has them), \"custom\" for the user's own text, \"invalid\"\n * for an option named without its variant or with an unknown one. A\n * multiple choice is classified entry by entry: \"invalid\" when any entry is\n * or the array is empty, \"custom\" when any entry is the user's own text.\n */\nexport const classifyChoiceAnswer = (\n input: Checkout.Input,\n answer: string,\n): ChoiceAnswerKind => {\n if (!input.multiple) return classifyChoiceEntry(input, answer);\n const entries = parseMultipleAnswer(answer);\n if (!entries || entries.length === 0) return \"invalid\";\n const kinds = entries.map((entry) => classifyChoiceEntry(input, entry));\n if (kinds.includes(\"invalid\")) return \"invalid\";\n return kinds.includes(\"custom\") ? \"custom\" : \"option\";\n};\n\nconst REASONING_EFFORTS = new Set([\"low\", \"medium\", \"high\"]);\n\nconst isReasoningEffort = (\n value: unknown,\n): value is NonNullable<Checkout.ModelAnswer[\"reasoningEffort\"]> =>\n typeof value === \"string\" && REASONING_EFFORTS.has(value);\n\n/** The model answer an input holds, or `undefined` when the text is not one. */\nexport const parseModelAnswer = (\n answer: string,\n): Checkout.ModelAnswer | undefined => {\n let value: unknown;\n try {\n value = JSON.parse(answer);\n } catch {\n return undefined;\n }\n if (typeof value !== \"object\" || value === null) return undefined;\n const { provider, model, reasoningEffort } = value as Record<string, unknown>;\n if (\n typeof provider !== \"string\" ||\n typeof model !== \"string\" ||\n provider === \"\" ||\n model === \"\"\n ) {\n return undefined;\n }\n if (reasoningEffort === undefined) return { provider, model };\n if (!isReasoningEffort(reasoningEffort)) return undefined;\n return { provider, model, reasoningEffort };\n};\n\n/** Whether `answer` is a model answer naming one of the input's providers. */\nexport const isValidModelAnswer = (input: Checkout.Input, answer: string) => {\n const parsed = parseModelAnswer(answer);\n return (\n parsed !== undefined &&\n (input.options?.some((option) => option.id === parsed.provider) ?? false)\n );\n};\n\nexport const stepProgress = (state: Checkout.State) => ({\n total: state.steps.length,\n done: state.steps.filter(\n (step) => step.status === \"done\" || step.status === \"skipped\",\n ).length,\n});\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,15 +1,20 @@
1
1
  {
2
2
  "name": "setup-agent",
3
- "version": "0.0.2",
4
- "description": "A live checkout shared between a browser and the coding agent installing the products in it, on Statewire",
3
+ "version": "0.0.4",
4
+ "description": "A live setup shared between a browser and the coding agent installing the products in it, on Statewire",
5
5
  "license": "MIT",
6
+ "homepage": "https://www.assistant-ui.com/",
6
7
  "repository": {
7
8
  "type": "git",
8
- "url": "git+https://github.com/assistant-ui/harness-sdk.git",
9
- "directory": "packages/checkout/core"
9
+ "url": "git+https://github.com/assistant-ui/assistant-ui.git",
10
+ "directory": "packages/setup-agent"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/assistant-ui/assistant-ui/issues"
10
14
  },
11
15
  "publishConfig": {
12
- "access": "public"
16
+ "access": "public",
17
+ "provenance": true
13
18
  },
14
19
  "type": "module",
15
20
  "bin": {
@@ -37,24 +42,26 @@
37
42
  "src",
38
43
  "README.md"
39
44
  ],
40
- "scripts": {
41
- "build": "aui-build",
42
- "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "test": "vitest run"
44
- },
45
45
  "dependencies": {
46
- "@assistant-ui/tap": "^0.9.7",
46
+ "@assistant-ui/tap": "^0.9.22",
47
47
  "statewire": "^0.19.4"
48
48
  },
49
49
  "devDependencies": {
50
- "@assistant-ui/x-buildutils": "^0.0.20",
50
+ "@assistant-ui/x-buildutils": "0.0.30",
51
51
  "@types/node": "^26.1.2",
52
52
  "@types/react": "^19.2.17",
53
53
  "react": "^19.2.8",
54
54
  "typescript": "^7.0.2",
55
- "vitest": "^4.1.10"
55
+ "vitest": "^5.0.3"
56
56
  },
57
57
  "engines": {
58
58
  "node": ">=20"
59
+ },
60
+ "sideEffects": false,
61
+ "scripts": {
62
+ "build": "aui-build",
63
+ "typecheck": "tsc -p tsconfig.json --noEmit",
64
+ "test": "vitest run",
65
+ "test:coverage": "vitest run --coverage --coverage.reporter=text --coverage.reporter=html --coverage.reporter=json-summary"
59
66
  }
60
- }
67
+ }
@@ -0,0 +1,464 @@
1
+ import { initialCheckoutState, type Checkout } from "./protocol";
2
+ import { describe, expect, it, onTestFinished, vi } from "vitest";
3
+ import {
4
+ chmod,
5
+ mkdtemp,
6
+ readFile,
7
+ rm,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import {
14
+ HELP,
15
+ INSTRUCTIONS,
16
+ askSeed,
17
+ diffEvents,
18
+ detectAgentKind,
19
+ isDirectInvocation,
20
+ upsertEnvLine,
21
+ waitForStart,
22
+ writeEnvSecret,
23
+ } from "./cli";
24
+
25
+ describe("the agent's briefing", () => {
26
+ it("describes a setup without the wording of a purchase", () => {
27
+ const briefing = HELP + INSTRUCTIONS("https://example.test/session");
28
+ expect(briefing).not.toMatch(/\b(?:cart|shop|checkout|purchase)\b/i);
29
+ });
30
+ });
31
+
32
+ describe("askSeed", () => {
33
+ const parse = (argv: string[]) => {
34
+ const flags = new Map<string, string | true>();
35
+ const rest: string[] = [];
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const arg = argv[i]!;
38
+ if (!arg.startsWith("--")) rest.push(arg);
39
+ else if (argv[i + 1]?.startsWith("--") === false)
40
+ flags.set(arg.slice(2), argv[++i]!);
41
+ else flags.set(arg.slice(2), true);
42
+ }
43
+ return askSeed(rest, flags);
44
+ };
45
+
46
+ const withExit = (run: () => void) => {
47
+ const exit = vi.spyOn(process, "exit").mockImplementation(() => {
48
+ throw new Error("exit");
49
+ });
50
+ const stderr = vi.spyOn(process.stderr, "write").mockReturnValue(true);
51
+ try {
52
+ expect(run).toThrow("exit");
53
+ return stderr.mock.lastCall?.[0];
54
+ } finally {
55
+ exit.mockRestore();
56
+ stderr.mockRestore();
57
+ }
58
+ };
59
+
60
+ it("asks a multi-select choice with an icon on every option by default", () => {
61
+ expect(
62
+ parse([
63
+ "Which integrations?",
64
+ "--choices",
65
+ "slack,email",
66
+ "--icons",
67
+ "slack=message,email=mail",
68
+ ]),
69
+ ).toEqual({
70
+ kind: "choice",
71
+ prompt: "Which integrations?",
72
+ options: [
73
+ { id: "slack", label: "slack", icon: "message" },
74
+ { id: "email", label: "email", icon: "mail" },
75
+ ],
76
+ multiple: true,
77
+ optional: false,
78
+ });
79
+ expect(
80
+ parse([
81
+ "Pick",
82
+ "--choices",
83
+ "a,b",
84
+ "--icons",
85
+ "a=code,b=zap",
86
+ "--multiple",
87
+ ]),
88
+ ).toHaveProperty("multiple", true);
89
+ });
90
+
91
+ it("asks a single choice only with --single", () => {
92
+ const seed = parse([
93
+ "Which package manager?",
94
+ "--choices",
95
+ "pnpm,npm",
96
+ "--icons",
97
+ "pnpm=package,npm=package",
98
+ "--single",
99
+ "--default",
100
+ "pnpm",
101
+ ]);
102
+ expect(seed).not.toHaveProperty("multiple");
103
+ expect(seed).toHaveProperty("default", "pnpm");
104
+ expect(
105
+ withExit(() =>
106
+ parse([
107
+ "Pick",
108
+ "--choices",
109
+ "a",
110
+ "--icons",
111
+ "a=code",
112
+ "--single",
113
+ "--multiple",
114
+ ]),
115
+ ),
116
+ ).toContain("--single and --multiple exclude each other");
117
+ });
118
+
119
+ it("refuses a choice missing an icon, naming the pack and the flag to pass", () => {
120
+ const message = withExit(() =>
121
+ parse(["Pick", "--choices", "a,b,c", "--icons", "b=mail"]),
122
+ );
123
+ expect(message).toContain('"a", "c" have none');
124
+ expect(message).toContain('--icons "a=<icon>,b=<icon>,c=<icon>"');
125
+ expect(message).toContain("code, terminal, braces");
126
+ expect(message).toContain("question");
127
+ });
128
+
129
+ it("refuses an icon outside the pack or on an unknown choice", () => {
130
+ expect(
131
+ withExit(() => parse(["Pick", "--choices", "a", "--icons", "a=rocket"])),
132
+ ).toContain('unknown icon "rocket"');
133
+ expect(
134
+ withExit(() => parse(["Pick", "--choices", "a", "--icons", "b=mail"])),
135
+ ).toContain('no choice "b"');
136
+ });
137
+
138
+ it("leaves presets single-select with their own brand marks", () => {
139
+ const framework = parse(["--preset", "framework"]);
140
+ expect(framework).not.toHaveProperty("multiple");
141
+ expect(framework.options?.[0]).toHaveProperty("icon", "vercel");
142
+ const project = parse([
143
+ "--preset",
144
+ "project",
145
+ "--choices",
146
+ "apps/web=Next.js",
147
+ ]);
148
+ expect(project).not.toHaveProperty("multiple");
149
+ expect(project.options?.[0]).toEqual({
150
+ id: "apps/web",
151
+ label: "apps/web",
152
+ description: "Next.js",
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("upsertEnvLine", () => {
158
+ it.each(["", "1KEY", "KEY.*", "(A+)+$", "KEY=VALUE", "KEY\nOTHER", "KEY "])(
159
+ "rejects invalid environment keys in the exported helper: %j",
160
+ (key) => {
161
+ expect(() => upsertEnvLine("KEY=old\n", key, "new")).toThrow(
162
+ "not an environment variable name",
163
+ );
164
+ },
165
+ );
166
+
167
+ it("matches assignment keys literally, including export and whitespace", () => {
168
+ expect(
169
+ upsertEnvLine("KEY_LONG=keep\nexport KEY \t=old\n", "KEY", "new"),
170
+ ).toBe("KEY_LONG=keep\nKEY=new\n");
171
+ expect(upsertEnvLine("KEY_LONG=keep\n", "KEY", "new")).toBe(
172
+ "KEY_LONG=keep\nKEY=new\n",
173
+ );
174
+ expect(upsertEnvLine("export =old\n", "export", "new")).toBe(
175
+ "export=new\n",
176
+ );
177
+ });
178
+ it("appends, replaces, and keeps the rest of the file", () => {
179
+ expect(upsertEnvLine("", "OPENAI_API_KEY", "sk-1")).toBe(
180
+ "OPENAI_API_KEY=sk-1\n",
181
+ );
182
+ expect(
183
+ upsertEnvLine(
184
+ "A=1\nexport OPENAI_API_KEY = old\nB=2",
185
+ "OPENAI_API_KEY",
186
+ "sk-2",
187
+ ),
188
+ ).toBe("A=1\nOPENAI_API_KEY=sk-2\nB=2\n");
189
+ expect(upsertEnvLine("A=1\n", "B", "2")).toBe("A=1\nB=2\n");
190
+ });
191
+ });
192
+
193
+ describe("detectAgentKind", () => {
194
+ it("names the agent from the variables it exports", () => {
195
+ expect(detectAgentKind({ CLAUDECODE: "1" })).toBe("claude");
196
+ expect(detectAgentKind({ CODEX_SANDBOX_NETWORK_DISABLED: "1" })).toBe(
197
+ "codex",
198
+ );
199
+ expect(detectAgentKind({ CURSOR_AGENT: "1" })).toBe("cursor");
200
+ expect(detectAgentKind({ GEMINI_CLI: "1" })).toBe("gemini");
201
+ expect(detectAgentKind({ CLAUDECODE: "" })).toBeUndefined();
202
+ expect(detectAgentKind({})).toBeUndefined();
203
+ });
204
+ });
205
+
206
+ describe("isDirectInvocation", () => {
207
+ it("is true only in a terminal with no agent in the environment", () => {
208
+ expect(isDirectInvocation({}, { isTTY: true }, { isTTY: true })).toBe(true);
209
+ expect(
210
+ isDirectInvocation({ CLAUDECODE: "1" }, { isTTY: true }, { isTTY: true }),
211
+ ).toBe(false);
212
+ expect(isDirectInvocation({}, { isTTY: undefined }, { isTTY: true })).toBe(
213
+ false,
214
+ );
215
+ expect(isDirectInvocation({}, { isTTY: true }, { isTTY: false })).toBe(
216
+ false,
217
+ );
218
+ });
219
+ });
220
+
221
+ describe("chat stream events", () => {
222
+ it("delivers new user messages once and replays only unacknowledged messages", () => {
223
+ const before = initialCheckoutState();
224
+ const after: Checkout.State = {
225
+ ...before,
226
+ log: [
227
+ { id: "l1", role: "agent", phase: "planning", at: 1, text: "Ready" },
228
+ { id: "l2", role: "user", phase: "planning", at: 2, text: "Use pnpm" },
229
+ {
230
+ id: "l3",
231
+ role: "user",
232
+ phase: "planning",
233
+ at: 3,
234
+ text: "Keep the theme",
235
+ acknowledgedAt: 4,
236
+ },
237
+ ],
238
+ };
239
+ expect(diffEvents(before, after)).toEqual([
240
+ { event: "message.created", messageId: "l2", text: "Use pnpm" },
241
+ ]);
242
+ expect(diffEvents(after, after)).toEqual([]);
243
+ });
244
+ });
245
+
246
+ describe("begin planning", () => {
247
+ it("surfaces a multiple choice answer as an array and flags the user's own text", () => {
248
+ const before = initialCheckoutState();
249
+ const asked: Checkout.Input = {
250
+ phase: "planning",
251
+ id: "q1",
252
+ kind: "choice",
253
+ prompt: "Which integrations?",
254
+ options: [
255
+ { id: "slack", label: "slack" },
256
+ { id: "email", label: "email" },
257
+ ],
258
+ multiple: true,
259
+ optional: false,
260
+ status: "open",
261
+ createdAt: 1,
262
+ };
263
+ const answered = (answer: string): Checkout.State => ({
264
+ ...before,
265
+ inputs: [{ ...asked, status: "answered", answer, answeredAt: 2 }],
266
+ });
267
+ expect(diffEvents(before, answered('["slack","email"]'))).toEqual([
268
+ {
269
+ event: "input.answered",
270
+ inputId: "q1",
271
+ prompt: "Which integrations?",
272
+ answer: ["slack", "email"],
273
+ },
274
+ ]);
275
+ expect(diffEvents(before, answered('["email","Discord"]'))).toEqual([
276
+ {
277
+ event: "input.answered",
278
+ inputId: "q1",
279
+ prompt: "Which integrations?",
280
+ answer: ["email", "Discord"],
281
+ custom: true,
282
+ },
283
+ ]);
284
+ });
285
+
286
+ it("emits a start event only when the browser starts planning", () => {
287
+ const waiting = initialCheckoutState();
288
+ const connected = {
289
+ ...waiting,
290
+ agent: { ...waiting.agent, connected: true, lastSeenAt: 1 },
291
+ };
292
+ expect(diffEvents(waiting, connected)).toEqual([]);
293
+ const planning: Checkout.State = { ...connected, status: "planning" };
294
+ expect(diffEvents(connected, planning)).toEqual([
295
+ { event: "planning.started" },
296
+ ]);
297
+ expect(diffEvents(planning, planning)).toEqual([]);
298
+ expect(diffEvents(waiting, { ...waiting, status: "done" })).toEqual([
299
+ { event: "finished", next: expect.stringContaining("Keep the stream") },
300
+ ]);
301
+ expect(diffEvents(waiting, { ...waiting, status: "cancelled" })).toEqual([
302
+ { event: "cancelled", next: expect.stringContaining("Keep the stream") },
303
+ ]);
304
+ });
305
+
306
+ it("announces the next setup the browser creates on the same link", () => {
307
+ const done: Checkout.State = {
308
+ ...initialCheckoutState(),
309
+ id: "c1",
310
+ status: "done",
311
+ log: [{ id: "l1", role: "user", phase: "installing", at: 1, text: "x" }],
312
+ };
313
+ const next: Checkout.State = {
314
+ ...initialCheckoutState(),
315
+ id: "c2",
316
+ products: [{ slug: "cloud", name: "Cloud" }],
317
+ };
318
+ expect(diffEvents(done, next)).toEqual([
319
+ { event: "setup.created", products: ["cloud"] },
320
+ ]);
321
+ expect(diffEvents(next, next)).toEqual([]);
322
+ expect(diffEvents(initialCheckoutState(), initialCheckoutState())).toEqual(
323
+ [],
324
+ );
325
+ });
326
+
327
+ it.each(["planning", "cancelled"] as const)(
328
+ "blocks while waiting, then releases on %s",
329
+ async (status) => {
330
+ let state: Checkout.State | undefined;
331
+ let notify = () => {};
332
+ const unsubscribe = vi.fn();
333
+ const settled = vi.fn();
334
+ const result = waitForStart({
335
+ get state() {
336
+ return state;
337
+ },
338
+ subscribe: (listener) => {
339
+ notify = listener;
340
+ return unsubscribe;
341
+ },
342
+ }).then(settled);
343
+ state = initialCheckoutState();
344
+ notify();
345
+ await Promise.resolve();
346
+ expect(settled).not.toHaveBeenCalled();
347
+ expect(unsubscribe).not.toHaveBeenCalled();
348
+ state = { ...state, status };
349
+ notify();
350
+ await result;
351
+ expect(settled).toHaveBeenCalledWith(status);
352
+ expect(unsubscribe).toHaveBeenCalledOnce();
353
+ },
354
+ );
355
+
356
+ it.each(["planning", "installing"] as const)(
357
+ "resolves immediately when rejoining a %s session",
358
+ async (status) => {
359
+ const unsubscribe = vi.fn();
360
+ await expect(
361
+ waitForStart({
362
+ state: { ...initialCheckoutState(), status },
363
+ subscribe: () => unsubscribe,
364
+ }),
365
+ ).resolves.toBe(status);
366
+ expect(unsubscribe).toHaveBeenCalledOnce();
367
+ },
368
+ );
369
+
370
+ it.each(["done", "cancelled"] as const)(
371
+ "waits past a %s setup for the next one to begin",
372
+ async (status) => {
373
+ let state: Checkout.State = {
374
+ ...initialCheckoutState(),
375
+ id: "c1",
376
+ status,
377
+ };
378
+ let notify = () => {};
379
+ const settled = vi.fn();
380
+ const result = waitForStart({
381
+ get state() {
382
+ return state;
383
+ },
384
+ subscribe: (listener) => {
385
+ notify = listener;
386
+ return () => {};
387
+ },
388
+ }).then(settled);
389
+ await Promise.resolve();
390
+ expect(settled).not.toHaveBeenCalled();
391
+ state = { ...initialCheckoutState(), id: "c2" };
392
+ notify();
393
+ await Promise.resolve();
394
+ expect(settled).not.toHaveBeenCalled();
395
+ state = { ...state, status: "cancelled" };
396
+ notify();
397
+ await result;
398
+ expect(settled).toHaveBeenCalledWith("cancelled");
399
+ },
400
+ );
401
+ });
402
+
403
+ describe("writeEnvSecret", () => {
404
+ const tempDir = async () => {
405
+ const dir = await mkdtemp(join(tmpdir(), "setup-agent-"));
406
+ onTestFinished(() => rm(dir, { recursive: true, force: true }));
407
+ return dir;
408
+ };
409
+
410
+ it("leaves the secret deposited when the file cannot be opened", async () => {
411
+ const dir = await tempDir();
412
+ const take = vi.fn(async () => "sk-test");
413
+ await expect(
414
+ writeEnvSecret(
415
+ join(dir, "missing", ".env.local"),
416
+ "OPENAI_API_KEY",
417
+ take,
418
+ ),
419
+ ).rejects.toThrow();
420
+ expect(take).not.toHaveBeenCalled();
421
+ });
422
+
423
+ it.skipIf(process.platform === "win32")(
424
+ "creates the file readable only by its owner",
425
+ async () => {
426
+ const file = join(await tempDir(), ".env.local");
427
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test");
428
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
429
+ expect(await readFile(file, "utf8")).toBe("OPENAI_API_KEY=sk-test\n");
430
+ },
431
+ );
432
+
433
+ it("keeps the rest of an existing file and restricts it to its owner", async () => {
434
+ const file = join(await tempDir(), ".env.local");
435
+ await writeFile(file, "A=1\nOPENAI_API_KEY=old\nB=2\n");
436
+ await chmod(file, 0o644);
437
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => "sk-test");
438
+ expect(await readFile(file, "utf8")).toBe(
439
+ "A=1\nOPENAI_API_KEY=sk-test\nB=2\n",
440
+ );
441
+ if (process.platform !== "win32") {
442
+ expect((await stat(file)).mode & 0o777).toBe(0o600);
443
+ }
444
+ });
445
+
446
+ it("refuses an invalid key before taking the secret", async () => {
447
+ const take = vi.fn(async () => "sk-test");
448
+ await expect(
449
+ writeEnvSecret(join(await tempDir(), ".env.local"), "BAD KEY", take),
450
+ ).rejects.toThrow("not an environment variable name");
451
+ expect(take).not.toHaveBeenCalled();
452
+ });
453
+
454
+ it("keeps lines another writer added while the secret was being taken", async () => {
455
+ const file = join(await tempDir(), ".env.local");
456
+ await writeEnvSecret(file, "OPENAI_API_KEY", async () => {
457
+ await writeFile(file, "OTHER_KEY=1\n");
458
+ return "sk-test";
459
+ });
460
+ expect(await readFile(file, "utf8")).toBe(
461
+ "OTHER_KEY=1\nOPENAI_API_KEY=sk-test\n",
462
+ );
463
+ });
464
+ });