@alpacakit/agents-conventions 0.1.0-beta.35 → 0.1.0-beta.37

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.
@@ -0,0 +1,212 @@
1
+ /**
2
+ * The three ways an agent enters the shared home — a browser sign-in into a
3
+ * managed home, a pasted credential, or a plain OpenAI-compatible endpoint.
4
+ * Each owns its ORDER and its FAILURE POLICY, which is the whole reason these
5
+ * live here and not in an app: a half-registered credential must not depend
6
+ * on which product ran the command.
7
+ */
8
+ import { mkdir } from "node:fs/promises";
9
+ import { isAgentError, } from "@alpacakit/agents";
10
+ import { openAiCompatibleAgentConfig } from "@alpacakit/agents/config";
11
+ import { createRuntimeFactory, openAgentsHome, } from "./home.js";
12
+ import { rejectInvalidName } from "./names.js";
13
+ import { createKeyringSecretRef, resolveManagedHomePath } from "./paths.js";
14
+ import { AGENT_ADD_KIND } from "./values.js";
15
+ const PRIVATE_DIRECTORY_MODE = 0o700;
16
+ const REGISTER_FAULT_MESSAGE = {
17
+ loginNotRegistered: (profileId, managedHome) => `Sign-in succeeded into "${managedHome}" but profile "${profileId}" could not be registered. The credential is on disk and unreferenced; fix the agents home and run the same login again.`,
18
+ };
19
+ export const LOGIN_MANAGED_HOME_RESULT = {
20
+ registered: "registered",
21
+ profileExists: "profile_exists",
22
+ unknownAgent: "unknown_agent",
23
+ unsupported: "unsupported",
24
+ loginFailed: "login_failed",
25
+ };
26
+ export const ADD_API_KEY_PROFILE_RESULT = {
27
+ registered: "registered",
28
+ profileExists: "profile_exists",
29
+ emptyKey: "empty_key",
30
+ };
31
+ export const ADD_AGENT_CONFIG_RESULT = {
32
+ added: "added",
33
+ exists: "exists",
34
+ };
35
+ /**
36
+ * Sign in with the agent's own CLI into a home this package owns, then record
37
+ * it. A failed sign-in leaves the directory in place — a device flow that
38
+ * timed out has already written state the next attempt continues from — but
39
+ * NEVER writes the profile, so the home never points at a home that cannot
40
+ * authenticate. A sign-in that succeeds and then cannot be recorded is a
41
+ * fault, not a result: silently discarding a completed browser flow would
42
+ * leave a live credential nobody knows about.
43
+ */
44
+ export async function loginManagedHome(input) {
45
+ const invalidName = rejectInvalidName(input.profileId);
46
+ if (invalidName !== null) {
47
+ return invalidName;
48
+ }
49
+ const home = openAgentsHome(input);
50
+ if (await findProfile(home.config, input.profileId)) {
51
+ return {
52
+ kind: LOGIN_MANAGED_HOME_RESULT.profileExists,
53
+ profileId: input.profileId,
54
+ };
55
+ }
56
+ const runtimes = createRuntimeFactory(home, await home.config.loadRuntimeConfig());
57
+ const agents = runtimes.ambient.agents();
58
+ const agent = agents.find((candidate) => candidate.id === input.agentId);
59
+ // Two different problems with two different fixes: an id this home does not
60
+ // have (add or enable the agent config) versus a method this agent's CLI
61
+ // does not offer (use another method). Collapsing them would send the
62
+ // operator looking in the wrong place.
63
+ if (agent === undefined) {
64
+ return {
65
+ kind: LOGIN_MANAGED_HOME_RESULT.unknownAgent,
66
+ agentId: input.agentId,
67
+ known: agents.map((candidate) => candidate.id),
68
+ };
69
+ }
70
+ const login = agent.login;
71
+ if (login === null || !login.supports(input.method)) {
72
+ return {
73
+ kind: LOGIN_MANAGED_HOME_RESULT.unsupported,
74
+ agentId: input.agentId,
75
+ method: input.method,
76
+ supported: login?.methods() ?? [],
77
+ };
78
+ }
79
+ const managedHome = resolveManagedHomePath({
80
+ rootDir: home.agentsHome,
81
+ profileId: input.profileId,
82
+ });
83
+ await mkdir(managedHome, { recursive: true, mode: PRIVATE_DIRECTORY_MODE });
84
+ const request = {
85
+ method: input.method,
86
+ homeDir: managedHome,
87
+ ...(input.signal === undefined ? {} : { signal: input.signal }),
88
+ };
89
+ const handle = login.start(request, home.location);
90
+ const drained = drainLoginEvents(handle.events, input.onEvent);
91
+ try {
92
+ await handle.result;
93
+ }
94
+ catch (thrown) {
95
+ await drained;
96
+ if (!isAgentError(thrown)) {
97
+ throw thrown;
98
+ }
99
+ return {
100
+ kind: LOGIN_MANAGED_HOME_RESULT.loginFailed,
101
+ detail: thrown.message,
102
+ managedHome,
103
+ };
104
+ }
105
+ await drained;
106
+ let profile;
107
+ try {
108
+ profile = await home.config.profiles.addHomeDir({
109
+ id: input.profileId,
110
+ agentId: input.agentId,
111
+ path: managedHome,
112
+ });
113
+ }
114
+ catch (cause) {
115
+ throw new Error(REGISTER_FAULT_MESSAGE.loginNotRegistered(input.profileId, managedHome), { cause });
116
+ }
117
+ return { kind: LOGIN_MANAGED_HOME_RESULT.registered, profile, managedHome };
118
+ }
119
+ /**
120
+ * Store the key in the family keychain, then reference it from the home. If
121
+ * the reference cannot be written the stored secret is removed again, so a
122
+ * failed `credential add` never leaves an orphaned key in the operator's
123
+ * keychain that no command can ever name.
124
+ *
125
+ * A blank key is rejected before the keychain is touched: it is the operator's
126
+ * mistake (an empty paste, an empty pipe), not a keychain failure, so it comes
127
+ * back as a result and leaves nothing behind. The key is stored trimmed —
128
+ * `echo "sk-…" | … --stdin` otherwise saves a trailing newline that every
129
+ * later request sends to the provider.
130
+ */
131
+ export async function addApiKeyProfile(input) {
132
+ const invalidName = rejectInvalidName(input.profileId);
133
+ if (invalidName !== null) {
134
+ return invalidName;
135
+ }
136
+ const apiKey = input.apiKey.trim();
137
+ if (apiKey === "") {
138
+ return {
139
+ kind: ADD_API_KEY_PROFILE_RESULT.emptyKey,
140
+ profileId: input.profileId,
141
+ };
142
+ }
143
+ const home = openAgentsHome(input);
144
+ if (await findProfile(home.config, input.profileId)) {
145
+ return {
146
+ kind: ADD_API_KEY_PROFILE_RESULT.profileExists,
147
+ profileId: input.profileId,
148
+ };
149
+ }
150
+ const secret = createKeyringSecretRef(input.profileId);
151
+ await home.keyring.set(secret.id, apiKey);
152
+ try {
153
+ const profile = await home.config.profiles.addApiKey({
154
+ id: input.profileId,
155
+ agentId: input.agentId,
156
+ secret,
157
+ });
158
+ return { kind: ADD_API_KEY_PROFILE_RESULT.registered, profile };
159
+ }
160
+ catch (thrown) {
161
+ await home.keyring.delete(secret.id);
162
+ throw thrown;
163
+ }
164
+ }
165
+ /**
166
+ * One record per addable kind (design principle 7): the key IS the support
167
+ * declaration, so adding a kind is adding a row rather than a branch.
168
+ */
169
+ const AGENT_CONFIG_BUILDER = {
170
+ [AGENT_ADD_KIND.openaiCompatible]: (request) => openAiCompatibleAgentConfig({
171
+ id: request.id,
172
+ baseUrl: request.baseUrl,
173
+ model: request.model,
174
+ ...(request.apiKeyEnv === undefined
175
+ ? {}
176
+ : { apiKey: envTemplate(request.apiKeyEnv) }),
177
+ ...(request.engine === undefined ? {} : { engine: request.engine }),
178
+ }),
179
+ };
180
+ /** Add an agent of the requested kind. `input.kind` is the discriminator. */
181
+ export async function addAgentConfig(input) {
182
+ const invalidName = rejectInvalidName(input.id);
183
+ if (invalidName !== null) {
184
+ return invalidName;
185
+ }
186
+ const home = openAgentsHome(input);
187
+ const existing = await home.config.agentConfigs.list();
188
+ if (existing.some((agentConfig) => agentConfig.id === input.id)) {
189
+ return { kind: ADD_AGENT_CONFIG_RESULT.exists, id: input.id };
190
+ }
191
+ const agentConfig = await home.config.agentConfigs.add(AGENT_CONFIG_BUILDER[input.kind](input));
192
+ return { kind: ADD_AGENT_CONFIG_RESULT.added, agentConfig };
193
+ }
194
+ function envTemplate(name) {
195
+ return `\${${name}}`;
196
+ }
197
+ async function findProfile(config, profileId) {
198
+ return (await config.profiles.list()).some((profile) => profile.id === profileId);
199
+ }
200
+ /**
201
+ * Events are informational; a consumer that does not want them must not stall
202
+ * the sign-in, and a consumer that does must see them all before the result is
203
+ * reported. Awaiting the drain after the result gives both.
204
+ */
205
+ async function drainLoginEvents(events, onEvent) {
206
+ if (onEvent === undefined) {
207
+ return;
208
+ }
209
+ for await (const event of events) {
210
+ onEvent(event);
211
+ }
212
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The one way an agent leaves the shared home. `name` is a record name, the
3
+ * same position `connection login <name>` uses, and profiles win over agent
4
+ * configs because a profile is the more specific thing an operator names.
5
+ */
6
+ import type { AgentConfig, ProfileRecord } from "@alpacakit/agents/config";
7
+ import { type AgentsHomeInput } from "./home.js";
8
+ import { type InvalidNameResult } from "./names.js";
9
+ export declare const REMOVE_AGENTS_HOME_ENTRY_RESULT: {
10
+ readonly removedProfile: "removed_profile";
11
+ readonly removedAgentConfig: "removed_agent_config";
12
+ readonly notFound: "not_found";
13
+ };
14
+ export type RemoveAgentsHomeEntryInput = AgentsHomeInput & {
15
+ readonly name: string;
16
+ };
17
+ export type RemoveAgentsHomeEntryResult = {
18
+ readonly kind: typeof REMOVE_AGENTS_HOME_ENTRY_RESULT.removedProfile;
19
+ readonly profile: ProfileRecord;
20
+ /** The login home deleted, or `null` when the path was not ours to delete. */
21
+ readonly removedManagedHome: string | null;
22
+ readonly removedSecret: boolean;
23
+ } | {
24
+ readonly kind: typeof REMOVE_AGENTS_HOME_ENTRY_RESULT.removedAgentConfig;
25
+ readonly agentConfig: AgentConfig;
26
+ } | {
27
+ readonly kind: typeof REMOVE_AGENTS_HOME_ENTRY_RESULT.notFound;
28
+ readonly name: string;
29
+ } | InvalidNameResult;
30
+ /**
31
+ * The record goes first, then the credential it pointed at: if deleting the
32
+ * credential fails the operator is left with an inert directory or an orphaned
33
+ * keychain entry and a loud error — never with a live record pointing at a
34
+ * credential that is already gone.
35
+ *
36
+ * Only artifacts this package created are deleted. A home the operator pointed
37
+ * at themselves, or a secret behind someone else's resolver, is theirs.
38
+ */
39
+ export declare function removeAgentsHomeEntry(input: RemoveAgentsHomeEntryInput): Promise<RemoveAgentsHomeEntryResult>;
40
+ //# sourceMappingURL=remove.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"remove.d.ts","sourceRoot":"","sources":["../src/remove.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,0BAA0B,CAAC;AAE3E,OAAO,EAAE,KAAK,eAAe,EAAkB,MAAM,WAAW,CAAC;AACjE,OAAO,EAAE,KAAK,iBAAiB,EAAqB,MAAM,YAAY,CAAC;AAGvE,eAAO,MAAM,+BAA+B;;;;CAIlC,CAAC;AAEX,MAAM,MAAM,0BAA0B,GAAG,eAAe,GAAG;IACzD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,2BAA2B,GACnC;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,+BAA+B,CAAC,cAAc,CAAC;IACrE,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,8EAA8E;IAC9E,QAAQ,CAAC,kBAAkB,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3C,QAAQ,CAAC,aAAa,EAAE,OAAO,CAAC;CACjC,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,+BAA+B,CAAC,kBAAkB,CAAC;IACzE,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAC;CACnC,GACD;IACE,QAAQ,CAAC,IAAI,EAAE,OAAO,+BAA+B,CAAC,QAAQ,CAAC;IAC/D,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;CACvB,GACD,iBAAiB,CAAC;AAEtB;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,0BAA0B,GAChC,OAAO,CAAC,2BAA2B,CAAC,CAyBtC"}
package/dist/remove.js ADDED
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one way an agent leaves the shared home. `name` is a record name, the
3
+ * same position `connection login <name>` uses, and profiles win over agent
4
+ * configs because a profile is the more specific thing an operator names.
5
+ */
6
+ import { rm } from "node:fs/promises";
7
+ import { AUTH_MATERIAL_KIND } from "@alpacakit/agents";
8
+ import { openAgentsHome } from "./home.js";
9
+ import { rejectInvalidName } from "./names.js";
10
+ import { createKeyringSecretRef, isManagedHomePath } from "./paths.js";
11
+ export const REMOVE_AGENTS_HOME_ENTRY_RESULT = {
12
+ removedProfile: "removed_profile",
13
+ removedAgentConfig: "removed_agent_config",
14
+ notFound: "not_found",
15
+ };
16
+ /**
17
+ * The record goes first, then the credential it pointed at: if deleting the
18
+ * credential fails the operator is left with an inert directory or an orphaned
19
+ * keychain entry and a loud error — never with a live record pointing at a
20
+ * credential that is already gone.
21
+ *
22
+ * Only artifacts this package created are deleted. A home the operator pointed
23
+ * at themselves, or a secret behind someone else's resolver, is theirs.
24
+ */
25
+ export async function removeAgentsHomeEntry(input) {
26
+ // Nothing this package created can carry a name outside the grammar, so a
27
+ // name outside it cannot match a record — and must never reach the delete.
28
+ const invalidName = rejectInvalidName(input.name);
29
+ if (invalidName !== null) {
30
+ return invalidName;
31
+ }
32
+ const home = openAgentsHome(input);
33
+ const profile = await home.config.profiles.remove(input.name);
34
+ if (profile !== null) {
35
+ return {
36
+ kind: REMOVE_AGENTS_HOME_ENTRY_RESULT.removedProfile,
37
+ profile,
38
+ removedManagedHome: await removeManagedHome(home.agentsHome, profile),
39
+ removedSecret: await removeManagedSecret(home.keyring, profile),
40
+ };
41
+ }
42
+ const agentConfig = await home.config.agentConfigs.remove(input.name);
43
+ if (agentConfig !== null) {
44
+ return {
45
+ kind: REMOVE_AGENTS_HOME_ENTRY_RESULT.removedAgentConfig,
46
+ agentConfig,
47
+ };
48
+ }
49
+ return { kind: REMOVE_AGENTS_HOME_ENTRY_RESULT.notFound, name: input.name };
50
+ }
51
+ async function removeManagedHome(agentsHome, profile) {
52
+ if (profile.material.kind !== AUTH_MATERIAL_KIND.homeDir) {
53
+ return null;
54
+ }
55
+ const { path } = profile.material;
56
+ if (!isManagedHomePath({ rootDir: agentsHome, profileId: profile.id, path })) {
57
+ return null;
58
+ }
59
+ await rm(path, { recursive: true, force: true });
60
+ return path;
61
+ }
62
+ async function removeManagedSecret(keyring, profile) {
63
+ if (profile.material.kind !== AUTH_MATERIAL_KIND.apiKey) {
64
+ return false;
65
+ }
66
+ const managed = createKeyringSecretRef(profile.id);
67
+ const { secret } = profile.material;
68
+ if (secret.resolver !== managed.resolver || secret.id !== managed.id) {
69
+ return false;
70
+ }
71
+ await keyring.delete(secret.id);
72
+ return true;
73
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The family vocabulary, spelled once. Every constant here is a decision
3
+ * about WHERE shared agent state lives on a machine — the one thing every
4
+ * AlpacaKit-family product must agree on to see the same agents.
5
+ */
6
+ export declare const AGENTS_HOME_ENV_VAR = "ALPACA_AGENTS_HOME";
7
+ export declare const AGENTS_HOME_DIRECTORY = ".alpaca/agents";
8
+ /** Keychain service for agent credentials — distinct from integrations (I1). */
9
+ export declare const AGENTS_KEYRING_SERVICE = "alpaca.agents";
10
+ export declare const AGENTS_KEYRING_RESOLVER = "agents-home-keyring";
11
+ /** Login homes this package creates, and is therefore allowed to delete. */
12
+ export declare const AGENTS_MANAGED_HOMES_DIRECTORY = "managed-homes";
13
+ export declare const AGENTS_KEYRING_ACCOUNT: {
14
+ readonly prefix: "profile";
15
+ readonly apiKeySuffix: "api-key";
16
+ readonly separator: ":";
17
+ };
18
+ /**
19
+ * Which agent kinds an operator can add to the shared home. Deciding this is
20
+ * the family's job, not a product's, so the vocabulary lives beside the
21
+ * procedure that acts on it — `./cli` only spells it as a flag.
22
+ *
23
+ * A literal, because `AGENT_CONFIG_KIND` values are branded and cannot be a
24
+ * CLI enum; a test pins the two together so they cannot drift.
25
+ */
26
+ export declare const AGENT_ADD_KIND: {
27
+ readonly openaiCompatible: "openai-compatible";
28
+ };
29
+ export type AgentAddKind = (typeof AGENT_ADD_KIND)[keyof typeof AGENT_ADD_KIND];
30
+ //# sourceMappingURL=values.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"values.d.ts","sourceRoot":"","sources":["../src/values.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,eAAO,MAAM,mBAAmB,uBAAuB,CAAC;AACxD,eAAO,MAAM,qBAAqB,mBAAmB,CAAC;AACtD,gFAAgF;AAChF,eAAO,MAAM,sBAAsB,kBAAkB,CAAC;AACtD,eAAO,MAAM,uBAAuB,wBAAwB,CAAC;AAC7D,4EAA4E;AAC5E,eAAO,MAAM,8BAA8B,kBAAkB,CAAC;AAC9D,eAAO,MAAM,sBAAsB;;;;CAIzB,CAAC;AAEX;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;CAEjB,CAAC;AAEX,MAAM,MAAM,YAAY,GAAG,CAAC,OAAO,cAAc,CAAC,CAAC,MAAM,OAAO,cAAc,CAAC,CAAC"}
package/dist/values.js ADDED
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The family vocabulary, spelled once. Every constant here is a decision
3
+ * about WHERE shared agent state lives on a machine — the one thing every
4
+ * AlpacaKit-family product must agree on to see the same agents.
5
+ */
6
+ export const AGENTS_HOME_ENV_VAR = "ALPACA_AGENTS_HOME";
7
+ export const AGENTS_HOME_DIRECTORY = ".alpaca/agents";
8
+ /** Keychain service for agent credentials — distinct from integrations (I1). */
9
+ export const AGENTS_KEYRING_SERVICE = "alpaca.agents";
10
+ export const AGENTS_KEYRING_RESOLVER = "agents-home-keyring";
11
+ /** Login homes this package creates, and is therefore allowed to delete. */
12
+ export const AGENTS_MANAGED_HOMES_DIRECTORY = "managed-homes";
13
+ export const AGENTS_KEYRING_ACCOUNT = {
14
+ prefix: "profile",
15
+ apiKeySuffix: "api-key",
16
+ separator: ":",
17
+ };
18
+ /**
19
+ * Which agent kinds an operator can add to the shared home. Deciding this is
20
+ * the family's job, not a product's, so the vocabulary lives beside the
21
+ * procedure that acts on it — `./cli` only spells it as a flag.
22
+ *
23
+ * A literal, because `AGENT_CONFIG_KIND` values are branded and cannot be a
24
+ * CLI enum; a test pins the two together so they cannot drift.
25
+ */
26
+ export const AGENT_ADD_KIND = {
27
+ openaiCompatible: "openai-compatible",
28
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alpacakit/agents-conventions",
3
- "version": "0.1.0-beta.35",
4
- "description": "AlpacaKit-family conventions for @alpacakit/agents machine-local config (paths, keyring, managed homes).",
3
+ "version": "0.1.0-beta.37",
4
+ "description": "AlpacaKit-family conventions for @alpacakit/agents machine-local config (paths, keyring, managed homes) and the shared `agent` command surface.",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "publishConfig": {
@@ -12,6 +12,10 @@
12
12
  ".": {
13
13
  "types": "./dist/index.d.ts",
14
14
  "import": "./dist/index.js"
15
+ },
16
+ "./cli": {
17
+ "types": "./dist/cli.d.ts",
18
+ "import": "./dist/cli.js"
15
19
  }
16
20
  },
17
21
  "types": "./dist/index.d.ts",
@@ -21,11 +25,18 @@
21
25
  "LICENSE"
22
26
  ],
23
27
  "dependencies": {
24
- "@alpacakit/agents": "0.1.0-beta.35"
28
+ "@alpacakit/core": "0.1.0-beta.37",
29
+ "@alpacakit/agents": "0.1.0-beta.37"
25
30
  },
26
31
  "devDependencies": {
27
32
  "typescript": "^5.9.3",
28
- "vitest": "^4.0.18"
33
+ "vitest": "^4.0.18",
34
+ "zod": "4.3.6",
35
+ "@alpacakit/channels": "0.1.0-beta.37"
36
+ },
37
+ "peerDependencies": {
38
+ "zod": "^4.3.6",
39
+ "@alpacakit/channels": "^0.1.0-beta.37"
29
40
  },
30
41
  "scripts": {
31
42
  "build": "tsc -p tsconfig.build.json",