@stigmer/cli 3.1.8 → 3.1.9

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,12 +1,19 @@
1
- // `share agent` dispatch: enable or disable public sharing for an agent and
2
- // report the hosted chat link + embed snippet.
1
+ // `share agent` dispatch: enable or disable sharing for an agent and report
2
+ // the hosted chat link + embed snippet.
3
3
  //
4
- // The one correctness invariant here: the `updateSharing` RPC replaces
5
- // `spec.sharing` WHOLESALE. A naive "set enabled" would silently wipe any
6
- // `allowed_origins` or visitor messages the owner configured in the console.
7
- // So this module always reads the current state first and sends the complete
8
- // block back with only `enabled` flipped — the same merge-preserve discipline
9
- // as the web dialog's draftFromAgent (sdk/react ShareAgentDialog.tsx).
4
+ // Sharing lives in its own AgentShare resource (decision 011) — the agent is
5
+ // never modified. This module resolves the agent, reads its canonical share,
6
+ // and commits changes via `agentShare.apply`, an idempotent upsert keyed on
7
+ // the share's (org, slug) identity: the first enable creates the share, later
8
+ // toggles update it, one code path.
9
+ //
10
+ // The one correctness invariant here: apply replaces the share's spec
11
+ // WHOLESALE. A naive "set enabled" would silently wipe any allowed_origins,
12
+ // visitor messages, audience, or credential bindings the owner configured in
13
+ // the console. So this module always reads the current share first and sends
14
+ // the complete spec back with only the requested fields flipped — the same
15
+ // merge-preserve discipline as the web dialog. (The rotatable link token is
16
+ // exempt: it is server-owned status, which survives every apply verbatim.)
10
17
  //
11
18
  // URL and snippet shapes come from @stigmer/sdk's sharing helpers — the single
12
19
  // source of truth shared with the web console — so every surface emits
@@ -15,17 +22,18 @@
15
22
 
16
23
  import { create } from "@bufbuild/protobuf";
17
24
  import type { Agent } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
25
+ import type { AgentShare } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/api_pb";
18
26
  import {
27
+ GetAgentSharesByAgentRequestSchema,
19
28
  RotateShareLinkInputSchema,
20
- UpdateAgentSharingInputSchema,
21
- } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/io_pb";
29
+ } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/io_pb";
30
+ import { AgentShareAudience } from "@stigmer/protos/ai/stigmer/agentic/agentshare/v1/spec_pb";
22
31
  import {
23
- AgentSharingAudience,
24
- AgentSharingMessagesSchema,
25
- AgentSharingSchema,
26
- type AgentSharing,
27
- } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/spec_pb";
28
- import { buildChatUrl, buildEmbedSnippet, type Stigmer } from "@stigmer/sdk";
32
+ buildChatUrl,
33
+ buildEmbedSnippet,
34
+ type AgentShareInput,
35
+ type Stigmer,
36
+ } from "@stigmer/sdk";
29
37
  import { UsageError } from "../errors/index.js";
30
38
  import { CommandResult } from "../output/index.js";
31
39
  import { isAgentId } from "./reference.js";
@@ -39,7 +47,7 @@ export interface ShareAgentOptions {
39
47
  /** Desired sharing state: `true` to enable, `false` to disable. */
40
48
  readonly enabled: boolean;
41
49
  /**
42
- * Desired audience. Omitted means "keep the agent's current audience" —
50
+ * Desired audience. Omitted means "keep the share's current audience" —
43
51
  * a plain toggle must never flip an org-members-only share back to
44
52
  * public (or vice versa).
45
53
  */
@@ -48,7 +56,7 @@ export interface ShareAgentOptions {
48
56
  * Rotate the share-link token: the server generates a fresh `?k=`
49
57
  * secret and the current link (tokened or plain) stops working
50
58
  * immediately. The token is server-owned status — never part of the
51
- * sharing block this module merges.
59
+ * spec this module merges.
52
60
  */
53
61
  readonly resetLink?: boolean;
54
62
  /** The app origin serving the hosted chat page and `embed.js`. */
@@ -63,8 +71,9 @@ export interface ShareAgentOptions {
63
71
 
64
72
  /**
65
73
  * Enable or disable sharing for the referenced agent and describe the
66
- * outcome. Idempotent: when the agent is already in the desired state, no
67
- * write is issued (the link is still reported when sharing is on).
74
+ * outcome. Idempotent: when the canonical share is already in the desired
75
+ * state, no write is issued (the link is still reported when sharing is
76
+ * on). When the agent has never been shared, enabling creates the share.
68
77
  */
69
78
  export async function shareAgent(
70
79
  client: Stigmer,
@@ -84,7 +93,8 @@ export async function shareAgent(
84
93
  }
85
94
 
86
95
  const agent = await resolveAgentRef(client, ref, org);
87
- const current = agent.spec?.sharing;
96
+ let share = await resolveCanonicalShare(client, agent);
97
+ const current = share?.spec;
88
98
 
89
99
  const currentAudience = audienceFromProto(current?.audience);
90
100
  const targetAudience = options.audience ?? currentAudience;
@@ -103,83 +113,133 @@ export async function shareAgent(
103
113
  );
104
114
  }
105
115
 
116
+ // Never-shared + disable is a no-op, not a write: creating a share row
117
+ // just to mark it disabled would materialize a resource the owner never
118
+ // asked for. The one exception is an explicit --audience org, which is
119
+ // real configuration worth persisting as a paused share (mirroring the
120
+ // pre-promotion behavior of storing audience on a disabled block).
106
121
  const alreadyInState =
107
- (current?.enabled ?? false) === options.enabled &&
108
- currentAudience === targetAudience;
122
+ share !== null
123
+ ? (current?.enabled ?? false) === options.enabled &&
124
+ currentAudience === targetAudience
125
+ : !options.enabled && targetAudience !== "org";
109
126
 
110
127
  if (!alreadyInState) {
111
- // Send the COMPLETE sharing block with only the requested fields changed,
112
- // so a CLI toggle can never wipe console-configured origins, visitor
113
- // messages, or an org-members-only audience.
114
- await client.agent.updateSharing(
115
- create(UpdateAgentSharingInputSchema, {
116
- resourceId: agent.metadata?.id ?? "",
117
- sharing: preservingSharing(current, options.enabled, targetAudience),
118
- }),
128
+ // Apply the COMPLETE spec with only the requested fields changed, so a
129
+ // CLI toggle can never wipe console-configured origins, visitor
130
+ // messages, credential bindings, or an org-members-only audience.
131
+ share = await client.agentShare.apply(
132
+ preservingShareInput(agent, share, options.enabled, targetAudience),
119
133
  );
120
134
  }
121
135
 
122
136
  // The rotation is a separate targeted RPC (the token is server-owned
123
- // status, not part of the sharing block). The returned agent carries the
124
- // fresh token, so the link printed below is the new one.
125
- let linkToken = agent.status?.shareLinkToken ?? "";
126
- if (options.resetLink === true) {
127
- const rotated = await client.agent.rotateShareLink(
137
+ // status, not part of the spec apply merges). The returned share carries
138
+ // the fresh token, so the link printed below is the new one.
139
+ if (options.resetLink === true && share !== null) {
140
+ share = await client.agentShare.rotateShareLink(
128
141
  create(RotateShareLinkInputSchema, {
129
- resourceId: agent.metadata?.id ?? "",
142
+ resourceId: share.metadata?.id ?? "",
130
143
  }),
131
144
  );
132
- linkToken = rotated.status?.shareLinkToken ?? "";
133
145
  }
134
146
 
135
- return describeOutcome(agent, options, targetAudience, linkToken, alreadyInState && options.resetLink !== true);
147
+ return describeOutcome(
148
+ agent,
149
+ share,
150
+ options,
151
+ targetAudience,
152
+ alreadyInState && options.resetLink !== true,
153
+ );
154
+ }
155
+
156
+ /**
157
+ * The agent's canonical share: the one whose slug equals the agent's (the
158
+ * server's default on create), else the first entry, else null when the
159
+ * agent has never been shared. Mirrors the web dialog's selection so both
160
+ * surfaces manage the same row (decision 011 D3: one canonical share in
161
+ * Phase A).
162
+ */
163
+ async function resolveCanonicalShare(
164
+ client: Stigmer,
165
+ agent: Agent,
166
+ ): Promise<AgentShare | null> {
167
+ const result = await client.agentShare.getByAgent(
168
+ create(GetAgentSharesByAgentRequestSchema, {
169
+ agentId: agent.metadata?.id ?? "",
170
+ }),
171
+ );
172
+ const agentSlug = agent.metadata?.slug ?? "";
173
+ return (
174
+ result.items.find((share) => share.metadata?.slug === agentSlug) ??
175
+ result.items[0] ??
176
+ null
177
+ );
136
178
  }
137
179
 
138
- // Unspecified means public by contract (pre-audience shares keep their
139
- // anyone-with-link behavior).
140
- function audienceFromProto(audience: AgentSharingAudience | undefined): ShareAudience {
141
- return audience === AgentSharingAudience.org ? "org" : "public";
180
+ // Unspecified means public by contract (a share created without an explicit
181
+ // audience is an anyone-with-link share).
182
+ function audienceFromProto(audience: AgentShareAudience | undefined): ShareAudience {
183
+ return audience === AgentShareAudience.org ? "org" : "public";
142
184
  }
143
185
 
144
- // The full sharing config with the desired `enabled` and audience,
145
- // preserving origins and messages (empty defaults when the agent was never
146
- // shared before). The audience is written explicitly — never left
147
- // unspecified — so a console-managed org share can't drift back to public.
148
- function preservingSharing(
149
- current: AgentSharing | undefined,
186
+ // The full share input with the desired `enabled` and audience, preserving
187
+ // origins, messages, and credential bindings (empty defaults when the agent
188
+ // was never shared before). Identity comes from the existing share when one
189
+ // exists — a manifest-created share may carry a non-default slug, and
190
+ // applying with the agent's slug would create a SECOND share — and from the
191
+ // agent otherwise (the server's own D2 default, made explicit). The audience
192
+ // is written explicitly — never left unspecified — so a console-managed org
193
+ // share can't drift back to public.
194
+ function preservingShareInput(
195
+ agent: Agent,
196
+ share: AgentShare | null,
150
197
  enabled: boolean,
151
198
  audience: ShareAudience,
152
- ): AgentSharing {
153
- return create(AgentSharingSchema, {
199
+ ): AgentShareInput {
200
+ const agentOrg = agent.metadata?.org ?? "";
201
+ const agentSlug = agent.metadata?.slug ?? "";
202
+ const current = share?.spec;
203
+ return {
204
+ org: share?.metadata?.org || agentOrg,
205
+ slug: share?.metadata?.slug || agentSlug,
206
+ name: share?.metadata?.name || agent.metadata?.name || agentSlug,
207
+ agentRef: { org: agentOrg, slug: agentSlug },
154
208
  enabled,
155
209
  audience:
156
210
  audience === "org"
157
- ? AgentSharingAudience.org
158
- : AgentSharingAudience.public,
211
+ ? AgentShareAudience.org
212
+ : AgentShareAudience.public,
159
213
  allowedOrigins: [...(current?.allowedOrigins ?? [])],
160
- messages: create(AgentSharingMessagesSchema, {
214
+ messages: {
161
215
  rateLimited: current?.messages?.rateLimited ?? "",
162
216
  unavailable: current?.messages?.unavailable ?? "",
163
217
  conversationEnded: current?.messages?.conversationEnded ?? "",
164
- }),
165
- });
218
+ },
219
+ environmentRefs: (current?.environmentRefs ?? []).map((envRef) => ({
220
+ org: envRef.org,
221
+ slug: envRef.slug,
222
+ })),
223
+ };
166
224
  }
167
225
 
168
- // Build the user-facing result. Org/slug come from the RESOLVED agent's
169
- // metadata (authoritative even when the user passed an ID), matching how the
170
- // web share dialog derives them. The link token comes from the agent's
171
- // status (post-rotation when --reset-link ran) and rides the printed URL
172
- // and snippet — public audience only (org access is gated by membership).
226
+ // Build the user-facing result. Org/slug come from the SHARE's metadata —
227
+ // the identity in the hosted chat URL (a share may carry a non-default
228
+ // slug) — falling back to the resolved agent's on disable-when-never-shared.
229
+ // The link token comes from the share's status (post-rotation when
230
+ // --reset-link ran) and rides the printed URL and snippet — public audience
231
+ // only (org access is gated by membership).
173
232
  function describeOutcome(
174
233
  agent: Agent,
234
+ share: AgentShare | null,
175
235
  options: ShareAgentOptions,
176
236
  audience: ShareAudience,
177
- linkToken: string,
178
237
  alreadyInState: boolean,
179
238
  ): CommandResult {
180
- const org = agent.metadata?.org ?? "";
181
- const slug = agent.metadata?.slug ?? "";
239
+ const org = share?.metadata?.org || (agent.metadata?.org ?? "");
240
+ const slug = share?.metadata?.slug || (agent.metadata?.slug ?? "");
182
241
  const name = agent.metadata?.name || slug;
242
+ const linkToken = share?.status?.shareLinkToken ?? "";
183
243
 
184
244
  if (!options.enabled) {
185
245
  const result = CommandResult.success(